Skip to content

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.

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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.

  • /cart/add.js answers 422 with { 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. Show description and 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.
  • product is null for a card whose product the quiz never received; the filter above drops it.
  • A variant with available: false is left out of the select; a product with no available variant is left out of the kit.
  • 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/CODE with the code from discount_codes[] before the add if the kit should get it.
  • A subscription product’s variants[].selling_plans lists its plans; add selling_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, on quiz.finished.
  • On Tapcart, /cart/add.js does not work; the app’s window.Tapcart.action('cart/add', { lineItems }) takes the same lines with variantId and quantity.