NB11 GTM Handover
The NB11 test (Featured Products section on the homepage plus the new v4 switcher on the product page) already sends its events into window.dataLayer. This page explains how that tracking works, lists the changes to our container so the events reach GA4, and shows how to check them on the test theme before you publish.
- Container
- GTM-WNJCZ75Z
- Workspace
- new one, e.g. "NB11"
- GA4 property
- NB Shopify - info-fc88
- Measurement ID
- G-F3DJQRMRTZ
Don't publish the Default Workspace. It holds older unpublished changes that aren't part of this. Make these edits in a new workspace (e.g. "NB11") and publish only that one.
How the tracking works
Who is in which arm
- Shopify Rollouts splits visitors 50/50 in 7 markets: US, UK, Canada, Singapore, Hong Kong, Australia and International. EU markets are not in the test.
- Shopify doesn't tell the page which arm a visitor is in, so the theme labels each visitor by what it actually showed them. On the homepage, Featured Products row present =
NB11-B, absent =NB11-A. On the main product page, v4 switcher present =NB11-B, otherwiseNB11-A. - The label is saved in localStorage
nb-arm-v3and onwindow.nbArm, and every other page reuses it. Until a visitor reaches the homepage or a main product page it is the theme id149369520264, meaning not known yet. - The same label goes to every nb_* event, to Microsoft Clarity (custom tag
nb_arm) and to the cart as attribute_nb_theme, which ends up on the order. A_nb_deviceattribute (mobile / tablet / desktop) sits next to it. - EU visitors are always labelled
NB11-Abecause they never see the new parts. Exclude EU countries from any A vs B comparison.
Featured Products on the homepage
Four signals:
- Homepage view.
nb_arm_viewwithnb_page: homefires on load, whether or not the visitor scrolls down to the row. - Card seen.
nb_fp_card_viewfires once per card per page, the first time half of the card is on screen. The carousel's loop copies are ignored. This is the "saw the row" signal: use sessions with at least one card view as the base for the row's click and progression rates. - Card click.
nb_fp_card_clickcarries the product, the variant and the card's position. It fires as the link is followed, so a few can be lost when the browser leaves the page before GTM sends. - Arrival on the product page. Every card link carries
&nb_fp=<position>. The product page reads it, addsnb_source: fp_cardandnb_positionto itsnb_arm_view, then removesnb_fpfrom the address bar so a reload or a shared link isn't counted again. This also works when the card is opened in a new tab, so count arrivals, not clicks, for "homepage to product page through the row". - Cards link to each market's own main product with the colour preselected (
?variant=), sonb_varianton the click and on the arrival match.
Product page (the switcher)
nb_variant_selectfires each time the selected variant changes (style tab or colour), after the page has updated.nb_optionsays which one changed.nb_switchersays which switcher rendered:v4in B,livein A.nb_atc_clickis an Add to cart click (main button or sticky bar) with the variant the form is actually submitting. It's a click, not a confirmed add: count add-to-carts from Shopify's ownadd_to_cart, and usenb_atc_clickto split by arm and by button.- Only the main product section is tracked. Quick views elsewhere on the site are ignored.
Orders and revenue
- Shopify's commerce events in GA4 (add_to_cart, purchase from the Google & YouTube app) don't carry
nb_arm. Splitting the funnel by arm only works in Explorations with session segments: sessions that includenb_arm_viewwithnb_arm=NB11-AorNB11-B, plus the EU exclusion. Standard reports show zeros per arm. - Shopify's own rollout report splits sessions, orders and conversion rate per arm by itself. That is the headline for revenue. The order attribute
_nb_themegives the arm per order. - Ignore the line-item property
_nb_theme. It's left over from an older test and says -B on everything.
What we're measuring
- Featured Products: share of sessions that land on the homepage and go on to view a product page, and how many of those came through the row.
- v4 switcher: add-to-cart rate for sessions that land on a product page. Homepage arrivals are left out because the row steers them.
- Guardrails for both: orders and revenue per visitor.
Things to know
- Our container loads only after the visitor accepts cookies, in the regions where Shopify shows its banner. Both arms lose the same share, but GA4 will count fewer visitors than Shopify's own report.
- Product URLs opened from a card carry
nb_fp=Nwhen the page loads. Shopify's page_view (and your pixel's shopify_page_view) may record it in page_location before the theme removes it. Exclude the parameter if you don't want it in page reports. - If the rollout is paused, everyone is served control and gets relabelled
NB11-Aon their next homepage or product page view. Read full in-test days only. - The row sits low on the homepage, so many homepage visitors never scroll to it.
Setup
Fastest: import the changes in one go
Steps 1 to 5 below are in one import file, built from published version 7: nb11-gtm-import.json (save it as a .json file).
- Admin → Import Container → choose the file → Workspace: New, name it "NB11" → Merge → Overwrite conflicting tags, triggers, and variables.
- Before you confirm, the preview should show: 8 new variables (DLV - nb_page … DLV - nb_switcher); modified: JS - nb-arm from localStorage, the triggers NB PDP events and All pages - arm known, the tag GA4 - NB PDP events (GA4 - nb_arm_ping may also show as modified, its content is the same); nothing deleted.
- If the preview shows a new tag, cancel: a name didn't match and the import would add a second GA4 tag, which would double-count events.
- Then do step 6 (GA4 dimensions) and step 7 (Preview, then publish this workspace only).
Or make the same changes by hand:
-
Point the arm variable at the new key
Variables →JS - nb-arm from localStorageThe theme now stores the visitor's arm under
nb-arm-v3. Change the key fromnb-arm-v2tonb-arm-v3. If you prefer to replace the whole Custom JavaScript, this is the equivalent:function () { try { return localStorage.getItem('nb-arm-v3') || undefined; } catch (e) { return undefined; } } -
Match the new arm values in the arm-known trigger
Triggers →All pages - arm knownSet the condition to:
JS - nb-arm from localStoragematches RegEx^NB11-[AB]$
This keeps the existing
GA4 - nb_arm_pingtag firing on every page once the visitor's arm is known. Nothing on the tag itself changes. -
Add 8 Data Layer Variables
Variables → New → Data Layer Variable (Version 2)Data Layer Variable Name is exactly the key below. Name them the same way as the existing
nb_productandnb_armvariables.nb_pagenb_variantnb_positionnb_sourcenb_variant_titlenb_colornb_optionnb_switcherAlready there and unchanged: nb_arm, nb_product, nb_style, atc_location.
-
Add the two new events to the NB events trigger
Triggers →NB PDP events(Custom Event, uses RegEx)Add
nb_fp_card_view,nb_fp_card_clickandnb_variant_selectto the event name RegEx. As of published version 7 (which also hasnb_home_cta_click) it becomes:^(nb_style_select|nb_size_guide_open|nb_specs_view|nb_atc_click|nb_home_sticky_view|nb_home_sticky_click|nb_arm_view|nb_home_cta_click|nb_fp_card_view|nb_fp_card_click|nb_variant_select)$
If the RegEx already lists more names than this, keep them and only add the three.
-
Send the new parameters with the GA4 event tag
Tags →GA4 - NB PDP events→ Event ParametersAdd one row per new variable. Parameter name = the key, value = its Data Layer Variable:
nb_page,nb_variant,nb_position,nb_sourcenb_variant_title,nb_color,nb_option,nb_switcher
Keep the existing rows (nb_arm, nb_product, nb_style, atc_location). The event name stays
{{Event}}. -
Register the 8 parameters in GA4
GA4 → Admin → Custom definitions → Create custom dimension · Scope: EventDimension name and event parameter both equal the key: nb_page, nb_variant, nb_position, nb_source, nb_variant_title, nb_color, nb_option, nb_switcher. GA4 only fills a dimension from the day it's registered, so do this before the test starts.
-
Check in Preview on the test theme, then publish
Run through the checklist below, then Submit → Publish (version name e.g. "NB11 events")Publishing early is fine. The live store doesn't write
nb-arm-v3until NB11 goes live, soGA4 - nb_arm_pingstays quiet on live until then. No running test needs the old key.
Preview checklist
NB11 is only on our test theme for now. In Tag Assistant, connect to https://nanobag.com/?preview_theme_id=151562584200. A "Previewing" bar appears and the test theme stays on for that browser. Switch market by adding ?country=GB (or US, CA, SG, HK, AU, DE) to any address. If the cookie banner shows, accept it, because the container only loads after consent.
- Homepage, test market (
/?country=GB)Expectnb_arm_viewwithnb_page: home,nb_arm: NB11-B. Both GA4 tags fire. In DevTools, Local Storagenb-arm-v3=NB11-B. - Scroll to "Choose Your Nanobag", then swipe the rowExpect one
nb_fp_card_viewper card the first time half of it is on screen, withnb_product,nb_variant,nb_variant_title(e.g. "Daypack / Black") andnb_position. Swiping back to a card already seen sends nothing. - Click a card in "Choose Your Nanobag"Expect
nb_fp_card_clickwithnb_product,nb_variant,nb_variant_title,nb_position(1–6). On the product page that opens:nb_arm_viewwithnb_page: pdp,nb_source: fp_cardand the samenb_position. Thenb_fpbit then disappears from the address bar, as intended. - Change colour, then styleColour:
nb_variant_selectwithnb_option: color,nb_switcher: v4. Style tab:nb_style_select, thennb_variant_selectwithnb_option: style. Each carries the newnb_variant. - Add to cart, main button and sticky barExpect
nb_atc_clickwithatc_locationmain or sticky, andnb_variantequal to the variant you just picked. - EU control check (
/?country=DE)No "Choose Your Nanobag" section and no card events.nb_arm_viewshowsnb_arm: NB11-A. That's expected, because EU isn't in the test. - GA4 DebugViewThe same events arrive with their parameters.
Event reference
Every event carries nb_arm: NB11-A (control), NB11-B (new section and switcher), or 149369520264 while the visitor hasn't reached the homepage or a main product page yet.
| Event | When it fires | Parameters |
|---|---|---|
nb_arm_view | Homepage or main product page loads | nb_page home / pdp. On pdp also nb_product, nb_variant, and nb_source = fp_card + nb_position when opened from a card |
nb_fp_card_view new | A card in the homepage Featured Products row is half on screen for the first time on this page | nb_product, nb_variant, nb_variant_title, nb_position (number, 1 = first card) |
nb_fp_card_click new | A card in the homepage Featured Products row is clicked | nb_product, nb_variant, nb_variant_title, nb_position (number, 1 = first card) |
nb_variant_select new | The shopper changes style or colour on the product page, once the page has updated | nb_variant, nb_variant_title, nb_style, nb_color, nb_option color / style, nb_switcher v4 / v3 / live |
nb_style_select | A style option is clicked | nb_style |
nb_atc_click | Add to cart is clicked | atc_location main / sticky, nb_variant = the variant being added |
nb_size_guide_open | Size guide opened | none beyond the common ones |
nb_specs_view | Bag specs strip half visible, once per page | none beyond the common ones |
All product page events also carry nb_product (product handle) and nb_variant (variant id selected at that moment).
Google Sheet feed (live-test only)
Your Sheet feed is on the test theme as snippets/nb-vishesh-sheet-test.liquid (commit 77d9c65). It never goes to the live theme. It sends the theme's own card events to your Apps Script URL: one row per nb_fp_card_view and per nb_fp_card_click, under your event names. It waits for analytics consent like every other tracker, and card events from before consent are sent once it arrives. It uses sendBeacon, so clicks still arrive after the browser moves to the product page.
What each request contains
POST to your /exec URL, body is JSON sent as plain text{
"event_name": "featured_collection_impression", // or "featured_collection_click"
"product_handle": "nanobag-uk-1",
"variant_id": "43335990280328",
"variant_title": "Daypack / Black",
"position": 2,
"arm": "NB11-B",
"country": "GB",
"currency": "GBP",
"page": "/"
}Changed from your original: product_id is now product_handle; product_title and price are gone (prices differ by market currency and the variant id identifies the product exactly); position, arm, country, currency and page are new. If you need title or price, tell Shubham and we'll add them to the card events.
Update your Apps Script
Replace everything in Code.gs with this file, then save.
/**
* Nanobag NB11 - Featured Products Sheet feed (live-test only).
* Replace everything in Code.gs with this file.
* The theme sends one POST per card impression or click: JSON in the body, sent as plain text.
*/
// Leave empty if this script is attached to the Sheet (Sheet > Extensions > Apps Script).
// If it is a standalone script, paste the Sheet ID from the Sheet's URL here.
const SHEET_ID = '';
const SHEET_NAME = 'events';
const HEADERS = ['received_at', 'event_name', 'product_handle', 'variant_id', 'variant_title',
'position', 'arm', 'country', 'currency', 'page'];
const ALLOWED = ['featured_collection_impression', 'featured_collection_click'];
function doPost(e) {
const lock = LockService.getScriptLock();
try {
const d = JSON.parse(e.postData.contents);
// The URL is public: anything that isn't one of our two events is dropped
if (ALLOWED.indexOf(d.event_name) === -1 || !/^\d+$/.test(String(d.variant_id))) {
return ContentService.createTextOutput('ignored');
}
// Requests arrive at the same moment; without the lock rows get lost
lock.waitLock(10000);
getSheet_().appendRow([
new Date(), d.event_name, cell_(d.product_handle), cell_(d.variant_id), cell_(d.variant_title),
Number(d.position) || '', cell_(d.arm), cell_(d.country), cell_(d.currency), cell_(d.page)
]);
return ContentService.createTextOutput('ok');
} catch (err) {
console.error(err); // the browser never sees failures: check Executions in Apps Script
return ContentService.createTextOutput('error');
} finally {
lock.releaseLock();
}
}
// Opening the /exec URL in a browser shows this line: a quick check that the deployment is live.
function doGet() {
return ContentService.createTextOutput('NB11 sheet feed running');
}
// The "events" tab, created with its header row the first time if it doesn't exist.
function getSheet_() {
const book = SHEET_ID ? SpreadsheetApp.openById(SHEET_ID) : SpreadsheetApp.getActiveSpreadsheet();
let sheet = book.getSheetByName(SHEET_NAME);
if (!sheet) {
sheet = book.insertSheet(SHEET_NAME);
sheet.appendRow(HEADERS);
sheet.setFrozenRows(1);
}
return sheet;
}
// Cut long values, and stop a leading = + - @ from being read as a Sheets formula.
function cell_(v) {
return String(v == null ? '' : v).slice(0, 200).replace(/^[=+\-@]/, "'$&");
}- The script creates an
eventstab with the header row on the first event. If a tab calledeventsalready exists with your old columns, rename it first (e.g.events_old). - If the script isn't attached to the Sheet (opened from Sheet → Extensions → Apps Script), paste the Sheet ID into
SHEET_IDat the top. - Deploy: Execute as Me, Who has access Anyone. With "Anyone with Google account", most visitors' requests are refused.
- After every code change: Deploy → Manage deployments → Edit → New version. That keeps the same /exec URL, which is written into the theme. A brand-new deployment gets a new URL, and the theme would keep sending to the old one; if that happens, send Shubham the new URL.
- If Google asks you to authorize the script on deploy, allow it: it needs access to the Sheet.
- Check: open your /exec URL in a browser. It should show "NB11 sheet feed running".
- Failures never show in the browser. Check Executions in Apps Script.
Test it
- Open
https://nanobag.com/?preview_theme_id=151562584200, thenhttps://nanobag.com/?country=GB. Accept the cookie banner if it shows. - Scroll to "Choose Your Nanobag": one impression row per card that is half on screen. Swipe the row, then click a card: one click row with that card's variant and position.
/?country=DEsends nothing: the row isn't shown in EU.
GA4 stays the record for the test. Once NB11 is live, every treatment visitor would send about 7 rows (6 impressions and a click), so the Sheet is for testing only.
The code
Source as of commit 89839b9 on the live-test branch. Anything inside {{ }} or {% %} is Liquid, which Shopify fills in before the browser runs the script. For example {{ theme.id }} becomes 149369520264 and {{ product.handle | json }} becomes the product handle in quotes.
Event helper
layout/theme.liquid · lines 71–78
Defines window.nbTrack. Every NB11 event goes through it, and it only pushes to window.dataLayer.
{%- comment -%} NB11 events go to window.dataLayer for the theme's GTM (WNJCZ75Z) only.
Not published as Shopify custom events: the Customer Events pixel container stays clean. {%- endcomment -%}
<script>
window.nbTrack = function (payload) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(payload);
};
</script>Arm label
layout/theme.liquid · lines 829–888
Runs at the end of every page. Decides NB11-A / NB11-B on the homepage and the main product page, saves it, fires nb_arm_view, and reads and removes nb_fp on card arrivals.
{%- comment -%} NB11 test arm - the ONE place the arm is decided. Rollouts tells the
storefront nothing, so the arm is read off what rendered: only the treatment has the
Featured Products section on the homepage ([data-nb11-fp]) and the v4 switcher on the
flagship PDP (.nb-switcher-v4). Only those two pages decide - every other page (gift
product, collections, cart) keeps the stored value, so browsing never relabels a
shopper. Runs here, after content_for_layout, because the homepage check needs the
whole page parsed. EU and other out-of-test markets render neither and read A:
filter them out by country, as in NB10. Next test: bump KEY and the NB11 prefix so a
stored arm can never leak into it. window.nbArm is what every nb_* event and the cart
stamp below send; the bare theme id means "arm unknown". {%- endcomment -%}
<script>
(function () {
var KEY = 'nb-arm-v3';
var arm = '{{ theme.id }}';
try { arm = localStorage.getItem(KEY) || arm; } catch (e) {}
{%- unless request.design_mode %}
var payload = null;
{%- if template.name == 'index' %}
arm = document.querySelector('[data-nb11-fp]') ? 'NB11-B' : 'NB11-A';
payload = { event: 'nb_arm_view', nb_page: 'home' };
{%- elsif template.name == 'product' and template.suffix == blank %}
var picker = document.querySelector('.custom-prod-design-section variant-picker');
if (picker) {
arm = picker.classList.contains('nb-switcher-v4') ? 'NB11-B' : 'NB11-A';
payload = { event: 'nb_arm_view', nb_page: 'pdp', nb_product: {{ product.handle | json }}, nb_variant: '{{ product.selected_or_first_available_variant.id }}' };
// Opened from a Featured Products card: its link carries nb_fp=<position>. Taken out
// of the address bar once read, so a reload, bookmark or shared link isn't counted again.
try {
var url = new URL(location.href);
var fpPos = url.searchParams.get('nb_fp');
if (fpPos) {
payload.nb_source = 'fp_card';
payload.nb_position = Number(fpPos);
url.searchParams.delete('nb_fp');
history.replaceState(history.state, '', url.pathname + url.search + url.hash);
}
} catch (e) {}
}
{%- endif %}
if (payload) {
try { localStorage.setItem(KEY, arm); } catch (e) {}
payload.nb_arm = arm;
window.nbTrack(payload);
}
{%- endunless %}
// Clarity custom tag so recordings and heatmaps filter by arm (queue stub = Clarity's own loader shape)
window.clarity = window.clarity || function () { (window.clarity.q = window.clarity.q || []).push(arguments); };
function apply(v) {
window.nbArm = v;
window.clarity('set', 'nb_arm', v);
}
apply(arm);
// Back/forward cache restores this page without rerunning the script, so a collection
// page opened before the arm was known would keep sending the old value - re-read it.
window.addEventListener('pageshow', function (e) {
if (!e.persisted) return;
try { apply(localStorage.getItem(KEY) || window.nbArm); } catch (err) {}
});
})();
</script>Featured Products: Show section gate and arm marker
sections/featured-products-carousel.liquid · lines 1–9
The row and its CSS/JS only render when "Show section" is ticked (treatment). The hidden span is what the arm script looks for on the homepage.
{%- comment -%} NB11: renders nothing (no CSS, no JS, no arm marker) until "Show section" is
ticked. The rollout treatment ticks it per market; control leaves it off. {%- endcomment -%}
{%- if section.settings.nb11_show -%}
{{ 'section-featured-products-carousel.css' | asset_url | stylesheet_tag }}
<script src="{{ 'featured-products-carousel.js' | asset_url }}" defer="defer"></script>
{%- comment -%} NB11 arm marker: layout/theme.liquid labels a homepage visitor NB11-B when this
exists. Rendered whenever the section is enabled, even if no card resolves below, so an
empty row in some market can't flip a treatment visitor to A. {%- endcomment -%}
<span data-nb11-fp hidden></span>Featured Products: card link
snippets/featured-products-carousel-card.liquid · lines 127–138
Each card links to the market's product with the colour preselected and adds nb_fp=<position>. The data attributes feed the click event.
<div class="card media-card featured-carousel__card">
<a
href="{{ card_url }}{% if product != blank %}{% if card_url contains '?' %}&{% else %}?{% endif %}nb_fp={{ position }}{% endif %}"
class="media-card__link flex flex-col w-full h-full relative"
aria-label="{{ card_title | escape }}{% if colour_label != blank %}, {{ colour_label | escape }}{% endif %}"
{%- if product != blank %}
data-nb-fp-product="{{ product.handle | escape }}"
data-nb-fp-variant="{{ variant.id }}"
data-nb-fp-variant-title="{{ variant.title | escape }}"
data-nb-fp-position="{{ position }}"
{%- endif %}
>Featured Products: card seen and card click
assets/featured-products-carousel.js · lines 342–377
Fires nb_fp_card_click when a card link is clicked (arrows and drags don't count) and nb_fp_card_view once per real card the first time half of it is on screen.
// NB11 test: card clicks to GA through GTM. One listener for every carousel
// on the page. Only the card links carry data-nb-fp-product, so arrows never
// count, and a drag is never a click (the slider stops it before it gets
// here). Nothing is prevented or awaited, so the product page opens as usual.
// window.nbTrack (layout/theme.liquid) sends it to GTM.
// The arrival is reported by the product page itself: the card link
// carries nb_fp=<position> (featured-products-carousel-card.liquid).
const cardFields = (link) => ({
nb_product: link.dataset.nbFpProduct,
nb_variant: link.dataset.nbFpVariant,
nb_variant_title: link.dataset.nbFpVariantTitle || '',
nb_position: Number(link.dataset.nbFpPosition),
nb_arm: window.nbArm || '',
});
document.addEventListener('click', (event) => {
const link = event.target.closest?.('a[data-nb-fp-product]');
if (!link) return;
window.nbTrack({ event: 'nb_fp_card_click', ...cardFields(link) });
});
// NB11 test: a card counts as seen (nb_fp_card_view) once per page, the first
// time half of it is on screen. Only real cards are watched: the loop's copies
// are added later and the scroller always settles back on the real card.
// The ratio is checked because the observer's first report fires for every
// card, with isIntersecting true for even a sliver.
const seen = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.intersectionRatio < 0.5) return;
seen.unobserve(entry.target);
window.nbTrack({ event: 'nb_fp_card_view', ...cardFields(entry.target) });
});
}, { threshold: 0.5 });
document.querySelectorAll('a[data-nb-fp-product]').forEach((link) => {
if (!link.closest('[data-loop-clone]')) seen.observe(link);
});Product page events
snippets/nb-pdp-analytics.liquid · lines 1–130
All product page events: nb_variant_select, nb_style_select, nb_atc_click, nb_size_guide_open, nb_specs_view.
{%- comment -%}
PDP behavioral events for the switcher A/B test. Rendered on BOTH arms with the same
event names, so GA4 compares like-for-like. Sent through window.nbTrack (layout/theme.liquid):
window.dataLayer for GTM-WNJCZ75Z. Every event carries nb_product, nb_variant (the variant selected at
that moment) and nb_arm.
Events:
nb_variant_select { nb_variant_title, nb_style, nb_color, nb_option, nb_switcher }
the selected variant changed (style tab or colour), after the page updated
nb_style_select { nb_product, nb_style } a Style option was clicked (card or flat)
nb_size_guide_open { nb_product } a Size Guide trigger was clicked
nb_specs_view { nb_product } the bag-specs strip became 50% visible (once per pageview)
nb_atc_click { nb_product, atc_location } add-to-cart clicked ('main' or 'sticky')
(nb_arm_view moved to layout/theme.liquid, which decides the NB11 arm for the whole site)
@param {object} product - the PDP product (handle goes on every event)
@param {boolean} tip_gate - IGNORED since the tip text went live in all 7 markets.
Kept so the existing render call stays valid. The arm is no longer derived here.
{%- endcomment -%}
<script>
(function () {
if (window.__nbPdpAnalytics) return;
window.__nbPdpAnalytics = true;
var productHandle = {{ product.handle | json }};
var currentVariant = '{{ product.selected_or_first_available_variant.id }}';
// nb_arm comes from window.nbArm, set by the NB11 arm script in layout/theme.liquid.
// It runs at the end of <body>, after this snippet, so read it when each event fires
// (always a user action or an observer, i.e. later), never at load.
function push(payload) {
payload.nb_product = productHandle;
payload.nb_variant = payload.nb_variant || currentVariant;
payload.nb_arm = window.nbArm || '{{ theme.id }}';
window.nbTrack(payload);
}
function switcherVersion() {
var picker = document.querySelector('.custom-prod-design-section variant-picker');
if (!picker) return '';
if (picker.classList.contains('nb-switcher-v4')) return 'v4';
if (picker.classList.contains('nb-switcher-v3')) return 'v3';
return 'live';
}
// Variant changes - the theme dispatches variant:change (not bubbling) on the main
// product form once the new variant is in place, so listen in the capture phase. Forms
// outside the main product section (quick views) are ignored.
document.addEventListener('variant:change', function (e) {
var v = e.detail && e.detail.variant;
if (!v || !v.id) return;
var t = e.target;
if (t && t.closest && !t.closest('.custom-prod-design-section')) return;
currentVariant = String(v.id);
push({
event: 'nb_variant_select',
nb_variant_title: v.title || '',
nb_style: v.option1 || '',
nb_color: v.option2 || '',
nb_option: e.detail.optionChangedIsColor ? 'color' : 'style',
nb_switcher: switcherVersion()
});
}, true);
// Style selection - option1 is the Style group in both the card and the flat picker
document.addEventListener('change', function (e) {
var input = e.target;
if (!input || input.type !== 'radio' || !input.closest) return;
var fieldset = input.closest('fieldset[data-option-index="option1"]');
if (!fieldset || !fieldset.closest('variant-picker')) return;
push({ event: 'nb_style_select', nb_style: input.value || '' });
});
// Size Guide opens - bubble-phase listener; works because the popup no longer stops propagation
document.addEventListener('click', function (e) {
var t = e.target;
if (t && t.closest && t.closest('[data-cn-sg-open]')) {
push({ event: 'nb_size_guide_open' });
}
});
// The variant the form is about to submit. pdp-variant-shim writes the new id into the
// forms the moment a colour is picked, before variant:change, so a fast tap would
// otherwise report the previous variant. FormData skips the disabled placeholder input.
function submittedVariant(el) {
try {
var form = el.form || el.closest('form');
var id = form && new FormData(form).get('id');
if (id) return String(id);
} catch (err) {}
return currentVariant;
}
// Add-to-cart - sticky checked first so a sticky click never double-counts as main
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) return;
var sticky = t.closest('.product-sticky-form__submit');
if (sticky) {
push({ event: 'nb_atc_click', atc_location: 'sticky', nb_variant: submittedVariant(sticky) });
return;
}
var btn = t.closest('button[name="add"]');
if (btn && btn.closest('form[data-type="add-to-cart-form"]')) {
push({ event: 'nb_atc_click', atc_location: 'main', nb_variant: submittedVariant(btn) });
}
});
// Specs strip viewed - fires once per pageview at 50% visibility; no-op on the control arm
function observeSpecs() {
var el = document.querySelector('.nb-bag-specs');
if (!el || !('IntersectionObserver' in window)) return;
var io = new IntersectionObserver(function (entries) {
for (var i = 0; i < entries.length; i++) {
if (entries[i].isIntersecting) {
push({ event: 'nb_specs_view' });
io.disconnect();
return;
}
}
}, { threshold: 0.5 });
io.observe(el);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', observeSpecs);
} else {
observeSpecs();
}
})();
</script>Arm on the cart and order
layout/theme.liquid · lines 890–950
Writes the arm and device into cart attributes _nb_theme and _nb_device once the cart has items, so every order carries them.
{%- comment -%} A/B attribution, cart-level half: stamp the serving arm as a cart
attribute so Flow can tag orders by test arm. Rollouts serve both arms from the same
theme id, so the id alone can't tell A from B - this stamp sends window.nbArm from
the NB11 arm script above (bare theme id until the visitor has seen the homepage or
the flagship PDP = unattributed). data-cookieconsent="ignore" is required - Cookiebot's auto-blocking rewrites
unmarked inline scripts to text/plain pre-consent, which would punch consent-shaped
holes in the attribution (first-party, no PII - the line-item property in
buy-buttons is the no-JS backstop). The sessionStorage guard keys on the stamped
value, so the stamp upgrades itself once the arm becomes known; skipped while the
cart is empty so bots and bounces never mint carts. {%- endcomment -%}
<script data-cookieconsent="ignore">
(function () {
var KEY = 'nb-theme-stamped';
var stamping = false;
function armValue() {
return window.nbArm || '{{ theme.id }}';
}
function deviceValue() {
{%- comment -%} Bands mirror this theme's layout breakpoints (<768 mobile,
768-991 tablet band, 992+ desktop) so the value means "which layout they saw",
not what the hardware was - Shopify removed clientDetails from the Order API,
the browser is the only witness left for per-device order forensics {%- endcomment -%}
try {
if (window.matchMedia('(min-width: 992px)').matches) return 'desktop';
if (window.matchMedia('(min-width: 768px)').matches) return 'tablet';
return 'mobile';
} catch (e) { return 'unknown'; }
}
function stamp() {
var val = armValue();
var dev = deviceValue();
var stamped = val + '|' + dev;
try { if (sessionStorage.getItem(KEY) === stamped) return; } catch (e) {}
if (stamping) return;
stamping = true;
fetch('/cart.js', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (cart) {
if (!cart.item_count) return;
if (cart.attributes && cart.attributes._nb_theme === val && cart.attributes._nb_device === dev) {
try { sessionStorage.setItem(KEY, stamped); } catch (e) {}
return;
}
return fetch('/cart/update.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ attributes: { _nb_theme: val, _nb_device: dev } })
}).then(function (r) {
if (r.ok) { try { sessionStorage.setItem(KEY, stamped); } catch (e) {} }
});
})
.catch(function () {})
.then(function () { stamping = false; });
}
stamp();
{%- comment -%} AJAX adds don't reload the page and the drawer checkouts straight
from there - the theme fires cart:updated on every cart mutation {%- endcomment -%}
document.addEventListener('cart:updated', stamp);
document.addEventListener('ajaxProduct:added', stamp);
})();
</script>