Add a companion product with a quiz pick
Goal: the shopper adds the serum the quiz picked; the page adds the matching applicator to the cart as well, once, without a second click.
Uses: Storefront JS API, cart.add_requested, cart.added, and Shopify’s cart routes /cart.js and /cart/add.js. Nothing to install; no API key. Events fire on the Plus and Enterprise plans.
The two cart events
Section titled “The two cart events”cart.add_requested fires on the add-to-cart click, before the quiz’s own cart call, with items[] of { variant_id, product_id, quantity, product }. It is a notification: it cannot cancel or change the add. On the web that is the only cart event: the quiz’s add goes to Shopify’s /cart/add.js and no cart.added follows. Inside the Tapcart app the quiz adds through Tapcart’s bridge and cart.added fires with the lines it accepted (cart.failed when the bridge threw).
Add the companion on the web
Section titled “Add the companion on the web”The map names the companion variant per recommended product. The handler waits for the quiz’s add to land in the cart (reading /cart.js), then adds the companion through the same route the quiz used, once per companion.
<script> window.octaneai = window.octaneai || []; window.octaneai.push(function (octaneai) { var COMPANIONS = { 'gid://shopify/Product/8692896989320': { variant_id: 40926435967057, quantity: 1 } }; var added = {}; var root = (window.Shopify && window.Shopify.routes && window.Shopify.routes.root) || '/';
function numeric(id) { return Number(String(id).replace(/^.*\//, '')); }
function cartHas(variantId, tries) { return fetch(root + 'cart.js', { credentials: 'same-origin' }) .then(function (r) { return r.json(); }) .then(function (cart) { if (cart.items.some(function (item) { return item.variant_id === variantId; })) return true; if (tries <= 0) return false; return new Promise(function (resolve) { setTimeout(resolve, 500); }).then(function () { return cartHas(variantId, tries - 1); }); }); }
function addCompanion(companion) { return fetch(root + 'cart/add.js', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ items: [{ id: companion.variant_id, quantity: companion.quantity }] }) }).then(function (r) { if (r.status === 422) return r.json().then(function (body) { console.warn('companion not added:', body.description); }); if (r.ok) document.dispatchEvent(new CustomEvent('cart:refresh', { bubbles: true })); }); }
octaneai.on('cart.add_requested', function (event) { event.data.object.items.forEach(function (item) { var companion = COMPANIONS[item.product_id]; if (!companion || added[companion.variant_id]) return; added[companion.variant_id] = true; cartHas(numeric(item.variant_id), 10).then(function (landed) { if (landed) return addCompanion(companion); added[companion.variant_id] = false; }); }); }); });</script>The wait matters: the quiz’s add is asynchronous and can fail (a variant sold out between the page load and the click), and a companion without its product is worse than none. Ten reads half a second apart give the quiz’s add five seconds to land.
On Tapcart
Section titled “On Tapcart”Inside the Tapcart app /cart/add.js does not work; the quiz adds through window.Tapcart.action('cart/add', ...) and reports cart.added once the bridge took the lines, so the companion goes through the same bridge from that event:
window.octaneai = window.octaneai || [];window.octaneai.push(function (octaneai) { var COMPANIONS = { 'gid://shopify/Product/8692896989320': { variant_id: 40926435967057, quantity: 1 } }; var added = {}; octaneai.on('cart.added', function (event) { event.data.object.items.forEach(function (item) { var companion = COMPANIONS[item.product_id]; if (!companion || added[companion.variant_id] || !window.Tapcart) return; added[companion.variant_id] = true; window.Tapcart.action('cart/add', { lineItems: [{ variantId: String(companion.variant_id), quantity: companion.quantity }] }); }); });});The bridge is fire-and-forget: Tapcart shows the result in its native cart, and nothing comes back to the page.
Errors to handle
Section titled “Errors to handle”- A companion that is out of stock:
/cart/add.jsanswers422with{ status, message, description }(“The product ‘Applicator’ is already sold out.”); the handler logs it and moves on, the quiz’s product stays in the cart. On Tapcart the bridge takes the line and the app decides; the page never hears. - The quiz’s own add failed (the recommended variant sold out): the wait ends without the variant in the cart and no companion is added; the quiz shows its own error to the shopper.
/cart.jsand/cart/add.jsare storefront routes: on a page off the store’s domain there is nowindow.Shopify.routes.root, the requests go to a path that does not exist, and nothing is added. The quiz’s own add does not work there either.
Things to know
Section titled “Things to know”- One add per companion per page load:
addedkeeps a second click on the same card, or two cards sharing a companion, from adding it twice. Quantities are the companion’s, not the pick’s. - A shopper who already had the recommended variant in the cart passes the wait at once; if the quiz’s add then fails, the companion is still added. Compare quantities from a
/cart.jsread taken oncart.add_requestedwhen that matters. - The add-all-to-cart button and a multi-item checkout are not reported as
cart.add_requested; only single-card adds trigger the companion. - The companion line carries none of the line-item properties the quiz writes for order attribution, so it is not credited to the quiz in Octane AI’s analytics; the pick is.
- The theme’s cart drawer does not know about the companion until it re-renders;
cart:refreshis one of the events the quiz dispatches after its own add for themes that listen, others update on the next cart page load.
