From 2762d0c6ed5b85c398c16da036f8e9d2a9552837 Mon Sep 17 00:00:00 2001 From: rockwellll Date: Sat, 24 Oct 2026 15:10:33 -0300 Subject: [PATCH 1/8] Keep the arrival UTM when visitors click the site's own UTM links Stores tag their own popups, banners and menu links with UTMs. Every page or tracked event re-read the URL and overwrote hello_utm with a fresh observed_at, so a click on the store's own popup replaced the click that brought the visitor, and single-page stores re-stamped the same UTM on every event. A UTM now counts as an arrival only when it is on the URL the document was loaded with and the visitor did not come from another page of the same site. In-site UTMs go to hello_utm_internal and are sent as internal_utm_params. Hellotext's own UTMs always count as an arrival. Re-reading the same UTM in the same document, on a reload or on a back navigation keeps observed_at. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- __tests__/models/page_test.js | 23 +++ __tests__/models/utm_navigation_test.js | 214 ++++++++++++++++++++++++ src/models/page.js | 6 +- src/models/utm.js | 198 ++++++++++++++++++++-- 5 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 __tests__/models/utm_navigation_test.js diff --git a/README.md b/README.md index 44bc24d4..a7d74ade 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,9 @@ Hellotext.removeEventListener(eventName, callback) | Event | When it fires | Callback payload | | ------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `session-set` | The session value is set, including when an existing session is restored during initialization. | The current session value, also available as `Hellotext.session`. | -| `utm-set` | UTM parameters are saved. | A JSON string containing the saved UTM parameters and `observed_at`. Use `JSON.parse` to read it as an object. | +| `utm-set` | The UTM parameters the visitor arrived with are saved. | A JSON string containing the saved UTM parameters and `observed_at`. Use `JSON.parse` to read it as an object. | + +UTM parameters on links inside the site (a store's own popup, banner or menu link) do not replace the ones the visitor arrived with and do not fire `utm-set`. They are kept apart and sent with events as `internal_utm_params`. Hellotext's own UTMs always count as an arrival. See [Understanding Sessions](/docs/sessions.md) and [Tracking Events](/docs/tracking.md). diff --git a/__tests__/models/page_test.js b/__tests__/models/page_test.js index 0445977b..aba63797 100644 --- a/__tests__/models/page_test.js +++ b/__tests__/models/page_test.js @@ -194,6 +194,29 @@ describe('Page', () => { utm_params: {} }) }) + + it('adds the in-site UTM when the visitor clicked one of the site\'s own UTM links', () => { + mockUtm.current = { source: 'google', medium: 'cpc' } + mockUtm.internal = { source: 'pop_up_tv', medium: 'pop_up_tv', observed_at: '2026-10-24T18:00:00.000Z' } + + const page = new Page() + + expect(page.trackingData).toEqual({ + page: { + url: 'https://example.com/current-page?param=value', + title: 'Current Page Title', + path: '/current-page' + }, + utm_params: { source: 'google', medium: 'cpc' }, + internal_utm_params: { source: 'pop_up_tv', medium: 'pop_up_tv', observed_at: '2026-10-24T18:00:00.000Z' } + }) + }) + + it('leaves the in-site UTM out when there is none', () => { + mockUtm.internal = {} + + expect(new Page().trackingData).not.toHaveProperty('internal_utm_params') + }) }) describe('domain getter', () => { diff --git a/__tests__/models/utm_navigation_test.js b/__tests__/models/utm_navigation_test.js new file mode 100644 index 00000000..88191204 --- /dev/null +++ b/__tests__/models/utm_navigation_test.js @@ -0,0 +1,214 @@ +/** + * @jest-environment jsdom + */ + +jest.mock('../../src/hellotext', () => { + return { + __esModule: true, + default: { + eventEmitter: { + dispatch: jest.fn() + } + } + } +}) + +import { Cookies } from '../../src/models/cookies' +import { UTM } from '../../src/models/utm' + +jest.mock('../../src/models/cookies') + +describe('UTM arrival and in-site navigation', () => { + const now = '2026-10-24T18:00:00.000Z' + const documentStart = Date.parse('2026-10-24T17:59:00.000Z') + const ad = '?utm_source=google&utm_medium=cpc&utm_campaign=brand' + const popup = '?utm_source=pop_up_tv&utm_medium=pop_up_tv&utm_campaign=pop_up_tv' + const hellotext = '?utm_source=hellotext&utm_medium=whatsapp&utm_campaign=product_recommender' + + let cookies + let navigationEntry + + const visit = ({ search, loadedWith = search, referrer = '', type = 'navigate', hostname = 'www.store.com' }) => { + window.location = { search, hostname } + navigationEntry = { name: `https://${hostname}/landing${loadedWith}`, type } + Object.defineProperty(document, 'referrer', { value: referrer, configurable: true }) + + return new UTM() + } + + const written = name => Cookies.set.mock.calls.filter(([cookie]) => cookie === name).map(([, value]) => JSON.parse(value)) + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date(now)) + jest.clearAllMocks() + + cookies = {} + Cookies.get = jest.fn(name => cookies[name]) + Cookies.set = jest.fn((name, value) => (cookies[name] = value)) + Cookies.delete = jest.fn(name => delete cookies[name]) + + delete window.location + window.performance.getEntriesByType = jest.fn(type => (type === 'navigation' ? [navigationEntry] : [])) + Object.defineProperty(window.performance, 'timeOrigin', { value: documentStart, configurable: true }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + describe('arrivals', () => { + it('stores the UTM the visitor arrived with from another site', () => { + visit({ search: ad, referrer: 'https://www.instagram.com/' }) + + expect(written('hello_utm')).toEqual([{ source: 'google', medium: 'cpc', campaign: 'brand', observed_at: now }]) + expect(written('hello_utm_internal')).toEqual([]) + }) + + it('stores the UTM of a visit with no referrer', () => { + visit({ search: ad }) + + expect(written('hello_utm')).toHaveLength(1) + }) + + it('keeps a same-site redirect that carries the same UTMs as the arrival', () => { + visit({ search: ad, referrer: `https://www.store.com/${ad}` }) + + expect(written('hello_utm')).toHaveLength(1) + expect(written('hello_utm_internal')).toEqual([]) + }) + + it('clears the in-site UTM of the previous visit', () => { + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', observed_at: '2026-10-20T10:00:00.000Z' }) + + visit({ search: ad }) + + expect(Cookies.delete).toHaveBeenCalledWith('hello_utm_internal') + }) + }) + + describe('in-site UTMs', () => { + it('keeps the arrival when a single-page store navigates to a link with UTMs', () => { + cookies.hello_utm = JSON.stringify({ source: 'hellotext', medium: 'whatsapp', observed_at: '2026-10-24T17:59:30.000Z' }) + + visit({ search: popup, loadedWith: '' }) + + expect(written('hello_utm')).toEqual([]) + expect(written('hello_utm_internal')).toEqual([ + { source: 'pop_up_tv', medium: 'pop_up_tv', campaign: 'pop_up_tv', observed_at: now }, + ]) + }) + + it('keeps the arrival when a reloading store opens its own link from another page of the site', () => { + visit({ search: popup, referrer: 'https://www.store.com/tv/samsung-55' }) + + expect(written('hello_utm')).toEqual([]) + expect(written('hello_utm_internal')).toHaveLength(1) + }) + + it('treats another subdomain of the store as the same site', () => { + visit({ search: popup, referrer: 'https://blog.store.com/post' }) + + expect(written('hello_utm')).toEqual([]) + expect(written('hello_utm_internal')).toHaveLength(1) + }) + + it('treats a single-page navigation to different UTMs as in-site even after an ad arrival', () => { + visit({ search: popup, loadedWith: ad }) + + expect(written('hello_utm')).toEqual([]) + expect(written('hello_utm_internal')).toHaveLength(1) + }) + + it('keeps the first time the same in-site UTM was seen', () => { + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', campaign: 'pop_up_tv', observed_at: '2026-10-24T17:59:30.000Z' }) + + visit({ search: popup, loadedWith: '' }) + + expect(Cookies.set).not.toHaveBeenCalled() + }) + }) + + describe('Hellotext UTMs', () => { + it('stores a Hellotext link opened inside the store as the arrival', () => { + cookies.hello_utm = JSON.stringify({ source: 'google', medium: 'cpc', observed_at: '2026-10-24T17:59:30.000Z' }) + + visit({ search: hellotext, loadedWith: '', referrer: 'https://www.store.com/' }) + + expect(written('hello_utm')).toEqual([ + { source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: now }, + ]) + expect(written('hello_utm_internal')).toEqual([]) + }) + + it('keeps the time of a Hellotext UTM read again in the same document', () => { + cookies.hello_utm = JSON.stringify({ source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: '2026-10-24T17:59:30.000Z' }) + + visit({ search: hellotext, loadedWith: '' }) + + expect(Cookies.set).not.toHaveBeenCalled() + }) + }) + + describe('observation time', () => { + const storedAd = observedAt => JSON.stringify({ source: 'google', medium: 'cpc', campaign: 'brand', observed_at: observedAt }) + + it('keeps the time when the same arrival is read again in this document', () => { + cookies.hello_utm = storedAd('2026-10-24T17:59:30.000Z') + + visit({ search: ad }) + + expect(Cookies.set).not.toHaveBeenCalled() + }) + + it('keeps the time on a reload', () => { + cookies.hello_utm = storedAd('2026-10-20T10:00:00.000Z') + + visit({ search: ad, type: 'reload' }) + + expect(Cookies.set).not.toHaveBeenCalled() + }) + + it('keeps the time on a back or forward navigation', () => { + cookies.hello_utm = storedAd('2026-10-20T10:00:00.000Z') + + visit({ search: ad, type: 'back_forward' }) + + expect(Cookies.set).not.toHaveBeenCalled() + }) + + it('stamps a new time for a new click on the same link', () => { + cookies.hello_utm = storedAd('2026-10-20T10:00:00.000Z') + + visit({ search: ad, referrer: 'https://www.google.com/' }) + + expect(written('hello_utm')).toEqual([{ source: 'google', medium: 'cpc', campaign: 'brand', observed_at: now }]) + }) + }) + + describe('without navigation timing', () => { + it('treats the current address as the one the document was loaded with', () => { + window.performance.getEntriesByType = undefined + window.location = { search: ad, hostname: 'www.store.com' } + Object.defineProperty(document, 'referrer', { value: '', configurable: true }) + + new UTM() + + expect(written('hello_utm')).toHaveLength(1) + }) + }) + + describe('internal getter', () => { + it('returns the stored in-site UTM', () => { + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv' }) + window.location = { search: '', hostname: 'www.store.com' } + + expect(new UTM().internal).toEqual({ source: 'pop_up_tv', medium: 'pop_up_tv' }) + }) + + it('returns an empty object without one', () => { + window.location = { search: '', hostname: 'www.store.com' } + + expect(new UTM().internal).toEqual({}) + }) + }) +}) diff --git a/src/models/page.js b/src/models/page.js index 8a5f6221..f122ff89 100644 --- a/src/models/page.js +++ b/src/models/page.js @@ -48,9 +48,12 @@ class Page { /** * Get page data for tracking - * @returns {Object} Object containing page object and utm_params + * @returns {Object} Object containing page object, utm_params and, when the + * visitor clicked one of the site's own UTM links, internal_utm_params */ get trackingData() { + const internalUtmParams = this.utm.internal || {} + return { page: { url: this.url, @@ -58,6 +61,7 @@ class Page { path: this.path, }, utm_params: this.utmParams, + ...(Object.keys(internalUtmParams).length && { internal_utm_params: internalUtmParams }), } } diff --git a/src/models/utm.js b/src/models/utm.js index bb95c8af..0eb81300 100644 --- a/src/models/utm.js +++ b/src/models/utm.js @@ -1,38 +1,208 @@ import { Cookies } from './cookies' +import { Page } from './page' +const UTM_KEYS = ['source', 'medium', 'campaign', 'term', 'content'] +const ARRIVAL_COOKIE = 'hello_utm' +const IN_SITE_COOKIE = 'hello_utm_internal' + +/** + * Reads the UTM parameters on the current URL and keeps the click that + * brought the visitor to the site. + * + * A UTM is an arrival when it is on the URL the document was loaded with and + * the visitor did not come from another page of the same site. It is stored in + * `hello_utm`, sent with every event as `utm_params`, and announced with + * `utm-set`. + * + * Any other UTM was picked up while the visitor was already browsing the site: + * the store's own popup, banner or menu links often carry UTMs. Letting those + * replace the arrival would hand the sale to the store's own link, so they are + * stored apart in `hello_utm_internal` and sent as `internal_utm_params`, + * where the server can still recognise them when a commerce platform reports + * the same UTM on the order. Two signals mark them, whatever the store: + * - single-page stores navigate without reloading, so the UTM differs from the + * one on the URL the document was loaded with; + * - stores that reload report the page the visitor came from, so the referrer + * is another page of the same site with different UTMs. + * + * Hellotext's own UTMs are never in-site: a Hellotext link opened inside the + * store, from the webchat for example, is our click. + * + * `observed_at` is when the click happened. Reading the same UTM again keeps + * it: single-page stores build a new Page for every tracked event, and a reload + * or a back navigation is not a new click. Only a new navigation to the URL + * stamps a new time. + */ class UTM { constructor() { - const urlSearchParams = new URLSearchParams(window.location.search) - - const utmsFromUrl = { - source: urlSearchParams.get('utm_source'), - medium: urlSearchParams.get('utm_medium'), - campaign: urlSearchParams.get('utm_campaign'), - term: urlSearchParams.get('utm_term'), - content: urlSearchParams.get('utm_content'), - } + const utms = UTM.fromSearch(window.location.search) + if (!utms) return - this.save(utmsFromUrl) + if (UTM.isArrival(utms)) { + this.saveArrival(utms) + } else if (UTM.isHellotext(utms)) { + this.saveIfChanged(ARRIVAL_COOKIE, utms) + } else { + this.saveIfChanged(IN_SITE_COOKIE, utms) + } } + /** + * Stores the given UTM parameters as the visitor's current click, observed + * now. Callers use it for clicks they know are Hellotext's, such as a + * webchat product card. + * @param {Object} utmParams + */ save(utmParams) { + this.write(ARRIVAL_COOKIE, utmParams) + } + + /** + * The click that brought the visitor, as sent with events in `utm_params`. + * @returns {Object} + */ + get current() { + return UTM.read(ARRIVAL_COOKIE) + } + + /** + * The last UTM picked up while browsing the site, sent with events in + * `internal_utm_params`. + * @returns {Object} + */ + get internal() { + return UTM.read(IN_SITE_COOKIE) + } + + /** + * Stores an arrival unless it is the one already stored and this is not a + * new click on it: it was stored during this document, or this document is + * a reload or a back or forward navigation. A new arrival also clears the + * in-site UTM, which belonged to the previous visit. + */ + saveArrival(utms) { + const stored = this.current + + if ( + UTM.same(utms, stored) && + (UTM.restoredNavigation || UTM.observedDuringThisDocument(stored)) + ) + return + + this.write(ARRIVAL_COOKIE, utms) + if (Object.keys(this.internal).length) Cookies.delete(IN_SITE_COOKIE) + } + + saveIfChanged(cookie, utms) { + if (UTM.same(utms, UTM.read(cookie))) return + + this.write(cookie, utms) + } + + write(cookie, utmParams) { if (!utmParams.source || !utmParams.medium) return const cleanUtms = Object.fromEntries( - Object.entries(utmParams).filter(([_, value]) => value), + Object.entries(utmParams).filter(([key, value]) => value && key !== 'observed_at'), ) cleanUtms.observed_at = new Date().toISOString() - Cookies.set('hello_utm', JSON.stringify(cleanUtms)) + Cookies.set(cookie, JSON.stringify(cleanUtms)) } - get current() { + /** + * The UTM parameters in a query string, or null without both a source and a + * medium. + */ + static fromSearch(search) { + const params = new URLSearchParams(search || '') + const utms = Object.fromEntries( + UTM_KEYS.map(key => [key, params.get(`utm_${key}`)]).filter(([_, value]) => value), + ) + + return utms.source && utms.medium ? utms : null + } + + /** + * On the URL the document was loaded with, and not handed over from another + * page of this site that carried different UTMs (a redirect that keeps the + * query is still the same arrival). + */ + static isArrival(utms) { + if (!UTM.same(utms, UTM.fromSearch(UTM.documentSearch))) return false + + const referrer = UTM.sameSiteReferrer + return !referrer || UTM.same(utms, UTM.fromSearch(referrer.search)) + } + + static isHellotext(utms) { + return String(utms.source).toLowerCase() === 'hellotext' + } + + static same(a, b) { + if (!a || !b) return false + + return UTM_KEYS.every(key => (a[key] || '') === (b[key] || '')) + } + + static read(cookie) { try { - return JSON.parse(Cookies.get('hello_utm')) || {} + return JSON.parse(Cookies.get(cookie)) || {} } catch (e) { return {} } } + + /** + * The query string the document was loaded with. Single-page stores change + * the address without loading a new document, and the navigation entry + * keeps the original. Without it, the current address is the best answer. + */ + static get documentSearch() { + try { + const entry = window.performance?.getEntriesByType?.('navigation')?.[0] + if (entry?.name) return new URL(entry.name).search + } catch (e) { + // An unparsable entry falls back to the current address. + } + + return window.location.search + } + + /** + * The referrer as a URL when it is another page of this site, compared by + * root domain so subdomains of one store count as the same site. + */ + static get sameSiteReferrer() { + try { + if (typeof document === 'undefined' || !document.referrer || !window.location.hostname) + return null + + const referrer = new URL(document.referrer) + const sameSite = + Page.getRootDomain(referrer.hostname) === Page.getRootDomain(window.location.hostname) + + return sameSite ? referrer : null + } catch (e) { + return null + } + } + + static get restoredNavigation() { + try { + const type = window.performance?.getEntriesByType?.('navigation')?.[0]?.type + return type === 'reload' || type === 'back_forward' + } catch (e) { + return false + } + } + + static observedDuringThisDocument(stored) { + const documentStart = window.performance?.timeOrigin + const observedAt = Date.parse(stored?.observed_at) + + return Boolean(documentStart) && !Number.isNaN(observedAt) && observedAt >= documentStart + } } export { UTM } From 7bfee56075c5986878b66d00e3b27dbc608aa15a Mon Sep 17 00:00:00 2001 From: rockwellll Date: Sat, 24 Oct 2026 15:15:15 -0300 Subject: [PATCH 2/8] Keep same-site pages that carry in-site UTMs in-site On a reloading store, a page reached from the store's own tagged link often keeps the same UTMs in its links (pagination, filters). The referrer then carries the same UTMs, which was read as a redirect of the arrival: the store's link overwrote hello_utm and the in-site marker was deleted. Same-site pages whose UTMs equal the stored in-site ones now stay in-site. Likewise, an arrival whose UTMs the store keeps on the next page is the same click, so its observed_at is no longer re-stamped. Co-Authored-By: Claude Opus 5.5 --- __tests__/models/utm_navigation_test.js | 27 +++++++++++++++++++++ src/models/utm.js | 31 +++++++++++++++---------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/__tests__/models/utm_navigation_test.js b/__tests__/models/utm_navigation_test.js index 88191204..de226000 100644 --- a/__tests__/models/utm_navigation_test.js +++ b/__tests__/models/utm_navigation_test.js @@ -126,6 +126,25 @@ describe('UTM arrival and in-site navigation', () => { expect(Cookies.set).not.toHaveBeenCalled() }) + + it('stays in-site when a reloading store keeps the in-site UTMs on the next page', () => { + cookies.hello_utm = JSON.stringify({ source: 'google', medium: 'cpc', campaign: 'brand', observed_at: '2026-10-24T17:00:00.000Z' }) + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', campaign: 'pop_up_tv', observed_at: '2026-10-24T17:30:00.000Z' }) + + visit({ search: popup, referrer: `https://www.store.com/tv${popup}` }) + + expect(Cookies.set).not.toHaveBeenCalled() + expect(Cookies.delete).not.toHaveBeenCalled() + expect(JSON.parse(cookies.hello_utm)).toMatchObject({ source: 'google', observed_at: '2026-10-24T17:00:00.000Z' }) + }) + + it('treats the in-site UTMs as an arrival when the visitor comes back from outside the site', () => { + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', campaign: 'pop_up_tv', observed_at: '2026-10-20T10:00:00.000Z' }) + + visit({ search: popup, referrer: 'https://mail.google.com/' }) + + expect(written('hello_utm')).toEqual([{ source: 'pop_up_tv', medium: 'pop_up_tv', campaign: 'pop_up_tv', observed_at: now }]) + }) }) describe('Hellotext UTMs', () => { @@ -183,6 +202,14 @@ describe('UTM arrival and in-site navigation', () => { expect(written('hello_utm')).toEqual([{ source: 'google', medium: 'cpc', campaign: 'brand', observed_at: now }]) }) + + it("keeps the time when a reloading store keeps the arrival's UTMs on the next page", () => { + cookies.hello_utm = storedAd('2026-10-24T17:00:00.000Z') + + visit({ search: ad, referrer: `https://www.store.com/category${ad}` }) + + expect(Cookies.set).not.toHaveBeenCalled() + }) }) describe('without navigation timing', () => { diff --git a/src/models/utm.js b/src/models/utm.js index 0eb81300..d89e3826 100644 --- a/src/models/utm.js +++ b/src/models/utm.js @@ -76,18 +76,19 @@ class UTM { /** * Stores an arrival unless it is the one already stored and this is not a - * new click on it: it was stored during this document, or this document is - * a reload or a back or forward navigation. A new arrival also clears the - * in-site UTM, which belonged to the previous visit. + * new click on it: it was stored during this document, this document is a + * reload or a back or forward navigation, or the visitor came from another + * page of this site whose links kept the arrival's UTMs. A new arrival also + * clears the in-site UTM, which belonged to the previous visit. */ saveArrival(utms) { const stored = this.current + const sameClick = + UTM.restoredNavigation || + UTM.observedDuringThisDocument(stored) || + Boolean(UTM.sameSiteReferrer) - if ( - UTM.same(utms, stored) && - (UTM.restoredNavigation || UTM.observedDuringThisDocument(stored)) - ) - return + if (UTM.same(utms, stored) && sameClick) return this.write(ARRIVAL_COOKIE, utms) if (Object.keys(this.internal).length) Cookies.delete(IN_SITE_COOKIE) @@ -124,15 +125,21 @@ class UTM { } /** - * On the URL the document was loaded with, and not handed over from another - * page of this site that carried different UTMs (a redirect that keeps the - * query is still the same arrival). + * On the URL the document was loaded with, and either reached from outside + * the site or handed over from another page of this site that carried the + * same UTMs: a redirect that keeps the query is still the arrival. When those + * same UTMs are the stored in-site ones, the visitor is browsing on from the + * store's own link (pagination and filters often keep the query), so they + * stay in-site. */ static isArrival(utms) { if (!UTM.same(utms, UTM.fromSearch(UTM.documentSearch))) return false const referrer = UTM.sameSiteReferrer - return !referrer || UTM.same(utms, UTM.fromSearch(referrer.search)) + if (!referrer) return true + if (UTM.same(utms, UTM.read(IN_SITE_COOKIE))) return false + + return UTM.same(utms, UTM.fromSearch(referrer.search)) } static isHellotext(utms) { From 69a520105841e927f3f7124fdd3f716e9d761807 Mon Sep 17 00:00:00 2001 From: rockwellll Date: Sat, 24 Oct 2026 15:16:50 -0300 Subject: [PATCH 3/8] Restamp repeated Hellotext clicks in new documents A Hellotext link opened from an untagged page of the store compared only UTM values, so a campaign stored on an earlier visit kept its old observed_at and fired no utm-set on a genuine new click, leaving integrations such as the VTEX orderForm stamp with the old click. Hellotext clicks now keep their time only when read again during the same document or on a reload or back navigation. Co-Authored-By: Claude Opus 5.5 --- __tests__/models/utm_navigation_test.js | 26 +++++++++++++++++++++++++ src/models/utm.js | 21 +++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/__tests__/models/utm_navigation_test.js b/__tests__/models/utm_navigation_test.js index de226000..bd97e8c4 100644 --- a/__tests__/models/utm_navigation_test.js +++ b/__tests__/models/utm_navigation_test.js @@ -159,6 +159,32 @@ describe('UTM arrival and in-site navigation', () => { expect(written('hello_utm_internal')).toEqual([]) }) + it('stamps a new click on a Hellotext link the visitor also clicked on an earlier visit', () => { + cookies.hello_utm = JSON.stringify({ source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: '2026-10-20T10:00:00.000Z' }) + + visit({ search: hellotext, referrer: 'https://www.store.com/tv' }) + + expect(written('hello_utm')).toEqual([ + { source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: now }, + ]) + }) + + it('stamps it again when a single-page store opens it in-page on a new visit', () => { + cookies.hello_utm = JSON.stringify({ source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: '2026-10-20T10:00:00.000Z' }) + + visit({ search: hellotext, loadedWith: '' }) + + expect(written('hello_utm')).toHaveLength(1) + }) + + it('keeps the time of a Hellotext link reloaded from inside the store', () => { + cookies.hello_utm = JSON.stringify({ source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: '2026-10-20T10:00:00.000Z' }) + + visit({ search: hellotext, referrer: 'https://www.store.com/tv', type: 'reload' }) + + expect(Cookies.set).not.toHaveBeenCalled() + }) + it('keeps the time of a Hellotext UTM read again in the same document', () => { cookies.hello_utm = JSON.stringify({ source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: '2026-10-24T17:59:30.000Z' }) diff --git a/src/models/utm.js b/src/models/utm.js index d89e3826..e0b86627 100644 --- a/src/models/utm.js +++ b/src/models/utm.js @@ -41,7 +41,7 @@ class UTM { if (UTM.isArrival(utms)) { this.saveArrival(utms) } else if (UTM.isHellotext(utms)) { - this.saveIfChanged(ARRIVAL_COOKIE, utms) + this.saveHellotext(utms) } else { this.saveIfChanged(IN_SITE_COOKIE, utms) } @@ -94,6 +94,25 @@ class UTM { if (Object.keys(this.internal).length) Cookies.delete(IN_SITE_COOKIE) } + /** + * Stores a Hellotext click opened from inside the store. Reading it again + * during this document, or on a reload or a back or forward navigation, + * keeps its time. A new document is a new click even when an earlier visit + * stored the same campaign: the cookie lives for years, and `utm-set` must + * fire again so integrations restamp the order with the fresh click. + */ + saveHellotext(utms) { + const stored = this.current + + if ( + UTM.same(utms, stored) && + (UTM.restoredNavigation || UTM.observedDuringThisDocument(stored)) + ) + return + + this.write(ARRIVAL_COOKIE, utms) + } + saveIfChanged(cookie, utms) { if (UTM.same(utms, UTM.read(cookie))) return From 708cdd339fc5b94aafa8ecd1349e63b33374fbb7 Mon Sep 17 00:00:00 2001 From: rockwellll Date: Sat, 24 Oct 2026 15:18:30 -0300 Subject: [PATCH 4/8] Clear the in-site UTM on every new click Only outside arrivals cleared hello_utm_internal. A Hellotext link opened in the store and the webchat's explicit save() recorded a new click but kept the previous store banner, which every later event kept sending as internal_utm_params. All three paths now record the click through one helper that clears the in-site marker once the new click is written. Co-Authored-By: Claude Opus 5.5 --- __tests__/models/utm_navigation_test.js | 29 +++++++++++++++++++++++++ src/models/utm.js | 28 ++++++++++++++++++------ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/__tests__/models/utm_navigation_test.js b/__tests__/models/utm_navigation_test.js index bd97e8c4..f99a60ec 100644 --- a/__tests__/models/utm_navigation_test.js +++ b/__tests__/models/utm_navigation_test.js @@ -177,6 +177,35 @@ describe('UTM arrival and in-site navigation', () => { expect(written('hello_utm')).toHaveLength(1) }) + it('clears the in-site UTM when a Hellotext link is opened after a store banner', () => { + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', observed_at: '2026-10-24T17:59:10.000Z' }) + + visit({ search: hellotext, loadedWith: '' }) + + expect(written('hello_utm')).toHaveLength(1) + expect(Cookies.delete).toHaveBeenCalledWith('hello_utm_internal') + }) + + it('clears the in-site UTM when the webchat saves a Hellotext click', () => { + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', observed_at: '2026-10-24T17:59:10.000Z' }) + window.location = { search: '', hostname: 'www.store.com' } + + new UTM().save({ source: 'hellotext', medium: 'webchat', campaign: 'webchat_widget' }) + + expect(written('hello_utm')).toHaveLength(1) + expect(Cookies.delete).toHaveBeenCalledWith('hello_utm_internal') + }) + + it('keeps the in-site UTM when an explicit save has nothing to store', () => { + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', observed_at: '2026-10-24T17:59:10.000Z' }) + window.location = { search: '', hostname: 'www.store.com' } + + new UTM().save({ source: 'hellotext' }) + + expect(Cookies.set).not.toHaveBeenCalled() + expect(Cookies.delete).not.toHaveBeenCalled() + }) + it('keeps the time of a Hellotext link reloaded from inside the store', () => { cookies.hello_utm = JSON.stringify({ source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: '2026-10-20T10:00:00.000Z' }) diff --git a/src/models/utm.js b/src/models/utm.js index e0b86627..90dd96a8 100644 --- a/src/models/utm.js +++ b/src/models/utm.js @@ -54,7 +54,7 @@ class UTM { * @param {Object} utmParams */ save(utmParams) { - this.write(ARRIVAL_COOKIE, utmParams) + this.recordClick(utmParams) } /** @@ -78,8 +78,7 @@ class UTM { * Stores an arrival unless it is the one already stored and this is not a * new click on it: it was stored during this document, this document is a * reload or a back or forward navigation, or the visitor came from another - * page of this site whose links kept the arrival's UTMs. A new arrival also - * clears the in-site UTM, which belonged to the previous visit. + * page of this site whose links kept the arrival's UTMs. */ saveArrival(utms) { const stored = this.current @@ -90,8 +89,7 @@ class UTM { if (UTM.same(utms, stored) && sameClick) return - this.write(ARRIVAL_COOKIE, utms) - if (Object.keys(this.internal).length) Cookies.delete(IN_SITE_COOKIE) + this.recordClick(utms) } /** @@ -110,7 +108,18 @@ class UTM { ) return - this.write(ARRIVAL_COOKIE, utms) + this.recordClick(utms) + } + + /** + * Records a new click as the visitor's current one, whether an arrival, a + * Hellotext link opened in the store or an explicit save. The in-site UTM + * described the browsing before it, so it is cleared rather than sent along + * with the new click on every event. + */ + recordClick(utms) { + if (!this.write(ARRIVAL_COOKIE, utms)) return + if (Object.keys(this.internal).length) Cookies.delete(IN_SITE_COOKIE) } saveIfChanged(cookie, utms) { @@ -119,8 +128,12 @@ class UTM { this.write(cookie, utms) } + /** + * Writes the UTM parameters to the cookie, observed now. Returns whether + * they had the source and medium needed to be written. + */ write(cookie, utmParams) { - if (!utmParams.source || !utmParams.medium) return + if (!utmParams.source || !utmParams.medium) return false const cleanUtms = Object.fromEntries( Object.entries(utmParams).filter(([key, value]) => value && key !== 'observed_at'), @@ -128,6 +141,7 @@ class UTM { cleanUtms.observed_at = new Date().toISOString() Cookies.set(cookie, JSON.stringify(cleanUtms)) + return true } /** From 430d7c12aab8264528f44343422d7ac5ae158733 Mon Sep 17 00:00:00 2001 From: rockwellll Date: Sat, 24 Oct 2026 15:20:04 -0300 Subject: [PATCH 5/8] Keep in-site UTMs in-site on reloads and back navigations Reloading a single-page store on the store's own tagged link loads the document with the link's UTMs while the referrer is still the one the visit started with, so the link was taken as a new arrival: it overwrote the real acquisition and cleared hello_utm_internal. A reload or a back or forward navigation that restores the stored in-site UTMs now keeps them in-site; a new navigation from outside the site with the same UTMs is still an arrival. Co-Authored-By: Claude Opus 5.5 --- __tests__/models/utm_navigation_test.js | 20 ++++++++++++++++++++ src/models/utm.js | 16 +++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/__tests__/models/utm_navigation_test.js b/__tests__/models/utm_navigation_test.js index f99a60ec..ff5a57ba 100644 --- a/__tests__/models/utm_navigation_test.js +++ b/__tests__/models/utm_navigation_test.js @@ -138,6 +138,26 @@ describe('UTM arrival and in-site navigation', () => { expect(JSON.parse(cookies.hello_utm)).toMatchObject({ source: 'google', observed_at: '2026-10-24T17:00:00.000Z' }) }) + it('stays in-site when a single-page store is reloaded on the store link', () => { + cookies.hello_utm = JSON.stringify({ source: 'google', medium: 'cpc', campaign: 'brand', observed_at: '2026-10-24T17:00:00.000Z' }) + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', campaign: 'pop_up_tv', observed_at: '2026-10-24T17:30:00.000Z' }) + + visit({ search: popup, referrer: 'https://www.google.com/', type: 'reload' }) + + expect(Cookies.set).not.toHaveBeenCalled() + expect(Cookies.delete).not.toHaveBeenCalled() + }) + + it('stays in-site when a back or forward navigation restores the store link without a referrer', () => { + cookies.hello_utm = JSON.stringify({ source: 'google', medium: 'cpc', campaign: 'brand', observed_at: '2026-10-24T17:00:00.000Z' }) + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', campaign: 'pop_up_tv', observed_at: '2026-10-24T17:30:00.000Z' }) + + visit({ search: popup, type: 'back_forward' }) + + expect(Cookies.set).not.toHaveBeenCalled() + expect(Cookies.delete).not.toHaveBeenCalled() + }) + it('treats the in-site UTMs as an arrival when the visitor comes back from outside the site', () => { cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', campaign: 'pop_up_tv', observed_at: '2026-10-20T10:00:00.000Z' }) diff --git a/src/models/utm.js b/src/models/utm.js index 90dd96a8..f660800e 100644 --- a/src/models/utm.js +++ b/src/models/utm.js @@ -160,17 +160,23 @@ class UTM { /** * On the URL the document was loaded with, and either reached from outside * the site or handed over from another page of this site that carried the - * same UTMs: a redirect that keeps the query is still the arrival. When those - * same UTMs are the stored in-site ones, the visitor is browsing on from the - * store's own link (pagination and filters often keep the query), so they - * stay in-site. + * same UTMs: a redirect that keeps the query is still the arrival. + * + * The stored in-site UTMs stay in-site in two cases, because neither is a + * new click: the visitor browses on from the store's own link (pagination + * and filters often keep the query), or a reload or a back or forward + * navigation restores a URL they were already on. A single-page store + * reloaded on the store's link loads the document with its UTMs while the + * referrer is still the one the visit started with. A new navigation from + * outside the site with the same UTMs is still an arrival. */ static isArrival(utms) { if (!UTM.same(utms, UTM.fromSearch(UTM.documentSearch))) return false const referrer = UTM.sameSiteReferrer + const inSite = UTM.same(utms, UTM.read(IN_SITE_COOKIE)) + if (inSite && (referrer || UTM.restoredNavigation)) return false if (!referrer) return true - if (UTM.same(utms, UTM.read(IN_SITE_COOKIE))) return false return UTM.same(utms, UTM.fromSearch(referrer.search)) } From 24e9cf092eb805a61414d6ff849f16acacb186e7 Mon Sep 17 00:00:00 2001 From: rockwellll Date: Sat, 24 Oct 2026 15:23:37 -0300 Subject: [PATCH 6/8] Apply the reload rule only to restored tagged URLs The navigation type describes the whole document, so on a single-page store it stays "reload" for links opened after reloading an untagged page. A Hellotext link opened in-page then kept an earlier visit's observed_at and fired no utm-set. The reload and back/forward rule now applies only when the UTMs were on the URL the document was loaded with; same-document re-reads still keep their time. Co-Authored-By: Claude Opus 5.5 --- __tests__/models/utm_navigation_test.js | 10 +++++++++ src/models/utm.js | 30 +++++++++++++++---------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/__tests__/models/utm_navigation_test.js b/__tests__/models/utm_navigation_test.js index ff5a57ba..34be595a 100644 --- a/__tests__/models/utm_navigation_test.js +++ b/__tests__/models/utm_navigation_test.js @@ -226,6 +226,16 @@ describe('UTM arrival and in-site navigation', () => { expect(Cookies.delete).not.toHaveBeenCalled() }) + it('stamps a Hellotext link opened in-page after a single-page store was reloaded on another page', () => { + cookies.hello_utm = JSON.stringify({ source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: '2026-10-20T10:00:00.000Z' }) + + visit({ search: hellotext, loadedWith: '', type: 'reload' }) + + expect(written('hello_utm')).toEqual([ + { source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: now }, + ]) + }) + it('keeps the time of a Hellotext link reloaded from inside the store', () => { cookies.hello_utm = JSON.stringify({ source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: '2026-10-20T10:00:00.000Z' }) diff --git a/src/models/utm.js b/src/models/utm.js index f660800e..e32fefbf 100644 --- a/src/models/utm.js +++ b/src/models/utm.js @@ -83,9 +83,7 @@ class UTM { saveArrival(utms) { const stored = this.current const sameClick = - UTM.restoredNavigation || - UTM.observedDuringThisDocument(stored) || - Boolean(UTM.sameSiteReferrer) + UTM.restored(utms) || UTM.observedDuringThisDocument(stored) || Boolean(UTM.sameSiteReferrer) if (UTM.same(utms, stored) && sameClick) return @@ -94,18 +92,16 @@ class UTM { /** * Stores a Hellotext click opened from inside the store. Reading it again - * during this document, or on a reload or a back or forward navigation, - * keeps its time. A new document is a new click even when an earlier visit - * stored the same campaign: the cookie lives for years, and `utm-set` must - * fire again so integrations restamp the order with the fresh click. + * during this document, or on a reload or a back or forward navigation that + * restores its URL, keeps its time. Anything else is a new click even when + * an earlier visit stored the same campaign: the cookie lives for years, and + * `utm-set` must fire again so integrations restamp the order with the fresh + * click. */ saveHellotext(utms) { const stored = this.current - if ( - UTM.same(utms, stored) && - (UTM.restoredNavigation || UTM.observedDuringThisDocument(stored)) - ) + if (UTM.same(utms, stored) && (UTM.restored(utms) || UTM.observedDuringThisDocument(stored))) return this.recordClick(utms) @@ -175,7 +171,7 @@ class UTM { const referrer = UTM.sameSiteReferrer const inSite = UTM.same(utms, UTM.read(IN_SITE_COOKIE)) - if (inSite && (referrer || UTM.restoredNavigation)) return false + if (inSite && (referrer || UTM.restored(utms))) return false if (!referrer) return true return UTM.same(utms, UTM.fromSearch(referrer.search)) @@ -234,6 +230,16 @@ class UTM { } } + /** + * Whether these UTMs are on a URL that a reload or a back or forward + * navigation restored. The navigation type describes the whole document, + * so on a single-page store it stays "reload" for links opened afterwards; + * only the UTMs on the URL the document was loaded with were restored. + */ + static restored(utms) { + return UTM.restoredNavigation && UTM.same(utms, UTM.fromSearch(UTM.documentSearch)) + } + static get restoredNavigation() { try { const type = window.performance?.getEntriesByType?.('navigation')?.[0]?.type From 7f12360889459665c007ca2681181c2e019c212c Mon Sep 17 00:00:00 2001 From: rockwellll Date: Sat, 24 Oct 2026 15:26:43 -0300 Subject: [PATCH 7/8] Clear the in-site UTM before publishing a new click Writing hello_utm fires utm-set synchronously, and the in-site cookie was only deleted afterwards, so a utm-set listener that tracked an event sent the old banner as internal_utm_params with the new click. A new click is now validated first, then the in-site UTM is cleared, then the click is written; an incomplete click still clears nothing. Co-Authored-By: Claude Opus 5.5 --- __tests__/models/utm_navigation_test.js | 13 +++++++++++++ src/models/utm.js | 20 ++++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/__tests__/models/utm_navigation_test.js b/__tests__/models/utm_navigation_test.js index 34be595a..19d5432b 100644 --- a/__tests__/models/utm_navigation_test.js +++ b/__tests__/models/utm_navigation_test.js @@ -216,6 +216,19 @@ describe('UTM arrival and in-site navigation', () => { expect(Cookies.delete).toHaveBeenCalledWith('hello_utm_internal') }) + it('clears the in-site UTM before the new click fires utm-set', () => { + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', observed_at: '2026-10-24T17:59:10.000Z' }) + let internalWhenSet = 'not written' + Cookies.set = jest.fn((name, value) => { + if (name === 'hello_utm') internalWhenSet = cookies.hello_utm_internal + cookies[name] = value + }) + + visit({ search: hellotext, loadedWith: '' }) + + expect(internalWhenSet).toBeUndefined() + }) + it('keeps the in-site UTM when an explicit save has nothing to store', () => { cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', observed_at: '2026-10-24T17:59:10.000Z' }) window.location = { search: '', hostname: 'www.store.com' } diff --git a/src/models/utm.js b/src/models/utm.js index e32fefbf..c431d663 100644 --- a/src/models/utm.js +++ b/src/models/utm.js @@ -111,11 +111,16 @@ class UTM { * Records a new click as the visitor's current one, whether an arrival, a * Hellotext link opened in the store or an explicit save. The in-site UTM * described the browsing before it, so it is cleared rather than sent along - * with the new click on every event. + * with the new click on every event. It is cleared before the click is + * written: writing `hello_utm` fires `utm-set` synchronously, and a listener + * that tracks an event must not send the old in-site UTM with the new click. + * A click without a source and a medium is not written and clears nothing. */ recordClick(utms) { - if (!this.write(ARRIVAL_COOKIE, utms)) return + if (!UTM.complete(utms)) return + if (Object.keys(this.internal).length) Cookies.delete(IN_SITE_COOKIE) + this.write(ARRIVAL_COOKIE, utms) } saveIfChanged(cookie, utms) { @@ -125,11 +130,11 @@ class UTM { } /** - * Writes the UTM parameters to the cookie, observed now. Returns whether - * they had the source and medium needed to be written. + * Writes the UTM parameters to the cookie, observed now, when they have the + * source and medium needed to be stored. */ write(cookie, utmParams) { - if (!utmParams.source || !utmParams.medium) return false + if (!UTM.complete(utmParams)) return const cleanUtms = Object.fromEntries( Object.entries(utmParams).filter(([key, value]) => value && key !== 'observed_at'), @@ -137,7 +142,10 @@ class UTM { cleanUtms.observed_at = new Date().toISOString() Cookies.set(cookie, JSON.stringify(cleanUtms)) - return true + } + + static complete(utmParams) { + return Boolean(utmParams?.source && utmParams?.medium) } /** From f6b6a18d1d43e1baac1f66e94b024d692956aada Mon Sep 17 00:00:00 2001 From: rockwellll Date: Sat, 24 Oct 2026 15:30:47 -0300 Subject: [PATCH 8/8] Never take a reload or back navigation as a new click Only the latest in-site UTM is stored, so reloading or going back to an earlier store link after opening another one promoted it to an arrival, overwriting the acquisition and clearing the in-site cookie. A reload or back or forward navigation now never records a new click: restored UTMs other than the stored arrival are in-site, a restored Hellotext link does not replace a newer arrival, and with no arrival stored the restored UTMs are the arrival. Co-Authored-By: Claude Opus 5.5 --- __tests__/models/utm_navigation_test.js | 36 ++++++++++++++++++++++ src/models/utm.js | 41 +++++++++++++++---------- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/__tests__/models/utm_navigation_test.js b/__tests__/models/utm_navigation_test.js index 19d5432b..a88014fd 100644 --- a/__tests__/models/utm_navigation_test.js +++ b/__tests__/models/utm_navigation_test.js @@ -158,6 +158,34 @@ describe('UTM arrival and in-site navigation', () => { expect(Cookies.delete).not.toHaveBeenCalled() }) + it('stays in-site when a reload restores an earlier store link after another one was opened', () => { + const bannerA = '?utm_source=banner_a&utm_medium=banner_a&utm_campaign=banner_a' + cookies.hello_utm = JSON.stringify({ source: 'google', medium: 'cpc', campaign: 'brand', observed_at: '2026-10-24T17:00:00.000Z' }) + cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', campaign: 'pop_up_tv', observed_at: '2026-10-24T17:40:00.000Z' }) + + visit({ search: bannerA, referrer: 'https://www.google.com/', type: 'reload' }) + + expect(written('hello_utm')).toEqual([]) + expect(Cookies.delete).not.toHaveBeenCalled() + expect(written('hello_utm_internal')).toEqual([ + { source: 'banner_a', medium: 'banner_a', campaign: 'banner_a', observed_at: now }, + ]) + }) + + it("keeps a newer arrival when the visitor goes back to an older visit's tagged landing", () => { + cookies.hello_utm = JSON.stringify({ source: 'facebook', medium: 'paid', observed_at: '2026-10-24T17:00:00.000Z' }) + + visit({ search: ad, type: 'back_forward' }) + + expect(written('hello_utm')).toEqual([]) + }) + + it('takes a restored tagged URL as the arrival when none is stored', () => { + visit({ search: ad, type: 'reload' }) + + expect(written('hello_utm')).toEqual([{ source: 'google', medium: 'cpc', campaign: 'brand', observed_at: now }]) + }) + it('treats the in-site UTMs as an arrival when the visitor comes back from outside the site', () => { cookies.hello_utm_internal = JSON.stringify({ source: 'pop_up_tv', medium: 'pop_up_tv', campaign: 'pop_up_tv', observed_at: '2026-10-20T10:00:00.000Z' }) @@ -249,6 +277,14 @@ describe('UTM arrival and in-site navigation', () => { ]) }) + it('keeps a newer arrival when the visitor goes back to an older Hellotext link', () => { + cookies.hello_utm = JSON.stringify({ source: 'google', medium: 'cpc', campaign: 'brand', observed_at: '2026-10-24T17:00:00.000Z' }) + + visit({ search: hellotext, referrer: 'https://www.store.com/tv', type: 'back_forward' }) + + expect(Cookies.set).not.toHaveBeenCalled() + }) + it('keeps the time of a Hellotext link reloaded from inside the store', () => { cookies.hello_utm = JSON.stringify({ source: 'hellotext', medium: 'whatsapp', campaign: 'product_recommender', observed_at: '2026-10-20T10:00:00.000Z' }) diff --git a/src/models/utm.js b/src/models/utm.js index c431d663..2084f483 100644 --- a/src/models/utm.js +++ b/src/models/utm.js @@ -92,17 +92,18 @@ class UTM { /** * Stores a Hellotext click opened from inside the store. Reading it again - * during this document, or on a reload or a back or forward navigation that - * restores its URL, keeps its time. Anything else is a new click even when - * an earlier visit stored the same campaign: the cookie lives for years, and - * `utm-set` must fire again so integrations restamp the order with the fresh - * click. + * during this document keeps its time, and a reload or a back or forward + * navigation that restores its URL is not a new click, so it neither + * restamps it nor replaces a newer arrival. Anything else is a new click + * even when an earlier visit stored the same campaign: the cookie lives for + * years, and `utm-set` must fire again so integrations restamp the order + * with the fresh click. */ saveHellotext(utms) { const stored = this.current - if (UTM.same(utms, stored) && (UTM.restored(utms) || UTM.observedDuringThisDocument(stored))) - return + if (UTM.restored(utms) && UTM.complete(stored)) return + if (UTM.same(utms, stored) && UTM.observedDuringThisDocument(stored)) return this.recordClick(utms) } @@ -166,21 +167,29 @@ class UTM { * the site or handed over from another page of this site that carried the * same UTMs: a redirect that keeps the query is still the arrival. * - * The stored in-site UTMs stay in-site in two cases, because neither is a - * new click: the visitor browses on from the store's own link (pagination - * and filters often keep the query), or a reload or a back or forward - * navigation restores a URL they were already on. A single-page store - * reloaded on the store's link loads the document with its UTMs while the - * referrer is still the one the visit started with. A new navigation from - * outside the site with the same UTMs is still an arrival. + * A reload or a back or forward navigation is never a new click. When it + * restores UTMs other than the stored arrival, they are in-site: a single- + * page store reloaded on one of the store's links loads the document with + * its UTMs while the referrer is still the one the visit started with, and + * the visitor may have opened other store links since, so the in-site + * cookie alone cannot recognise it. With no arrival stored there is nothing + * to protect, and the restored UTMs are the arrival. + * + * The stored in-site UTMs also stay in-site when the visitor browses on + * from the store's own link: pagination and filters often keep the query. + * A new navigation from outside the site with the same UTMs is an arrival. */ static isArrival(utms) { if (!UTM.same(utms, UTM.fromSearch(UTM.documentSearch))) return false + if (UTM.restored(utms)) { + const stored = UTM.read(ARRIVAL_COOKIE) + return !UTM.complete(stored) || UTM.same(utms, stored) + } + const referrer = UTM.sameSiteReferrer - const inSite = UTM.same(utms, UTM.read(IN_SITE_COOKIE)) - if (inSite && (referrer || UTM.restored(utms))) return false if (!referrer) return true + if (UTM.same(utms, UTM.read(IN_SITE_COOKIE))) return false return UTM.same(utms, UTM.fromSearch(referrer.search)) }