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
});
- });
- }
+ }
+ });
})();
}