Skip to content

Product Add ons

Chris Taylor-Guest edited this page Aug 30, 2026 · 24 revisions

Product Add-ons

The Product Add-ons for Shop module (product-addons-for-shop) lets a product offer other products alongside itself - screens with a bench desk, a cable tray, extra shelves with a cupboard. The shopper ticks what they want in a small panel under the Add to basket button, makes any remaining choices, and adds each add-on with its own Add to basket button. Add-ons never ride along on the main product's button; whichever order the two are bought in, the basket still gathers them into a tidy group.

Where it lives now. Add-ons used to have a sidebar link of their own. They are now an Add-ons tab on Shop → Catalogue. Old links still work.

It needs the Shop and Shop Variations modules. Product 3D Views and the Space Planner are optional - with them, the product picture can become the product with its add-ons.

What the shopper sees

  • The add-ons panel sits under Add to basket (place the Shop: Product add-ons block beneath Shop: Add to basket in the product layout - it renders nothing on products without add-ons). Each add-on is a row: tick it and its remaining choices appear, with a quantity field and an Add to basket button styled to match the main product's own. The row's little picture follows the choices: the exact variation once they resolve one, and before that the nearest variation to whatever is already settled - so an add-on whose colour is matched to the desk shows that colour while its fabric is still to pick, rather than whichever finish the listing photograph happens to be in. The listing's own picture is used only when nothing is settled at all.

  • Sizes sort themselves out. An option set to match follows the main product silently - pick a 1400mm desk and the screens are 1400mm, no question asked. If a size has no matching add-on size, that add-on simply says it isn't available for that choice. The panel still states what it settled on ("Width: 1400mm - matches your width"), swatch and all, rather than deciding behind the shopper's back.

  • A value fixed by you is stated, not hidden. An option set to always one value is not a choice, so it gets no buttons - but the panel names it ("Colour: White") and shows its swatch, so nobody is surprised by what turns up.

  • Colours follow, until overridden. An option set to follow, changeable pre-selects from the main product (frame colour matching the desk's) and keeps following as the shopper changes their mind - until they change it, after which their choice sticks. A "Match my choice again" link puts it back. The option's name says where the pick came from: "Frame Colour - White (matching your choice)".

  • Recommendations, until overridden. An option set to pick a recommendation, changeable pre-selects a value the admin chose and names it - "Frame Colour - White (recommended)" - rather than one derived from the main product. The shopper can pick something else, and a "Back to the recommendation" link restores it; picking the recommended value back by hand does the same thing, so the note returns and the link disappears. If the recommended value later disappears from the product it degrades to a plain choice.

  • Colour & image previews are yours to switch off, per screen size. Pointing at one of an add-on's colour or picture choices pops a bigger look at it - the full swatch, or a block of colour big enough to actually judge. On a desktop that costs a shopper nothing. On a phone there is no pointing: pressing a swatch to choose it is the pointing, so the enlarged picture lands over the page whether they asked for it or not. The Shop: Product add-ons block now carries a Colour & image previews setting with the usual desktop / tablet / phone switch, so you can keep it where it helps and drop it where it doesn't. The little label naming the colour stays either way - that is the part a shopper actually needs. It is the same setting, and the same reasoning, as the one on the main product's options block; the two are set separately because they are two different boxes on the page. Leave a screen size blank and it follows the wider one, so changing nothing changes nothing.

  • On a narrow screen the button says just "Add". An add-on's row puts its quantity picker and its own button side by side, and on a phone there is not always room for the full Add to basket beside the picker - the wording was breaking onto a second line and making every row taller for no gain. Where the row is that tight the button simply reads Add instead. Nothing else changes, and on any normal width the full wording is back.

  • An accessory can be bought on its own. Nothing in the panel waits for the main product to be configured: every choice is pickable, and the Add to basket button beside it works, whether or not the desk above has been settled. Where an add-on's option would normally match the main product's, and that main option is still unchosen, the shopper simply picks it themselves - "Pick one now, or choose your width above and it will match that". Do choose the desk afterwards and the match takes over as usual, which is rather the point of a matched option: a screen has to be the width of the desk it bolts to. Anything already in the basket stays exactly as it was bought. (Before 0.1.17 those choices were held shut until the main product was configured.)

  • Some accessories only apply to some configurations. A desk sold with or without cable ports has nowhere to put a power module when the shopper picks "without", so an add-on can carry conditions. It is offered as normal from the moment the page loads, and takes itself off - panel and showcase both - the instant the shopper picks a choice you have not named. Nothing has been ruled out until they choose, so nothing hides while they are still deciding. An add-on with no conditions (nearly all of them) is offered exactly as before.

  • The choices look like the product's own. Each option's swatches and buttons sit after its name, and hovering a colour or picture pops the same enlarged look, with the value's name, that the main product page gives.

  • An add-on that comes one way - a cable tray with no options at all - is simply added, no choices asked for.

  • Sold-out add-ons stand down. An add-on with nothing left to sell disappears from the panel and the showcase alike, rather than sitting there tempting people into an order you cannot fill. Anything hanging off it (an accessory's own accessory) goes with it. Where only some of its variations have run dry, the add-on stays and those particular choices are struck through, marked Out of stock and unpickable - the rest of the range carries on as normal. Backorder and pre-order add-ons still sell past zero, exactly as they do on their own pages.

  • Quantities are suggested, not imposed. A recommended-count add-on pre-fills the right number (3 screen sets for a 6 Person desk) and shows a gentle "We'd recommend…" note if changed. A free-count add-on (extra shelves) just starts at 1 and lets them pick. From 0.1.17 the count sits in the same minus/plus pill the main product uses - same shape, same buttons, typed into if that is quicker - rather than the little numeric box with the browser's own arrows it used to wear.

  • The count follows how many of the product they are buying (0.1.31). One screen per desk is four screens for four desks, so raising the main product's own quantity raises the accessory's with it, and the sum is written underneath - "1 for each Impulse Bench Desk, so 4 for the 4 you are buying" - before anything is added. The figure in the pill is always the figure that goes in the basket, so the basket never springs a number on anybody. Type your own and it is yours outright: it stops following, exactly as the main product's own quantity box behaves. This is a per-add-on setting (Count one lot per main product bought), ticked to begin with on a recommended count and left clear on a free one, so the accessory that is bought once however big the order is - a delivery upgrade, an assembly service - can say so and stay at one. Accessories hanging off another accessory count against that one: four desks each with a pedestal, and a lock for each pedestal, is four locks.

  • Learn more opens the add-on's full description - pictures, designed description, specification - in a popup, without leaving the page, with a Close button under the description. On a phone it sits beside the price from version 0.1.10, with the name keeping the full width above it; before 0.1.9 it was parked in the corner at every width, where a name long enough to wrap - and most of them are - arrived underneath it. The popup shows a spinner while the description is on its way, which on a page nobody has visited for a while can be a couple of seconds.

  • The photo stays with the customer while they configure an add-on (version 0.1.9, with Variations 0.1.128 or later). The pinned picture on a phone used to let go at the end of the main product's own choices, which is exactly where the accessories box begins - so choosing a screen changed the 3D view and the customer could not see it happen.

  • Clicking an add-on's picture opens its pictures. Both in the panel and on the showcase card, the picture is a button: it opens a popup with a large view and a thumbnail strip underneath, walkable with the arrow keys and closed with Escape. The variation's own photographs come first and the listing's general pictures follow, with a hairline in the strip where one group ends and the other begins - the same variation the row's thumbnail is showing, so the big view never disagrees with the picture that opened it. An add-on that comes exactly one way just shows the listing's pictures.

  • The link remembers the ticks. Ticked add-ons are written into the page's address as the shopper goes - choices and quantities too - alongside the main product's own option choices, which Shop variations writes there. From version 0.1.29 the address says so in words: the accessory by its own web address name and each choice by its name, so a shared link reads ...&pad=impulse-3-drawer-pedestal.80cm.maple rather than a row of reference numbers. Links shared before that still work exactly as they did. Two spots keep a reference number, because a name would not say which thing was meant: the same accessory offered twice on one page, and one choice name used on two of that accessory's own option lists. Copy the address, send it to someone, and they open the page with the same add-ons ticked and configured. Only quantities the shopper set by hand travel; a recommended count is left to recompute, so a shared 6-person desk still suggests the right number of screens even if the recipient changes the desk. The back button is left out of it entirely.

  • The showcase is an extra tab in the product page's own tab strip (after Specification, named with your word - see Settings), with a card per add-on: Learn more, and an Add button that jumps back to the configurator with that add-on opened. From version 0.1.10 the card's writing opens the description too - name, blurb or price, not just the button underneath - and the cards sit two across on a phone, the same as the product tiles in your category pages, collapsing at whichever width you've set under Styles. A product with only one accessory gets a single card at a sensible width in the middle of the row rather than one stretched across the whole page. Its pictures follow the panel's: choose a desk finish up top and the accessory cards further down the page change to match, rather than sitting there in whatever finish the catalogue photograph happened to be. On a page with no add-ons panel at all there is nothing to follow, so the cards keep the listing's own pictures.

The basket, checkout and documents

Each add-on becomes its own basket line - priced, stocked, taxed and delivered exactly as if bought from its own page - grouped under the main product with an "Accessory for…" caption (once each; the caption is the label). Inside a group the basket draws no dividing line between the product and its accessories, so the set reads as one entry. The grouping follows through checkout, the order confirmation, order emails, the admin order view and quote documents (PDF included).

The group forms whichever way round the shopper buys: adding an add-on tags a main product already in the basket, and adding the main product afterwards joins add-ons already there. An add-on bought without its main product at all is simply a normal line, with a note saying what it was chosen for.

Removing the main product asks "Remove its accessories too?" - either answer, with a working Undo that restores the whole set. Quantities stay independently editable; if they drift from the recommendation, the basket line notes what the recommendation would be.

Admin

  • Per product: the Add-ons section on the product edit screen. Search for a product to link (a product with a required personalisation field is refused - the panel cannot collect those). If the product you are linking already offers this one, that is a loop: rather than refusing outright the panel offers to add it without its own add-ons, which stops the chain there and lets the two sit alongside each other - a coffee table offered with a sofa, and the sofa offered with the coffee table, without either page ending up offering itself. For each of the add-on's options choose: matches a main option / follows one but changeable / pick a recommendation but changeable / shopper chooses / always one value. Value translations are automatic (shared attribute, then matching name) with a per-value override table for exceptions. Under When to offer it, add a condition if the accessory only suits some versions of the product: pick one of the main product's options and tick the choices it applies to (Cable Port → With cable ports). The accessory still shows from the off; it disappears when a customer picks a choice you have not ticked. Add more than one condition and all of them have to hold. Leave it alone and the add-on is offered however the product is configured. A condition whose option you later rename or delete stops the add-on being offered at all rather than risking a wrong sale, and says so in the warnings. Under When to offer each of its choices, do the same thing one level down for the case where the accessory fits but one of its sizes does not: tick the add-on's own choices to govern and the versions of this product they suit, and those choices leave the menu whenever anything else is picked, the rest of the accessory staying exactly where it is. An 80cm-deep pedestal butts the end of an 800-deep desk run, so on a 120cm corner desk with two 600-deep arms it comes off the list and the 60cm one carries on being offered. Anything you do not mention is always offered, and an option whose every choice is ruled out reads as "not available for the chosen ..." rather than an empty row. Where a condition leaves exactly one choice standing, the customer is not asked to pick it - the row simply states it, since there is nothing to decide. An option that has come with one choice all along is untouched. Set the quantity rule - the count itself, and whether it is a count per main product bought or a flat figure - the 3D context key, whether the space planner may place it on its own, and whether it offers its own add-ons here - untick that and whatever the accessory offers on its own page stays there. Warnings appear for anything that would silently not work - a desk width with no matching screen width, a missing 3D file. The order is yours: each add-on shows its place in the list ("2 of 5") with up and down arrows, and that order is the one shoppers meet on the product page and in the showcase alike. The move saves on the spot, and nothing you were half way through typing is lost when you shuffle. A saved add-on sits closed (0.1.30): the card shows its name, its place in the order, the arrows, Edit and Remove, and nothing else, so a desk with eight accessories reads as a list of eight lines rather than a morning's scrolling. Edit opens it back up, Close puts it away, and saving the rules closes it for you. A newly added one opens straight away, since you added it to set it up. Anything you would want to see with the card shut stays on the closed line - that it is switched off, how many warnings it has, and whether you left rules unsaved in it.
  • Shop → Product add-ons: an overview of every link in the catalogue, and an Attach rate report - for each pairing, how many orders contained the main product, how many had the add-on bought with it, units and revenue, over a date range. Only add-ons genuinely bought as attachments count.
  • You still see the sold-out ones. Signed in with shop access, the panel and the showcase show every add-on, with an Out of stock badge and a line saying shoppers cannot see it. Sold-out choices are struck through but still clickable, so you can configure and demonstrate one - the Add to basket button stays shut, so nobody can accidentally sell what is not there. Signed out, or as an ordinary shopper, none of that is in the page at all.
  • Settings (Shop settings → Product add-ons): what add-ons are called sitewide - singular and plural (Deskwell says "Accessory"/"Accessories") - and where the showcase appears: the automatic tab, only where the showcase block is placed, or nowhere.

3D combined models (optional)

With Product 3D Views installed, a model file can be tagged with the add-on combination it shows. Upload a desk-with-screens file against the same variation as the plain desk and tag it screens (the little tag chip beside the model, in the 3D lists); tag quantity-specific files shelves:1, shelves:2 and so on. When the shopper ticks the add-on, the viewer swaps to the tagged file and paints it live - fabric, frame colour, overrides and all. AR uses the same model.

The rule is exact or base: a combination without its own tagged file shows the plain product, never a guess. An add-on at a non-recommended quantity drops out of the preview (the panel says "3D preview shows the standard arrangement"). The admin section shows how many variations have files for each tag, so gaps are a choice rather than a surprise.

An accessory that comes in sizes needs a tag per size. Under the context key box, tick any of the add-on's own choices that should change the combined model - a pedestal offered in 30cm and 39cm, say - and the shopper's pick is added to the tag: pedestal-30cm, pedestal-39cm. Upload one file per combination and tag them to match. Leave the boxes clear (the usual case) and one file covers the accessory however it is configured. The coverage line then reads one row per tag, so a size you have not modelled yet is visible rather than quietly falling back to the plain desk. A choice that only changes the accessory's colour does not want a tick: colours are painted live on the one combined file.

After uploading combined files, run Detect + Save in the fabric panel as usual - a file the config has never measured tiles its fabric wrong, exactly as with any other model.

Space planner (optional)

The combined model follows the shopper into the planner. A desk bought with its screens stages as one thing - the desk drawn with the screens on it, from the tagged file - and the screens do not also turn up leaning against the wall as loose panels. The same holds whichever order the two went into the basket.

An add-on that contributes no combined-model tag stages the way it always did: its own placeable item where the link allows it to stand alone (a pedestal), list-only for a fit-inside part like a shelf, which still appears in the plan's parts list. Lines of one group stay tied together in the plan's paperwork.

Take an add-on back out on the basket page and the desk goes back to being a plain desk in the planner - the room follows the basket, not what the basket used to say. The exact or base rule still applies: a combination with no tagged file draws the plain product.

Putting a plan back into the basket restores each line as it was bought - grouping and personalisation included. Items placed from the planner's own catalogue stay plain products, as they always were.

For developers

  • Tables: pad_links, pad_settings. Config stores option names and value slugs, never ids, so sheet re-imports cannot break mappings.
  • config.hideChildAddons (0.1.26) ends the chain at one add-on: buildAddon in lib/payload.ts never asks what that product itself offers, so children is empty by design. wouldCreateCycle follows only links without it, and a new link that carries it can close nothing, which is what allows a two-way pair (sofa ↔ coffee table). The create route takes it as an optional body flag and answers a refused link with loop: true so the editor can offer the way out by name; the PATCH route re-asks the question before letting the chain run on again. Absent is the old behaviour, so nothing already linked changes. The read-time visited set and MAX_CHAIN_DEPTH still stand behind all of it.
  • Visibility conditions live in config.showWhen - a list of { mainOption, valueSlugs } (option name, value slugs, like everything else here). isAddonApplicable in lib/mapping.ts is the single test, used by the box (top-level add-ons against the main product's options, chain children against their own parent's) and by the showcase island. Rules AND, values within a rule OR, an empty valueSlugs is ignored as unfinished. The rule is offered unless ruled out: an unchosen driving option has ruled nothing out and passes (0.1.21 - 0.1.19 and 0.1.20 held the add-on back until it was answered, which read as an accessory that would not appear). The one deliberate false with nothing incompatible picked is an option name that no longer resolves - a broken config rather than a shopper's choice, where the condition cannot be tested at all and guessing yes is the failure it was written to prevent. The admin coverage check names both that and a rule with nothing ticked. The showcase gets mainOptions and each card's showWhen in its payload, both optional so a payload cached before 0.1.19 renders exactly as it did.
  • Choice-level conditions live in config.valueShowWhen - a list of { addonOption, addonValueSlugs, mainOption, mainValueSlugs }, option names and value slugs throughout, with isAddonValueAvailable / availableAddonValues in lib/mapping.ts as the single test (0.1.22). Same polarity as showWhen and for the same reason: a choice is offered unless ruled out, rules AND per value, the main values inside one rule OR, and a half-filled rule (either list empty) is ignored. A value no rule names is always offered. The box narrows the option it renders, drops a chosen or followed value that has since been ruled out, and treats an option with every value ruled out - or a match/fixed value that has been - as the add-on being unavailable for that configuration, said in words. Stock, variation resolution and the 3D context key all keep the full value list: a size still exists whether or not this product can take it. The one deliberate false is showWhen's: a mainOption name that no longer resolves hides the governed values, and the admin coverage check says so. narrowedToSingleValue (0.1.23) reports an option a rule has cut to exactly one value; the box settles it, adds it to lockedOptionIds and renders it as a stated line, so the context key and the price follow with no click. It returns null for an option that only ever had one value - settling those would change how every existing add-on behaves.
  • config.quantity.scaleWithMain (0.1.31) says the count is per one of the main product, so the box multiplies it by the main product's own quantity. Absent means the mode decides - scalesWithMain in lib/mapping.ts reads a recommended count as per-item and a free one as the shopper's own figure - which is why nothing already linked changed when it shipped. The main quantity arrives on shop's new cactus-shop-purchase-quantity window event (modules/shop/components/public/purchase-quantity.ts), published by shop's own buy row and by both of shop-variations' (the detail slot's and the granular block's), keyed on the listing product so a box only ever multiplies by its own page's figure. An install whose shop is too old to publish it counts as one, which is the behaviour it had before. In the box, state.qty is now the total to add rather than a per-unit figure (null still means "follow the recommendation"); perUnitQty is derived back from it for the two per-unit contracts - the line meta's recommendedPerUnit and the model context's qtyPerMain, which the space planner multiplies by the main item's count when it stages a plan. The basket's drift note asks scalesWithMain too, so an accessory that does not scale is no longer nagged about every time the order grows.
  • The 3D tag an add-on announces is model_context_key plus, for each option named in config.modelContextOptions (option names, in order), the chosen value's slug reduced to letters, numbers and dashes - composeContextKey in lib/mapping.ts, used by both the storefront box and the admin coverage count. A nominated option with nothing settled announces nothing rather than the bare key, since exact-or-base at the far end would otherwise show a size nobody asked for.
  • Seams used: shop.cart-line-resolver (+ prefetch, which now receives the whole cart's lines), shop.product-editor-sections, shop.product-detail-tabs (with the new labelFor), shop.settings-sub-tabs, shop's generic basket groups (CartLineGroup, lib/cart-group.ts), shop's client cart (addToCart/setLineMeta - each add-on row adds its own line), shop-variations' purchase companions (window.__cactusPurchaseCompanions, now used only to stamp the main line with its group when related add-on lines are already in the basket) and its variant-selection broadcast, shop's purchase-quantity broadcast, and three window events of its own: cactus-shop-model-context (consumed by Product 3D Views), cactus-shop-accessory-focus and cactus-shop-addon-images.
  • The space planner contract is the modelContext bag on a cart line (documented in the planner's lib/client/cart-bridge.ts; neither module imports the other). Each add-on line states its own piece: { stage, bundleOf, qtyPerMain, contextKey?, valueIds? } - contextKey present means it is inside the main line's combined model, so stage is 'none' and it never stages as loose furniture. The main line carries the merged summary { contexts, extraValueIds, bundleKey, contextsFrom: 'bundle' }, stamped whichever way round the two were bought (the add-on's own button, or the purchase-companion on the main add). contextsFrom: 'bundle' is the important half: it tells the planner the list is a summary of lines it can see for itself, so it re-derives it at staging time and an add-on removed on the basket page stops being drawn. A bag without that marker keeps its stored list, which is all an older basket has.
  • The picture popup (components/public/AddonImageModal.tsx) is deliberately separate from Learn more: pictures only, no iframe. Its images come from data the payload already carried - the row's displayImages (see below) followed by the listing's selector.baseImages, deduplicated by URL so a picture filed under both appears once, in the earlier (variation) position.
  • displayImages on each resolved add-on is the single source for both the row thumbnail and the picture popup, so the two can never disagree: the resolved variant's imageUrls when the combination is complete, otherwise settledVariantImages() - the first enabled variation carrying pictures that answers to every value settled so far, aliases counted through shop-variations' own variantAnswersTo. Empty for an option-less (plain) add-on and when nothing is settled, where the listing picture is the honest answer. Note this is deliberately MORE eager than shop-variations' own product gallery, which waits for a complete combination: an add-on's colour is routinely locked by the link while a second option is still open, and the listing photograph is very often a different finish.
  • The showcase is a separate island (it renders inside shop's tab strip) and holds none of that state, so it does not re-derive displayImages - the box publishes them on cactus-shop-addon-images (lib/addon-images.ts: {parentProductId, images: Record<linkId, string[]>}, snapshot at window.__cactusAddonImages, JSON-deduped like the other broadcasts) and the showcase repeats them. Deliberately one source rather than two: re-deriving would mean shipping the whole box payload into the tab as well, and two copies of the maths free to disagree. Published for every add-on including chain children, ticked or not; an add-on with nothing better than its listing picture is absent from the bag rather than present-and-empty, so a showcase with no box on the page (nothing heard) and an add-on with no variation pictures both fall back to the server-rendered listing picture instead of rendering nothing. The showcase's cards carry the listing's images as an optional images field on ShowcaseCard, so a payload cached before this shipped simply renders an unclickable picture as it always did.
  • Stock (lib/stock.ts): isAddonOutOfStock = the plain row's own flag, or every enabled variation out of stock (a product with no enabled variations is the owner's doing, not the warehouse's, and is left alone). buildBoxPayload resolves shop's canSeeStockLevels() once as staffView and, for anyone else, prunes sold-out add-ons and their children out of the payload before it leaves the server - so the showcase (built from the same payload) can never disagree, and nothing about them reaches the browser. isValueOutOfStock(selector, constraints, optionId, valueId) answers the per-value question: constraints are every locked value (matched or fixed - settled by definition) plus picks made ABOVE the option in display order, the same directional rule shop-variations' own isValueAvailable uses, so a later pick can never grey out an earlier row. Staff are stopped at the button rather than the control (disabled={!r.variant.inStock}), exactly as shop-variations does it.
  • Ordering: pad_links.position (already what every read sorts by). POST /api/m/product-addons-for-shop/admin/products/<id>/links/reorder takes the whole orderedIds list, refuses anything that is not exactly that product's links (409 - the editor reloads and says so), and renumbers in one transaction with each statement pinned to the product id as well as the link id. The editor moves the row optimistically and deliberately does not re-fetch afterwards: a re-fetch hands every link editor a fresh link object, which resets its unsaved drafts.
  • The Learn more popup iframes shop's chrome-free /shop/products/<slug>/details view (noindex). It hides the site chrome by hiding everything in body except main - if core ever wraps main, the chrome reappears inside the popup and that selector is the line to revisit. Embedding it at all relies on core's security headers permitting same-origin framing (frame-ancestors 'self' / X-Frame-Options: SAMEORIGIN, core ≥ the release carrying this change) - with older headers the popup renders blank.

Clone this wiki locally