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..1c1a84a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Unreleased + +- 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, 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 90bc1e4..abe97e9 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 | 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 (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`. | +## 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 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. -* [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 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. -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.parent`) and asserts the payload of every mapped event, plus the escaping in the snippet. No dependencies; Node 22, the version CI runs. 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..24aa572 100644 --- a/snippets/global-header.html +++ b/snippets/global-header.html @@ -1,16 +1,18 @@ -{% if app.settings.google_analytics_enabled %} +{% if app.settings.google_analytics_enabled and app.settings.google_analytics_measurement_id.strip %} - + -{% 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..83636c5 --- /dev/null +++ b/tests/tracking.test.js @@ -0,0 +1,223 @@ +// Runs tracking.js the way the platform does: `app`, `analytics` and `window` are globals of the +// 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'); +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, { parent } = {}) { + const calls = []; + const handlers = {}; + 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: { 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_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', () => { + 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('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('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', () => { + const { calls, emit } = boot({}); + emit('product_added_to_cart', undefined); + emit('product_removed_from_cart', { quantity: 1 }); + assert.equal(calls.length, 0); +}); + +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_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 }); + assert.equal(calls[3][2].value, undefined); + assert.equal(calls[3][2].items[0].price, undefined); +}); + +test('view_item takes the sku from the product, then a single variant, else omits it', () => { + const { calls, emit } = boot({}); + 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('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('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, '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'); +}); + +test('begin_checkout value is item revenue, not the order total, and drops lines without a product', () => { + const { calls, emit } = boot({}); + 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, 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), [ + { 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('checkout events without a payload are dropped, and an empty cart or missing voucher leaves value and coupon unset', () => { + const { calls, emit } = boot({}); + 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('add_shipping_info carries the shipping tier and the same item revenue', () => { + const { calls, emit } = boot({}); + 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 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, 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, 14); + assert.equal(purchase.coupon, 'SAVE10'); + 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', () => { + 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], + [{ 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', () => { + 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 }); + 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.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 >= 3); + for (const expr of interpolations) assert.match(expr, /\|(escapejs|urlencode)$/, `unescaped interpolation: ${expr}`); +}); diff --git a/tracking.js b/tracking.js index 605e79f..b0ee251 100644 --- a/tracking.js +++ b/tracking.js @@ -1,108 +1,244 @@ -if (app.settings.google_analytics_enabled) { +// Google Analytics 4 storefront event tracker. +// +// 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 () { - - 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; + + var settings = app.settings; + var measurementId = String(settings.google_analytics_measurement_id).trim(); + + // 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 = (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 { + 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 () { + 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); + }; + + // 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); } - 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 - } - ] + // 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 : undefined; + }; + + // 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: + // 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.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 === 1 ? variants[0].sku : undefined), + item_category: category, + price: num(price && price.price), + quantity: 1, + index: index + }; + }; + + // 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; + }; + + var cartLineItem = function (line, index) { + 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: perUnit(lineTotal(line)), + discount: perUnit(num(line.total_discount)), + quantity: quantity, + index: index + }; + }; + + var checkoutLines = function (data) { + return ((data && data.lines) || []).filter(function (line) { + return line && line.product_id != null; + }).slice(0, MAX_ITEMS); + }; + + // 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 = lineTotal(line); + 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 {}; + } + }; - 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 - } - ] + // 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 : []).filter(function (product) { + return product && product.id != null; + }); + 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, + item_list_name: page.title, + currency: priced && priced.purchase_info.price.currency, + items: products.map(productItem) }); }); - 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 + analytics.subscribe('product_viewed', function (event) { + var product = event.data; + if (!product || product.id == null) { return; } + var price = product.purchase_info && product.purchase_info.price; + sendGa('view_item', { + currency: price && price.currency, + value: num(price && price.price), + items: [productItem(product, 0)] }); }); - 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 - }); + var cartLineEvent = function (name) { + return function (event) { + var line = event.data; + if (!line || line.product_id == null) { return; } + sendGa(name, { + currency: line.currency, + value: lineTotal(line), + 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) { + if (!event.data) { return; } + sendGa('begin_checkout', checkoutEcommerce(event.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_shipping_method_submitted', function (event) { + 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; + if (!data) { return; } + if (data.is_test && settings.google_analytics_skip_test_orders) { return; } + + 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); + + // The Ads conversion value is the order total the merchant was paid, by design. + if (adsEnabled) { + send('event', 'conversion', { + send_to: adsId + '/' + adsLabel, + transaction_id: data.number, + value: num(data.total_incl_tax), + currency: data.currency }); - }); - } + } + }); })(); }