From 85e4ab90ec1a4f9c0b5ad001f5a23e00bfe26c1c Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Wed, 2 Sep 2026 20:38:31 +0700 Subject: [PATCH 1/9] Fix Ads conversion value, gate on Measurement ID, unify item payloads, add events and tests - Ads conversion sends total_incl_tax (was total_tax) and the snippet now configures the AW- tag so send_to can attribute (#2) - Snippet and tracker do nothing until a Measurement ID is set; tracker calls are guarded so pages without the snippet never throw (#3) - user_id and debug_mode share one config object (#4) - Numeric money fields, per-unit price/discount, one item shape across the funnel, page fields out of items[] (#5) - view_item_list, remove_from_cart, add_shipping_info; Skip Test Orders setting (#6) - Google Ads / Next Commerce naming in labels and README (#7) - Node test harness running tracking.js with platform globals, CI workflow Closes #2, closes #3, closes #4, closes #5, closes #6, closes #7 Co-Authored-By: Claude Fable 5.1 --- .github/workflows/test.yml | 16 +++ CHANGELOG.md | 11 ++ README.md | 46 ++++++-- manifest.json | 23 ++-- package.json | 8 ++ snippets/global-header.html | 16 ++- tests/tracking.test.js | 89 ++++++++++++++ tracking.js | 228 ++++++++++++++++++++++-------------- 8 files changed, 319 insertions(+), 118 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 CHANGELOG.md create mode 100644 package.json create mode 100644 tests/tracking.test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0f58c2e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c171e65 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## Unreleased + +- Google Ads conversions now send the order total as the value (was the tax) and the `AW-` tag is configured in the snippet, so `send_to` can attribute (#2). +- The snippet and tracker are gated on a non-empty Measurement ID; a store that only ticks "Enable" no longer loads `gtag/js?id=` (#3). Tracker calls are guarded so a page without the snippet never throws. +- `debug_mode` and `user_id` are passed in one config object; debug mode works for logged-in shoppers (#4). +- All money fields are numbers, unit `price`/`discount` are derived from line totals, one item shape across the funnel (`item_id` = product id, `sku`, `item_variant`), and page fields are no longer stuffed into `items[]` (#5). +- New events: `view_item_list`, `remove_from_cart`, `add_shipping_info`. New "Skip Test Orders" setting (#6). Consent Mode defaults and `add_payment_info` are not included: the platform has no payment-step event, and consent defaults need a CMP to be useful. +- Settings labels, help text and README say Google Ads and Next Commerce (#7). +- Added a Node test harness (`npm test`) and a CI workflow. diff --git a/README.md b/README.md index 90bc1e4..24bf637 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,45 @@ # Google Analytics 4 -Google Analytics 4 app for 29 Next that integrates Google Analytics 4 into any storefront theme with [Storefront Event Tracking](https://developers.29next.com/docs/themes/event-tracking/). App also includes [Enhanced Ecommerce](https://developers.google.com/analytics/devguides/collection/ga4/ecommerce?client_type=gtag) event tracking using the `gtag()` events. +Google Analytics 4 app for Next Commerce. Installs the Google tag on any storefront theme and sends GA4 ecommerce events through [Storefront Event Tracking](https://developers.nextcommerce.com/docs/storefront/event-tracking). Optionally sends a Google Ads conversion on completed orders. -Also includes Google Adwords Conversion tracking integration with `gtag()` conversion events. +## Settings -**Google Analytics 4** -* [Installs Google Analytics 4](https://support.google.com/analytics/answer/9304153) globally with a setting for your Measurement ID +| Setting | Notes | +|---|---| +| Enable Google Analytics | Nothing loads until a Measurement ID is also set. | +| Google Analytics Measurement ID | `G-XXXXXXXXXX`. | +| Enable Google Ads Conversion Tracking | Configures the `AW-` tag and sends a `conversion` event on `checkout_completed`. | +| Google Ads Conversion ID | Including the `AW-` prefix, e.g. `AW-123456789`. | +| Google Ads Conversion Label | From the Ads conversion action. | +| Enable Debug Mode | Sends `debug_mode` so events show in GA4 DebugView. | +| Skip Test Orders | Suppresses `purchase` and `conversion` for orders flagged `is_test`. | +## Events -**Google Ecommerce Events** -* [Product Detail Impressions](https://developers.google.com/analytics/devguides/collection/ga4/ecommerce?client_type=gtag#view_item_details) -* [Add to Cart](https://developers.google.com/analytics/devguides/collection/ga4/ecommerce?client_type=gtag#add_or_remove_an_item_from_a_shopping_cart) -* [Begin Checkout](https://developers.google.com/analytics/devguides/collection/ga4/ecommerce?client_type=gtag#initiate_the_checkout_process) -* [Purchases](https://developers.google.com/analytics/devguides/collection/ga4/ecommerce?client_type=gtag#make_a_purchase_or_issue_a_refund) +| Storefront event | GA4 event | +|---|---| +| `product_category_viewed` | `view_item_list` | +| `product_viewed` | `view_item` | +| `product_added_to_cart` | `add_to_cart` | +| `product_removed_from_cart` | `remove_from_cart` | +| `checkout_started` | `begin_checkout` | +| `checkout_shipping_method_submitted` | `add_shipping_info` | +| `checkout_completed` | `purchase` (+ Ads `conversion` when enabled) | +`page_view` is sent by the Google tag itself on `config`. -**Google Adwords Conversion Tracking** +Items use one shape across the funnel so GA4 item reports join: `item_id` is the product id, `sku` and `item_variant` identify the child product, `price` and `discount` are per unit, and all money fields are numbers. -* [Google Tag for Google Ads Conversion Tracking](https://support.google.com/google-ads/answer/7548399?hl=en) +## Files +- `manifest.json` — settings schema, snippet location, event tracker mapping. +- `snippets/global-header.html` — loads gtag and configures the GA4 (and optional Ads) tag. Rendered only when a Measurement ID is set. +- `tracking.js` — the event tracker. Runs in the platform's sandboxed tracker frame and calls `gtag` on the storefront window; every call is guarded so a page without the snippet never throws. -See `tracking.js` for a complete detailed view of the implementation. +## Tests -For more information on individual tracking events, see the [Storefront Theme Event Tracking guide](https://developers.29next.com/docs/themes/event-tracking/). +```bash +npm test +``` + +`tests/tracking.test.js` runs `tracking.js` with the same globals the platform provides (`app`, `analytics`, `window.top`) and asserts the payload of every mapped event. No dependencies; Node 20+. diff --git a/manifest.json b/manifest.json index 0003e28..18e35d7 100644 --- a/manifest.json +++ b/manifest.json @@ -18,40 +18,47 @@ "type": "text", "label": "Google Analytics Measurement ID", "default": "", - "help_text": "", + "help_text": "Your GA4 Measurement ID (G-XXXXXXXXXX). The app does nothing until this is set.", "required": false, "max_length": 250 }, { "name": "google_adwords_conversion_enabled", "type": "checkbox", - "label": "Enable Google Adwords Conversion Tracking", - "help_text": "", + "label": "Enable Google Ads Conversion Tracking", + "help_text": "Sends a conversion event to Google Ads when an order is completed.", "default": false }, { "name": "google_adwords_conversion_id", "type": "text", - "label": "Google Adwords Conversion ID", + "label": "Google Ads Conversion ID", "default": "", "required": false, - "help_text": "Required when Google Adwords Conversion Tracking is enabled.", + "help_text": "Including the AW- prefix, e.g. AW-123456789. Required when Google Ads Conversion Tracking is enabled.", "max_length": 250 }, { "name": "google_adwords_conversion_label", "type": "text", - "label": "Google Adwords Conversion Label", + "label": "Google Ads Conversion Label", "default": "", "required": false, - "help_text": "Required when Google Adwords Conversion Tracking is enabled.", + "help_text": "Required when Google Ads Conversion Tracking is enabled.", "max_length": 250 }, { "name": "google_analytics_debug_mode", "type": "checkbox", "label": "Enable Debug Mode", - "help_text": "Debug mode allows you to monitor transactions in real time.", + "help_text": "Sends debug_mode so events appear in GA4 DebugView in real time.", + "default": false + }, + { + "name": "google_analytics_skip_test_orders", + "type": "checkbox", + "label": "Skip Test Orders", + "help_text": "Do not send purchase or conversion events for test orders.", "default": false } ] diff --git a/package.json b/package.json new file mode 100644 index 0000000..295504c --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "google-analytics-4", + "private": true, + "description": "Google Analytics 4 app for Next Commerce storefronts", + "scripts": { + "test": "node --test" + } +} diff --git a/snippets/global-header.html b/snippets/global-header.html index 98c9eb2..ae3c464 100644 --- a/snippets/global-header.html +++ b/snippets/global-header.html @@ -1,4 +1,4 @@ -{% if app.settings.google_analytics_enabled %} +{% if app.settings.google_analytics_enabled and app.settings.google_analytics_measurement_id %} @@ -7,10 +7,14 @@ function gtag() { dataLayer.push(arguments); } gtag('js', new Date()); - gtag('config', '{{ app.settings.google_analytics_measurement_id }}', - {% if user.is_authenticated %} { 'user_id': '{{ user.pk }}' }, {% endif %} - {% if app.settings.google_analytics_debug_mode %} { 'debug_mode': true }, {% endif %} - ); + gtag('config', '{{ app.settings.google_analytics_measurement_id }}', { + {% if user.is_authenticated %}'user_id': '{{ user.pk }}',{% endif %} + {% if app.settings.google_analytics_debug_mode %}'debug_mode': true,{% endif %} + }); + + {% if app.settings.google_adwords_conversion_enabled and app.settings.google_adwords_conversion_id %} + gtag('config', '{{ app.settings.google_adwords_conversion_id }}'); + {% endif %} -{% endif %} \ No newline at end of file +{% endif %} diff --git a/tests/tracking.test.js b/tests/tracking.test.js new file mode 100644 index 0000000..f2333cf --- /dev/null +++ b/tests/tracking.test.js @@ -0,0 +1,89 @@ +// Runs tracking.js the way the platform does: `app`, `analytics` and `window` are globals of the +// tracker frame, and gtag lives on window.top. Payload samples follow developer-docs +// content/docs/storefront/event-tracking.mdx. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '..', 'tracking.js'), 'utf8'); +// Objects built inside the vm have a different Object prototype; compare them as plain JSON. +const plain = value => JSON.parse(JSON.stringify(value)); + +function boot(settings, { withGtag = true } = {}) { + const calls = []; + const handlers = {}; + const top = { document: { title: 'Sheets | Velin' } }; + if (withGtag) top.gtag = (...args) => calls.push(args); + const context = { + app: { settings: { google_analytics_enabled: true, google_analytics_measurement_id: 'G-TEST', ...settings } }, + analytics: { subscribe: (name, fn) => { handlers[name] = fn; } }, + window: { top }, + }; + vm.runInNewContext(source, context); + return { calls, emit: (name, data) => handlers[name] && handlers[name]({ event_type: name, data }), handlers }; +} + +const line = { currency: 'USD', product_id: 111, sku: 'WATCH-BL', product_title: 'Timeless Watch', variant_title: 'Black', quantity: 2, price_incl_tax: '159.98', total_discount: '10.00', is_upsell: false }; +const checkout = { number: '109659', currency: 'USD', total_incl_tax: '164.97', shipping_incl_tax: '4.99', total_tax: '0.00', shipping_method: 'Express', voucher_discounts: [{ name: 'SAVE10' }], lines: [line], is_test: false }; + +test('does nothing without a measurement id', () => { + const { handlers } = boot({ google_analytics_measurement_id: '' }); + assert.deepEqual(Object.keys(handlers), []); +}); + +test('never throws when gtag is missing from the storefront window', () => { + const { emit } = boot({}, { withGtag: false }); + assert.doesNotThrow(() => emit('product_added_to_cart', line)); +}); + +test('add_to_cart sends numeric unit price and one item shape', () => { + const { calls, emit } = boot({}); + emit('product_added_to_cart', line); + const [, name, params] = calls[0]; + assert.equal(name, 'add_to_cart'); + assert.equal(params.value, 159.98); + assert.deepEqual(plain(params.items[0]), { item_id: '111', item_name: 'Timeless Watch', sku: 'WATCH-BL', item_variant: 'Black', price: 79.99, discount: 5, quantity: 2, index: 0 }); +}); + +test('remove_from_cart, view_item_list and add_shipping_info are mapped', () => { + const { calls, emit } = boot({}); + emit('product_removed_from_cart', line); + emit('product_category_viewed', [{ id: 5, title: 'Sheets', purchase_info: { price: { currency: 'USD', price: '109.99' } } }]); + emit('checkout_shipping_method_submitted', checkout); + assert.deepEqual(calls.map(c => c[1]), ['remove_from_cart', 'view_item_list', 'add_shipping_info']); + assert.equal(calls[1][2].items[0].price, 109.99); + assert.equal(calls[2][2].shipping_tier, 'Express'); +}); + +test('view_item uses the same item identity as the cart events', () => { + const { calls, emit } = boot({}); + emit('product_viewed', { id: 111, title: 'Timeless Watch', categories: [{ name: 'Watches' }], variants: [{ sku: 'WATCH-BL' }], purchase_info: { price: { currency: 'USD', price: '79.99' } } }); + assert.equal(calls[0][2].items[0].item_id, '111'); + assert.equal(calls[0][2].items[0].sku, 'WATCH-BL'); + assert.equal(calls[0][2].items[0].item_category, 'Watches'); + assert.equal(calls[0][2].value, 79.99); +}); + +test('purchase carries numeric totals and the Ads conversion uses the order total', () => { + const { calls, emit } = boot({ google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'AW-123', google_adwords_conversion_label: 'abc' }); + emit('checkout_completed', checkout); + const purchase = calls.find(c => c[1] === 'purchase')[2]; + const conversion = calls.find(c => c[1] === 'conversion')[2]; + assert.equal(purchase.transaction_id, '109659'); + assert.equal(purchase.value, 164.97); + assert.equal(purchase.shipping, 4.99); + assert.equal(purchase.tax, 0); + assert.equal(purchase.coupon, 'SAVE10'); + assert.deepEqual(plain(conversion), { send_to: 'AW-123/abc', transaction_id: '109659', value: 164.97, currency: 'USD' }); +}); + +test('test orders are skipped only when the setting is on', () => { + const on = boot({ google_analytics_skip_test_orders: true }); + on.emit('checkout_completed', { ...checkout, is_test: true }); + assert.equal(on.calls.length, 0); + const off = boot({}); + off.emit('checkout_completed', { ...checkout, is_test: true }); + assert.equal(off.calls.length, 1); +}); diff --git a/tracking.js b/tracking.js index 605e79f..7e874d7 100644 --- a/tracking.js +++ b/tracking.js @@ -1,108 +1,154 @@ -if (app.settings.google_analytics_enabled) { +// Google Analytics 4 storefront event tracker. +// Runs in the platform's sandboxed tracker frame; gtag lives on the storefront window (window.top), +// installed by snippets/global-header.html. Every gtag call goes through send() so a page without the +// snippet (no Measurement ID, or a theme without the global_header hook) never throws. +if (app.settings.google_analytics_enabled && app.settings.google_analytics_measurement_id) { (function () { - - let prepareLineItems = (event) => { - let result = [] - event.data?.lines?.forEach((line, _) => { - result.push({ - item_id: line.product_id, - item_name: line.product_title, - item_variant: line.variant_title, - discount: line.total_discount, - price: line.price_incl_tax, - quantity: line.quantity - }) - }); - return result; - } - - analytics.subscribe('product_viewed', (event) => { - window.top.gtag('event', 'view_item', { - currency: event.data?.purchase_info?.price?.currency, - value: event.data?.purchase_info?.price?.price, - items: [ - { - item_id: event.data?.id, - item_name: event.data?.title, - sku: event.data?.sku, - item_category: event.data?.categories?.length ? event.data.categories[0].name : "", - price: event.data?.purchase_info?.price?.price, - quantity: 1, - page_path: window.top.location.pathname, - page_title: window.top.document.title, - page_location: window.top.document.location.href, - page_referrer: window.top.document.referrer - } - ] - }); + var settings = app.settings; + + var send = function () { + var top = window.top; + if (top && typeof top.gtag === 'function') { + top.gtag.apply(top, arguments); + } + }; + + // GA4 expects numbers; the storefront payload carries decimal strings ("79.99"). + var num = function (value) { + var n = parseFloat(value); + return isNaN(n) ? undefined : n; + }; + + var round = function (value) { + return value === undefined ? undefined : Math.round(value * 100) / 100; + }; + + var coupon = function (data) { + var vouchers = data && data.voucher_discounts; + return vouchers && vouchers.length ? vouchers[0].name : ''; + }; + + // One item shape for the whole funnel so item-scoped reports join across events: + // item_id is the product id everywhere, sku and item_variant identify the child. + var productItem = function (product, index) { + var price = product && product.purchase_info && product.purchase_info.price; + var variants = product && product.variants; + var category = product && product.categories && product.categories.length ? product.categories[0].name : undefined; + return { + item_id: String(product.id), + item_name: product.title, + sku: product.sku || (variants && variants.length ? variants[0].sku : undefined), + item_category: category, + price: num(price && price.price), + quantity: 1, + index: index + }; + }; + + var cartLineItem = function (line, index) { + var quantity = num(line.quantity) || 1; + var lineTotal = num(line.price_incl_tax); + var lineDiscount = num(line.total_discount); + return { + item_id: String(line.product_id), + item_name: line.product_title, + sku: line.sku || undefined, + item_variant: line.variant_title || undefined, + price: round(lineTotal === undefined ? undefined : lineTotal / quantity), + discount: round(lineDiscount === undefined ? undefined : lineDiscount / quantity), + quantity: quantity, + index: index + }; + }; + + var checkoutItems = function (data) { + return ((data && data.lines) || []).map(cartLineItem); + }; + + var currencyOf = function (data) { + return data && data.currency; + }; + + analytics.subscribe('product_category_viewed', function (event) { + var products = Array.isArray(event.data) ? event.data : []; + if (!products.length) { return; } + var first = products[0].purchase_info && products[0].purchase_info.price; + send('event', 'view_item_list', { + item_list_name: window.top && window.top.document ? window.top.document.title : undefined, + currency: first && first.currency, + items: products.map(productItem) + }); }); - analytics.subscribe('product_added_to_cart', (event) => { - window.top.gtag('event', 'add_to_cart', { - currency: event.data?.currency, - value: event.data?.price_incl_tax, - items: [ - { - item_id: event.data?.product_id, - item_name: event.data?.product_title, - sku: event.data?.sku, - price: (event.data?.price_incl_tax / event.data?.quantity).toFixed(2), - quantity: event.data?.quantity, - page_path: window.top.location.pathname, - page_title: window.top.document.title, - page_location: window.top.document.location.href, - page_referrer: window.top.document.referrer - } - ] + analytics.subscribe('product_viewed', function (event) { + var product = event.data || {}; + var price = product.purchase_info && product.purchase_info.price; + send('event', 'view_item', { + currency: price && price.currency, + value: num(price && price.price), + items: [productItem(product, 0)] }); }); - analytics.subscribe('checkout_started', (event) => { - window.top.gtag('event', 'begin_checkout', { - currency: event.data?.currency, - value: event.data?.total_incl_tax, - coupon: event.data?.voucher_discounts?.length ? event.data?.voucher_discounts[0].name : "", - items: prepareLineItems(event), - page_path: window.top.location.pathname, - page_title: window.top.document.title, - page_location: window.top.document.location.href, - page_referrer: window.top.document.referrer + var cartLineEvent = function (name) { + return function (event) { + var line = event.data || {}; + send('event', name, { + currency: currencyOf(line), + value: num(line.price_incl_tax), + items: [cartLineItem(line, 0)] + }); + }; + }; + + analytics.subscribe('product_added_to_cart', cartLineEvent('add_to_cart')); + analytics.subscribe('product_removed_from_cart', cartLineEvent('remove_from_cart')); + + analytics.subscribe('checkout_started', function (event) { + var data = event.data || {}; + send('event', 'begin_checkout', { + currency: currencyOf(data), + value: num(data.total_incl_tax), + coupon: coupon(data), + items: checkoutItems(data) }); }); - analytics.subscribe('checkout_completed', (event) => { - window.top.gtag('event', 'purchase', { - currency: event.data?.currency, - value: event.data?.total_incl_tax, - transaction_id: event.data?.number, - coupon: event.data?.voucher_discounts?.length ? event.data?.voucher_discounts[0].name : "", - shipping: event.data?.shipping_incl_tax, - tax: event.data?.total_tax, - items: prepareLineItems(event), - page_path: window.top.location.pathname, - page_title: window.top.document.title, - page_location: window.top.document.location.href, - page_referrer: window.top.document.referrer + analytics.subscribe('checkout_shipping_method_submitted', function (event) { + var data = event.data || {}; + send('event', 'add_shipping_info', { + currency: currencyOf(data), + value: num(data.total_incl_tax), + coupon: coupon(data), + shipping_tier: data.shipping_method || undefined, + items: checkoutItems(data) }); }); - if (app.settings.google_adwords_conversion_enabled) { - - analytics.subscribe('checkout_completed', (event) => { - adwordsAccount = app.settings.google_adwords_conversion_id + "/" + app.settings.google_adwords_conversion_label; - window.top.gtag('event', 'conversion', { - send_to: adwordsAccount, - transaction_id: event.data?.number, - value: event.data?.total_tax, - currency: event.data?.currency, - page_path: window.top.location.pathname, - page_title: window.top.document.title, - page_location: window.top.document.location.href, - page_referrer: window.top.document.referrer - }); + analytics.subscribe('checkout_completed', function (event) { + var data = event.data || {}; + if (data.is_test && settings.google_analytics_skip_test_orders) { return; } + + send('event', 'purchase', { + currency: currencyOf(data), + value: num(data.total_incl_tax), + transaction_id: data.number, + coupon: coupon(data), + shipping: num(data.shipping_incl_tax), + tax: num(data.total_tax), + items: checkoutItems(data) }); - } + + if (settings.google_adwords_conversion_enabled && settings.google_adwords_conversion_id && settings.google_adwords_conversion_label) { + send('event', 'conversion', { + send_to: settings.google_adwords_conversion_id + '/' + settings.google_adwords_conversion_label, + transaction_id: data.number, + value: num(data.total_incl_tax), + currency: currencyOf(data) + }); + } + }); })(); } From bd759692ae9f88b664b62fa36825c57e6682339f Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Wed, 2 Sep 2026 20:51:11 +0700 Subject: [PATCH 2/9] Address review: escape snippet values, guard category entries, drop arbitrary sku, validate AW- id - escapejs/urlencode on every setting interpolated into the snippet's JS - product_category_viewed skips null entries; list identified by path + title - view_item only names a sku when the product has one or a single variant - unit price/discount left undefined when quantity is not positive - Ads conversion requires an AW- id - tests for begin_checkout (two lines), null category entries, missing quantity, and a bare Ads id Co-Authored-By: Claude Fable 5.1 --- snippets/global-header.html | 8 ++++---- tests/tracking.test.js | 41 ++++++++++++++++++++++++++++++++++--- tracking.js | 36 +++++++++++++++++++++----------- 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/snippets/global-header.html b/snippets/global-header.html index ae3c464..b0f2ccb 100644 --- a/snippets/global-header.html +++ b/snippets/global-header.html @@ -1,19 +1,19 @@ {% if app.settings.google_analytics_enabled and app.settings.google_analytics_measurement_id %} - + diff --git a/tests/tracking.test.js b/tests/tracking.test.js index f2333cf..cf55eea 100644 --- a/tests/tracking.test.js +++ b/tests/tracking.test.js @@ -14,7 +14,7 @@ const plain = value => JSON.parse(JSON.stringify(value)); function boot(settings, { withGtag = true } = {}) { const calls = []; const handlers = {}; - const top = { document: { title: 'Sheets | Velin' } }; + const top = { document: { title: 'Sheets | Velin' }, location: { pathname: '/c/sheets/' } }; if (withGtag) top.gtag = (...args) => calls.push(args); const context = { app: { settings: { google_analytics_enabled: true, google_analytics_measurement_id: 'G-TEST', ...settings } }, @@ -57,11 +57,46 @@ test('remove_from_cart, view_item_list and add_shipping_info are mapped', () => assert.equal(calls[2][2].shipping_tier, 'Express'); }); +test('begin_checkout maps a multi-line cart with coupon and numeric value', () => { + const { calls, emit } = boot({}); + const second = { ...line, product_id: 222, sku: 'PILLOW', product_title: 'Pillow Cover', variant_title: '', quantity: 1, price_incl_tax: '39.99', total_discount: '0.00' }; + emit('checkout_started', { ...checkout, lines: [line, second] }); + const [, name, params] = calls[0]; + assert.equal(name, 'begin_checkout'); + assert.equal(params.value, 164.97); + assert.equal(params.currency, 'USD'); + assert.equal(params.coupon, 'SAVE10'); + assert.deepEqual(plain(params.items), [ + { item_id: '111', item_name: 'Timeless Watch', sku: 'WATCH-BL', item_variant: 'Black', price: 79.99, discount: 5, quantity: 2, index: 0 }, + { item_id: '222', item_name: 'Pillow Cover', sku: 'PILLOW', price: 39.99, discount: 0, quantity: 1, index: 1 }, + ]); +}); + +test('category lists skip null entries and identify the list by path', () => { + const { calls, emit } = boot({}); + emit('product_category_viewed', [null, { id: 5, title: 'Sheets', purchase_info: { price: { currency: 'USD', price: '109.99' } } }]); + assert.equal(calls[0][2].items.length, 1); + assert.equal(calls[0][2].item_list_id, '/c/sheets/'); +}); + +test('a missing quantity leaves unit price undefined instead of inventing one', () => { + const { calls, emit } = boot({}); + emit('product_added_to_cart', { ...line, quantity: undefined }); + assert.equal(calls[0][2].items[0].price, undefined); + assert.equal(calls[0][2].items[0].quantity, undefined); +}); + +test('Ads conversion is skipped when the id lacks the AW- prefix', () => { + const { calls, emit } = boot({ google_adwords_conversion_enabled: true, google_adwords_conversion_id: '123', google_adwords_conversion_label: 'abc' }); + emit('checkout_completed', checkout); + assert.deepEqual(calls.map(c => c[1]), ['purchase']); +}); + test('view_item uses the same item identity as the cart events', () => { const { calls, emit } = boot({}); - emit('product_viewed', { id: 111, title: 'Timeless Watch', categories: [{ name: 'Watches' }], variants: [{ sku: 'WATCH-BL' }], purchase_info: { price: { currency: 'USD', price: '79.99' } } }); + emit('product_viewed', { id: 111, title: 'Timeless Watch', categories: [{ name: 'Watches' }], variants: [{ sku: 'WATCH-BL' }, { sku: 'WATCH-BR' }], purchase_info: { price: { currency: 'USD', price: '79.99' } } }); assert.equal(calls[0][2].items[0].item_id, '111'); - assert.equal(calls[0][2].items[0].sku, 'WATCH-BL'); + assert.equal(calls[0][2].items[0].sku, undefined, 'multi-variant product: viewed variant unknown, sku omitted'); assert.equal(calls[0][2].items[0].item_category, 'Watches'); assert.equal(calls[0][2].value, 79.99); }); diff --git a/tracking.js b/tracking.js index 7e874d7..78fa930 100644 --- a/tracking.js +++ b/tracking.js @@ -31,14 +31,16 @@ if (app.settings.google_analytics_enabled && app.settings.google_analytics_measu // One item shape for the whole funnel so item-scoped reports join across events: // item_id is the product id everywhere, sku and item_variant identify the child. + // A product payload only names a sku when it has one (or a single variant); on a + // multi-variant PDP the viewed variant is not in the payload, so sku is omitted. var productItem = function (product, index) { - var price = product && product.purchase_info && product.purchase_info.price; - var variants = product && product.variants; - var category = product && product.categories && product.categories.length ? product.categories[0].name : undefined; + var price = product.purchase_info && product.purchase_info.price; + var variants = product.variants; + var category = product.categories && product.categories.length ? product.categories[0].name : undefined; return { item_id: String(product.id), item_name: product.title, - sku: product.sku || (variants && variants.length ? variants[0].sku : undefined), + sku: product.sku || (variants && variants.length === 1 ? variants[0].sku : undefined), item_category: category, price: num(price && price.price), quantity: 1, @@ -46,17 +48,20 @@ if (app.settings.google_analytics_enabled && app.settings.google_analytics_measu }; }; + // Unit price/discount are derived from line totals; when the payload has no positive + // quantity they are left undefined rather than invented. var cartLineItem = function (line, index) { - var quantity = num(line.quantity) || 1; - var lineTotal = num(line.price_incl_tax); - var lineDiscount = num(line.total_discount); + var quantity = num(line.quantity); + var perUnit = function (total) { + return total === undefined || !(quantity > 0) ? undefined : round(total / quantity); + }; return { item_id: String(line.product_id), item_name: line.product_title, sku: line.sku || undefined, item_variant: line.variant_title || undefined, - price: round(lineTotal === undefined ? undefined : lineTotal / quantity), - discount: round(lineDiscount === undefined ? undefined : lineDiscount / quantity), + price: perUnit(num(line.price_incl_tax)), + discount: perUnit(num(line.total_discount)), quantity: quantity, index: index }; @@ -70,12 +75,18 @@ if (app.settings.google_analytics_enabled && app.settings.google_analytics_measu return data && data.currency; }; + // The category payload carries the products only (no category object), so the list is + // identified by the page: path as the stable id, title as the display name. analytics.subscribe('product_category_viewed', function (event) { - var products = Array.isArray(event.data) ? event.data : []; + var products = (Array.isArray(event.data) ? event.data : []).filter(function (product) { + return product && product.id !== undefined; + }); if (!products.length) { return; } var first = products[0].purchase_info && products[0].purchase_info.price; + var top = window.top || {}; send('event', 'view_item_list', { - item_list_name: window.top && window.top.document ? window.top.document.title : undefined, + item_list_id: top.location ? top.location.pathname : undefined, + item_list_name: top.document ? top.document.title : undefined, currency: first && first.currency, items: products.map(productItem) }); @@ -140,7 +151,8 @@ if (app.settings.google_analytics_enabled && app.settings.google_analytics_measu items: checkoutItems(data) }); - if (settings.google_adwords_conversion_enabled && settings.google_adwords_conversion_id && settings.google_adwords_conversion_label) { + // send_to needs the AW- form; a bare numeric id would fail silently in Ads. + if (settings.google_adwords_conversion_enabled && /^AW-\d+$/.test(settings.google_adwords_conversion_id || '') && settings.google_adwords_conversion_label) { send('event', 'conversion', { send_to: settings.google_adwords_conversion_id + '/' + settings.google_adwords_conversion_label, transaction_id: data.number, From 3c9d8ece2db56dd3efb69707532032a977e55b04 Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Wed, 2 Sep 2026 20:59:38 +0700 Subject: [PATCH 3/9] Skip products whose id is null or undefined in view_item_list and view_item Co-Authored-By: Claude Fable 5.1 --- tests/tracking.test.js | 9 ++++++++- tracking.js | 5 +++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/tracking.test.js b/tests/tracking.test.js index cf55eea..31776eb 100644 --- a/tests/tracking.test.js +++ b/tests/tracking.test.js @@ -74,7 +74,7 @@ test('begin_checkout maps a multi-line cart with coupon and numeric value', () = test('category lists skip null entries and identify the list by path', () => { const { calls, emit } = boot({}); - emit('product_category_viewed', [null, { id: 5, title: 'Sheets', purchase_info: { price: { currency: 'USD', price: '109.99' } } }]); + emit('product_category_viewed', [null, { id: null, title: 'Broken' }, { id: 5, title: 'Sheets', purchase_info: { price: { currency: 'USD', price: '109.99' } } }]); assert.equal(calls[0][2].items.length, 1); assert.equal(calls[0][2].item_list_id, '/c/sheets/'); }); @@ -92,6 +92,13 @@ test('Ads conversion is skipped when the id lacks the AW- prefix', () => { assert.deepEqual(calls.map(c => c[1]), ['purchase']); }); +test('view_item is skipped when the payload has no id', () => { + const { calls, emit } = boot({}); + emit('product_viewed', undefined); + emit('product_viewed', { title: 'No id' }); + assert.equal(calls.length, 0); +}); + test('view_item uses the same item identity as the cart events', () => { const { calls, emit } = boot({}); emit('product_viewed', { id: 111, title: 'Timeless Watch', categories: [{ name: 'Watches' }], variants: [{ sku: 'WATCH-BL' }, { sku: 'WATCH-BR' }], purchase_info: { price: { currency: 'USD', price: '79.99' } } }); diff --git a/tracking.js b/tracking.js index 78fa930..f90e6ff 100644 --- a/tracking.js +++ b/tracking.js @@ -79,7 +79,7 @@ if (app.settings.google_analytics_enabled && app.settings.google_analytics_measu // identified by the page: path as the stable id, title as the display name. analytics.subscribe('product_category_viewed', function (event) { var products = (Array.isArray(event.data) ? event.data : []).filter(function (product) { - return product && product.id !== undefined; + return product && product.id != null; }); if (!products.length) { return; } var first = products[0].purchase_info && products[0].purchase_info.price; @@ -93,7 +93,8 @@ if (app.settings.google_analytics_enabled && app.settings.google_analytics_measu }); analytics.subscribe('product_viewed', function (event) { - var product = event.data || {}; + var product = event.data; + if (!product || product.id == null) { return; } var price = product.purchase_info && product.purchase_info.price; send('event', 'view_item', { currency: price && price.currency, From 9cd68421945388c32c14a4b97cc31a5fb35d38e5 Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Wed, 2 Sep 2026 21:04:57 +0700 Subject: [PATCH 4/9] Parity with the GTM review: trim the measurement id in both gates, drop cart events without a line, Node version in README Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- snippets/global-header.html | 6 +++--- tests/tracking.test.js | 11 +++++++++++ tracking.js | 6 ++++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 24bf637..8a43394 100644 --- a/README.md +++ b/README.md @@ -42,4 +42,4 @@ Items use one shape across the funnel so GA4 item reports join: `item_id` is the npm test ``` -`tests/tracking.test.js` runs `tracking.js` with the same globals the platform provides (`app`, `analytics`, `window.top`) and asserts the payload of every mapped event. No dependencies; Node 20+. +`tests/tracking.test.js` runs `tracking.js` with the same globals the platform provides (`app`, `analytics`, `window.top`) and asserts the payload of every mapped event. No dependencies; Node 22, the version CI runs. diff --git a/snippets/global-header.html b/snippets/global-header.html index b0f2ccb..e95f646 100644 --- a/snippets/global-header.html +++ b/snippets/global-header.html @@ -1,13 +1,13 @@ -{% if app.settings.google_analytics_enabled and app.settings.google_analytics_measurement_id %} +{% if app.settings.google_analytics_enabled and app.settings.google_analytics_measurement_id.strip %} - + diff --git a/tests/tracking.test.js b/tests/tracking.test.js index 47bcabd..82390f0 100644 --- a/tests/tracking.test.js +++ b/tests/tracking.test.js @@ -1,6 +1,6 @@ // Runs tracking.js the way the platform does: `app`, `analytics` and `window` are globals of the -// tracker frame, and gtag lives on window.top. Payload samples follow developer-docs -// content/docs/storefront/event-tracking.mdx. +// tracker frame, and gtag lives on the parent (storefront) window. Payload samples follow the +// storefront event-tracking reference: https://developers.nextcommerce.com/docs/storefront/event-tracking const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -11,30 +11,62 @@ const source = fs.readFileSync(path.join(__dirname, '..', 'tracking.js'), 'utf8' // Objects built inside the vm have a different Object prototype; compare them as plain JSON. const plain = value => JSON.parse(JSON.stringify(value)); -function boot(settings, { withGtag = true } = {}) { +function boot(settings, { parent } = {}) { const calls = []; const handlers = {}; - const top = { document: { title: 'Sheets | Velin' }, location: { pathname: '/c/sheets/' } }; - if (withGtag) top.gtag = (...args) => calls.push(args); + const storefront = parent === undefined + ? { document: { title: 'Sheets | Velin' }, location: { pathname: '/c/sheets/' }, gtag: (...args) => calls.push(args) } + : parent; const context = { app: { settings: { google_analytics_enabled: true, google_analytics_measurement_id: 'G-TEST', ...settings } }, analytics: { subscribe: (name, fn) => { handlers[name] = fn; } }, - window: { top }, + window: { parent: storefront }, }; vm.runInNewContext(source, context); return { calls, emit: (name, data) => handlers[name] && handlers[name]({ event_type: name, data }), handlers }; } +const names = calls => calls.map(c => c[1]); const line = { currency: 'USD', product_id: 111, sku: 'WATCH-BL', product_title: 'Timeless Watch', variant_title: 'Black', quantity: 2, price_incl_tax: '159.98', total_discount: '10.00', is_upsell: false }; -const checkout = { number: '109659', currency: 'USD', total_incl_tax: '164.97', shipping_incl_tax: '4.99', total_tax: '0.00', shipping_method: 'Express', voucher_discounts: [{ name: 'SAVE10' }], lines: [line], is_test: false }; +const second = { ...line, product_id: 222, sku: 'PILLOW', product_title: 'Pillow Cover', variant_title: '', quantity: 1, price_incl_tax: '39.99', total_discount: '0.00' }; +const checkout = { number: '109659', currency: 'USD', total_incl_tax: '204.96', shipping_incl_tax: '4.99', total_tax: '0.00', shipping_method: 'Express', voucher_discounts: [{ name: 'SAVE10' }], lines: [line, second], is_test: false }; +const product = { id: 111, title: 'Timeless Watch', categories: [{ name: 'Watches' }], variants: [{ sku: 'WATCH-BL' }], purchase_info: { price: { currency: 'USD', price: '79.99' } } }; + +test('the tracker stays off when disabled or when the measurement id is empty, whitespace or missing', () => { + for (const settings of [{ google_analytics_enabled: false }, { google_analytics_measurement_id: '' }, { google_analytics_measurement_id: ' ' }, { google_analytics_measurement_id: null }, { google_analytics_measurement_id: undefined }]) { + assert.deepEqual(Object.keys(boot(settings).handlers), [], JSON.stringify(settings)); + } +}); + +test('never throws when the parent has no gtag, is missing, or is cross-origin', () => { + const crossOrigin = new Proxy({}, { get() { throw new Error('SecurityError'); } }); + for (const [label, parent] of [['plain', {}], ['null', null], ['cross-origin', crossOrigin]]) { + const { emit } = boot({}, { parent }); + for (const [name, data] of [['product_category_viewed', [product]], ['product_viewed', product], ['product_added_to_cart', line], ['checkout_started', checkout], ['checkout_completed', checkout]]) { + assert.doesNotThrow(() => emit(name, data), `${name} with ${label} parent`); + } + } +}); -test('does nothing without a measurement id', () => { - const { handlers } = boot({ google_analytics_measurement_id: '' }); - assert.deepEqual(Object.keys(handlers), []); +test('every GA4 event targets the configured measurement id', () => { + const { calls, emit } = boot({}); + emit('product_category_viewed', [product]); + emit('product_viewed', product); + emit('product_added_to_cart', line); + emit('checkout_started', checkout); + emit('checkout_shipping_method_submitted', checkout); + emit('checkout_completed', checkout); + assert.equal(calls.length, 6); + for (const [, name, params] of calls) assert.equal(params.send_to, 'G-TEST', name); }); -test('a whitespace measurement id counts as unset', () => { - assert.deepEqual(Object.keys(boot({ google_analytics_measurement_id: ' ' }).handlers), []); +test('add_to_cart sends numeric unit price and one item shape', () => { + const { calls, emit } = boot({}); + emit('product_added_to_cart', line); + const [, name, params] = calls[0]; + assert.equal(name, 'add_to_cart'); + assert.equal(params.value, 159.98); + assert.deepEqual(plain(params.items[0]), { item_id: '111', item_name: 'Timeless Watch', sku: 'WATCH-BL', item_variant: 'Black', price: 79.99, discount: 5, quantity: 2, index: 0 }); }); test('cart events without a line payload are dropped', () => { @@ -44,37 +76,64 @@ test('cart events without a line payload are dropped', () => { assert.equal(calls.length, 0); }); -test('never throws when gtag is missing from the storefront window', () => { - const { emit } = boot({}, { withGtag: false }); - assert.doesNotThrow(() => emit('product_added_to_cart', line)); +test('unit price guards zero, negative, string and non-numeric inputs', () => { + const { calls, emit } = boot({}); + emit('product_added_to_cart', { ...line, quantity: 0 }); + emit('product_added_to_cart', { ...line, quantity: -1 }); + emit('product_added_to_cart', { ...line, quantity: '3', price_incl_tax: '10.00', total_discount: '1.00' }); + emit('product_added_to_cart', { ...line, quantity: undefined, price_incl_tax: 'abc' }); + assert.deepEqual(plain(calls[0][2].items[0]), { item_id: '111', item_name: 'Timeless Watch', sku: 'WATCH-BL', item_variant: 'Black', quantity: 0, index: 0 }); + assert.equal(calls[1][2].items[0].price, undefined); + assert.deepEqual(plain(calls[2][2].items[0]), { item_id: '111', item_name: 'Timeless Watch', sku: 'WATCH-BL', item_variant: 'Black', price: 3.33, discount: 0.33, quantity: 3, index: 0 }); + assert.equal(calls[3][2].value, undefined); + assert.equal(calls[3][2].items[0].price, undefined); }); -test('add_to_cart sends numeric unit price and one item shape', () => { +test('view_item takes the sku from the product, then a single variant, else omits it', () => { const { calls, emit } = boot({}); - emit('product_added_to_cart', line); - const [, name, params] = calls[0]; - assert.equal(name, 'add_to_cart'); - assert.equal(params.value, 159.98); - assert.deepEqual(plain(params.items[0]), { item_id: '111', item_name: 'Timeless Watch', sku: 'WATCH-BL', item_variant: 'Black', price: 79.99, discount: 5, quantity: 2, index: 0 }); + emit('product_viewed', product); + emit('product_viewed', { ...product, sku: 'TOP-SKU' }); + emit('product_viewed', { ...product, variants: [{ sku: 'A' }, { sku: 'B' }] }); + emit('product_viewed', { ...product, variants: [], categories: [] }); + assert.deepEqual(plain(calls[0][2]), { currency: 'USD', value: 79.99, items: [{ item_id: '111', item_name: 'Timeless Watch', sku: 'WATCH-BL', item_category: 'Watches', price: 79.99, quantity: 1, index: 0 }], send_to: 'G-TEST' }); + assert.equal(calls[1][2].items[0].sku, 'TOP-SKU'); + assert.equal(calls[2][2].items[0].sku, undefined); + assert.equal(calls[3][2].items[0].sku, undefined); + assert.equal(calls[3][2].items[0].item_category, undefined); }); -test('remove_from_cart, view_item_list and add_shipping_info are mapped', () => { +test('view_item is skipped when the payload has no id', () => { const { calls, emit } = boot({}); - emit('product_removed_from_cart', line); - emit('product_category_viewed', [{ id: 5, title: 'Sheets', purchase_info: { price: { currency: 'USD', price: '109.99' } } }]); - emit('checkout_shipping_method_submitted', checkout); - assert.deepEqual(calls.map(c => c[1]), ['remove_from_cart', 'view_item_list', 'add_shipping_info']); - assert.equal(calls[1][2].items[0].price, 109.99); - assert.equal(calls[2][2].shipping_tier, 'Express'); + emit('product_viewed', undefined); + emit('product_viewed', { title: 'No id' }); + emit('product_viewed', { id: null }); + assert.equal(calls.length, 0); +}); + +test('view_item_list skips invalid entries, identifies the list by path, caps at 200 items, and takes currency from the first priced product', () => { + const { calls, emit } = boot({}); + emit('product_category_viewed', []); + emit('product_category_viewed', { id: 1 }); + emit('product_category_viewed', [null, { id: null }]); + assert.equal(calls.length, 0); + const many = Array.from({ length: 250 }, (_, i) => ({ id: i + 1, title: `P${i + 1}` })); + emit('product_category_viewed', [null, { id: null, title: 'Broken' }, { id: 5, title: 'Unpriced' }, ...many, { id: 9, title: 'Sheets', purchase_info: { price: { currency: 'USD', price: '109.99' } } }]); + const params = calls[0][2]; + assert.equal(params.items.length, 200); + assert.equal(params.item_list_id, '/c/sheets/'); + assert.equal(params.item_list_name, 'Sheets | Velin'); + assert.equal(params.currency, undefined, 'no priced product within the first 200'); + const { calls: c2, emit: e2 } = boot({}); + e2('product_category_viewed', [{ id: 5, title: 'Unpriced' }, { id: 9, title: 'Sheets', purchase_info: { price: { currency: 'EUR', price: '99.00' } } }]); + assert.equal(c2[0][2].currency, 'EUR'); }); -test('begin_checkout maps a multi-line cart with coupon and numeric value', () => { +test('begin_checkout value is item revenue, not the order total, and drops lines without a product', () => { const { calls, emit } = boot({}); - const second = { ...line, product_id: 222, sku: 'PILLOW', product_title: 'Pillow Cover', variant_title: '', quantity: 1, price_incl_tax: '39.99', total_discount: '0.00' }; - emit('checkout_started', { ...checkout, lines: [line, second] }); + emit('checkout_started', { ...checkout, lines: [line, second, { product_id: null, quantity: 1, price_incl_tax: '5.00' }] }); const [, name, params] = calls[0]; assert.equal(name, 'begin_checkout'); - assert.equal(params.value, 164.97); + assert.equal(params.value, 199.97, '159.98 + 39.99, deleted-product line ignored'); assert.equal(params.currency, 'USD'); assert.equal(params.coupon, 'SAVE10'); assert.deepEqual(plain(params.items), [ @@ -83,53 +142,54 @@ test('begin_checkout maps a multi-line cart with coupon and numeric value', () = ]); }); -test('category lists skip null entries and identify the list by path', () => { +test('checkout events without a payload are dropped, and an empty cart or missing voucher leaves value and coupon unset', () => { const { calls, emit } = boot({}); - emit('product_category_viewed', [null, { id: null, title: 'Broken' }, { id: 5, title: 'Sheets', purchase_info: { price: { currency: 'USD', price: '109.99' } } }]); - assert.equal(calls[0][2].items.length, 1); - assert.equal(calls[0][2].item_list_id, '/c/sheets/'); -}); - -test('a missing quantity leaves unit price undefined instead of inventing one', () => { - const { calls, emit } = boot({}); - emit('product_added_to_cart', { ...line, quantity: undefined }); - assert.equal(calls[0][2].items[0].price, undefined); - assert.equal(calls[0][2].items[0].quantity, undefined); -}); - -test('Ads conversion is skipped when the id lacks the AW- prefix', () => { - const { calls, emit } = boot({ google_adwords_conversion_enabled: true, google_adwords_conversion_id: '123', google_adwords_conversion_label: 'abc' }); - emit('checkout_completed', checkout); - assert.deepEqual(calls.map(c => c[1]), ['purchase']); -}); - -test('view_item is skipped when the payload has no id', () => { - const { calls, emit } = boot({}); - emit('product_viewed', undefined); - emit('product_viewed', { title: 'No id' }); + emit('checkout_started', undefined); + emit('checkout_completed', null); assert.equal(calls.length, 0); + emit('checkout_shipping_method_submitted', { ...checkout, lines: [], voucher_discounts: [], shipping_method: null }); + const params = plain(calls[0][2]); + assert.deepEqual(params, { currency: 'USD', items: [], send_to: 'G-TEST' }); }); -test('view_item uses the same item identity as the cart events', () => { +test('add_shipping_info carries the shipping tier and the same item revenue', () => { const { calls, emit } = boot({}); - emit('product_viewed', { id: 111, title: 'Timeless Watch', categories: [{ name: 'Watches' }], variants: [{ sku: 'WATCH-BL' }, { sku: 'WATCH-BR' }], purchase_info: { price: { currency: 'USD', price: '79.99' } } }); - assert.equal(calls[0][2].items[0].item_id, '111'); - assert.equal(calls[0][2].items[0].sku, undefined, 'multi-variant product: viewed variant unknown, sku omitted'); - assert.equal(calls[0][2].items[0].item_category, 'Watches'); - assert.equal(calls[0][2].value, 79.99); + emit('checkout_shipping_method_submitted', checkout); + assert.equal(calls[0][1], 'add_shipping_info'); + assert.equal(calls[0][2].shipping_tier, 'Express'); + assert.equal(calls[0][2].value, 199.97); }); -test('purchase carries numeric totals and the Ads conversion uses the order total', () => { +test('purchase reconciles to its items and carries shipping and tax separately; the Ads conversion uses the order total', () => { const { calls, emit } = boot({ google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'AW-123', google_adwords_conversion_label: 'abc' }); emit('checkout_completed', checkout); const purchase = calls.find(c => c[1] === 'purchase')[2]; const conversion = calls.find(c => c[1] === 'conversion')[2]; assert.equal(purchase.transaction_id, '109659'); - assert.equal(purchase.value, 164.97); + assert.equal(purchase.value, 199.97); + assert.equal(purchase.value, purchase.items.reduce((sum, i) => sum + i.price * i.quantity, 0)); assert.equal(purchase.shipping, 4.99); assert.equal(purchase.tax, 0); assert.equal(purchase.coupon, 'SAVE10'); - assert.deepEqual(plain(conversion), { send_to: 'AW-123/abc', transaction_id: '109659', value: 164.97, currency: 'USD' }); + assert.deepEqual(plain(conversion), { send_to: 'AW-123/abc', transaction_id: '109659', value: 204.96, currency: 'USD' }); +}); + +test('Ads conversion needs the checkbox, a numeric id (bare, prefixed, spaced or lower-case) and a label', () => { + const cases = [ + [{ google_adwords_conversion_enabled: false, google_adwords_conversion_id: 'AW-123', google_adwords_conversion_label: 'abc' }, null], + [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'AW-123' }, null], + [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: undefined, google_adwords_conversion_label: 'abc' }, null], + [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'AW-123/abc', google_adwords_conversion_label: 'abc' }, null], + [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'AW-123', google_adwords_conversion_label: 'abc' }, 'AW-123/abc'], + [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: '123', google_adwords_conversion_label: 'abc' }, 'AW-123/abc'], + [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: ' aw-123 ', google_adwords_conversion_label: ' abc ' }, 'AW-123/abc'], + ]; + for (const [settings, expected] of cases) { + const { calls, emit } = boot(settings); + emit('checkout_completed', checkout); + const conversion = calls.find(c => c[1] === 'conversion'); + assert.equal(conversion ? conversion[2].send_to : null, expected, JSON.stringify(settings)); + } }); test('test orders are skipped only when the setting is on', () => { @@ -138,5 +198,15 @@ test('test orders are skipped only when the setting is on', () => { assert.equal(on.calls.length, 0); const off = boot({}); off.emit('checkout_completed', { ...checkout, is_test: true }); - assert.equal(off.calls.length, 1); + off.emit('checkout_completed', { ...checkout, is_test: null }); + assert.deepEqual(names(off.calls), ['purchase', 'purchase']); +}); + +test('every template interpolation inside the snippet script block is escaped, and the gates match the tracker', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'snippets', 'global-header.html'), 'utf8'); + assert.match(html.split('\n')[0], /google_analytics_enabled and app\.settings\.google_analytics_measurement_id\.strip/); + assert.match(html, /google_adwords_conversion_id\.strip and app\.settings\.google_adwords_conversion_label\.strip/); + const interpolations = [...html.matchAll(/\{\{\s*([^}]+?)\s*\}\}/g)].map(m => m[1]); + assert.ok(interpolations.length >= 4); + for (const expr of interpolations) assert.match(expr, /\|(escapejs|urlencode)$/, `unescaped interpolation: ${expr}`); }); diff --git a/tracking.js b/tracking.js index 3427a19..269d242 100644 --- a/tracking.js +++ b/tracking.js @@ -1,20 +1,54 @@ // Google Analytics 4 storefront event tracker. -// Runs in the platform's sandboxed tracker frame; gtag lives on the storefront window (window.top), -// installed by snippets/global-header.html. Every gtag call goes through send() so a page without the -// snippet (no Measurement ID, or a theme without the global_header hook) never throws. -// The snippet applies the same gate (.strip in the template), so both halves agree on when the app is on. +// +// The platform runs this file inside an iframe that is a direct child of the storefront page. +// gtag is installed on that parent window by snippets/global-header.html. Every access to the +// parent goes through storefront() and send(), both wrapped in try/catch: when the storefront is +// itself embedded (theme preview, a landing page framing the store) the parent chain can be +// cross-origin and any property read throws. The snippet applies the same enable gate +// (.strip in the template), so both halves agree on when the app is on. if (app.settings.google_analytics_enabled && String(app.settings.google_analytics_measurement_id || '').trim()) { (function () { var settings = app.settings; + var measurementId = String(settings.google_analytics_measurement_id).trim(); + + // Google Ads ids are normalised the same way the snippet does it (strip, upper-case, + // accept a bare numeric id by adding the AW- prefix) so a bare or lower-case paste keeps working. + var adsId = (function () { + var raw = String(settings.google_adwords_conversion_id || '').trim().toUpperCase().replace(/^AW-/, ''); + return /^\d+$/.test(raw) ? 'AW-' + raw : ''; + })(); + var adsLabel = String(settings.google_adwords_conversion_label || '').trim(); + + var storefront = function () { + try { + var parent = window.parent; + // Touch a property so a cross-origin parent fails here, inside the try. + void parent.document; + return parent; + } catch (e) { + return null; + } + }; var send = function () { - var top = window.top; - if (top && typeof top.gtag === 'function') { - top.gtag.apply(top, arguments); + try { + var win = storefront(); + if (win && typeof win.gtag === 'function') { + win.gtag.apply(win, arguments); + } + } catch (e) { + // A page without the snippet, or a cross-origin parent, must never break the tracker. } }; + // Every GA4 event targets the configured property so a second Google tag on the page + // (theme, another app) does not also receive it. + var sendGa = function (name, params) { + params.send_to = measurementId; + send('event', name, params); + }; + // GA4 expects numbers; the storefront payload carries decimal strings ("79.99"). var num = function (value) { var n = parseFloat(value); @@ -27,9 +61,12 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic var coupon = function (data) { var vouchers = data && data.voucher_discounts; - return vouchers && vouchers.length ? vouchers[0].name : ''; + return vouchers && vouchers.length ? vouchers[0].name : undefined; }; + // GA4 caps an event at 200 items; over-limit events are dropped, not truncated. + var MAX_ITEMS = 200; + // One item shape for the whole funnel so item-scoped reports join across events: // item_id is the product id everywhere, sku and item_variant identify the child. // A product payload only names a sku when it has one (or a single variant); on a @@ -68,12 +105,37 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic }; }; - var checkoutItems = function (data) { - return ((data && data.lines) || []).map(cartLineItem); + var checkoutLines = function (data) { + return ((data && data.lines) || []).filter(function (line) { + return line && line.product_id != null; + }).slice(0, MAX_ITEMS); }; - var currencyOf = function (data) { - return data && data.currency; + // GA4 defines value on checkout events as item revenue (sum of price x quantity); + // shipping and tax travel in their own parameters. total_incl_tax is the order grand + // total, so value is rebuilt from the lines. + var checkoutEcommerce = function (data) { + var lines = checkoutLines(data); + var value; + lines.forEach(function (line) { + var total = num(line.price_incl_tax); + if (total !== undefined) { value = (value || 0) + total; } + }); + return { + currency: data.currency, + value: round(value), + coupon: coupon(data), + items: lines.map(cartLineItem) + }; + }; + + var pageContext = function () { + try { + var win = storefront(); + return win ? { path: win.location.pathname, title: win.document.title } : {}; + } catch (e) { + return {}; + } }; // The category payload carries the products only (no category object), so the list is @@ -81,14 +143,16 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic analytics.subscribe('product_category_viewed', function (event) { var products = (Array.isArray(event.data) ? event.data : []).filter(function (product) { return product && product.id != null; - }); + }).slice(0, MAX_ITEMS); if (!products.length) { return; } - var first = products[0].purchase_info && products[0].purchase_info.price; - var top = window.top || {}; - send('event', 'view_item_list', { - item_list_id: top.location ? top.location.pathname : undefined, - item_list_name: top.document ? top.document.title : undefined, - currency: first && first.currency, + var priced = products.filter(function (product) { + return product.purchase_info && product.purchase_info.price; + })[0]; + var page = pageContext(); + sendGa('view_item_list', { + item_list_id: page.path, + item_list_name: page.title, + currency: priced && priced.purchase_info.price.currency, items: products.map(productItem) }); }); @@ -97,7 +161,7 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic var product = event.data; if (!product || product.id == null) { return; } var price = product.purchase_info && product.purchase_info.price; - send('event', 'view_item', { + sendGa('view_item', { currency: price && price.currency, value: num(price && price.price), items: [productItem(product, 0)] @@ -108,8 +172,8 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic return function (event) { var line = event.data; if (!line || line.product_id == null) { return; } - send('event', name, { - currency: currencyOf(line), + sendGa(name, { + currency: line.currency, value: num(line.price_incl_tax), items: [cartLineItem(line, 0)] }); @@ -120,47 +184,35 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic analytics.subscribe('product_removed_from_cart', cartLineEvent('remove_from_cart')); analytics.subscribe('checkout_started', function (event) { - var data = event.data || {}; - send('event', 'begin_checkout', { - currency: currencyOf(data), - value: num(data.total_incl_tax), - coupon: coupon(data), - items: checkoutItems(data) - }); + if (!event.data) { return; } + sendGa('begin_checkout', checkoutEcommerce(event.data)); }); analytics.subscribe('checkout_shipping_method_submitted', function (event) { - var data = event.data || {}; - send('event', 'add_shipping_info', { - currency: currencyOf(data), - value: num(data.total_incl_tax), - coupon: coupon(data), - shipping_tier: data.shipping_method || undefined, - items: checkoutItems(data) - }); + if (!event.data) { return; } + var params = checkoutEcommerce(event.data); + params.shipping_tier = event.data.shipping_method || undefined; + sendGa('add_shipping_info', params); }); analytics.subscribe('checkout_completed', function (event) { - var data = event.data || {}; + var data = event.data; + if (!data) { return; } if (data.is_test && settings.google_analytics_skip_test_orders) { return; } - send('event', 'purchase', { - currency: currencyOf(data), - value: num(data.total_incl_tax), - transaction_id: data.number, - coupon: coupon(data), - shipping: num(data.shipping_incl_tax), - tax: num(data.total_tax), - items: checkoutItems(data) - }); + var params = checkoutEcommerce(data); + params.transaction_id = data.number; + params.shipping = num(data.shipping_incl_tax); + params.tax = num(data.total_tax); + sendGa('purchase', params); - // send_to needs the AW- form; a bare numeric id would fail silently in Ads. - if (settings.google_adwords_conversion_enabled && /^AW-\d+$/.test(settings.google_adwords_conversion_id || '') && settings.google_adwords_conversion_label) { + // The Ads conversion value is the order total the merchant was paid, by design. + if (settings.google_adwords_conversion_enabled && adsId && adsLabel) { send('event', 'conversion', { - send_to: settings.google_adwords_conversion_id + '/' + settings.google_adwords_conversion_label, + send_to: adsId + '/' + adsLabel, transaction_id: data.number, value: num(data.total_incl_tax), - currency: currencyOf(data) + currency: data.currency }); } }); From 56e950126830a896c551660db2b45a373606d011 Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Thu, 3 Sep 2026 10:48:11 +0700 Subject: [PATCH 6/9] Configure the Ads tag from the tracker so id and label validation live in one place The template cut filter stripped every AW- occurrence and the snippet gate could not mirror the tracker regex, so the snippet no longer touches the Ads settings; tracking.js validates id and label (alphanumeric, _ and -) and calls gtag('config', 'AW-...') at startup. Tests cover the startup config, malformed ids, and labels with / or quotes. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- README.md | 8 ++++---- snippets/global-header.html | 5 +---- tests/tracking.test.js | 17 ++++++++++++++--- tracking.js | 19 +++++++++++++++---- 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9362c6..1423502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,5 +8,5 @@ - All money fields are numbers, unit `price`/`discount` are derived from line totals, one item shape across the funnel (`item_id` = product id, `sku`, `item_variant`), and page fields are no longer stuffed into `items[]` (#5). - New events: `view_item_list`, `remove_from_cart`, `add_shipping_info`. New "Skip Test Orders" setting (#6). Consent Mode defaults and `add_payment_info` are not included: the platform has no payment-step event, and consent defaults need a CMP to be useful. - Settings labels, help text and README say Google Ads and Next Commerce (#7). -- Review follow-ups: the tracker reaches the storefront through `window.parent` inside try/catch (an embedded storefront no longer throws on every event); every GA4 event sets `send_to`; checkout `value` is item revenue rather than the order grand total; Ads ids are normalised (bare digits, spaces, lower case) in both snippet and tracker and the label is trimmed; coupon is omitted when absent; lines without a product are dropped; item lists are capped at GA4's 200-item limit. +- Review follow-ups: the tracker reaches the storefront through `window.parent` inside try/catch (an embedded storefront no longer throws on every event); every GA4 event sets `send_to`; checkout `value` is item revenue rather than the order grand total; the Ads tag is configured by the tracker, which is the single place that normalises the id (bare digits, spaces, lower case) and validates the label shape; coupon is omitted when absent; lines without a product are dropped; item lists are capped at GA4's 200-item limit. - Added a Node test harness (`npm test`) and a CI workflow. diff --git a/README.md b/README.md index 5b6f263..1dd424e 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Google Analytics 4 app for Next Commerce. Installs the Google tag on any storefr |---|---| | Enable Google Analytics | Nothing loads until a Measurement ID is also set. | | Google Analytics Measurement ID | `G-XXXXXXXXXX`. | -| Enable Google Ads Conversion Tracking | Configures the `AW-` tag and sends a `conversion` event on `checkout_completed`. | +| Enable Google Ads Conversion Tracking | The tracker configures the `AW-` tag at startup and sends a `conversion` event on `checkout_completed`. | | Google Ads Conversion ID | `AW-123456789`; a bare `123456789` or lower-case paste is normalised. | -| Google Ads Conversion Label | From the Ads conversion action. | +| Google Ads Conversion Label | From the Ads conversion action (letters, digits, `_`, `-`). | | Enable Debug Mode | Sends `debug_mode` so events show in GA4 DebugView. | | Skip Test Orders | Suppresses `purchase` and `conversion` for orders flagged `is_test`. | @@ -33,8 +33,8 @@ Items share identifiers across the funnel so GA4 item reports join: `item_id` is ## Files - `manifest.json` — settings schema, snippet location, event tracker mapping. -- `snippets/global-header.html` — loads gtag and configures the GA4 (and optional Ads) tag. Rendered only when a Measurement ID is set. -- `tracking.js` — the event tracker. Runs in the platform's tracker frame (a direct child of the storefront page) and calls `gtag` on the parent window; every parent access is wrapped so a page without the snippet, or an embedded storefront with a cross-origin parent, never throws. +- `snippets/global-header.html` — loads gtag and configures the GA4 tag. Rendered only when a Measurement ID is set. +- `tracking.js` — the event tracker, which also validates the Ads settings and configures the `AW-` tag. Runs in the platform's tracker frame (a direct child of the storefront page) and calls `gtag` on the parent window; every parent access is wrapped so a page without the snippet, or an embedded storefront with a cross-origin parent, never throws. ## Tests diff --git a/snippets/global-header.html b/snippets/global-header.html index a2ff42a..24aa572 100644 --- a/snippets/global-header.html +++ b/snippets/global-header.html @@ -12,10 +12,7 @@ {% if app.settings.google_analytics_debug_mode %}'debug_mode': true,{% endif %} }); - {# Same normalisation as tracking.js: strip, upper-case, and accept a bare numeric id by adding the AW- prefix. #} - {% if app.settings.google_adwords_conversion_enabled and app.settings.google_adwords_conversion_id.strip and app.settings.google_adwords_conversion_label.strip %} - gtag('config', 'AW-{{ app.settings.google_adwords_conversion_id.strip|upper|cut:"AW-"|escapejs }}'); - {% endif %} + {# The Google Ads tag is configured by tracking.js, which validates and normalises the id and label in one place. #} {% endif %} diff --git a/tests/tracking.test.js b/tests/tracking.test.js index 82390f0..b9446d6 100644 --- a/tests/tracking.test.js +++ b/tests/tracking.test.js @@ -174,8 +174,19 @@ test('purchase reconciles to its items and carries shipping and tax separately; assert.deepEqual(plain(conversion), { send_to: 'AW-123/abc', transaction_id: '109659', value: 204.96, currency: 'USD' }); }); -test('Ads conversion needs the checkbox, a numeric id (bare, prefixed, spaced or lower-case) and a label', () => { +test('the Ads destination is configured once at startup only when the settings validate', () => { + const on = boot({ google_adwords_conversion_enabled: true, google_adwords_conversion_id: ' aw-123 ', google_adwords_conversion_label: 'abc' }); + assert.deepEqual(on.calls, [['config', 'AW-123']]); + for (const settings of [{}, { google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'AW-123' }, { google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'aw123', google_adwords_conversion_label: 'abc' }, { google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'aw-123-aw-456', google_adwords_conversion_label: 'abc' }]) { + assert.equal(boot(settings).calls.length, 0, JSON.stringify(settings)); + } +}); + +test('Ads conversion needs the checkbox, a numeric id (bare, prefixed, spaced or lower-case) and a well-formed label', () => { const cases = [ + [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'AW-123', google_adwords_conversion_label: 'Abc/Def' }, null], + [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'AW-123', google_adwords_conversion_label: "ab'c" }, null], + [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'AW-123', google_adwords_conversion_label: 'AbC_d-9' }, 'AW-123/AbC_d-9'], [{ google_adwords_conversion_enabled: false, google_adwords_conversion_id: 'AW-123', google_adwords_conversion_label: 'abc' }, null], [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: 'AW-123' }, null], [{ google_adwords_conversion_enabled: true, google_adwords_conversion_id: undefined, google_adwords_conversion_label: 'abc' }, null], @@ -205,8 +216,8 @@ test('test orders are skipped only when the setting is on', () => { test('every template interpolation inside the snippet script block is escaped, and the gates match the tracker', () => { const html = fs.readFileSync(path.join(__dirname, '..', 'snippets', 'global-header.html'), 'utf8'); assert.match(html.split('\n')[0], /google_analytics_enabled and app\.settings\.google_analytics_measurement_id\.strip/); - assert.match(html, /google_adwords_conversion_id\.strip and app\.settings\.google_adwords_conversion_label\.strip/); + assert.doesNotMatch(html, /adwords/, 'Ads settings are handled by tracking.js only'); const interpolations = [...html.matchAll(/\{\{\s*([^}]+?)\s*\}\}/g)].map(m => m[1]); - assert.ok(interpolations.length >= 4); + assert.ok(interpolations.length >= 3); for (const expr of interpolations) assert.match(expr, /\|(escapejs|urlencode)$/, `unescaped interpolation: ${expr}`); }); diff --git a/tracking.js b/tracking.js index 269d242..b97ed7e 100644 --- a/tracking.js +++ b/tracking.js @@ -12,13 +12,18 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic var settings = app.settings; var measurementId = String(settings.google_analytics_measurement_id).trim(); - // Google Ads ids are normalised the same way the snippet does it (strip, upper-case, - // accept a bare numeric id by adding the AW- prefix) so a bare or lower-case paste keeps working. + // Google Ads settings are normalised and validated here only; the snippet does not touch + // them (a Django template cannot mirror this regex). A bare numeric id gets the AW- prefix + // so an older paste keeps working; labels are the alphanumeric/_/- tokens Ads issues. var adsId = (function () { var raw = String(settings.google_adwords_conversion_id || '').trim().toUpperCase().replace(/^AW-/, ''); return /^\d+$/.test(raw) ? 'AW-' + raw : ''; })(); - var adsLabel = String(settings.google_adwords_conversion_label || '').trim(); + var adsLabel = (function () { + var raw = String(settings.google_adwords_conversion_label || '').trim(); + return /^[A-Za-z0-9_-]+$/.test(raw) ? raw : ''; + })(); + var adsEnabled = Boolean(settings.google_adwords_conversion_enabled && adsId && adsLabel); var storefront = function () { try { @@ -49,6 +54,12 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic send('event', name, params); }; + // Ads conversion tracking needs the AW- destination configured before send_to can attribute. + // gtag queues calls until gtag.js loads, and this runs before any storefront event fires. + if (adsEnabled) { + send('config', adsId); + } + // GA4 expects numbers; the storefront payload carries decimal strings ("79.99"). var num = function (value) { var n = parseFloat(value); @@ -207,7 +218,7 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic sendGa('purchase', params); // The Ads conversion value is the order total the merchant was paid, by design. - if (settings.google_adwords_conversion_enabled && adsId && adsLabel) { + if (adsEnabled) { send('event', 'conversion', { send_to: adsId + '/' + adsLabel, transaction_id: data.number, From c9956c6d5ea1e9760fba624cb91bc389a043d62b Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Thu, 3 Sep 2026 10:56:45 +0700 Subject: [PATCH 7/9] Item-list cap: say truncation, take the currency before truncating; changelog says the tracker configures the Ads tag Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++-- tests/tracking.test.js | 2 +- tracking.js | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1423502..1c7ee64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,11 @@ ## Unreleased -- Google Ads conversions now send the order total as the value (was the tax) and the `AW-` tag is configured in the snippet, so `send_to` can attribute (#2). +- Google Ads conversions now send the order total as the value (was the tax) and the `AW-` tag is configured by the tracker, so `send_to` can attribute (#2). - The snippet and tracker are gated on a non-empty Measurement ID; a store that only ticks "Enable" no longer loads `gtag/js?id=` (#3). Tracker calls are guarded so a page without the snippet never throws. - `debug_mode` and `user_id` are passed in one config object; debug mode works for logged-in shoppers (#4). - All money fields are numbers, unit `price`/`discount` are derived from line totals, one item shape across the funnel (`item_id` = product id, `sku`, `item_variant`), and page fields are no longer stuffed into `items[]` (#5). - New events: `view_item_list`, `remove_from_cart`, `add_shipping_info`. New "Skip Test Orders" setting (#6). Consent Mode defaults and `add_payment_info` are not included: the platform has no payment-step event, and consent defaults need a CMP to be useful. - Settings labels, help text and README say Google Ads and Next Commerce (#7). -- Review follow-ups: the tracker reaches the storefront through `window.parent` inside try/catch (an embedded storefront no longer throws on every event); every GA4 event sets `send_to`; checkout `value` is item revenue rather than the order grand total; the Ads tag is configured by the tracker, which is the single place that normalises the id (bare digits, spaces, lower case) and validates the label shape; coupon is omitted when absent; lines without a product are dropped; item lists are capped at GA4's 200-item limit. +- Review follow-ups: the tracker reaches the storefront through `window.parent` inside try/catch (an embedded storefront no longer throws on every event); every GA4 event sets `send_to`; checkout `value` is item revenue rather than the order grand total; the Ads tag is configured by the tracker, which is the single place that normalises the id (bare digits, spaces, lower case) and validates the label shape; coupon is omitted when absent; lines without a product are dropped; item lists are truncated to GA4's 200-item limit. - Added a Node test harness (`npm test`) and a CI workflow. diff --git a/tests/tracking.test.js b/tests/tracking.test.js index b9446d6..ff6abc2 100644 --- a/tests/tracking.test.js +++ b/tests/tracking.test.js @@ -122,7 +122,7 @@ test('view_item_list skips invalid entries, identifies the list by path, caps at assert.equal(params.items.length, 200); assert.equal(params.item_list_id, '/c/sheets/'); assert.equal(params.item_list_name, 'Sheets | Velin'); - assert.equal(params.currency, undefined, 'no priced product within the first 200'); + assert.equal(params.currency, 'USD', 'currency is taken before truncation'); const { calls: c2, emit: e2 } = boot({}); e2('product_category_viewed', [{ id: 5, title: 'Unpriced' }, { id: 9, title: 'Sheets', purchase_info: { price: { currency: 'EUR', price: '99.00' } } }]); assert.equal(c2[0][2].currency, 'EUR'); diff --git a/tracking.js b/tracking.js index b97ed7e..e901d2b 100644 --- a/tracking.js +++ b/tracking.js @@ -75,7 +75,8 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic return vouchers && vouchers.length ? vouchers[0].name : undefined; }; - // GA4 caps an event at 200 items; over-limit events are dropped, not truncated. + // GA4 rejects an event with more than 200 items outright, so longer lists are truncated + // to the first 200: a partial list still reaches the report, a dropped event does not. var MAX_ITEMS = 200; // One item shape for the whole funnel so item-scoped reports join across events: @@ -154,11 +155,13 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic analytics.subscribe('product_category_viewed', function (event) { var products = (Array.isArray(event.data) ? event.data : []).filter(function (product) { return product && product.id != null; - }).slice(0, MAX_ITEMS); + }); if (!products.length) { return; } + // Currency comes from the first priced product in the whole list, before truncation. var priced = products.filter(function (product) { return product.purchase_info && product.purchase_info.price; })[0]; + products = products.slice(0, MAX_ITEMS); var page = pageContext(); sendGa('view_item_list', { item_list_id: page.path, From d643ea4bcd3279c6489171f4092d94775b98b08a Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Thu, 3 Sep 2026 11:30:36 +0700 Subject: [PATCH 8/9] Line prices and checkout value exclude tax (price_excl_tax) since tax is reported separately Observed on the aptest full-funnel walk: order line price_incl_tax includes tax, so purchase value carried tax while tax was also sent. Fixture lines now carry distinct excl/incl values. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- README.md | 2 +- tests/tracking.test.js | 14 +++++++------- tracking.js | 17 ++++++++++++----- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c7ee64..1c1a84a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,5 +8,5 @@ - All money fields are numbers, unit `price`/`discount` are derived from line totals, one item shape across the funnel (`item_id` = product id, `sku`, `item_variant`), and page fields are no longer stuffed into `items[]` (#5). - New events: `view_item_list`, `remove_from_cart`, `add_shipping_info`. New "Skip Test Orders" setting (#6). Consent Mode defaults and `add_payment_info` are not included: the platform has no payment-step event, and consent defaults need a CMP to be useful. - Settings labels, help text and README say Google Ads and Next Commerce (#7). -- Review follow-ups: the tracker reaches the storefront through `window.parent` inside try/catch (an embedded storefront no longer throws on every event); every GA4 event sets `send_to`; checkout `value` is item revenue rather than the order grand total; the Ads tag is configured by the tracker, which is the single place that normalises the id (bare digits, spaces, lower case) and validates the label shape; coupon is omitted when absent; lines without a product are dropped; item lists are truncated to GA4's 200-item limit. +- Review follow-ups: the tracker reaches the storefront through `window.parent` inside try/catch (an embedded storefront no longer throws on every event); every GA4 event sets `send_to`; checkout `value` is item revenue rather than the order grand total, and line prices exclude tax (`price_excl_tax`) since `tax` is reported separately; the Ads tag is configured by the tracker, which is the single place that normalises the id (bare digits, spaces, lower case) and validates the label shape; coupon is omitted when absent; lines without a product are dropped; item lists are truncated to GA4's 200-item limit. - Added a Node test harness (`npm test`) and a CI workflow. diff --git a/README.md b/README.md index 1dd424e..94cda64 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Google Analytics 4 app for Next Commerce. Installs the Google tag on any storefr `page_view` is sent by the Google tag itself on `config`. -Items share identifiers across the funnel so GA4 item reports join: `item_id` is the product id, `sku` and `item_variant` identify the child product, `price` and `discount` are per unit, and all money fields are numbers. `value` on `begin_checkout`, `add_shipping_info` and `purchase` is item revenue (the sum of the lines), as GA4 defines it; `shipping` and `tax` travel in their own parameters. The Ads `conversion` value is the order total the merchant was paid. Every GA4 event carries `send_to` for the configured Measurement ID so a second Google tag on the page does not receive it. +Items share identifiers across the funnel so GA4 item reports join: `item_id` is the product id, `sku` and `item_variant` identify the child product, `price` and `discount` are per unit, and all money fields are numbers. `value` on `begin_checkout`, `add_shipping_info` and `purchase` is item revenue (the sum of the lines, excluding tax where the payload provides `price_excl_tax`), as GA4 defines it; `shipping` and `tax` travel in their own parameters. The Ads `conversion` value is the order total the merchant was paid. Every GA4 event carries `send_to` for the configured Measurement ID so a second Google tag on the page does not receive it. ## Files diff --git a/tests/tracking.test.js b/tests/tracking.test.js index ff6abc2..83636c5 100644 --- a/tests/tracking.test.js +++ b/tests/tracking.test.js @@ -27,9 +27,9 @@ function boot(settings, { parent } = {}) { } const names = calls => calls.map(c => c[1]); -const line = { currency: 'USD', product_id: 111, sku: 'WATCH-BL', product_title: 'Timeless Watch', variant_title: 'Black', quantity: 2, price_incl_tax: '159.98', total_discount: '10.00', is_upsell: false }; -const second = { ...line, product_id: 222, sku: 'PILLOW', product_title: 'Pillow Cover', variant_title: '', quantity: 1, price_incl_tax: '39.99', total_discount: '0.00' }; -const checkout = { number: '109659', currency: 'USD', total_incl_tax: '204.96', shipping_incl_tax: '4.99', total_tax: '0.00', shipping_method: 'Express', voucher_discounts: [{ name: 'SAVE10' }], lines: [line, second], is_test: false }; +const line = { currency: 'USD', product_id: 111, sku: 'WATCH-BL', product_title: 'Timeless Watch', variant_title: 'Black', quantity: 2, price_excl_tax: '159.98', price_incl_tax: '171.18', total_discount: '10.00', is_upsell: false }; +const second = { ...line, product_id: 222, sku: 'PILLOW', product_title: 'Pillow Cover', variant_title: '', quantity: 1, price_excl_tax: '39.99', price_incl_tax: '42.79', total_discount: '0.00' }; +const checkout = { number: '109659', currency: 'USD', total_incl_tax: '218.96', shipping_incl_tax: '4.99', total_tax: '14.00', shipping_method: 'Express', voucher_discounts: [{ name: 'SAVE10' }], lines: [line, second], is_test: false }; const product = { id: 111, title: 'Timeless Watch', categories: [{ name: 'Watches' }], variants: [{ sku: 'WATCH-BL' }], purchase_info: { price: { currency: 'USD', price: '79.99' } } }; test('the tracker stays off when disabled or when the measurement id is empty, whitespace or missing', () => { @@ -80,8 +80,8 @@ test('unit price guards zero, negative, string and non-numeric inputs', () => { const { calls, emit } = boot({}); emit('product_added_to_cart', { ...line, quantity: 0 }); emit('product_added_to_cart', { ...line, quantity: -1 }); - emit('product_added_to_cart', { ...line, quantity: '3', price_incl_tax: '10.00', total_discount: '1.00' }); - emit('product_added_to_cart', { ...line, quantity: undefined, price_incl_tax: 'abc' }); + emit('product_added_to_cart', { ...line, quantity: '3', price_excl_tax: '10.00', price_incl_tax: '10.70', total_discount: '1.00' }); + emit('product_added_to_cart', { ...line, quantity: undefined, price_excl_tax: undefined, price_incl_tax: 'abc' }); assert.deepEqual(plain(calls[0][2].items[0]), { item_id: '111', item_name: 'Timeless Watch', sku: 'WATCH-BL', item_variant: 'Black', quantity: 0, index: 0 }); assert.equal(calls[1][2].items[0].price, undefined); assert.deepEqual(plain(calls[2][2].items[0]), { item_id: '111', item_name: 'Timeless Watch', sku: 'WATCH-BL', item_variant: 'Black', price: 3.33, discount: 0.33, quantity: 3, index: 0 }); @@ -169,9 +169,9 @@ test('purchase reconciles to its items and carries shipping and tax separately; assert.equal(purchase.value, 199.97); assert.equal(purchase.value, purchase.items.reduce((sum, i) => sum + i.price * i.quantity, 0)); assert.equal(purchase.shipping, 4.99); - assert.equal(purchase.tax, 0); + assert.equal(purchase.tax, 14); assert.equal(purchase.coupon, 'SAVE10'); - assert.deepEqual(plain(conversion), { send_to: 'AW-123/abc', transaction_id: '109659', value: 204.96, currency: 'USD' }); + assert.deepEqual(plain(conversion), { send_to: 'AW-123/abc', transaction_id: '109659', value: 218.96, currency: 'USD' }); }); test('the Ads destination is configured once at startup only when the settings validate', () => { diff --git a/tracking.js b/tracking.js index e901d2b..6e39472 100644 --- a/tracking.js +++ b/tracking.js @@ -98,8 +98,15 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic }; }; - // Unit price/discount are derived from line totals; when the payload has no positive - // quantity they are left undefined rather than invented. + // Line totals exclude tax where the payload offers it: tax is reported in its own + // parameter, so a tax-inclusive price would count it twice. Unit price/discount are + // derived from line totals; when the payload has no positive quantity they are left + // undefined rather than invented. + var lineTotal = function (line) { + var excl = num(line.price_excl_tax); + return excl === undefined ? num(line.price_incl_tax) : excl; + }; + var cartLineItem = function (line, index) { var quantity = num(line.quantity); var perUnit = function (total) { @@ -110,7 +117,7 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic item_name: line.product_title, sku: line.sku || undefined, item_variant: line.variant_title || undefined, - price: perUnit(num(line.price_incl_tax)), + price: perUnit(lineTotal(line)), discount: perUnit(num(line.total_discount)), quantity: quantity, index: index @@ -130,7 +137,7 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic var lines = checkoutLines(data); var value; lines.forEach(function (line) { - var total = num(line.price_incl_tax); + var total = lineTotal(line); if (total !== undefined) { value = (value || 0) + total; } }); return { @@ -188,7 +195,7 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic if (!line || line.product_id == null) { return; } sendGa(name, { currency: line.currency, - value: num(line.price_incl_tax), + value: lineTotal(line), items: [cartLineItem(line, 0)] }); }; From b37e6a6b032f605e6d82a9ac95243fa2a34d874a Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Thu, 3 Sep 2026 11:57:55 +0700 Subject: [PATCH 9/9] Document the price_excl_tax fallback, the zero-price case, and discount being total_discount as sent Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- tracking.js | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 94cda64..abe97e9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Google Analytics 4 app for Next Commerce. Installs the Google tag on any storefr `page_view` is sent by the Google tag itself on `config`. -Items share identifiers across the funnel so GA4 item reports join: `item_id` is the product id, `sku` and `item_variant` identify the child product, `price` and `discount` are per unit, and all money fields are numbers. `value` on `begin_checkout`, `add_shipping_info` and `purchase` is item revenue (the sum of the lines, excluding tax where the payload provides `price_excl_tax`), as GA4 defines it; `shipping` and `tax` travel in their own parameters. The Ads `conversion` value is the order total the merchant was paid. Every GA4 event carries `send_to` for the configured Measurement ID so a second Google tag on the page does not receive it. +Items share identifiers across the funnel so GA4 item reports join: `item_id` is the product id, `sku` and `item_variant` identify the child product, `price` and `discount` are per unit, and all money fields are numbers. `value` on `begin_checkout`, `add_shipping_info` and `purchase` is item revenue (the sum of the lines' `price_excl_tax`; if a payload ever lacks that field the tax-inclusive total is used, so a store seeing values that include tax should check its payloads), as GA4 defines it; `shipping` and `tax` travel in their own parameters. The Ads `conversion` value is the order total the merchant was paid. Every GA4 event carries `send_to` for the configured Measurement ID so a second Google tag on the page does not receive it. ## Files diff --git a/tracking.js b/tracking.js index 6e39472..b0ee251 100644 --- a/tracking.js +++ b/tracking.js @@ -98,10 +98,12 @@ if (app.settings.google_analytics_enabled && String(app.settings.google_analytic }; }; - // Line totals exclude tax where the payload offers it: tax is reported in its own - // parameter, so a tax-inclusive price would count it twice. Unit price/discount are - // derived from line totals; when the payload has no positive quantity they are left - // undefined rather than invented. + // Line totals exclude tax: tax is reported in its own parameter, so a tax-inclusive + // price would count it twice. The platform sends price_excl_tax on every cart and order + // line; the price_incl_tax fallback only fires when a payload is missing the field + // outright (a value of 0 is a real free line and is kept). Unit price and discount are + // derived from the line totals; when the payload has no positive quantity they are left + // undefined rather than invented. discount is the platform's total_discount as sent. var lineTotal = function (line) { var excl = num(line.price_excl_tax); return excl === undefined ? num(line.price_incl_tax) : excl;