An editable kit with a live total
Goal: the result page’s products appear as a kit the shopper edits: untick a step, pick another size, change a quantity, see the total move, then add the whole kit with one button.
Uses: Storefront JS API, quiz.finished (products[], terminal_page) and Shopify’s cart route /cart/add.js. Nothing to install; no API key. Events fire on the Plus and Enterprise plans.
Build the kit from the finish
Section titled “Build the kit from the finish”products[] lists every product the session showed; the ones on the result page are those with page_key === terminal_page. Each carries a product object: title, variants[] with variant_id, title, price, available, and selected_variant_id, the variant the card was built for.
<form id="kit" hidden> <ul id="kit-lines"></ul> <p>Total: <strong id="kit-total"></strong></p> <button type="submit">Add the kit to my cart</button> <p id="kit-error" role="alert"></p></form><script> window.octaneai = window.octaneai || []; window.octaneai.push(function (octaneai) { var form = document.getElementById('kit'); var list = document.getElementById('kit-lines'); var totalEl = document.getElementById('kit-total'); var errorEl = document.getElementById('kit-error'); var currency = '';
function text(value) { return String(value).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
function line(product) { var variants = product.variants.filter(function (v) { return v.available; }); if (!variants.length) return ''; var options = variants.map(function (v) { var selected = v.variant_id === product.selected_variant_id ? ' selected' : ''; return '<option value="' + text(v.variant_id) + '" data-price="' + text(v.price) + '"' + selected + '>' + text(v.title) + ' - ' + text(v.price) + '</option>'; }).join(''); return '<li><label><input type="checkbox" name="keep" checked> ' + text(product.title) + '</label> ' + '<select name="variant">' + options + '</select> ' + '<input type="number" name="quantity" value="1" min="1" max="10"></li>'; }
function total() { var sum = 0; list.querySelectorAll('li').forEach(function (li) { if (!li.querySelector('[name=keep]').checked) return; var select = li.querySelector('[name=variant]'); var price = Number(select.options[select.selectedIndex].dataset.price); sum += price * Number(li.querySelector('[name=quantity]').value || 0); }); totalEl.textContent = sum.toFixed(2) + ' ' + currency; }
octaneai.on('quiz.finished', function (event) { var result = event.data.object; var seen = {}; var kit = result.products.filter(function (p) { if (p.page_key !== result.terminal_page || !p.product || seen[p.product_id]) return false; seen[p.product_id] = true; return true; }); if (!kit.length) return; currency = kit[0].product.currency || ''; list.innerHTML = kit.map(function (p) { return line(p.product); }).join(''); form.hidden = false; total(); });
form.addEventListener('input', total); form.addEventListener('submit', function (e) { e.preventDefault(); errorEl.textContent = ''; var items = []; list.querySelectorAll('li').forEach(function (li) { if (!li.querySelector('[name=keep]').checked) return; items.push({ id: Number(li.querySelector('[name=variant]').value.replace(/^.*\//, '')), quantity: Number(li.querySelector('[name=quantity]').value) }); }); if (!items.length) return; var button = form.querySelector('button'); button.disabled = true; var root = (window.Shopify && window.Shopify.routes && window.Shopify.routes.root) || '/'; fetch(root + 'cart/add.js', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ items: items }) }).then(function (response) { return response.json().then(function (body) { if (!response.ok) throw new Error(body.description || body.message || 'Could not add the kit'); window.location.href = root + 'cart'; }); }).catch(function (err) { errorEl.textContent = err.message; button.disabled = false; }); }); });</script>variant_id is a Shopify GID (gid://shopify/ProductVariant/40926435967056); the cart route takes the number at the end, which is what the replace strips it to.
Errors to handle
Section titled “Errors to handle”/cart/add.jsanswers422with{ status, message, description }when any line cannot be added (sold out since the page loaded, a quantity above stock); the whole call is refused, nothing lands. Showdescriptionand let the shopper fix the line; a retry line by line is what the quiz’s own add-all button does when the batch fails.productisnullfor a card whose product the quiz never received; the filter above drops it.- A variant with
available: falseis left out of the select; a product with no available variant is left out of the kit.
Things to know
Section titled “Things to know”- This add is the page’s own, not the quiz’s: it carries none of the line-item properties the quiz writes for order attribution, so orders from the kit are not credited to the quiz in Octane AI’s analytics. Add from the quiz’s cards when attribution matters.
- The quiz’s own add-to-cart applies the quiz’s discount code to the cart when that setting is on; this add does not. Call
/discount/CODEwith the code fromdiscount_codes[]before the add if the kit should get it. - A subscription product’s
variants[].selling_planslists its plans; addselling_plan: <numeric id>to a line to add it as a subscription. - Prices are decimal strings in
currency; the total above is a plain sum, without Shopify’s rounding, tax or automatic discounts. The cart page shows the real total. products[]grows if a rule reveals another card after the finish; the kit is built once, onquiz.finished.- On Tapcart,
/cart/add.jsdoes not work; the app’swindow.Tapcart.action('cart/add', { lineItems })takes the same lines withvariantIdandquantity.
