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..a88014fd --- /dev/null +++ b/__tests__/models/utm_navigation_test.js @@ -0,0 +1,375 @@ +/** + * @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() + }) + + 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('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('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' }) + + 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', () => { + 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('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('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('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' } + + new UTM().save({ source: 'hellotext' }) + + expect(Cookies.set).not.toHaveBeenCalled() + 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 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' }) + + 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' }) + + 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 }]) + }) + + 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', () => { + 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..2084f483 100644 --- a/src/models/utm.js +++ b/src/models/utm.js @@ -1,38 +1,277 @@ 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.saveHellotext(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) { - if (!utmParams.source || !utmParams.medium) return + this.recordClick(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, 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. + */ + saveArrival(utms) { + const stored = this.current + const sameClick = + UTM.restored(utms) || UTM.observedDuringThisDocument(stored) || Boolean(UTM.sameSiteReferrer) + + if (UTM.same(utms, stored) && sameClick) return + + this.recordClick(utms) + } + + /** + * Stores a Hellotext click opened from inside the store. Reading it again + * 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.restored(utms) && UTM.complete(stored)) return + if (UTM.same(utms, stored) && UTM.observedDuringThisDocument(stored)) return + + 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. 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 (!UTM.complete(utms)) return + + if (Object.keys(this.internal).length) Cookies.delete(IN_SITE_COOKIE) + this.write(ARRIVAL_COOKIE, utms) + } + + saveIfChanged(cookie, utms) { + if (UTM.same(utms, UTM.read(cookie))) return + + this.write(cookie, utms) + } + + /** + * Writes the UTM parameters to the cookie, observed now, when they have the + * source and medium needed to be stored. + */ + write(cookie, utmParams) { + if (!UTM.complete(utmParams)) 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() { + static complete(utmParams) { + return Boolean(utmParams?.source && utmParams?.medium) + } + + /** + * 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 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. + * + * 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 + 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) { + 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 + } + } + + /** + * 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 + 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 }