NB11 · GTM setup · 9 Oct 2026

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, otherwise NB11-A.
  • The label is saved in localStorage nb-arm-v3 and on window.nbArm, and every other page reuses it. Until a visitor reaches the homepage or a main product page it is the theme id 149369520264, 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_device attribute (mobile / tablet / desktop) sits next to it.
  • EU visitors are always labelled NB11-A because 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_view with nb_page: home fires on load, whether or not the visitor scrolls down to the row.
  • Card seen. nb_fp_card_view fires 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_click carries 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, adds nb_source: fp_card and nb_position to its nb_arm_view, then removes nb_fp from 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=), so nb_variant on the click and on the arrival match.

Product page (the switcher)

  • nb_variant_select fires each time the selected variant changes (style tab or colour), after the page has updated. nb_option says which one changed. nb_switcher says which switcher rendered: v4 in B, live in A.
  • nb_atc_click is 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 own add_to_cart, and use nb_atc_click to 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 include nb_arm_view with nb_arm = NB11-A or NB11-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_theme gives 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=N when 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-A on 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:

  1. Point the arm variable at the new key

    Variables → JS - nb-arm from localStorage

    The theme now stores the visitor's arm under nb-arm-v3. Change the key from nb-arm-v2 to nb-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; }
    }
  2. Match the new arm values in the arm-known trigger

    Triggers → All pages - arm known

    Set the condition to: JS - nb-arm from localStorage matches RegEx

    ^NB11-[AB]$

    This keeps the existing GA4 - nb_arm_ping tag firing on every page once the visitor's arm is known. Nothing on the tag itself changes.

  3. 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_product and nb_arm variables.

    nb_pagenb_variantnb_positionnb_sourcenb_variant_titlenb_colornb_optionnb_switcher

    Already there and unchanged: nb_arm, nb_product, nb_style, atc_location.

  4. 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_click and nb_variant_select to the event name RegEx. As of published version 7 (which also has nb_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.

  5. Send the new parameters with the GA4 event tag

    Tags → GA4 - NB PDP events → Event Parameters

    Add one row per new variable. Parameter name = the key, value = its Data Layer Variable:

    • nb_page, nb_variant, nb_position, nb_source
    • nb_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}}.

  6. Register the 8 parameters in GA4

    GA4 → Admin → Custom definitions → Create custom dimension · Scope: Event

    Dimension 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.

  7. 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-v3 until NB11 goes live, so GA4 - nb_arm_ping stays 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.

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.

EventWhen it firesParameters
nb_arm_viewHomepage or main product page loadsnb_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 newA card in the homepage Featured Products row is half on screen for the first time on this pagenb_product, nb_variant, nb_variant_title, nb_position (number, 1 = first card)
nb_fp_card_click newA card in the homepage Featured Products row is clickednb_product, nb_variant, nb_variant_title, nb_position (number, 1 = first card)
nb_variant_select newThe shopper changes style or colour on the product page, once the page has updatednb_variant, nb_variant_title, nb_style, nb_color, nb_option color / style, nb_switcher v4 / v3 / live
nb_style_selectA style option is clickednb_style
nb_atc_clickAdd to cart is clickedatc_location main / sticky, nb_variant = the variant being added
nb_size_guide_openSize guide openednone beyond the common ones
nb_specs_viewBag specs strip half visible, once per pagenone 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 events tab with the header row on the first event. If a tab called events already 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_ID at 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, then https://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=DE sends 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 '?' %}&amp;{% 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>