@shware/analytics 11.3.1 → 11.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,8 +25,9 @@ const socialChannels = channelsBy("social");
25
25
  const videoChannels = channelsBy("video");
26
26
  const aiChannels = channelsBy("ai");
27
27
  /**
28
- * A tag as text; absent when missing, empty, or the strings a broken template writes
29
- * (`undefined`, `null`, an unexpanded `{{macro}}` other than Meta's placement, read elsewhere).
28
+ * A tag as text; absent when missing, empty, or the strings a broken template writes (`undefined`,
29
+ * `null`). A macro the ad platform did not expand (`{{campaign_name}}`, `__CAMPAIGN_NAME__`) is
30
+ * kept as written, so the misspelt template shows in the reports and gets fixed.
30
31
  */
31
32
  function text(tags, key) {
32
33
  const value = tags[key];
@@ -36,8 +37,8 @@ function text(tags, key) {
36
37
  }
37
38
  /**
38
39
  * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click
39
- * ids — unlike GA4, where a `gclid` wins over the utm unless the property sets "manual tagging
40
- * overrides auto-tagging": a utm is written for this link by whoever placed it, while a click id
40
+ * ids — unlike GA4, where a usable `gclid` always wins over the utm (Universal Analytics' "manual
41
+ * tagging overrides auto-tagging" has no GA4 counterpart): a utm is written for this link by whoever placed it, while a click id
41
42
  * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or
42
43
  * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that
43
44
  * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.
@@ -1 +1 @@
1
- {"version":3,"file":"classify.cjs","names":["META_PLACEMENTS","REFERRER_SITES","SOURCE_ALIASES","CLICK_ID_CHANNELS","AD_LANDING_PAGE","ANDROID_APP_HOSTS","REFERRERS_NOT_A_TOUCH","DISPLAY_MEDIUMS","PAID_MEDIUM","EMAIL_MEDIUM","EMAIL_MEDIUMS","TOUCH_PRIORITY"],"sources":["../../src/attribution/classify.ts"],"sourcesContent":["import type { TrackTags } from '../track/types';\nimport {\n AD_LANDING_PAGE,\n ANDROID_APP_HOSTS,\n CLICK_ID_CHANNELS,\n type ChannelGroup,\n DIRECT_CHANNEL,\n DISPLAY_MEDIUMS,\n EMAIL_MEDIUM,\n EMAIL_MEDIUMS,\n MEDIUM_NOT_SET,\n META_PLACEMENTS,\n NO_MEDIUM,\n PAID_MEDIUM,\n REFERRERS_NOT_A_TOUCH,\n REFERRER_SITES,\n SOURCE_ALIASES,\n TOUCH_PRIORITY,\n} from './vocabulary';\n\n/** What a session's tags say about how it arrived: the touch, as the session row stores it. */\nexport interface Touch {\n /** The source, as GA calls it: a `Channel`, a `utm_source` outside the list, a referring host, or `(direct)`. */\n channel: string;\n /** How, as GA calls it: `utm_medium` as declared, else what the rule that named the channel implies; `(none)` for direct. */\n medium: string;\n /** GA4's default channel group of the two. */\n channel_group: ChannelGroup;\n /** `utm_campaign`, else what a product rule captured (a referral code); null when neither. */\n campaign: string | null;\n /** `TOUCH_PRIORITY` of the rule that named the channel; null for direct, which is no touch. */\n priority: number | null;\n}\n\n/** What a product rule names, when it recognises the session; the channel group is derived. */\nexport type ProductTouch = Omit<Touch, 'channel_group'>;\n\n/**\n * A product's own rule: a landing page convention, a code in the URL. Tried after the campaign\n * rules (utm, click id, ad landing page) and before the referrer, so a link shared on Facebook\n * or WhatsApp is credited to what the product recognises, not to the site it was shared on.\n * Returns null when the session is not one of its.\n */\nexport type TouchRule = (tags: TrackTags) => ProductTouch | null;\n\nexport interface ClassifyOptions {\n /**\n * The product's own hosts, as patterns over the lower-cased referrer host: a session that\n * starts from an internal link after the session timeout refers to them, which is navigation,\n * not acquisition. The SDK adds the payment and sign-in providers (`REFERRERS_NOT_A_TOUCH`).\n */\n ownHosts?: readonly RegExp[];\n /** The product's rules, in order; the first that names the session wins. */\n rules?: readonly TouchRule[];\n}\n\nconst metaPlacements = new Set<string>(META_PLACEMENTS);\n/** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */\nconst ORGANIC_GROUPS = new Set<ChannelGroup>([\n 'organic_search',\n 'organic_social',\n 'organic_video',\n 'organic_ai',\n 'referral',\n]);\n/**\n * The groups a campaign rule ranks with a referrer in: the organic ones, and email. Email reaches\n * people we already know — often because an ad brought them — and its links (a welcome mail, a\n * trial reminder) come between that ad and the purchase, so it must not take the ad's credit.\n * With no ad in the window it still wins as the latest touch.\n */\nconst REFERRER_TIER_GROUPS = new Set<ChannelGroup>([...ORGANIC_GROUPS, 'email']);\nconst referrerHostOf = /^https?:\\/\\/([^/:?#]+)/i;\nconst androidAppOf = /^android-app:\\/\\/([^/?#]+)/i;\nconst channelsBy = (medium: string) =>\n new Set<string>(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));\nconst searchChannels = channelsBy('organic');\nconst socialChannels = channelsBy('social');\nconst videoChannels = channelsBy('video');\nconst aiChannels = channelsBy('ai');\n\n/**\n * A tag as text; absent when missing, empty, or the strings a broken template writes\n * (`undefined`, `null`, an unexpanded `{{macro}}` other than Meta's placement, read elsewhere).\n */\nfunction text(tags: TrackTags, key: string): string | null {\n const value = tags[key];\n if (value === undefined || value === null) return null;\n const s = String(value);\n return s === '' || s === 'undefined' || s === 'null' ? null : s;\n}\n\n/**\n * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click\n * ids — unlike GA4, where a `gclid` wins over the utm unless the property sets \"manual tagging\n * overrides auto-tagging\": a utm is written for this link by whoever placed it, while a click id\n * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or\n * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that\n * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.\n * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.\n */\nfunction utmChannel(tags: TrackTags): string | null {\n // A source with the rest of the query glued on (`email&utm_medium=promo`, `toolify/`,\n // `x?utm_source=x`): a link built by hand or double-encoded. Keep the source.\n const source = text(tags, 'utm_source')?.toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!source) return null;\n return (SOURCE_ALIASES as Record<string, string>)[source] ?? source;\n}\n\n/**\n * 2. A click id in this page's URL — the tags never fill one from the cookie an earlier click\n * left (that is `_gcl_aw`, `_fbc`, …). Only a value counts, not the key: clients before mid-2025\n * wrote `fbclid: null` (`URLSearchParams.get` of a missing parameter) into every page's tags, and\n * reading the key alone named those sessions Meta clicks — Google ad clicks among them, as `fbclid`\n * is listed first. A platform always puts a value on its click.\n */\nfunction clickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key]) => text(tags, key) !== null)?.[1] ?? null;\n}\n\n/** The channel of the first click id in the tags that its platform puts on ad clicks only. */\nfunction adClickChannel(tags: TrackTags): string | null {\n return (\n CLICK_ID_CHANNELS.find(([key, , on]) => on === 'ads' && text(tags, key) !== null)?.[1] ?? null\n );\n}\n\n/** 3. A landing page reserved for one channel's ads. */\nfunction landingChannel(tags: TrackTags): string | null {\n const location = text(tags, 'page_location');\n return location ? (AD_LANDING_PAGE.exec(location)?.[1] ?? null) : null;\n}\n\n/** 4. The product's rules, in order. */\nfunction productTouch(tags: TrackTags, rules: readonly TouchRule[]): ProductTouch | null {\n for (const rule of rules) {\n const touch = rule(tags);\n if (touch) return touch;\n }\n return null;\n}\n\n/**\n * 5. The referrer's host: a known search engine or social network folded to its channel, any\n * other site kept as its host, and null for no referrer or one that is no touch. An Android app's\n * referrer (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).\n */\nfunction referrer(\n tags: TrackTags,\n ownHosts: readonly RegExp[]\n): { channel: string; medium: string } | null {\n const url = text(tags, 'page_referrer');\n const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : undefined;\n const host = app\n ? (ANDROID_APP_HOSTS[app] ?? app)\n : url\n ? referrerHostOf.exec(url)?.[1]?.toLowerCase()\n : undefined;\n if (!host) return null;\n if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;\n if (ownHosts.some((pattern) => pattern.test(host))) return null;\n const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));\n return site ? { channel: site[0], medium: site[1] } : { channel: host, medium: 'referral' };\n}\n\n/**\n * GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source\n * decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because\n * GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's\n * placement names as a medium are a paid Meta click (see `META_PLACEMENTS`), and the AI\n * assistants have `organic_ai`. Checked in GA4's order — paid before organic, the organic groups before\n * email, referral last — so a pair that fits two rules lands where GA4 would put it.\n */\nexport function channelGroupOf(channel: string, medium: string): ChannelGroup {\n if (channel === DIRECT_CHANNEL) return 'direct';\n if ((DISPLAY_MEDIUMS as readonly string[]).includes(medium)) return 'display';\n // A Meta placement as the medium is a Meta ad whatever the source was tagged as.\n if (metaPlacements.has(medium)) return 'paid_social';\n if (PAID_MEDIUM.test(medium)) {\n if (socialChannels.has(channel) || videoChannels.has(channel)) return 'paid_social';\n if (searchChannels.has(channel)) return 'paid_search';\n return 'paid_other';\n }\n if (medium === 'social' || socialChannels.has(channel)) return 'organic_social';\n if (medium === 'video' || videoChannels.has(channel)) return 'organic_video';\n if (medium === 'ai' || aiChannels.has(channel)) return 'organic_ai';\n if (medium === 'organic' || searchChannels.has(channel)) return 'organic_search';\n if (EMAIL_MEDIUM.test(medium) || (EMAIL_MEDIUMS as readonly string[]).includes(channel))\n return 'email';\n if (medium === 'affiliate') return 'affiliate';\n if (medium === 'referral') return 'referral';\n return 'unassigned';\n}\n\n/**\n * Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when\n * the session is written; the result is stored, and the views only read it.\n *\n * The rules, in order, the first that says something naming the channel: an explicit\n * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;\n * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that\n * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,\n * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`\n * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad\n * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:\n * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad\n * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a\n * product rule captured — even when the utm named the channel, so a tagged referral link keeps\n * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):\n * campaign touches, then the product's own as they declare, then the referrer — except that a\n * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,\n * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should\n * not take the credit from an ad clicked earlier in the attribution window. When a product rule\n * matched too, the stronger of the two tiers holds.\n */\nexport function classifyTouch(tags: TrackTags, options: ClassifyOptions = {}): Touch {\n const utm = utmChannel(tags);\n const click = clickChannel(tags);\n const landing = landingChannel(tags);\n const product = productTouch(tags, options.rules ?? []);\n const referred = referrer(tags, options.ownHosts ?? []);\n\n const channel =\n utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? DIRECT_CHANNEL;\n const declaredMedium = text(tags, 'utm_medium')?.toLowerCase() ?? null;\n const namedMedium =\n declaredMedium ??\n (utm\n ? MEDIUM_NOT_SET\n : click || landing\n ? 'cpc'\n : (product?.medium ?? referred?.medium ?? NO_MEDIUM));\n // What proves a paid click on this channel: an ad-only click id of it, or its ad landing page.\n // Another channel's says nothing about this one (an ad link copied and shared under a utm of\n // its own), and `fbclid` is on organic Meta links too, so neither counts here.\n const paid = adClickChannel(tags) === channel || landing === channel;\n const medium =\n paid &&\n (namedMedium === MEDIUM_NOT_SET || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium)))\n ? 'cpc'\n : namedMedium;\n const channelGroup = channelGroupOf(channel, medium);\n const campaign = text(tags, 'utm_campaign') ?? product?.campaign ?? null;\n const campaignTier =\n utm || click || landing\n ? REFERRER_TIER_GROUPS.has(channelGroup)\n ? TOUCH_PRIORITY.referrer\n : TOUCH_PRIORITY.campaign\n : null;\n // The stronger of the campaign rule's tier and the product rule's own: a referral link shared\n // under `utm_source=whatsapp` is named by the utm, and still ranks as the programme declares.\n const ranked = [campaignTier, product?.priority ?? null].filter((tier) => tier !== null);\n const priority =\n ranked.length > 0 ? Math.min(...ranked) : referred ? TOUCH_PRIORITY.referrer : null;\n\n return { channel, medium, channel_group: channelGroup, campaign, priority };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"classify.cjs","names":["META_PLACEMENTS","REFERRER_SITES","SOURCE_ALIASES","CLICK_ID_CHANNELS","AD_LANDING_PAGE","ANDROID_APP_HOSTS","REFERRERS_NOT_A_TOUCH","DISPLAY_MEDIUMS","PAID_MEDIUM","EMAIL_MEDIUM","EMAIL_MEDIUMS","TOUCH_PRIORITY"],"sources":["../../src/attribution/classify.ts"],"sourcesContent":["import type { TrackTags } from '../track/types';\nimport {\n AD_LANDING_PAGE,\n ANDROID_APP_HOSTS,\n CLICK_ID_CHANNELS,\n type ChannelGroup,\n DIRECT_CHANNEL,\n DISPLAY_MEDIUMS,\n EMAIL_MEDIUM,\n EMAIL_MEDIUMS,\n MEDIUM_NOT_SET,\n META_PLACEMENTS,\n NO_MEDIUM,\n PAID_MEDIUM,\n REFERRERS_NOT_A_TOUCH,\n REFERRER_SITES,\n SOURCE_ALIASES,\n TOUCH_PRIORITY,\n} from './vocabulary';\n\n/** What a session's tags say about how it arrived: the touch, as the session row stores it. */\nexport interface Touch {\n /** The source, as GA calls it: a `Channel`, a `utm_source` outside the list, a referring host, or `(direct)`. */\n channel: string;\n /** How, as GA calls it: `utm_medium` as declared, else what the rule that named the channel implies; `(none)` for direct. */\n medium: string;\n /** GA4's default channel group of the two. */\n channel_group: ChannelGroup;\n /** `utm_campaign`, else what a product rule captured (a referral code); null when neither. */\n campaign: string | null;\n /** `TOUCH_PRIORITY` of the rule that named the channel; null for direct, which is no touch. */\n priority: number | null;\n}\n\n/** What a product rule names, when it recognises the session; the channel group is derived. */\nexport type ProductTouch = Omit<Touch, 'channel_group'>;\n\n/**\n * A product's own rule: a landing page convention, a code in the URL. Tried after the campaign\n * rules (utm, click id, ad landing page) and before the referrer, so a link shared on Facebook\n * or WhatsApp is credited to what the product recognises, not to the site it was shared on.\n * Returns null when the session is not one of its.\n */\nexport type TouchRule = (tags: TrackTags) => ProductTouch | null;\n\nexport interface ClassifyOptions {\n /**\n * The product's own hosts, as patterns over the lower-cased referrer host: a session that\n * starts from an internal link after the session timeout refers to them, which is navigation,\n * not acquisition. The SDK adds the payment and sign-in providers (`REFERRERS_NOT_A_TOUCH`).\n */\n ownHosts?: readonly RegExp[];\n /** The product's rules, in order; the first that names the session wins. */\n rules?: readonly TouchRule[];\n}\n\nconst metaPlacements = new Set<string>(META_PLACEMENTS);\n/** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */\nconst ORGANIC_GROUPS = new Set<ChannelGroup>([\n 'organic_search',\n 'organic_social',\n 'organic_video',\n 'organic_ai',\n 'referral',\n]);\n/**\n * The groups a campaign rule ranks with a referrer in: the organic ones, and email. Email reaches\n * people we already know — often because an ad brought them — and its links (a welcome mail, a\n * trial reminder) come between that ad and the purchase, so it must not take the ad's credit.\n * With no ad in the window it still wins as the latest touch.\n */\nconst REFERRER_TIER_GROUPS = new Set<ChannelGroup>([...ORGANIC_GROUPS, 'email']);\nconst referrerHostOf = /^https?:\\/\\/([^/:?#]+)/i;\nconst androidAppOf = /^android-app:\\/\\/([^/?#]+)/i;\nconst channelsBy = (medium: string) =>\n new Set<string>(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));\nconst searchChannels = channelsBy('organic');\nconst socialChannels = channelsBy('social');\nconst videoChannels = channelsBy('video');\nconst aiChannels = channelsBy('ai');\n\n/**\n * A tag as text; absent when missing, empty, or the strings a broken template writes (`undefined`,\n * `null`). A macro the ad platform did not expand (`{{campaign_name}}`, `__CAMPAIGN_NAME__`) is\n * kept as written, so the misspelt template shows in the reports and gets fixed.\n */\nfunction text(tags: TrackTags, key: string): string | null {\n const value = tags[key];\n if (value === undefined || value === null) return null;\n const s = String(value);\n return s === '' || s === 'undefined' || s === 'null' ? null : s;\n}\n\n/**\n * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click\n * ids — unlike GA4, where a usable `gclid` always wins over the utm (Universal Analytics' \"manual\n * tagging overrides auto-tagging\" has no GA4 counterpart): a utm is written for this link by whoever placed it, while a click id\n * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or\n * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that\n * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.\n * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.\n */\nfunction utmChannel(tags: TrackTags): string | null {\n // A source with the rest of the query glued on (`email&utm_medium=promo`, `toolify/`,\n // `x?utm_source=x`): a link built by hand or double-encoded. Keep the source.\n const source = text(tags, 'utm_source')?.toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!source) return null;\n return (SOURCE_ALIASES as Record<string, string>)[source] ?? source;\n}\n\n/**\n * 2. A click id in this page's URL — the tags never fill one from the cookie an earlier click\n * left (that is `_gcl_aw`, `_fbc`, …). Only a value counts, not the key: clients before mid-2025\n * wrote `fbclid: null` (`URLSearchParams.get` of a missing parameter) into every page's tags, and\n * reading the key alone named those sessions Meta clicks — Google ad clicks among them, as `fbclid`\n * is listed first. A platform always puts a value on its click.\n */\nfunction clickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key]) => text(tags, key) !== null)?.[1] ?? null;\n}\n\n/** The channel of the first click id in the tags that its platform puts on ad clicks only. */\nfunction adClickChannel(tags: TrackTags): string | null {\n return (\n CLICK_ID_CHANNELS.find(([key, , on]) => on === 'ads' && text(tags, key) !== null)?.[1] ?? null\n );\n}\n\n/** 3. A landing page reserved for one channel's ads. */\nfunction landingChannel(tags: TrackTags): string | null {\n const location = text(tags, 'page_location');\n return location ? (AD_LANDING_PAGE.exec(location)?.[1] ?? null) : null;\n}\n\n/** 4. The product's rules, in order. */\nfunction productTouch(tags: TrackTags, rules: readonly TouchRule[]): ProductTouch | null {\n for (const rule of rules) {\n const touch = rule(tags);\n if (touch) return touch;\n }\n return null;\n}\n\n/**\n * 5. The referrer's host: a known search engine or social network folded to its channel, any\n * other site kept as its host, and null for no referrer or one that is no touch. An Android app's\n * referrer (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).\n */\nfunction referrer(\n tags: TrackTags,\n ownHosts: readonly RegExp[]\n): { channel: string; medium: string } | null {\n const url = text(tags, 'page_referrer');\n const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : undefined;\n const host = app\n ? (ANDROID_APP_HOSTS[app] ?? app)\n : url\n ? referrerHostOf.exec(url)?.[1]?.toLowerCase()\n : undefined;\n if (!host) return null;\n if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;\n if (ownHosts.some((pattern) => pattern.test(host))) return null;\n const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));\n return site ? { channel: site[0], medium: site[1] } : { channel: host, medium: 'referral' };\n}\n\n/**\n * GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source\n * decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because\n * GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's\n * placement names as a medium are a paid Meta click (see `META_PLACEMENTS`), and the AI\n * assistants have `organic_ai`. Checked in GA4's order — paid before organic, the organic groups before\n * email, referral last — so a pair that fits two rules lands where GA4 would put it.\n */\nexport function channelGroupOf(channel: string, medium: string): ChannelGroup {\n if (channel === DIRECT_CHANNEL) return 'direct';\n if ((DISPLAY_MEDIUMS as readonly string[]).includes(medium)) return 'display';\n // A Meta placement as the medium is a Meta ad whatever the source was tagged as.\n if (metaPlacements.has(medium)) return 'paid_social';\n if (PAID_MEDIUM.test(medium)) {\n if (socialChannels.has(channel) || videoChannels.has(channel)) return 'paid_social';\n if (searchChannels.has(channel)) return 'paid_search';\n return 'paid_other';\n }\n if (medium === 'social' || socialChannels.has(channel)) return 'organic_social';\n if (medium === 'video' || videoChannels.has(channel)) return 'organic_video';\n if (medium === 'ai' || aiChannels.has(channel)) return 'organic_ai';\n if (medium === 'organic' || searchChannels.has(channel)) return 'organic_search';\n if (EMAIL_MEDIUM.test(medium) || (EMAIL_MEDIUMS as readonly string[]).includes(channel))\n return 'email';\n if (medium === 'affiliate') return 'affiliate';\n if (medium === 'referral') return 'referral';\n return 'unassigned';\n}\n\n/**\n * Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when\n * the session is written; the result is stored, and the views only read it.\n *\n * The rules, in order, the first that says something naming the channel: an explicit\n * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;\n * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that\n * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,\n * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`\n * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad\n * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:\n * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad\n * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a\n * product rule captured — even when the utm named the channel, so a tagged referral link keeps\n * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):\n * campaign touches, then the product's own as they declare, then the referrer — except that a\n * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,\n * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should\n * not take the credit from an ad clicked earlier in the attribution window. When a product rule\n * matched too, the stronger of the two tiers holds.\n */\nexport function classifyTouch(tags: TrackTags, options: ClassifyOptions = {}): Touch {\n const utm = utmChannel(tags);\n const click = clickChannel(tags);\n const landing = landingChannel(tags);\n const product = productTouch(tags, options.rules ?? []);\n const referred = referrer(tags, options.ownHosts ?? []);\n\n const channel =\n utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? DIRECT_CHANNEL;\n const declaredMedium = text(tags, 'utm_medium')?.toLowerCase() ?? null;\n const namedMedium =\n declaredMedium ??\n (utm\n ? MEDIUM_NOT_SET\n : click || landing\n ? 'cpc'\n : (product?.medium ?? referred?.medium ?? NO_MEDIUM));\n // What proves a paid click on this channel: an ad-only click id of it, or its ad landing page.\n // Another channel's says nothing about this one (an ad link copied and shared under a utm of\n // its own), and `fbclid` is on organic Meta links too, so neither counts here.\n const paid = adClickChannel(tags) === channel || landing === channel;\n const medium =\n paid &&\n (namedMedium === MEDIUM_NOT_SET || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium)))\n ? 'cpc'\n : namedMedium;\n const channelGroup = channelGroupOf(channel, medium);\n const campaign = text(tags, 'utm_campaign') ?? product?.campaign ?? null;\n const campaignTier =\n utm || click || landing\n ? REFERRER_TIER_GROUPS.has(channelGroup)\n ? TOUCH_PRIORITY.referrer\n : TOUCH_PRIORITY.campaign\n : null;\n // The stronger of the campaign rule's tier and the product rule's own: a referral link shared\n // under `utm_source=whatsapp` is named by the utm, and still ranks as the programme declares.\n const ranked = [campaignTier, product?.priority ?? null].filter((tier) => tier !== null);\n const priority =\n ranked.length > 0 ? Math.min(...ranked) : referred ? TOUCH_PRIORITY.referrer : null;\n\n return { channel, medium, channel_group: channelGroup, campaign, priority };\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"classify.d.cts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAqBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK;;;;;;;YAQpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBAwHH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;wBA0CjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
1
+ {"version":3,"file":"classify.d.cts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAqBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK;;;;;;;YAQpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBAyHH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;wBA0CjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
@@ -1 +1 @@
1
- {"version":3,"file":"classify.d.mts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAqBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK;;;;;;;YAQpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBAwHH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;wBA0CjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
1
+ {"version":3,"file":"classify.d.mts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAqBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK;;;;;;;YAQpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBAyHH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;wBA0CjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
@@ -24,8 +24,9 @@ const socialChannels = channelsBy("social");
24
24
  const videoChannels = channelsBy("video");
25
25
  const aiChannels = channelsBy("ai");
26
26
  /**
27
- * A tag as text; absent when missing, empty, or the strings a broken template writes
28
- * (`undefined`, `null`, an unexpanded `{{macro}}` other than Meta's placement, read elsewhere).
27
+ * A tag as text; absent when missing, empty, or the strings a broken template writes (`undefined`,
28
+ * `null`). A macro the ad platform did not expand (`{{campaign_name}}`, `__CAMPAIGN_NAME__`) is
29
+ * kept as written, so the misspelt template shows in the reports and gets fixed.
29
30
  */
30
31
  function text(tags, key) {
31
32
  const value = tags[key];
@@ -35,8 +36,8 @@ function text(tags, key) {
35
36
  }
36
37
  /**
37
38
  * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click
38
- * ids — unlike GA4, where a `gclid` wins over the utm unless the property sets "manual tagging
39
- * overrides auto-tagging": a utm is written for this link by whoever placed it, while a click id
39
+ * ids — unlike GA4, where a usable `gclid` always wins over the utm (Universal Analytics' "manual
40
+ * tagging overrides auto-tagging" has no GA4 counterpart): a utm is written for this link by whoever placed it, while a click id
40
41
  * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or
41
42
  * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that
42
43
  * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.
@@ -1 +1 @@
1
- {"version":3,"file":"classify.mjs","names":[],"sources":["../../src/attribution/classify.ts"],"sourcesContent":["import type { TrackTags } from '../track/types';\nimport {\n AD_LANDING_PAGE,\n ANDROID_APP_HOSTS,\n CLICK_ID_CHANNELS,\n type ChannelGroup,\n DIRECT_CHANNEL,\n DISPLAY_MEDIUMS,\n EMAIL_MEDIUM,\n EMAIL_MEDIUMS,\n MEDIUM_NOT_SET,\n META_PLACEMENTS,\n NO_MEDIUM,\n PAID_MEDIUM,\n REFERRERS_NOT_A_TOUCH,\n REFERRER_SITES,\n SOURCE_ALIASES,\n TOUCH_PRIORITY,\n} from './vocabulary';\n\n/** What a session's tags say about how it arrived: the touch, as the session row stores it. */\nexport interface Touch {\n /** The source, as GA calls it: a `Channel`, a `utm_source` outside the list, a referring host, or `(direct)`. */\n channel: string;\n /** How, as GA calls it: `utm_medium` as declared, else what the rule that named the channel implies; `(none)` for direct. */\n medium: string;\n /** GA4's default channel group of the two. */\n channel_group: ChannelGroup;\n /** `utm_campaign`, else what a product rule captured (a referral code); null when neither. */\n campaign: string | null;\n /** `TOUCH_PRIORITY` of the rule that named the channel; null for direct, which is no touch. */\n priority: number | null;\n}\n\n/** What a product rule names, when it recognises the session; the channel group is derived. */\nexport type ProductTouch = Omit<Touch, 'channel_group'>;\n\n/**\n * A product's own rule: a landing page convention, a code in the URL. Tried after the campaign\n * rules (utm, click id, ad landing page) and before the referrer, so a link shared on Facebook\n * or WhatsApp is credited to what the product recognises, not to the site it was shared on.\n * Returns null when the session is not one of its.\n */\nexport type TouchRule = (tags: TrackTags) => ProductTouch | null;\n\nexport interface ClassifyOptions {\n /**\n * The product's own hosts, as patterns over the lower-cased referrer host: a session that\n * starts from an internal link after the session timeout refers to them, which is navigation,\n * not acquisition. The SDK adds the payment and sign-in providers (`REFERRERS_NOT_A_TOUCH`).\n */\n ownHosts?: readonly RegExp[];\n /** The product's rules, in order; the first that names the session wins. */\n rules?: readonly TouchRule[];\n}\n\nconst metaPlacements = new Set<string>(META_PLACEMENTS);\n/** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */\nconst ORGANIC_GROUPS = new Set<ChannelGroup>([\n 'organic_search',\n 'organic_social',\n 'organic_video',\n 'organic_ai',\n 'referral',\n]);\n/**\n * The groups a campaign rule ranks with a referrer in: the organic ones, and email. Email reaches\n * people we already know — often because an ad brought them — and its links (a welcome mail, a\n * trial reminder) come between that ad and the purchase, so it must not take the ad's credit.\n * With no ad in the window it still wins as the latest touch.\n */\nconst REFERRER_TIER_GROUPS = new Set<ChannelGroup>([...ORGANIC_GROUPS, 'email']);\nconst referrerHostOf = /^https?:\\/\\/([^/:?#]+)/i;\nconst androidAppOf = /^android-app:\\/\\/([^/?#]+)/i;\nconst channelsBy = (medium: string) =>\n new Set<string>(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));\nconst searchChannels = channelsBy('organic');\nconst socialChannels = channelsBy('social');\nconst videoChannels = channelsBy('video');\nconst aiChannels = channelsBy('ai');\n\n/**\n * A tag as text; absent when missing, empty, or the strings a broken template writes\n * (`undefined`, `null`, an unexpanded `{{macro}}` other than Meta's placement, read elsewhere).\n */\nfunction text(tags: TrackTags, key: string): string | null {\n const value = tags[key];\n if (value === undefined || value === null) return null;\n const s = String(value);\n return s === '' || s === 'undefined' || s === 'null' ? null : s;\n}\n\n/**\n * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click\n * ids — unlike GA4, where a `gclid` wins over the utm unless the property sets \"manual tagging\n * overrides auto-tagging\": a utm is written for this link by whoever placed it, while a click id\n * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or\n * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that\n * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.\n * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.\n */\nfunction utmChannel(tags: TrackTags): string | null {\n // A source with the rest of the query glued on (`email&utm_medium=promo`, `toolify/`,\n // `x?utm_source=x`): a link built by hand or double-encoded. Keep the source.\n const source = text(tags, 'utm_source')?.toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!source) return null;\n return (SOURCE_ALIASES as Record<string, string>)[source] ?? source;\n}\n\n/**\n * 2. A click id in this page's URL — the tags never fill one from the cookie an earlier click\n * left (that is `_gcl_aw`, `_fbc`, …). Only a value counts, not the key: clients before mid-2025\n * wrote `fbclid: null` (`URLSearchParams.get` of a missing parameter) into every page's tags, and\n * reading the key alone named those sessions Meta clicks — Google ad clicks among them, as `fbclid`\n * is listed first. A platform always puts a value on its click.\n */\nfunction clickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key]) => text(tags, key) !== null)?.[1] ?? null;\n}\n\n/** The channel of the first click id in the tags that its platform puts on ad clicks only. */\nfunction adClickChannel(tags: TrackTags): string | null {\n return (\n CLICK_ID_CHANNELS.find(([key, , on]) => on === 'ads' && text(tags, key) !== null)?.[1] ?? null\n );\n}\n\n/** 3. A landing page reserved for one channel's ads. */\nfunction landingChannel(tags: TrackTags): string | null {\n const location = text(tags, 'page_location');\n return location ? (AD_LANDING_PAGE.exec(location)?.[1] ?? null) : null;\n}\n\n/** 4. The product's rules, in order. */\nfunction productTouch(tags: TrackTags, rules: readonly TouchRule[]): ProductTouch | null {\n for (const rule of rules) {\n const touch = rule(tags);\n if (touch) return touch;\n }\n return null;\n}\n\n/**\n * 5. The referrer's host: a known search engine or social network folded to its channel, any\n * other site kept as its host, and null for no referrer or one that is no touch. An Android app's\n * referrer (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).\n */\nfunction referrer(\n tags: TrackTags,\n ownHosts: readonly RegExp[]\n): { channel: string; medium: string } | null {\n const url = text(tags, 'page_referrer');\n const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : undefined;\n const host = app\n ? (ANDROID_APP_HOSTS[app] ?? app)\n : url\n ? referrerHostOf.exec(url)?.[1]?.toLowerCase()\n : undefined;\n if (!host) return null;\n if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;\n if (ownHosts.some((pattern) => pattern.test(host))) return null;\n const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));\n return site ? { channel: site[0], medium: site[1] } : { channel: host, medium: 'referral' };\n}\n\n/**\n * GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source\n * decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because\n * GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's\n * placement names as a medium are a paid Meta click (see `META_PLACEMENTS`), and the AI\n * assistants have `organic_ai`. Checked in GA4's order — paid before organic, the organic groups before\n * email, referral last — so a pair that fits two rules lands where GA4 would put it.\n */\nexport function channelGroupOf(channel: string, medium: string): ChannelGroup {\n if (channel === DIRECT_CHANNEL) return 'direct';\n if ((DISPLAY_MEDIUMS as readonly string[]).includes(medium)) return 'display';\n // A Meta placement as the medium is a Meta ad whatever the source was tagged as.\n if (metaPlacements.has(medium)) return 'paid_social';\n if (PAID_MEDIUM.test(medium)) {\n if (socialChannels.has(channel) || videoChannels.has(channel)) return 'paid_social';\n if (searchChannels.has(channel)) return 'paid_search';\n return 'paid_other';\n }\n if (medium === 'social' || socialChannels.has(channel)) return 'organic_social';\n if (medium === 'video' || videoChannels.has(channel)) return 'organic_video';\n if (medium === 'ai' || aiChannels.has(channel)) return 'organic_ai';\n if (medium === 'organic' || searchChannels.has(channel)) return 'organic_search';\n if (EMAIL_MEDIUM.test(medium) || (EMAIL_MEDIUMS as readonly string[]).includes(channel))\n return 'email';\n if (medium === 'affiliate') return 'affiliate';\n if (medium === 'referral') return 'referral';\n return 'unassigned';\n}\n\n/**\n * Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when\n * the session is written; the result is stored, and the views only read it.\n *\n * The rules, in order, the first that says something naming the channel: an explicit\n * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;\n * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that\n * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,\n * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`\n * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad\n * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:\n * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad\n * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a\n * product rule captured — even when the utm named the channel, so a tagged referral link keeps\n * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):\n * campaign touches, then the product's own as they declare, then the referrer — except that a\n * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,\n * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should\n * not take the credit from an ad clicked earlier in the attribution window. When a product rule\n * matched too, the stronger of the two tiers holds.\n */\nexport function classifyTouch(tags: TrackTags, options: ClassifyOptions = {}): Touch {\n const utm = utmChannel(tags);\n const click = clickChannel(tags);\n const landing = landingChannel(tags);\n const product = productTouch(tags, options.rules ?? []);\n const referred = referrer(tags, options.ownHosts ?? []);\n\n const channel =\n utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? DIRECT_CHANNEL;\n const declaredMedium = text(tags, 'utm_medium')?.toLowerCase() ?? null;\n const namedMedium =\n declaredMedium ??\n (utm\n ? MEDIUM_NOT_SET\n : click || landing\n ? 'cpc'\n : (product?.medium ?? referred?.medium ?? NO_MEDIUM));\n // What proves a paid click on this channel: an ad-only click id of it, or its ad landing page.\n // Another channel's says nothing about this one (an ad link copied and shared under a utm of\n // its own), and `fbclid` is on organic Meta links too, so neither counts here.\n const paid = adClickChannel(tags) === channel || landing === channel;\n const medium =\n paid &&\n (namedMedium === MEDIUM_NOT_SET || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium)))\n ? 'cpc'\n : namedMedium;\n const channelGroup = channelGroupOf(channel, medium);\n const campaign = text(tags, 'utm_campaign') ?? product?.campaign ?? null;\n const campaignTier =\n utm || click || landing\n ? REFERRER_TIER_GROUPS.has(channelGroup)\n ? TOUCH_PRIORITY.referrer\n : TOUCH_PRIORITY.campaign\n : null;\n // The stronger of the campaign rule's tier and the product rule's own: a referral link shared\n // under `utm_source=whatsapp` is named by the utm, and still ranks as the programme declares.\n const ranked = [campaignTier, product?.priority ?? null].filter((tier) => tier !== null);\n const priority =\n ranked.length > 0 ? Math.min(...ranked) : referred ? TOUCH_PRIORITY.referrer : null;\n\n return { channel, medium, channel_group: channelGroup, campaign, priority };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"classify.mjs","names":[],"sources":["../../src/attribution/classify.ts"],"sourcesContent":["import type { TrackTags } from '../track/types';\nimport {\n AD_LANDING_PAGE,\n ANDROID_APP_HOSTS,\n CLICK_ID_CHANNELS,\n type ChannelGroup,\n DIRECT_CHANNEL,\n DISPLAY_MEDIUMS,\n EMAIL_MEDIUM,\n EMAIL_MEDIUMS,\n MEDIUM_NOT_SET,\n META_PLACEMENTS,\n NO_MEDIUM,\n PAID_MEDIUM,\n REFERRERS_NOT_A_TOUCH,\n REFERRER_SITES,\n SOURCE_ALIASES,\n TOUCH_PRIORITY,\n} from './vocabulary';\n\n/** What a session's tags say about how it arrived: the touch, as the session row stores it. */\nexport interface Touch {\n /** The source, as GA calls it: a `Channel`, a `utm_source` outside the list, a referring host, or `(direct)`. */\n channel: string;\n /** How, as GA calls it: `utm_medium` as declared, else what the rule that named the channel implies; `(none)` for direct. */\n medium: string;\n /** GA4's default channel group of the two. */\n channel_group: ChannelGroup;\n /** `utm_campaign`, else what a product rule captured (a referral code); null when neither. */\n campaign: string | null;\n /** `TOUCH_PRIORITY` of the rule that named the channel; null for direct, which is no touch. */\n priority: number | null;\n}\n\n/** What a product rule names, when it recognises the session; the channel group is derived. */\nexport type ProductTouch = Omit<Touch, 'channel_group'>;\n\n/**\n * A product's own rule: a landing page convention, a code in the URL. Tried after the campaign\n * rules (utm, click id, ad landing page) and before the referrer, so a link shared on Facebook\n * or WhatsApp is credited to what the product recognises, not to the site it was shared on.\n * Returns null when the session is not one of its.\n */\nexport type TouchRule = (tags: TrackTags) => ProductTouch | null;\n\nexport interface ClassifyOptions {\n /**\n * The product's own hosts, as patterns over the lower-cased referrer host: a session that\n * starts from an internal link after the session timeout refers to them, which is navigation,\n * not acquisition. The SDK adds the payment and sign-in providers (`REFERRERS_NOT_A_TOUCH`).\n */\n ownHosts?: readonly RegExp[];\n /** The product's rules, in order; the first that names the session wins. */\n rules?: readonly TouchRule[];\n}\n\nconst metaPlacements = new Set<string>(META_PLACEMENTS);\n/** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */\nconst ORGANIC_GROUPS = new Set<ChannelGroup>([\n 'organic_search',\n 'organic_social',\n 'organic_video',\n 'organic_ai',\n 'referral',\n]);\n/**\n * The groups a campaign rule ranks with a referrer in: the organic ones, and email. Email reaches\n * people we already know — often because an ad brought them — and its links (a welcome mail, a\n * trial reminder) come between that ad and the purchase, so it must not take the ad's credit.\n * With no ad in the window it still wins as the latest touch.\n */\nconst REFERRER_TIER_GROUPS = new Set<ChannelGroup>([...ORGANIC_GROUPS, 'email']);\nconst referrerHostOf = /^https?:\\/\\/([^/:?#]+)/i;\nconst androidAppOf = /^android-app:\\/\\/([^/?#]+)/i;\nconst channelsBy = (medium: string) =>\n new Set<string>(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));\nconst searchChannels = channelsBy('organic');\nconst socialChannels = channelsBy('social');\nconst videoChannels = channelsBy('video');\nconst aiChannels = channelsBy('ai');\n\n/**\n * A tag as text; absent when missing, empty, or the strings a broken template writes (`undefined`,\n * `null`). A macro the ad platform did not expand (`{{campaign_name}}`, `__CAMPAIGN_NAME__`) is\n * kept as written, so the misspelt template shows in the reports and gets fixed.\n */\nfunction text(tags: TrackTags, key: string): string | null {\n const value = tags[key];\n if (value === undefined || value === null) return null;\n const s = String(value);\n return s === '' || s === 'undefined' || s === 'null' ? null : s;\n}\n\n/**\n * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click\n * ids — unlike GA4, where a usable `gclid` always wins over the utm (Universal Analytics' \"manual\n * tagging overrides auto-tagging\" has no GA4 counterpart): a utm is written for this link by whoever placed it, while a click id\n * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or\n * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that\n * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.\n * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.\n */\nfunction utmChannel(tags: TrackTags): string | null {\n // A source with the rest of the query glued on (`email&utm_medium=promo`, `toolify/`,\n // `x?utm_source=x`): a link built by hand or double-encoded. Keep the source.\n const source = text(tags, 'utm_source')?.toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!source) return null;\n return (SOURCE_ALIASES as Record<string, string>)[source] ?? source;\n}\n\n/**\n * 2. A click id in this page's URL — the tags never fill one from the cookie an earlier click\n * left (that is `_gcl_aw`, `_fbc`, …). Only a value counts, not the key: clients before mid-2025\n * wrote `fbclid: null` (`URLSearchParams.get` of a missing parameter) into every page's tags, and\n * reading the key alone named those sessions Meta clicks — Google ad clicks among them, as `fbclid`\n * is listed first. A platform always puts a value on its click.\n */\nfunction clickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key]) => text(tags, key) !== null)?.[1] ?? null;\n}\n\n/** The channel of the first click id in the tags that its platform puts on ad clicks only. */\nfunction adClickChannel(tags: TrackTags): string | null {\n return (\n CLICK_ID_CHANNELS.find(([key, , on]) => on === 'ads' && text(tags, key) !== null)?.[1] ?? null\n );\n}\n\n/** 3. A landing page reserved for one channel's ads. */\nfunction landingChannel(tags: TrackTags): string | null {\n const location = text(tags, 'page_location');\n return location ? (AD_LANDING_PAGE.exec(location)?.[1] ?? null) : null;\n}\n\n/** 4. The product's rules, in order. */\nfunction productTouch(tags: TrackTags, rules: readonly TouchRule[]): ProductTouch | null {\n for (const rule of rules) {\n const touch = rule(tags);\n if (touch) return touch;\n }\n return null;\n}\n\n/**\n * 5. The referrer's host: a known search engine or social network folded to its channel, any\n * other site kept as its host, and null for no referrer or one that is no touch. An Android app's\n * referrer (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).\n */\nfunction referrer(\n tags: TrackTags,\n ownHosts: readonly RegExp[]\n): { channel: string; medium: string } | null {\n const url = text(tags, 'page_referrer');\n const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : undefined;\n const host = app\n ? (ANDROID_APP_HOSTS[app] ?? app)\n : url\n ? referrerHostOf.exec(url)?.[1]?.toLowerCase()\n : undefined;\n if (!host) return null;\n if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;\n if (ownHosts.some((pattern) => pattern.test(host))) return null;\n const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));\n return site ? { channel: site[0], medium: site[1] } : { channel: host, medium: 'referral' };\n}\n\n/**\n * GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source\n * decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because\n * GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's\n * placement names as a medium are a paid Meta click (see `META_PLACEMENTS`), and the AI\n * assistants have `organic_ai`. Checked in GA4's order — paid before organic, the organic groups before\n * email, referral last — so a pair that fits two rules lands where GA4 would put it.\n */\nexport function channelGroupOf(channel: string, medium: string): ChannelGroup {\n if (channel === DIRECT_CHANNEL) return 'direct';\n if ((DISPLAY_MEDIUMS as readonly string[]).includes(medium)) return 'display';\n // A Meta placement as the medium is a Meta ad whatever the source was tagged as.\n if (metaPlacements.has(medium)) return 'paid_social';\n if (PAID_MEDIUM.test(medium)) {\n if (socialChannels.has(channel) || videoChannels.has(channel)) return 'paid_social';\n if (searchChannels.has(channel)) return 'paid_search';\n return 'paid_other';\n }\n if (medium === 'social' || socialChannels.has(channel)) return 'organic_social';\n if (medium === 'video' || videoChannels.has(channel)) return 'organic_video';\n if (medium === 'ai' || aiChannels.has(channel)) return 'organic_ai';\n if (medium === 'organic' || searchChannels.has(channel)) return 'organic_search';\n if (EMAIL_MEDIUM.test(medium) || (EMAIL_MEDIUMS as readonly string[]).includes(channel))\n return 'email';\n if (medium === 'affiliate') return 'affiliate';\n if (medium === 'referral') return 'referral';\n return 'unassigned';\n}\n\n/**\n * Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when\n * the session is written; the result is stored, and the views only read it.\n *\n * The rules, in order, the first that says something naming the channel: an explicit\n * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;\n * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that\n * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,\n * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`\n * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad\n * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:\n * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad\n * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a\n * product rule captured — even when the utm named the channel, so a tagged referral link keeps\n * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):\n * campaign touches, then the product's own as they declare, then the referrer — except that a\n * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,\n * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should\n * not take the credit from an ad clicked earlier in the attribution window. When a product rule\n * matched too, the stronger of the two tiers holds.\n */\nexport function classifyTouch(tags: TrackTags, options: ClassifyOptions = {}): Touch {\n const utm = utmChannel(tags);\n const click = clickChannel(tags);\n const landing = landingChannel(tags);\n const product = productTouch(tags, options.rules ?? []);\n const referred = referrer(tags, options.ownHosts ?? []);\n\n const channel =\n utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? DIRECT_CHANNEL;\n const declaredMedium = text(tags, 'utm_medium')?.toLowerCase() ?? null;\n const namedMedium =\n declaredMedium ??\n (utm\n ? MEDIUM_NOT_SET\n : click || landing\n ? 'cpc'\n : (product?.medium ?? referred?.medium ?? NO_MEDIUM));\n // What proves a paid click on this channel: an ad-only click id of it, or its ad landing page.\n // Another channel's says nothing about this one (an ad link copied and shared under a utm of\n // its own), and `fbclid` is on organic Meta links too, so neither counts here.\n const paid = adClickChannel(tags) === channel || landing === channel;\n const medium =\n paid &&\n (namedMedium === MEDIUM_NOT_SET || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium)))\n ? 'cpc'\n : namedMedium;\n const channelGroup = channelGroupOf(channel, medium);\n const campaign = text(tags, 'utm_campaign') ?? product?.campaign ?? null;\n const campaignTier =\n utm || click || landing\n ? REFERRER_TIER_GROUPS.has(channelGroup)\n ? TOUCH_PRIORITY.referrer\n : TOUCH_PRIORITY.campaign\n : null;\n // The stronger of the campaign rule's tier and the product rule's own: a referral link shared\n // under `utm_source=whatsapp` is named by the utm, and still ranks as the programme declares.\n const ranked = [campaignTier, product?.priority ?? null].filter((tier) => tier !== null);\n const priority =\n ranked.length > 0 ? Math.min(...ranked) : referred ? TOUCH_PRIORITY.referrer : null;\n\n return { channel, medium, channel_group: channelGroup, campaign, priority };\n}\n"],"mappings":"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"}
@@ -436,11 +436,19 @@ const PAID_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|pmax|performance_max)$/;
436
436
  * at all — so a medium in this list is a paid Meta click whatever the source says (a template with
437
437
  * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so
438
438
  * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it
439
- * is spelled … which means a placement Meta adds later shows up as unassigned until it is added
440
- * here. Meta's documented values plus the ones seen in the wild (`facebook_mobile_reels`,
441
- * `facebook_notification`, `an`), and the literal macro for a template that did not expand it.
439
+ * is spelled … which means a placement Meta adds later shows up as organic social (or unassigned,
440
+ * under a source that is no social site) until it is added here. Meta's documented values plus the
441
+ * ones seen in the wild (`facebook_mobile_reels`, `facebook_notification`, `an`), and the literal
442
+ * macro for a template that did not expand it.
443
+ *
444
+ * Accepted for the ads already running with that template; new ads should rather send
445
+ * `utm_medium=cpc` and the placement in a `placement` parameter of its own (collected as the
446
+ * `placement` tag): GA4 matches its paid rule on the medium alone, and files a placement medium
447
+ * under Organic Social.
442
448
  */
443
449
  const META_PLACEMENTS = [
450
+ "facebook_feed",
451
+ "facebook_instream",
444
452
  "facebook_desktop_feed",
445
453
  "facebook_mobile_feed",
446
454
  "facebook_mobile_reels",
@@ -473,6 +481,7 @@ const META_PLACEMENTS = [
473
481
  "messenger_stories",
474
482
  "messenger_sponsored_messages",
475
483
  "threads_feed",
484
+ "threads_stream",
476
485
  "whatsapp_status",
477
486
  "audience_network_classic",
478
487
  "audience_network_rewarded_video",
@@ -1 +1 @@
1
- {"version":3,"file":"vocabulary.cjs","names":[],"sources":["../../src/attribution/vocabulary.ts"],"sourcesContent":["import type { AdvertisingInfo } from '../track/types';\n\n/**\n * The vocabulary attribution is built from, shared by every product's `session` table and by the\n * dashboards that read it. Data for `classifyTouch` (./classify), which turns it into a channel;\n * a product's dashboards read the same names back. What stays with the product is its own hostnames\n * (referrer exclusions) and its landing-page conventions.\n */\n\n/** A session that arrived through nothing we can name. GA's spelling, so dashboards read as GA. */\nexport const DIRECT_CHANNEL = '(direct)';\n/** The medium of a direct session. */\nexport const NO_MEDIUM = '(none)';\n/** The medium of a tagged link that named a source but no medium. */\nexport const MEDIUM_NOT_SET = '(not set)';\n\n/** The channels the rules below can name. A `utm_source` outside this list is its own channel. */\nexport const CHANNELS = [\n 'meta',\n 'google',\n 'dv360',\n 'microsoft',\n 'tiktok',\n 'reddit',\n 'linkedin',\n 'snapchat',\n 'x',\n 'kakao',\n 'yandex',\n 'yahoo',\n 'duckduckgo',\n 'baidu',\n 'ecosia',\n 'naver',\n 'brave',\n 'pinterest',\n 'youtube',\n 'telegram',\n /** Gmail's web and Android app: a link in a mail that carried no utm. */\n 'gmail',\n /** AI assistants that send people to sites: ChatGPT tags its links `utm_source=chatgpt.com`, the\n * others show as a referrer. Their own channel group, `organic_ai`, since GA4's default groups\n * would file them under Referral / Unassigned and the question \"how much does AI bring\" comes up. */\n 'chatgpt',\n 'perplexity',\n 'gemini',\n 'claude',\n 'copilot',\n 'grok',\n /** and the Chinese ones — DeepSeek, 豆包, Kimi, 通义, 腾讯元宝, 文心一言, 智谱清言 */\n 'deepseek',\n 'doubao',\n 'kimi',\n 'qwen',\n 'yuanbao',\n 'ernie',\n 'zhipu',\n /** the product's own referral programme: the session came through a member's link or code.\n * How a product recognises one (a `/refer/<code>` path, a code at sign-up) is its own rule. Not\n * `referral`: that word is the channel group of any outside site, and a channel of the same name\n * would be impossible to tell apart in queries. */\n 'referral_program',\n] as const;\n\nexport type Channel = (typeof CHANNELS)[number];\n\n/** GA4's default channel groups, as identifiers; a dashboard maps them to display names. */\nexport const CHANNEL_GROUPS = [\n 'direct',\n 'paid_search',\n 'paid_social',\n 'paid_other',\n 'display',\n 'organic_search',\n 'organic_social',\n 'organic_video',\n /** not in GA4's default grouping: sessions an AI assistant sent, by referrer or by ChatGPT's utm */\n 'organic_ai',\n 'referral',\n 'email',\n 'affiliate',\n 'unassigned',\n] as const;\n\nexport type ChannelGroup = (typeof CHANNEL_GROUPS)[number];\n\n/** `utm_source` spellings that mean the same channel: Meta's `{{site_source_name}}` values and the usual synonyms. */\nexport const SOURCE_ALIASES = {\n fb: 'meta',\n ig: 'meta',\n an: 'meta',\n msg: 'meta',\n th: 'meta',\n facebook: 'meta',\n instagram: 'meta',\n bing: 'microsoft',\n twitter: 'x',\n 'google ads': 'google',\n googleads: 'google',\n adwords: 'google',\n 'chatgpt.com': 'chatgpt',\n 'chat.openai.com': 'chatgpt',\n openai: 'chatgpt',\n 'perplexity.ai': 'perplexity',\n 'claude.ai': 'claude',\n 'copilot.com': 'copilot',\n 'deepseek.com': 'deepseek',\n 'doubao.com': 'doubao',\n 'kimi.com': 'kimi',\n} as const satisfies Record<string, Channel>;\n\n/**\n * Which click id belongs to which channel, in priority order, and whether the platform puts it on\n * its ad clicks only (`'ads'`) or on other links as well (`'any'`). Every key is a field this SDK\n * collects (`AdvertisingInfo`).\n *\n * The third column decides whether the id can say a click was paid when a `utm_source` names the\n * channel but no `utm_medium` says how (a Reddit ad tagged `utm_source=reddit` and nothing more).\n * `fbclid` is `'any'`: Meta decorates every outbound link with it, organic posts, profile links\n * and shares included. `twclid` and `ko_click_id` are `'any'` until it is clear they never\n * decorate an organic link.\n *\n * Only URL parameters, never the cookies the tags keep beside them (`_fbc`, `_gcl_aw`, … — every\n * underscore key of `AdvertisingInfo`): a click cookie lives up to 90 days after the click, so\n * counting it named every later visit — typed in, from search, from an email — a new ad click.\n */\nexport const CLICK_ID_CHANNELS = [\n ['fbclid', 'meta', 'any'],\n ['gclid', 'google', 'ads'],\n ['gbraid', 'google', 'ads'],\n ['wbraid', 'google', 'ads'],\n ['gad_source', 'google', 'ads'],\n ['gad_campaignid', 'google', 'ads'],\n ['dclid', 'dv360', 'ads'],\n ['msclkid', 'microsoft', 'ads'],\n ['ttclid', 'tiktok', 'ads'],\n ['rdt_cid', 'reddit', 'ads'],\n ['li_fat_id', 'linkedin', 'ads'],\n ['ScCid', 'snapchat', 'ads'],\n ['twclid', 'x', 'any'],\n ['ko_click_id', 'kakao', 'any'],\n ['yclid', 'yandex', 'ads'],\n ['oppref', 'chatgpt', 'ads'],\n ['epik', 'pinterest', 'ads'],\n] as const satisfies readonly (readonly [keyof AdvertisingInfo, Channel, 'ads' | 'any'])[];\n\n/**\n * A landing page reserved for one channel's ads, for ads that lost their parameters:\n * `/lp/<channel>`, with or without host, query and hash; the capture is the channel. A product\n * that names its landing pages differently replaces this.\n */\nexport const AD_LANDING_PAGE = /^(?:https?:\\/\\/[^/]+)?\\/lp\\/([a-z]+)(?:[/?#]|$)/;\n\nexport type ReferrerMedium = 'organic' | 'social' | 'video' | 'ai' | 'email';\n\n/**\n * Referrer hosts that name a channel, as regular expressions over the lower-cased host,\n * with the medium GA4 gives that kind of site. Anything else that refers is `referral`, kept as\n * its host. A country TLD is one or two labels (`google.com`, `google.co.uk`, `google.com.hk`),\n * never an open tail, so `google.com.evil.io` is not Google. In match order: the AI assistants\n * first, because `gemini.google.com`, `copilot.microsoft.com` and `yiyan.baidu.com` would\n * otherwise be Google, Microsoft and Baidu search.\n */\nexport const REFERRER_SITES = [\n ['chatgpt', 'ai', /(^|\\.)(chatgpt\\.com|chat\\.openai\\.com)$/],\n ['perplexity', 'ai', /(^|\\.)perplexity\\.ai$/],\n ['gemini', 'ai', /^gemini\\.google\\.com$/],\n ['claude', 'ai', /(^|\\.)claude\\.ai$/],\n ['copilot', 'ai', /^copilot\\.microsoft\\.com$|(^|\\.)copilot\\.com$/],\n ['grok', 'ai', /(^|\\.)(grok\\.com|x\\.ai)$/],\n ['deepseek', 'ai', /(^|\\.)deepseek\\.com$/],\n ['doubao', 'ai', /(^|\\.)doubao\\.com$/],\n ['kimi', 'ai', /(^|\\.)(kimi\\.com|kimi\\.moonshot\\.cn|moonshot\\.cn)$/],\n ['qwen', 'ai', /^tongyi\\.aliyun\\.com$|(^|\\.)(tongyi\\.com|qianwen\\.com|qwen\\.ai)$/],\n ['yuanbao', 'ai', /^yuanbao\\.tencent\\.com$/],\n ['ernie', 'ai', /^yiyan\\.baidu\\.com$|(^|\\.)ernie\\.baidu\\.com$/],\n ['zhipu', 'ai', /(^|\\.)(chatglm\\.cn|zhipuai\\.cn|bigmodel\\.cn)$/],\n // Before google, whose pattern it would match.\n ['gmail', 'email', /^mail\\.google\\.com$/],\n ['google', 'organic', /(^|\\.)google\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['microsoft', 'organic', /(^|\\.)bing\\.com$/],\n ['yahoo', 'organic', /(^|\\.)yahoo\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['duckduckgo', 'organic', /(^|\\.)duckduckgo\\.com$/],\n ['baidu', 'organic', /(^|\\.)baidu\\.com$/],\n ['yandex', 'organic', /(^|\\.)yandex\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['ecosia', 'organic', /(^|\\.)ecosia\\.org$/],\n ['naver', 'organic', /(^|\\.)naver\\.com$/],\n ['brave', 'organic', /^search\\.brave\\.com$/],\n ['meta', 'social', /(^|\\.)(facebook\\.com|instagram\\.com|fb\\.com|threads\\.net)$/],\n ['x', 'social', /(^|\\.)(twitter\\.com|x\\.com|t\\.co)$/],\n ['linkedin', 'social', /(^|\\.)(linkedin\\.com|lnkd\\.in)$/],\n ['reddit', 'social', /(^|\\.)reddit\\.com$/],\n ['tiktok', 'social', /(^|\\.)tiktok\\.com$/],\n ['pinterest', 'social', /(^|\\.)pinterest\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['snapchat', 'social', /(^|\\.)snapchat\\.com$/],\n ['telegram', 'social', /^(t\\.me|telegram\\.me|(web\\.)?telegram\\.org)$/],\n ['youtube', 'video', /(^|\\.)(youtube\\.com|youtu\\.be)$/],\n] as const satisfies readonly (readonly [Channel, ReferrerMedium, RegExp])[];\n\n/**\n * The site an Android app's referrer stands for. A link opened from an app on Android arrives with\n * `android-app://<package>/` as its referrer rather than the site's URL; the apps below are read as\n * their sites, so the Google app is a Google search and the LinkedIn app is LinkedIn. A package\n * not listed is kept as the referring host, as GA4 shows it; Meta's apps send their mobile host\n * (`android-app://m.facebook.com`), which needs no entry.\n */\nexport const ANDROID_APP_HOSTS: Readonly<Record<string, string>> = {\n 'com.google.android.googlequicksearchbox': 'www.google.com',\n 'com.google.android.gm': 'mail.google.com',\n 'com.google.android.youtube': 'www.youtube.com',\n 'com.facebook.katana': 'www.facebook.com',\n 'com.instagram.android': 'www.instagram.com',\n 'com.linkedin.android': 'www.linkedin.com',\n 'com.reddit.frontpage': 'www.reddit.com',\n 'com.twitter.android': 'x.com',\n 'com.zhiliaoapp.musically': 'www.tiktok.com',\n 'com.ss.android.ugc.trill': 'www.tiktok.com',\n 'com.pinterest': 'www.pinterest.com',\n 'com.snapchat.android': 'www.snapchat.com',\n 'org.telegram.messenger': 't.me',\n};\n\n/**\n * Referrer hosts that are navigation, not acquisition, when a session happens to start on the way\n * back from them: the local dev host; the payment providers a visitor is bounced through; the\n * sign-in providers whose login lives on a host of its own (Google, Apple, Microsoft, WeChat,\n * Kakao, LINE, Naver, Yahoo, Twitch, X's OAuth 1.0a endpoint); and the hosted auth services a\n * product may sit behind (Auth0, Okta, Supabase, Firebase, Clerk). A product appends its own\n * hostnames — a session that starts from an internal link after the session timeout refers to\n * them. Matched before `REFERRER_SITES`, so `nid.naver.com` and `login.yahoo.com` are not read as\n * a search. Providers whose login shares the host with their content — Facebook, X's OAuth 2.0,\n * LinkedIn, GitHub, Discord — cannot be told apart by host (referrer policies strip the path) and\n * are left as the referrers they usually are. Regular expressions over the lower-cased host.\n */\nexport const REFERRERS_NOT_A_TOUCH = [\n /^localhost(:|$)/,\n // payment\n /(^|\\.)stripe\\.com$/,\n /^pay\\.google\\.com$/,\n /(^|\\.)paypal\\.com$/,\n /(^|\\.)alipay\\.com$/,\n /(^|\\.)paddle\\.com$/,\n /(^|\\.)lemonsqueezy\\.com$/,\n // app stores\n /^apps\\.apple\\.com$/,\n /^play\\.google\\.com$/,\n // sign-in providers with a login host of their own\n /^accounts\\.google\\.com$/,\n /^appleid\\.apple\\.com$/,\n /^login\\.(microsoftonline|live)\\.com$/,\n /^open\\.weixin\\.qq\\.com$/,\n /^(kauth|accounts)\\.kakao\\.com$/,\n /^access\\.line\\.me$/,\n /^nid\\.naver\\.com$/,\n /^login\\.yahoo\\.com$/,\n /^id\\.twitch\\.tv$/,\n /^api\\.twitter\\.com$/,\n // hosted auth services\n /(^|\\.)auth0\\.com$/,\n /(^|\\.)okta\\.com$/,\n /(^|\\.)supabase\\.co$/,\n /(^|\\.)firebaseapp\\.com$/,\n /(^|\\.)clerk\\.accounts\\.dev$/,\n] as const;\n\n/** GA4's paid mediums — cpc, cpm, ppc, retargeting, paid_social, … — plus Performance Max. */\nexport const PAID_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|pmax|performance_max)$/;\n/**\n * Meta's `{{placement}}` values, lower-cased, which an ad URL template of `utm_medium={{placement}}`\n * puts in the medium. Only an ad carries one — an organic post arrives as a referrer, with no utm\n * at all — so a medium in this list is a paid Meta click whatever the source says (a template with\n * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so\n * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it\n * is spelled … which means a placement Meta adds later shows up as unassigned until it is added\n * here. Meta's documented values plus the ones seen in the wild (`facebook_mobile_reels`,\n * `facebook_notification`, `an`), and the literal macro for a template that did not expand it.\n */\nexport const META_PLACEMENTS = [\n 'facebook_desktop_feed',\n 'facebook_mobile_feed',\n 'facebook_mobile_reels',\n 'facebook_reels',\n 'facebook_reels_overlay',\n 'facebook_right_column',\n 'facebook_marketplace',\n 'facebook_video_feeds',\n 'facebook_stories',\n 'facebook_search',\n 'facebook_instream_video',\n 'facebook_instant_article',\n 'facebook_groups_feed',\n 'facebook_profile_feed',\n 'facebook_notification',\n 'facebook_notifications',\n 'facebook_business_explore',\n 'instagram_feed',\n 'instagram_stories',\n 'instagram_reels',\n 'instagram_reels_overlay',\n 'instagram_explore',\n 'instagram_explore_home',\n 'instagram_search',\n 'instagram_shop',\n 'instagram_profile_feed',\n 'instagram_profile_reels',\n 'instagram_igtv',\n 'messenger_inbox',\n 'messenger_stories',\n 'messenger_sponsored_messages',\n 'threads_feed',\n 'whatsapp_status',\n 'audience_network_classic',\n 'audience_network_rewarded_video',\n 'audience_network_native_banner_and_interstitial',\n 'an',\n 'others',\n /** the macro itself, when Meta did not expand it (written in a field it does not substitute, or a\n * preview click): still only ever produced by a Meta ad URL, so still a paid Meta click */\n '{{placement}}',\n] as const;\nexport const DISPLAY_MEDIUMS = ['display', 'banner', 'expandable', 'interstitial'] as const;\n/** GA4's email spellings, as a source or a medium. */\nexport const EMAIL_MEDIUMS = ['email', 'e-mail', 'e_mail', 'e mail'] as const;\n/** A medium that says email or newsletter anywhere in it: `outbound email`, `cold_email`, `newsletter`, and GA4's four. */\nexport const EMAIL_MEDIUM = /(^|[^a-z])(e[-_ ]?mail|newsletter)([^a-z]|$)/;\n\n/** Where a touch came from: a tracked session, or a report by the user or staff. */\nexport const TOUCH_SOURCES = ['tracked', 'reported'] as const;\nexport type TouchSource = (typeof TOUCH_SOURCES)[number];\n\n/**\n * How a reported touch was learned:\n * - survey: the user answered \"how did you hear about us\" (onboarding, checkout)\n * - promo_code: the user redeemed a code that belongs to a channel\n * - phone: a call came in on a channel's dedicated number, marked by staff or the phone system\n * - manual: staff recorded what a customer told them\n */\nexport const REPORTED_TOUCH_KINDS = ['survey', 'promo_code', 'phone', 'manual'] as const;\nexport type ReportedTouchKind = (typeof REPORTED_TOUCH_KINDS)[number];\n\n/**\n * How much a touch is worth against another when attribution has to choose between sessions;\n * lower wins, and the latest wins among equals. Stored on the session when it is classified, so\n * renumbering means reclassifying.\n *\n * This ranks touches against each other. Within one session the rules that name its channel have\n * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —\n * and the first that says something wins; the session's priority is then the tier of that rule\n * (a campaign rule whose touch is organic by its own tag ranking as a referrer, see `classifyTouch`).\n * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the\n * later one is credited, while one session carrying both is named by its utm.\n */\nexport const TOUCH_PRIORITY = {\n /**\n * utm, click id, ad landing page: someone tagged that link (in that order within a session) —\n * unless the tag itself calls the touch organic, a referral or email, which then ranks as `referrer`\n */\n campaign: 1,\n /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */\n verified: 1,\n /** the browser said where the visitor came from; nobody chose it */\n referrer: 2,\n /** a reported touch that is the user's claim: a survey answer, a code that may have been shared */\n claimed: 3,\n} as const satisfies Record<string, number>;\n\n/** Which reported kinds are staff facts and which are the user's claims. */\nexport const REPORTED_TOUCH_PRIORITY = {\n phone: TOUCH_PRIORITY.verified,\n manual: TOUCH_PRIORITY.verified,\n survey: TOUCH_PRIORITY.claimed,\n promo_code: TOUCH_PRIORITY.claimed,\n} as const satisfies Record<ReportedTouchKind, number>;\n"],"mappings":"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"}
1
+ {"version":3,"file":"vocabulary.cjs","names":[],"sources":["../../src/attribution/vocabulary.ts"],"sourcesContent":["import type { AdvertisingInfo } from '../track/types';\n\n/**\n * The vocabulary attribution is built from, shared by every product's `session` table and by the\n * dashboards that read it. Data for `classifyTouch` (./classify), which turns it into a channel;\n * a product's dashboards read the same names back. What stays with the product is its own hostnames\n * (referrer exclusions) and its landing-page conventions.\n */\n\n/** A session that arrived through nothing we can name. GA's spelling, so dashboards read as GA. */\nexport const DIRECT_CHANNEL = '(direct)';\n/** The medium of a direct session. */\nexport const NO_MEDIUM = '(none)';\n/** The medium of a tagged link that named a source but no medium. */\nexport const MEDIUM_NOT_SET = '(not set)';\n\n/** The channels the rules below can name. A `utm_source` outside this list is its own channel. */\nexport const CHANNELS = [\n 'meta',\n 'google',\n 'dv360',\n 'microsoft',\n 'tiktok',\n 'reddit',\n 'linkedin',\n 'snapchat',\n 'x',\n 'kakao',\n 'yandex',\n 'yahoo',\n 'duckduckgo',\n 'baidu',\n 'ecosia',\n 'naver',\n 'brave',\n 'pinterest',\n 'youtube',\n 'telegram',\n /** Gmail's web and Android app: a link in a mail that carried no utm. */\n 'gmail',\n /** AI assistants that send people to sites: ChatGPT tags its links `utm_source=chatgpt.com`, the\n * others show as a referrer. Their own channel group, `organic_ai`, since GA4's default groups\n * would file them under Referral / Unassigned and the question \"how much does AI bring\" comes up. */\n 'chatgpt',\n 'perplexity',\n 'gemini',\n 'claude',\n 'copilot',\n 'grok',\n /** and the Chinese ones — DeepSeek, 豆包, Kimi, 通义, 腾讯元宝, 文心一言, 智谱清言 */\n 'deepseek',\n 'doubao',\n 'kimi',\n 'qwen',\n 'yuanbao',\n 'ernie',\n 'zhipu',\n /** the product's own referral programme: the session came through a member's link or code.\n * How a product recognises one (a `/refer/<code>` path, a code at sign-up) is its own rule. Not\n * `referral`: that word is the channel group of any outside site, and a channel of the same name\n * would be impossible to tell apart in queries. */\n 'referral_program',\n] as const;\n\nexport type Channel = (typeof CHANNELS)[number];\n\n/** GA4's default channel groups, as identifiers; a dashboard maps them to display names. */\nexport const CHANNEL_GROUPS = [\n 'direct',\n 'paid_search',\n 'paid_social',\n 'paid_other',\n 'display',\n 'organic_search',\n 'organic_social',\n 'organic_video',\n /** not in GA4's default grouping: sessions an AI assistant sent, by referrer or by ChatGPT's utm */\n 'organic_ai',\n 'referral',\n 'email',\n 'affiliate',\n 'unassigned',\n] as const;\n\nexport type ChannelGroup = (typeof CHANNEL_GROUPS)[number];\n\n/** `utm_source` spellings that mean the same channel: Meta's `{{site_source_name}}` values and the usual synonyms. */\nexport const SOURCE_ALIASES = {\n fb: 'meta',\n ig: 'meta',\n an: 'meta',\n msg: 'meta',\n th: 'meta',\n facebook: 'meta',\n instagram: 'meta',\n bing: 'microsoft',\n twitter: 'x',\n 'google ads': 'google',\n googleads: 'google',\n adwords: 'google',\n 'chatgpt.com': 'chatgpt',\n 'chat.openai.com': 'chatgpt',\n openai: 'chatgpt',\n 'perplexity.ai': 'perplexity',\n 'claude.ai': 'claude',\n 'copilot.com': 'copilot',\n 'deepseek.com': 'deepseek',\n 'doubao.com': 'doubao',\n 'kimi.com': 'kimi',\n} as const satisfies Record<string, Channel>;\n\n/**\n * Which click id belongs to which channel, in priority order, and whether the platform puts it on\n * its ad clicks only (`'ads'`) or on other links as well (`'any'`). Every key is a field this SDK\n * collects (`AdvertisingInfo`).\n *\n * The third column decides whether the id can say a click was paid when a `utm_source` names the\n * channel but no `utm_medium` says how (a Reddit ad tagged `utm_source=reddit` and nothing more).\n * `fbclid` is `'any'`: Meta decorates every outbound link with it, organic posts, profile links\n * and shares included. `twclid` and `ko_click_id` are `'any'` until it is clear they never\n * decorate an organic link.\n *\n * Only URL parameters, never the cookies the tags keep beside them (`_fbc`, `_gcl_aw`, … — every\n * underscore key of `AdvertisingInfo`): a click cookie lives up to 90 days after the click, so\n * counting it named every later visit — typed in, from search, from an email — a new ad click.\n */\nexport const CLICK_ID_CHANNELS = [\n ['fbclid', 'meta', 'any'],\n ['gclid', 'google', 'ads'],\n ['gbraid', 'google', 'ads'],\n ['wbraid', 'google', 'ads'],\n ['gad_source', 'google', 'ads'],\n ['gad_campaignid', 'google', 'ads'],\n ['dclid', 'dv360', 'ads'],\n ['msclkid', 'microsoft', 'ads'],\n ['ttclid', 'tiktok', 'ads'],\n ['rdt_cid', 'reddit', 'ads'],\n ['li_fat_id', 'linkedin', 'ads'],\n ['ScCid', 'snapchat', 'ads'],\n ['twclid', 'x', 'any'],\n ['ko_click_id', 'kakao', 'any'],\n ['yclid', 'yandex', 'ads'],\n ['oppref', 'chatgpt', 'ads'],\n ['epik', 'pinterest', 'ads'],\n] as const satisfies readonly (readonly [keyof AdvertisingInfo, Channel, 'ads' | 'any'])[];\n\n/**\n * A landing page reserved for one channel's ads, for ads that lost their parameters:\n * `/lp/<channel>`, with or without host, query and hash; the capture is the channel. A product\n * that names its landing pages differently replaces this.\n */\nexport const AD_LANDING_PAGE = /^(?:https?:\\/\\/[^/]+)?\\/lp\\/([a-z]+)(?:[/?#]|$)/;\n\nexport type ReferrerMedium = 'organic' | 'social' | 'video' | 'ai' | 'email';\n\n/**\n * Referrer hosts that name a channel, as regular expressions over the lower-cased host,\n * with the medium GA4 gives that kind of site. Anything else that refers is `referral`, kept as\n * its host. A country TLD is one or two labels (`google.com`, `google.co.uk`, `google.com.hk`),\n * never an open tail, so `google.com.evil.io` is not Google. In match order: the AI assistants\n * first, because `gemini.google.com`, `copilot.microsoft.com` and `yiyan.baidu.com` would\n * otherwise be Google, Microsoft and Baidu search.\n */\nexport const REFERRER_SITES = [\n ['chatgpt', 'ai', /(^|\\.)(chatgpt\\.com|chat\\.openai\\.com)$/],\n ['perplexity', 'ai', /(^|\\.)perplexity\\.ai$/],\n ['gemini', 'ai', /^gemini\\.google\\.com$/],\n ['claude', 'ai', /(^|\\.)claude\\.ai$/],\n ['copilot', 'ai', /^copilot\\.microsoft\\.com$|(^|\\.)copilot\\.com$/],\n ['grok', 'ai', /(^|\\.)(grok\\.com|x\\.ai)$/],\n ['deepseek', 'ai', /(^|\\.)deepseek\\.com$/],\n ['doubao', 'ai', /(^|\\.)doubao\\.com$/],\n ['kimi', 'ai', /(^|\\.)(kimi\\.com|kimi\\.moonshot\\.cn|moonshot\\.cn)$/],\n ['qwen', 'ai', /^tongyi\\.aliyun\\.com$|(^|\\.)(tongyi\\.com|qianwen\\.com|qwen\\.ai)$/],\n ['yuanbao', 'ai', /^yuanbao\\.tencent\\.com$/],\n ['ernie', 'ai', /^yiyan\\.baidu\\.com$|(^|\\.)ernie\\.baidu\\.com$/],\n ['zhipu', 'ai', /(^|\\.)(chatglm\\.cn|zhipuai\\.cn|bigmodel\\.cn)$/],\n // Before google, whose pattern it would match.\n ['gmail', 'email', /^mail\\.google\\.com$/],\n ['google', 'organic', /(^|\\.)google\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['microsoft', 'organic', /(^|\\.)bing\\.com$/],\n ['yahoo', 'organic', /(^|\\.)yahoo\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['duckduckgo', 'organic', /(^|\\.)duckduckgo\\.com$/],\n ['baidu', 'organic', /(^|\\.)baidu\\.com$/],\n ['yandex', 'organic', /(^|\\.)yandex\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['ecosia', 'organic', /(^|\\.)ecosia\\.org$/],\n ['naver', 'organic', /(^|\\.)naver\\.com$/],\n ['brave', 'organic', /^search\\.brave\\.com$/],\n ['meta', 'social', /(^|\\.)(facebook\\.com|instagram\\.com|fb\\.com|threads\\.net)$/],\n ['x', 'social', /(^|\\.)(twitter\\.com|x\\.com|t\\.co)$/],\n ['linkedin', 'social', /(^|\\.)(linkedin\\.com|lnkd\\.in)$/],\n ['reddit', 'social', /(^|\\.)reddit\\.com$/],\n ['tiktok', 'social', /(^|\\.)tiktok\\.com$/],\n ['pinterest', 'social', /(^|\\.)pinterest\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['snapchat', 'social', /(^|\\.)snapchat\\.com$/],\n ['telegram', 'social', /^(t\\.me|telegram\\.me|(web\\.)?telegram\\.org)$/],\n ['youtube', 'video', /(^|\\.)(youtube\\.com|youtu\\.be)$/],\n] as const satisfies readonly (readonly [Channel, ReferrerMedium, RegExp])[];\n\n/**\n * The site an Android app's referrer stands for. A link opened from an app on Android arrives with\n * `android-app://<package>/` as its referrer rather than the site's URL; the apps below are read as\n * their sites, so the Google app is a Google search and the LinkedIn app is LinkedIn. A package\n * not listed is kept as the referring host, as GA4 shows it; Meta's apps send their mobile host\n * (`android-app://m.facebook.com`), which needs no entry.\n */\nexport const ANDROID_APP_HOSTS: Readonly<Record<string, string>> = {\n 'com.google.android.googlequicksearchbox': 'www.google.com',\n 'com.google.android.gm': 'mail.google.com',\n 'com.google.android.youtube': 'www.youtube.com',\n 'com.facebook.katana': 'www.facebook.com',\n 'com.instagram.android': 'www.instagram.com',\n 'com.linkedin.android': 'www.linkedin.com',\n 'com.reddit.frontpage': 'www.reddit.com',\n 'com.twitter.android': 'x.com',\n 'com.zhiliaoapp.musically': 'www.tiktok.com',\n 'com.ss.android.ugc.trill': 'www.tiktok.com',\n 'com.pinterest': 'www.pinterest.com',\n 'com.snapchat.android': 'www.snapchat.com',\n 'org.telegram.messenger': 't.me',\n};\n\n/**\n * Referrer hosts that are navigation, not acquisition, when a session happens to start on the way\n * back from them: the local dev host; the payment providers a visitor is bounced through; the\n * sign-in providers whose login lives on a host of its own (Google, Apple, Microsoft, WeChat,\n * Kakao, LINE, Naver, Yahoo, Twitch, X's OAuth 1.0a endpoint); and the hosted auth services a\n * product may sit behind (Auth0, Okta, Supabase, Firebase, Clerk). A product appends its own\n * hostnames — a session that starts from an internal link after the session timeout refers to\n * them. Matched before `REFERRER_SITES`, so `nid.naver.com` and `login.yahoo.com` are not read as\n * a search. Providers whose login shares the host with their content — Facebook, X's OAuth 2.0,\n * LinkedIn, GitHub, Discord — cannot be told apart by host (referrer policies strip the path) and\n * are left as the referrers they usually are. Regular expressions over the lower-cased host.\n */\nexport const REFERRERS_NOT_A_TOUCH = [\n /^localhost(:|$)/,\n // payment\n /(^|\\.)stripe\\.com$/,\n /^pay\\.google\\.com$/,\n /(^|\\.)paypal\\.com$/,\n /(^|\\.)alipay\\.com$/,\n /(^|\\.)paddle\\.com$/,\n /(^|\\.)lemonsqueezy\\.com$/,\n // app stores\n /^apps\\.apple\\.com$/,\n /^play\\.google\\.com$/,\n // sign-in providers with a login host of their own\n /^accounts\\.google\\.com$/,\n /^appleid\\.apple\\.com$/,\n /^login\\.(microsoftonline|live)\\.com$/,\n /^open\\.weixin\\.qq\\.com$/,\n /^(kauth|accounts)\\.kakao\\.com$/,\n /^access\\.line\\.me$/,\n /^nid\\.naver\\.com$/,\n /^login\\.yahoo\\.com$/,\n /^id\\.twitch\\.tv$/,\n /^api\\.twitter\\.com$/,\n // hosted auth services\n /(^|\\.)auth0\\.com$/,\n /(^|\\.)okta\\.com$/,\n /(^|\\.)supabase\\.co$/,\n /(^|\\.)firebaseapp\\.com$/,\n /(^|\\.)clerk\\.accounts\\.dev$/,\n] as const;\n\n/** GA4's paid mediums — cpc, cpm, ppc, retargeting, paid_social, … — plus Performance Max. */\nexport const PAID_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|pmax|performance_max)$/;\n/**\n * Meta's `{{placement}}` values, lower-cased, which an ad URL template of `utm_medium={{placement}}`\n * puts in the medium. Only an ad carries one — an organic post arrives as a referrer, with no utm\n * at all — so a medium in this list is a paid Meta click whatever the source says (a template with\n * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so\n * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it\n * is spelled … which means a placement Meta adds later shows up as organic social (or unassigned,\n * under a source that is no social site) until it is added here. Meta's documented values plus the\n * ones seen in the wild (`facebook_mobile_reels`, `facebook_notification`, `an`), and the literal\n * macro for a template that did not expand it.\n *\n * Accepted for the ads already running with that template; new ads should rather send\n * `utm_medium=cpc` and the placement in a `placement` parameter of its own (collected as the\n * `placement` tag): GA4 matches its paid rule on the medium alone, and files a placement medium\n * under Organic Social.\n */\nexport const META_PLACEMENTS = [\n 'facebook_feed',\n 'facebook_instream',\n 'facebook_desktop_feed',\n 'facebook_mobile_feed',\n 'facebook_mobile_reels',\n 'facebook_reels',\n 'facebook_reels_overlay',\n 'facebook_right_column',\n 'facebook_marketplace',\n 'facebook_video_feeds',\n 'facebook_stories',\n 'facebook_search',\n 'facebook_instream_video',\n 'facebook_instant_article',\n 'facebook_groups_feed',\n 'facebook_profile_feed',\n 'facebook_notification',\n 'facebook_notifications',\n 'facebook_business_explore',\n 'instagram_feed',\n 'instagram_stories',\n 'instagram_reels',\n 'instagram_reels_overlay',\n 'instagram_explore',\n 'instagram_explore_home',\n 'instagram_search',\n 'instagram_shop',\n 'instagram_profile_feed',\n 'instagram_profile_reels',\n 'instagram_igtv',\n 'messenger_inbox',\n 'messenger_stories',\n 'messenger_sponsored_messages',\n 'threads_feed',\n 'threads_stream',\n 'whatsapp_status',\n 'audience_network_classic',\n 'audience_network_rewarded_video',\n 'audience_network_native_banner_and_interstitial',\n 'an',\n 'others',\n /** the macro itself, when Meta did not expand it (written in a field it does not substitute, or a\n * preview click): still only ever produced by a Meta ad URL, so still a paid Meta click */\n '{{placement}}',\n] as const;\nexport const DISPLAY_MEDIUMS = ['display', 'banner', 'expandable', 'interstitial'] as const;\n/** GA4's email spellings, as a source or a medium. */\nexport const EMAIL_MEDIUMS = ['email', 'e-mail', 'e_mail', 'e mail'] as const;\n/** A medium that says email or newsletter anywhere in it: `outbound email`, `cold_email`, `newsletter`, and GA4's four. */\nexport const EMAIL_MEDIUM = /(^|[^a-z])(e[-_ ]?mail|newsletter)([^a-z]|$)/;\n\n/** Where a touch came from: a tracked session, or a report by the user or staff. */\nexport const TOUCH_SOURCES = ['tracked', 'reported'] as const;\nexport type TouchSource = (typeof TOUCH_SOURCES)[number];\n\n/**\n * How a reported touch was learned:\n * - survey: the user answered \"how did you hear about us\" (onboarding, checkout)\n * - promo_code: the user redeemed a code that belongs to a channel\n * - phone: a call came in on a channel's dedicated number, marked by staff or the phone system\n * - manual: staff recorded what a customer told them\n */\nexport const REPORTED_TOUCH_KINDS = ['survey', 'promo_code', 'phone', 'manual'] as const;\nexport type ReportedTouchKind = (typeof REPORTED_TOUCH_KINDS)[number];\n\n/**\n * How much a touch is worth against another when attribution has to choose between sessions;\n * lower wins, and the latest wins among equals. Stored on the session when it is classified, so\n * renumbering means reclassifying.\n *\n * This ranks touches against each other. Within one session the rules that name its channel have\n * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —\n * and the first that says something wins; the session's priority is then the tier of that rule\n * (a campaign rule whose touch is organic by its own tag ranking as a referrer, see `classifyTouch`).\n * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the\n * later one is credited, while one session carrying both is named by its utm.\n */\nexport const TOUCH_PRIORITY = {\n /**\n * utm, click id, ad landing page: someone tagged that link (in that order within a session) —\n * unless the tag itself calls the touch organic, a referral or email, which then ranks as `referrer`\n */\n campaign: 1,\n /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */\n verified: 1,\n /** the browser said where the visitor came from; nobody chose it */\n referrer: 2,\n /** a reported touch that is the user's claim: a survey answer, a code that may have been shared */\n claimed: 3,\n} as const satisfies Record<string, number>;\n\n/** Which reported kinds are staff facts and which are the user's claims. */\nexport const REPORTED_TOUCH_PRIORITY = {\n phone: TOUCH_PRIORITY.verified,\n manual: TOUCH_PRIORITY.verified,\n survey: TOUCH_PRIORITY.claimed,\n promo_code: TOUCH_PRIORITY.claimed,\n} as const satisfies Record<ReportedTouchKind, number>;\n"],"mappings":"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"}
@@ -117,11 +117,17 @@ export declare const PAID_MEDIUM: RegExp;
117
117
  * at all — so a medium in this list is a paid Meta click whatever the source says (a template with
118
118
  * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so
119
119
  * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it
120
- * is spelled … which means a placement Meta adds later shows up as unassigned until it is added
121
- * here. Meta's documented values plus the ones seen in the wild (`facebook_mobile_reels`,
122
- * `facebook_notification`, `an`), and the literal macro for a template that did not expand it.
120
+ * is spelled … which means a placement Meta adds later shows up as organic social (or unassigned,
121
+ * under a source that is no social site) until it is added here. Meta's documented values plus the
122
+ * ones seen in the wild (`facebook_mobile_reels`, `facebook_notification`, `an`), and the literal
123
+ * macro for a template that did not expand it.
124
+ *
125
+ * Accepted for the ads already running with that template; new ads should rather send
126
+ * `utm_medium=cpc` and the placement in a `placement` parameter of its own (collected as the
127
+ * `placement` tag): GA4 matches its paid rule on the medium alone, and files a placement medium
128
+ * under Organic Social.
123
129
  */
124
- export declare const META_PLACEMENTS: readonly ['facebook_desktop_feed', 'facebook_mobile_feed', 'facebook_mobile_reels', 'facebook_reels', 'facebook_reels_overlay', 'facebook_right_column', 'facebook_marketplace', 'facebook_video_feeds', 'facebook_stories', 'facebook_search', 'facebook_instream_video', 'facebook_instant_article', 'facebook_groups_feed', 'facebook_profile_feed', 'facebook_notification', 'facebook_notifications', 'facebook_business_explore', 'instagram_feed', 'instagram_stories', 'instagram_reels', 'instagram_reels_overlay', 'instagram_explore', 'instagram_explore_home', 'instagram_search', 'instagram_shop', 'instagram_profile_feed', 'instagram_profile_reels', 'instagram_igtv', 'messenger_inbox', 'messenger_stories', 'messenger_sponsored_messages', 'threads_feed', 'whatsapp_status', 'audience_network_classic', 'audience_network_rewarded_video', 'audience_network_native_banner_and_interstitial', 'an', 'others',
130
+ export declare const META_PLACEMENTS: readonly ['facebook_feed', 'facebook_instream', 'facebook_desktop_feed', 'facebook_mobile_feed', 'facebook_mobile_reels', 'facebook_reels', 'facebook_reels_overlay', 'facebook_right_column', 'facebook_marketplace', 'facebook_video_feeds', 'facebook_stories', 'facebook_search', 'facebook_instream_video', 'facebook_instant_article', 'facebook_groups_feed', 'facebook_profile_feed', 'facebook_notification', 'facebook_notifications', 'facebook_business_explore', 'instagram_feed', 'instagram_stories', 'instagram_reels', 'instagram_reels_overlay', 'instagram_explore', 'instagram_explore_home', 'instagram_search', 'instagram_shop', 'instagram_profile_feed', 'instagram_profile_reels', 'instagram_igtv', 'messenger_inbox', 'messenger_stories', 'messenger_sponsored_messages', 'threads_feed', 'threads_stream', 'whatsapp_status', 'audience_network_classic', 'audience_network_rewarded_video', 'audience_network_native_banner_and_interstitial', 'an', 'others',
125
131
  /** the macro itself, when Meta did not expand it (written in a field it does not substitute, or a
126
132
  * preview click): still only ever produced by a Meta ad URL, so still a paid Meta click */
127
133
  '{{placement}}'];
@@ -1 +1 @@
1
- {"version":3,"file":"vocabulary.d.cts","names":[],"sources":["../../src/attribution/vocabulary.ts"],"mappings":";;;;;;;;qBAUa;;qBAEA;;qBAEA;;qBAGA;;;;;;;;;;;;;;YA+CD,kBAAkB;;qBAGjB;;;YAiBD,uBAAuB;;qBAGtB;WACP;WACA;WACA;WACC;WACD;WACM;WACC;WACL;WACG;WACK;WACH;WACF;WACM;WACI;WACX;WACS;WACJ;WACE;WACC;WACF;WACF;;;;;;;;;;;;;;;;;qBAkBD;;;;;;qBAyBA,iBAAe;YAEhB;;;;;;;;;qBAUC,qDAAc,uCAAA,mCAAA,mCAAA,oCAAA,iCAAA,qCAAA,mCAAA,iCAAA,iCAAA,oCAAA,kCAAA,kCAAA,qCAAA,wCAAA,2CAAA,uCAAA,4CAAA,uCAAA,wCAAA,wCAAA,uCAAA,uCAAA,qCAAA,kCAAA,yCAAA,uCAAA,uCAAA,0CAAA,yCAAA,yCAAA,uCAAA;;;;;;;;qBA2Cd,mBAAmB,SAAS;;;;;;;;;;;;;qBA4B5B,iCAAqB,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA;;qBAgCrB,aAAW;;;;;;;;;;;qBAWX;;;;qBA2CA;;qBAEA;;qBAEA,cAAY;;qBAGZ;YACD,sBAAsB;;;;;;;;qBASrB;YACD,4BAA4B;;;;;;;;;;;;;qBAc3B;;;;;WAKD;;WAEA;;WAEA;;WAED;;;qBAIE"}
1
+ {"version":3,"file":"vocabulary.d.cts","names":[],"sources":["../../src/attribution/vocabulary.ts"],"mappings":";;;;;;;;qBAUa;;qBAEA;;qBAEA;;qBAGA;;;;;;;;;;;;;;YA+CD,kBAAkB;;qBAGjB;;;YAiBD,uBAAuB;;qBAGtB;WACP;WACA;WACA;WACC;WACD;WACM;WACC;WACL;WACG;WACK;WACH;WACF;WACM;WACI;WACX;WACS;WACJ;WACE;WACC;WACF;WACF;;;;;;;;;;;;;;;;;qBAkBD;;;;;;qBAyBA,iBAAe;YAEhB;;;;;;;;;qBAUC,qDAAc,uCAAA,mCAAA,mCAAA,oCAAA,iCAAA,qCAAA,mCAAA,iCAAA,iCAAA,oCAAA,kCAAA,kCAAA,qCAAA,wCAAA,2CAAA,uCAAA,4CAAA,uCAAA,wCAAA,wCAAA,uCAAA,uCAAA,qCAAA,kCAAA,yCAAA,uCAAA,uCAAA,0CAAA,yCAAA,yCAAA,uCAAA;;;;;;;;qBA2Cd,mBAAmB,SAAS;;;;;;;;;;;;;qBA4B5B,iCAAqB,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA;;qBAgCrB,aAAW;;;;;;;;;;;;;;;;;qBAiBX;;;;qBA8CA;;qBAEA;;qBAEA,cAAY;;qBAGZ;YACD,sBAAsB;;;;;;;;qBASrB;YACD,4BAA4B;;;;;;;;;;;;;qBAc3B;;;;;WAKD;;WAEA;;WAEA;;WAED;;;qBAIE"}
@@ -117,11 +117,17 @@ export declare const PAID_MEDIUM: RegExp;
117
117
  * at all — so a medium in this list is a paid Meta click whatever the source says (a template with
118
118
  * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so
119
119
  * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it
120
- * is spelled … which means a placement Meta adds later shows up as unassigned until it is added
121
- * here. Meta's documented values plus the ones seen in the wild (`facebook_mobile_reels`,
122
- * `facebook_notification`, `an`), and the literal macro for a template that did not expand it.
120
+ * is spelled … which means a placement Meta adds later shows up as organic social (or unassigned,
121
+ * under a source that is no social site) until it is added here. Meta's documented values plus the
122
+ * ones seen in the wild (`facebook_mobile_reels`, `facebook_notification`, `an`), and the literal
123
+ * macro for a template that did not expand it.
124
+ *
125
+ * Accepted for the ads already running with that template; new ads should rather send
126
+ * `utm_medium=cpc` and the placement in a `placement` parameter of its own (collected as the
127
+ * `placement` tag): GA4 matches its paid rule on the medium alone, and files a placement medium
128
+ * under Organic Social.
123
129
  */
124
- export declare const META_PLACEMENTS: readonly ['facebook_desktop_feed', 'facebook_mobile_feed', 'facebook_mobile_reels', 'facebook_reels', 'facebook_reels_overlay', 'facebook_right_column', 'facebook_marketplace', 'facebook_video_feeds', 'facebook_stories', 'facebook_search', 'facebook_instream_video', 'facebook_instant_article', 'facebook_groups_feed', 'facebook_profile_feed', 'facebook_notification', 'facebook_notifications', 'facebook_business_explore', 'instagram_feed', 'instagram_stories', 'instagram_reels', 'instagram_reels_overlay', 'instagram_explore', 'instagram_explore_home', 'instagram_search', 'instagram_shop', 'instagram_profile_feed', 'instagram_profile_reels', 'instagram_igtv', 'messenger_inbox', 'messenger_stories', 'messenger_sponsored_messages', 'threads_feed', 'whatsapp_status', 'audience_network_classic', 'audience_network_rewarded_video', 'audience_network_native_banner_and_interstitial', 'an', 'others',
130
+ export declare const META_PLACEMENTS: readonly ['facebook_feed', 'facebook_instream', 'facebook_desktop_feed', 'facebook_mobile_feed', 'facebook_mobile_reels', 'facebook_reels', 'facebook_reels_overlay', 'facebook_right_column', 'facebook_marketplace', 'facebook_video_feeds', 'facebook_stories', 'facebook_search', 'facebook_instream_video', 'facebook_instant_article', 'facebook_groups_feed', 'facebook_profile_feed', 'facebook_notification', 'facebook_notifications', 'facebook_business_explore', 'instagram_feed', 'instagram_stories', 'instagram_reels', 'instagram_reels_overlay', 'instagram_explore', 'instagram_explore_home', 'instagram_search', 'instagram_shop', 'instagram_profile_feed', 'instagram_profile_reels', 'instagram_igtv', 'messenger_inbox', 'messenger_stories', 'messenger_sponsored_messages', 'threads_feed', 'threads_stream', 'whatsapp_status', 'audience_network_classic', 'audience_network_rewarded_video', 'audience_network_native_banner_and_interstitial', 'an', 'others',
125
131
  /** the macro itself, when Meta did not expand it (written in a field it does not substitute, or a
126
132
  * preview click): still only ever produced by a Meta ad URL, so still a paid Meta click */
127
133
  '{{placement}}'];
@@ -1 +1 @@
1
- {"version":3,"file":"vocabulary.d.mts","names":[],"sources":["../../src/attribution/vocabulary.ts"],"mappings":";;;;;;;;qBAUa;;qBAEA;;qBAEA;;qBAGA;;;;;;;;;;;;;;YA+CD,kBAAkB;;qBAGjB;;;YAiBD,uBAAuB;;qBAGtB;WACP;WACA;WACA;WACC;WACD;WACM;WACC;WACL;WACG;WACK;WACH;WACF;WACM;WACI;WACX;WACS;WACJ;WACE;WACC;WACF;WACF;;;;;;;;;;;;;;;;;qBAkBD;;;;;;qBAyBA,iBAAe;YAEhB;;;;;;;;;qBAUC,qDAAc,uCAAA,mCAAA,mCAAA,oCAAA,iCAAA,qCAAA,mCAAA,iCAAA,iCAAA,oCAAA,kCAAA,kCAAA,qCAAA,wCAAA,2CAAA,uCAAA,4CAAA,uCAAA,wCAAA,wCAAA,uCAAA,uCAAA,qCAAA,kCAAA,yCAAA,uCAAA,uCAAA,0CAAA,yCAAA,yCAAA,uCAAA;;;;;;;;qBA2Cd,mBAAmB,SAAS;;;;;;;;;;;;;qBA4B5B,iCAAqB,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA;;qBAgCrB,aAAW;;;;;;;;;;;qBAWX;;;;qBA2CA;;qBAEA;;qBAEA,cAAY;;qBAGZ;YACD,sBAAsB;;;;;;;;qBASrB;YACD,4BAA4B;;;;;;;;;;;;;qBAc3B;;;;;WAKD;;WAEA;;WAEA;;WAED;;;qBAIE"}
1
+ {"version":3,"file":"vocabulary.d.mts","names":[],"sources":["../../src/attribution/vocabulary.ts"],"mappings":";;;;;;;;qBAUa;;qBAEA;;qBAEA;;qBAGA;;;;;;;;;;;;;;YA+CD,kBAAkB;;qBAGjB;;;YAiBD,uBAAuB;;qBAGtB;WACP;WACA;WACA;WACC;WACD;WACM;WACC;WACL;WACG;WACK;WACH;WACF;WACM;WACI;WACX;WACS;WACJ;WACE;WACC;WACF;WACF;;;;;;;;;;;;;;;;;qBAkBD;;;;;;qBAyBA,iBAAe;YAEhB;;;;;;;;;qBAUC,qDAAc,uCAAA,mCAAA,mCAAA,oCAAA,iCAAA,qCAAA,mCAAA,iCAAA,iCAAA,oCAAA,kCAAA,kCAAA,qCAAA,wCAAA,2CAAA,uCAAA,4CAAA,uCAAA,wCAAA,wCAAA,uCAAA,uCAAA,qCAAA,kCAAA,yCAAA,uCAAA,uCAAA,0CAAA,yCAAA,yCAAA,uCAAA;;;;;;;;qBA2Cd,mBAAmB,SAAS;;;;;;;;;;;;;qBA4B5B,iCAAqB,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA;;qBAgCrB,aAAW;;;;;;;;;;;;;;;;;qBAiBX;;;;qBA8CA;;qBAEA;;qBAEA,cAAY;;qBAGZ;YACD,sBAAsB;;;;;;;;qBASrB;YACD,4BAA4B;;;;;;;;;;;;;qBAc3B;;;;;WAKD;;WAEA;;WAEA;;WAED;;;qBAIE"}
@@ -435,11 +435,19 @@ const PAID_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|pmax|performance_max)$/;
435
435
  * at all — so a medium in this list is a paid Meta click whatever the source says (a template with
436
436
  * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so
437
437
  * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it
438
- * is spelled … which means a placement Meta adds later shows up as unassigned until it is added
439
- * here. Meta's documented values plus the ones seen in the wild (`facebook_mobile_reels`,
440
- * `facebook_notification`, `an`), and the literal macro for a template that did not expand it.
438
+ * is spelled … which means a placement Meta adds later shows up as organic social (or unassigned,
439
+ * under a source that is no social site) until it is added here. Meta's documented values plus the
440
+ * ones seen in the wild (`facebook_mobile_reels`, `facebook_notification`, `an`), and the literal
441
+ * macro for a template that did not expand it.
442
+ *
443
+ * Accepted for the ads already running with that template; new ads should rather send
444
+ * `utm_medium=cpc` and the placement in a `placement` parameter of its own (collected as the
445
+ * `placement` tag): GA4 matches its paid rule on the medium alone, and files a placement medium
446
+ * under Organic Social.
441
447
  */
442
448
  const META_PLACEMENTS = [
449
+ "facebook_feed",
450
+ "facebook_instream",
443
451
  "facebook_desktop_feed",
444
452
  "facebook_mobile_feed",
445
453
  "facebook_mobile_reels",
@@ -472,6 +480,7 @@ const META_PLACEMENTS = [
472
480
  "messenger_stories",
473
481
  "messenger_sponsored_messages",
474
482
  "threads_feed",
483
+ "threads_stream",
475
484
  "whatsapp_status",
476
485
  "audience_network_classic",
477
486
  "audience_network_rewarded_video",
@@ -1 +1 @@
1
- {"version":3,"file":"vocabulary.mjs","names":[],"sources":["../../src/attribution/vocabulary.ts"],"sourcesContent":["import type { AdvertisingInfo } from '../track/types';\n\n/**\n * The vocabulary attribution is built from, shared by every product's `session` table and by the\n * dashboards that read it. Data for `classifyTouch` (./classify), which turns it into a channel;\n * a product's dashboards read the same names back. What stays with the product is its own hostnames\n * (referrer exclusions) and its landing-page conventions.\n */\n\n/** A session that arrived through nothing we can name. GA's spelling, so dashboards read as GA. */\nexport const DIRECT_CHANNEL = '(direct)';\n/** The medium of a direct session. */\nexport const NO_MEDIUM = '(none)';\n/** The medium of a tagged link that named a source but no medium. */\nexport const MEDIUM_NOT_SET = '(not set)';\n\n/** The channels the rules below can name. A `utm_source` outside this list is its own channel. */\nexport const CHANNELS = [\n 'meta',\n 'google',\n 'dv360',\n 'microsoft',\n 'tiktok',\n 'reddit',\n 'linkedin',\n 'snapchat',\n 'x',\n 'kakao',\n 'yandex',\n 'yahoo',\n 'duckduckgo',\n 'baidu',\n 'ecosia',\n 'naver',\n 'brave',\n 'pinterest',\n 'youtube',\n 'telegram',\n /** Gmail's web and Android app: a link in a mail that carried no utm. */\n 'gmail',\n /** AI assistants that send people to sites: ChatGPT tags its links `utm_source=chatgpt.com`, the\n * others show as a referrer. Their own channel group, `organic_ai`, since GA4's default groups\n * would file them under Referral / Unassigned and the question \"how much does AI bring\" comes up. */\n 'chatgpt',\n 'perplexity',\n 'gemini',\n 'claude',\n 'copilot',\n 'grok',\n /** and the Chinese ones — DeepSeek, 豆包, Kimi, 通义, 腾讯元宝, 文心一言, 智谱清言 */\n 'deepseek',\n 'doubao',\n 'kimi',\n 'qwen',\n 'yuanbao',\n 'ernie',\n 'zhipu',\n /** the product's own referral programme: the session came through a member's link or code.\n * How a product recognises one (a `/refer/<code>` path, a code at sign-up) is its own rule. Not\n * `referral`: that word is the channel group of any outside site, and a channel of the same name\n * would be impossible to tell apart in queries. */\n 'referral_program',\n] as const;\n\nexport type Channel = (typeof CHANNELS)[number];\n\n/** GA4's default channel groups, as identifiers; a dashboard maps them to display names. */\nexport const CHANNEL_GROUPS = [\n 'direct',\n 'paid_search',\n 'paid_social',\n 'paid_other',\n 'display',\n 'organic_search',\n 'organic_social',\n 'organic_video',\n /** not in GA4's default grouping: sessions an AI assistant sent, by referrer or by ChatGPT's utm */\n 'organic_ai',\n 'referral',\n 'email',\n 'affiliate',\n 'unassigned',\n] as const;\n\nexport type ChannelGroup = (typeof CHANNEL_GROUPS)[number];\n\n/** `utm_source` spellings that mean the same channel: Meta's `{{site_source_name}}` values and the usual synonyms. */\nexport const SOURCE_ALIASES = {\n fb: 'meta',\n ig: 'meta',\n an: 'meta',\n msg: 'meta',\n th: 'meta',\n facebook: 'meta',\n instagram: 'meta',\n bing: 'microsoft',\n twitter: 'x',\n 'google ads': 'google',\n googleads: 'google',\n adwords: 'google',\n 'chatgpt.com': 'chatgpt',\n 'chat.openai.com': 'chatgpt',\n openai: 'chatgpt',\n 'perplexity.ai': 'perplexity',\n 'claude.ai': 'claude',\n 'copilot.com': 'copilot',\n 'deepseek.com': 'deepseek',\n 'doubao.com': 'doubao',\n 'kimi.com': 'kimi',\n} as const satisfies Record<string, Channel>;\n\n/**\n * Which click id belongs to which channel, in priority order, and whether the platform puts it on\n * its ad clicks only (`'ads'`) or on other links as well (`'any'`). Every key is a field this SDK\n * collects (`AdvertisingInfo`).\n *\n * The third column decides whether the id can say a click was paid when a `utm_source` names the\n * channel but no `utm_medium` says how (a Reddit ad tagged `utm_source=reddit` and nothing more).\n * `fbclid` is `'any'`: Meta decorates every outbound link with it, organic posts, profile links\n * and shares included. `twclid` and `ko_click_id` are `'any'` until it is clear they never\n * decorate an organic link.\n *\n * Only URL parameters, never the cookies the tags keep beside them (`_fbc`, `_gcl_aw`, … — every\n * underscore key of `AdvertisingInfo`): a click cookie lives up to 90 days after the click, so\n * counting it named every later visit — typed in, from search, from an email — a new ad click.\n */\nexport const CLICK_ID_CHANNELS = [\n ['fbclid', 'meta', 'any'],\n ['gclid', 'google', 'ads'],\n ['gbraid', 'google', 'ads'],\n ['wbraid', 'google', 'ads'],\n ['gad_source', 'google', 'ads'],\n ['gad_campaignid', 'google', 'ads'],\n ['dclid', 'dv360', 'ads'],\n ['msclkid', 'microsoft', 'ads'],\n ['ttclid', 'tiktok', 'ads'],\n ['rdt_cid', 'reddit', 'ads'],\n ['li_fat_id', 'linkedin', 'ads'],\n ['ScCid', 'snapchat', 'ads'],\n ['twclid', 'x', 'any'],\n ['ko_click_id', 'kakao', 'any'],\n ['yclid', 'yandex', 'ads'],\n ['oppref', 'chatgpt', 'ads'],\n ['epik', 'pinterest', 'ads'],\n] as const satisfies readonly (readonly [keyof AdvertisingInfo, Channel, 'ads' | 'any'])[];\n\n/**\n * A landing page reserved for one channel's ads, for ads that lost their parameters:\n * `/lp/<channel>`, with or without host, query and hash; the capture is the channel. A product\n * that names its landing pages differently replaces this.\n */\nexport const AD_LANDING_PAGE = /^(?:https?:\\/\\/[^/]+)?\\/lp\\/([a-z]+)(?:[/?#]|$)/;\n\nexport type ReferrerMedium = 'organic' | 'social' | 'video' | 'ai' | 'email';\n\n/**\n * Referrer hosts that name a channel, as regular expressions over the lower-cased host,\n * with the medium GA4 gives that kind of site. Anything else that refers is `referral`, kept as\n * its host. A country TLD is one or two labels (`google.com`, `google.co.uk`, `google.com.hk`),\n * never an open tail, so `google.com.evil.io` is not Google. In match order: the AI assistants\n * first, because `gemini.google.com`, `copilot.microsoft.com` and `yiyan.baidu.com` would\n * otherwise be Google, Microsoft and Baidu search.\n */\nexport const REFERRER_SITES = [\n ['chatgpt', 'ai', /(^|\\.)(chatgpt\\.com|chat\\.openai\\.com)$/],\n ['perplexity', 'ai', /(^|\\.)perplexity\\.ai$/],\n ['gemini', 'ai', /^gemini\\.google\\.com$/],\n ['claude', 'ai', /(^|\\.)claude\\.ai$/],\n ['copilot', 'ai', /^copilot\\.microsoft\\.com$|(^|\\.)copilot\\.com$/],\n ['grok', 'ai', /(^|\\.)(grok\\.com|x\\.ai)$/],\n ['deepseek', 'ai', /(^|\\.)deepseek\\.com$/],\n ['doubao', 'ai', /(^|\\.)doubao\\.com$/],\n ['kimi', 'ai', /(^|\\.)(kimi\\.com|kimi\\.moonshot\\.cn|moonshot\\.cn)$/],\n ['qwen', 'ai', /^tongyi\\.aliyun\\.com$|(^|\\.)(tongyi\\.com|qianwen\\.com|qwen\\.ai)$/],\n ['yuanbao', 'ai', /^yuanbao\\.tencent\\.com$/],\n ['ernie', 'ai', /^yiyan\\.baidu\\.com$|(^|\\.)ernie\\.baidu\\.com$/],\n ['zhipu', 'ai', /(^|\\.)(chatglm\\.cn|zhipuai\\.cn|bigmodel\\.cn)$/],\n // Before google, whose pattern it would match.\n ['gmail', 'email', /^mail\\.google\\.com$/],\n ['google', 'organic', /(^|\\.)google\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['microsoft', 'organic', /(^|\\.)bing\\.com$/],\n ['yahoo', 'organic', /(^|\\.)yahoo\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['duckduckgo', 'organic', /(^|\\.)duckduckgo\\.com$/],\n ['baidu', 'organic', /(^|\\.)baidu\\.com$/],\n ['yandex', 'organic', /(^|\\.)yandex\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['ecosia', 'organic', /(^|\\.)ecosia\\.org$/],\n ['naver', 'organic', /(^|\\.)naver\\.com$/],\n ['brave', 'organic', /^search\\.brave\\.com$/],\n ['meta', 'social', /(^|\\.)(facebook\\.com|instagram\\.com|fb\\.com|threads\\.net)$/],\n ['x', 'social', /(^|\\.)(twitter\\.com|x\\.com|t\\.co)$/],\n ['linkedin', 'social', /(^|\\.)(linkedin\\.com|lnkd\\.in)$/],\n ['reddit', 'social', /(^|\\.)reddit\\.com$/],\n ['tiktok', 'social', /(^|\\.)tiktok\\.com$/],\n ['pinterest', 'social', /(^|\\.)pinterest\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['snapchat', 'social', /(^|\\.)snapchat\\.com$/],\n ['telegram', 'social', /^(t\\.me|telegram\\.me|(web\\.)?telegram\\.org)$/],\n ['youtube', 'video', /(^|\\.)(youtube\\.com|youtu\\.be)$/],\n] as const satisfies readonly (readonly [Channel, ReferrerMedium, RegExp])[];\n\n/**\n * The site an Android app's referrer stands for. A link opened from an app on Android arrives with\n * `android-app://<package>/` as its referrer rather than the site's URL; the apps below are read as\n * their sites, so the Google app is a Google search and the LinkedIn app is LinkedIn. A package\n * not listed is kept as the referring host, as GA4 shows it; Meta's apps send their mobile host\n * (`android-app://m.facebook.com`), which needs no entry.\n */\nexport const ANDROID_APP_HOSTS: Readonly<Record<string, string>> = {\n 'com.google.android.googlequicksearchbox': 'www.google.com',\n 'com.google.android.gm': 'mail.google.com',\n 'com.google.android.youtube': 'www.youtube.com',\n 'com.facebook.katana': 'www.facebook.com',\n 'com.instagram.android': 'www.instagram.com',\n 'com.linkedin.android': 'www.linkedin.com',\n 'com.reddit.frontpage': 'www.reddit.com',\n 'com.twitter.android': 'x.com',\n 'com.zhiliaoapp.musically': 'www.tiktok.com',\n 'com.ss.android.ugc.trill': 'www.tiktok.com',\n 'com.pinterest': 'www.pinterest.com',\n 'com.snapchat.android': 'www.snapchat.com',\n 'org.telegram.messenger': 't.me',\n};\n\n/**\n * Referrer hosts that are navigation, not acquisition, when a session happens to start on the way\n * back from them: the local dev host; the payment providers a visitor is bounced through; the\n * sign-in providers whose login lives on a host of its own (Google, Apple, Microsoft, WeChat,\n * Kakao, LINE, Naver, Yahoo, Twitch, X's OAuth 1.0a endpoint); and the hosted auth services a\n * product may sit behind (Auth0, Okta, Supabase, Firebase, Clerk). A product appends its own\n * hostnames — a session that starts from an internal link after the session timeout refers to\n * them. Matched before `REFERRER_SITES`, so `nid.naver.com` and `login.yahoo.com` are not read as\n * a search. Providers whose login shares the host with their content — Facebook, X's OAuth 2.0,\n * LinkedIn, GitHub, Discord — cannot be told apart by host (referrer policies strip the path) and\n * are left as the referrers they usually are. Regular expressions over the lower-cased host.\n */\nexport const REFERRERS_NOT_A_TOUCH = [\n /^localhost(:|$)/,\n // payment\n /(^|\\.)stripe\\.com$/,\n /^pay\\.google\\.com$/,\n /(^|\\.)paypal\\.com$/,\n /(^|\\.)alipay\\.com$/,\n /(^|\\.)paddle\\.com$/,\n /(^|\\.)lemonsqueezy\\.com$/,\n // app stores\n /^apps\\.apple\\.com$/,\n /^play\\.google\\.com$/,\n // sign-in providers with a login host of their own\n /^accounts\\.google\\.com$/,\n /^appleid\\.apple\\.com$/,\n /^login\\.(microsoftonline|live)\\.com$/,\n /^open\\.weixin\\.qq\\.com$/,\n /^(kauth|accounts)\\.kakao\\.com$/,\n /^access\\.line\\.me$/,\n /^nid\\.naver\\.com$/,\n /^login\\.yahoo\\.com$/,\n /^id\\.twitch\\.tv$/,\n /^api\\.twitter\\.com$/,\n // hosted auth services\n /(^|\\.)auth0\\.com$/,\n /(^|\\.)okta\\.com$/,\n /(^|\\.)supabase\\.co$/,\n /(^|\\.)firebaseapp\\.com$/,\n /(^|\\.)clerk\\.accounts\\.dev$/,\n] as const;\n\n/** GA4's paid mediums — cpc, cpm, ppc, retargeting, paid_social, … — plus Performance Max. */\nexport const PAID_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|pmax|performance_max)$/;\n/**\n * Meta's `{{placement}}` values, lower-cased, which an ad URL template of `utm_medium={{placement}}`\n * puts in the medium. Only an ad carries one — an organic post arrives as a referrer, with no utm\n * at all — so a medium in this list is a paid Meta click whatever the source says (a template with\n * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so\n * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it\n * is spelled … which means a placement Meta adds later shows up as unassigned until it is added\n * here. Meta's documented values plus the ones seen in the wild (`facebook_mobile_reels`,\n * `facebook_notification`, `an`), and the literal macro for a template that did not expand it.\n */\nexport const META_PLACEMENTS = [\n 'facebook_desktop_feed',\n 'facebook_mobile_feed',\n 'facebook_mobile_reels',\n 'facebook_reels',\n 'facebook_reels_overlay',\n 'facebook_right_column',\n 'facebook_marketplace',\n 'facebook_video_feeds',\n 'facebook_stories',\n 'facebook_search',\n 'facebook_instream_video',\n 'facebook_instant_article',\n 'facebook_groups_feed',\n 'facebook_profile_feed',\n 'facebook_notification',\n 'facebook_notifications',\n 'facebook_business_explore',\n 'instagram_feed',\n 'instagram_stories',\n 'instagram_reels',\n 'instagram_reels_overlay',\n 'instagram_explore',\n 'instagram_explore_home',\n 'instagram_search',\n 'instagram_shop',\n 'instagram_profile_feed',\n 'instagram_profile_reels',\n 'instagram_igtv',\n 'messenger_inbox',\n 'messenger_stories',\n 'messenger_sponsored_messages',\n 'threads_feed',\n 'whatsapp_status',\n 'audience_network_classic',\n 'audience_network_rewarded_video',\n 'audience_network_native_banner_and_interstitial',\n 'an',\n 'others',\n /** the macro itself, when Meta did not expand it (written in a field it does not substitute, or a\n * preview click): still only ever produced by a Meta ad URL, so still a paid Meta click */\n '{{placement}}',\n] as const;\nexport const DISPLAY_MEDIUMS = ['display', 'banner', 'expandable', 'interstitial'] as const;\n/** GA4's email spellings, as a source or a medium. */\nexport const EMAIL_MEDIUMS = ['email', 'e-mail', 'e_mail', 'e mail'] as const;\n/** A medium that says email or newsletter anywhere in it: `outbound email`, `cold_email`, `newsletter`, and GA4's four. */\nexport const EMAIL_MEDIUM = /(^|[^a-z])(e[-_ ]?mail|newsletter)([^a-z]|$)/;\n\n/** Where a touch came from: a tracked session, or a report by the user or staff. */\nexport const TOUCH_SOURCES = ['tracked', 'reported'] as const;\nexport type TouchSource = (typeof TOUCH_SOURCES)[number];\n\n/**\n * How a reported touch was learned:\n * - survey: the user answered \"how did you hear about us\" (onboarding, checkout)\n * - promo_code: the user redeemed a code that belongs to a channel\n * - phone: a call came in on a channel's dedicated number, marked by staff or the phone system\n * - manual: staff recorded what a customer told them\n */\nexport const REPORTED_TOUCH_KINDS = ['survey', 'promo_code', 'phone', 'manual'] as const;\nexport type ReportedTouchKind = (typeof REPORTED_TOUCH_KINDS)[number];\n\n/**\n * How much a touch is worth against another when attribution has to choose between sessions;\n * lower wins, and the latest wins among equals. Stored on the session when it is classified, so\n * renumbering means reclassifying.\n *\n * This ranks touches against each other. Within one session the rules that name its channel have\n * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —\n * and the first that says something wins; the session's priority is then the tier of that rule\n * (a campaign rule whose touch is organic by its own tag ranking as a referrer, see `classifyTouch`).\n * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the\n * later one is credited, while one session carrying both is named by its utm.\n */\nexport const TOUCH_PRIORITY = {\n /**\n * utm, click id, ad landing page: someone tagged that link (in that order within a session) —\n * unless the tag itself calls the touch organic, a referral or email, which then ranks as `referrer`\n */\n campaign: 1,\n /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */\n verified: 1,\n /** the browser said where the visitor came from; nobody chose it */\n referrer: 2,\n /** a reported touch that is the user's claim: a survey answer, a code that may have been shared */\n claimed: 3,\n} as const satisfies Record<string, number>;\n\n/** Which reported kinds are staff facts and which are the user's claims. */\nexport const REPORTED_TOUCH_PRIORITY = {\n phone: TOUCH_PRIORITY.verified,\n manual: TOUCH_PRIORITY.verified,\n survey: TOUCH_PRIORITY.claimed,\n promo_code: TOUCH_PRIORITY.claimed,\n} as const satisfies Record<ReportedTouchKind, number>;\n"],"mappings":"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"}
1
+ {"version":3,"file":"vocabulary.mjs","names":[],"sources":["../../src/attribution/vocabulary.ts"],"sourcesContent":["import type { AdvertisingInfo } from '../track/types';\n\n/**\n * The vocabulary attribution is built from, shared by every product's `session` table and by the\n * dashboards that read it. Data for `classifyTouch` (./classify), which turns it into a channel;\n * a product's dashboards read the same names back. What stays with the product is its own hostnames\n * (referrer exclusions) and its landing-page conventions.\n */\n\n/** A session that arrived through nothing we can name. GA's spelling, so dashboards read as GA. */\nexport const DIRECT_CHANNEL = '(direct)';\n/** The medium of a direct session. */\nexport const NO_MEDIUM = '(none)';\n/** The medium of a tagged link that named a source but no medium. */\nexport const MEDIUM_NOT_SET = '(not set)';\n\n/** The channels the rules below can name. A `utm_source` outside this list is its own channel. */\nexport const CHANNELS = [\n 'meta',\n 'google',\n 'dv360',\n 'microsoft',\n 'tiktok',\n 'reddit',\n 'linkedin',\n 'snapchat',\n 'x',\n 'kakao',\n 'yandex',\n 'yahoo',\n 'duckduckgo',\n 'baidu',\n 'ecosia',\n 'naver',\n 'brave',\n 'pinterest',\n 'youtube',\n 'telegram',\n /** Gmail's web and Android app: a link in a mail that carried no utm. */\n 'gmail',\n /** AI assistants that send people to sites: ChatGPT tags its links `utm_source=chatgpt.com`, the\n * others show as a referrer. Their own channel group, `organic_ai`, since GA4's default groups\n * would file them under Referral / Unassigned and the question \"how much does AI bring\" comes up. */\n 'chatgpt',\n 'perplexity',\n 'gemini',\n 'claude',\n 'copilot',\n 'grok',\n /** and the Chinese ones — DeepSeek, 豆包, Kimi, 通义, 腾讯元宝, 文心一言, 智谱清言 */\n 'deepseek',\n 'doubao',\n 'kimi',\n 'qwen',\n 'yuanbao',\n 'ernie',\n 'zhipu',\n /** the product's own referral programme: the session came through a member's link or code.\n * How a product recognises one (a `/refer/<code>` path, a code at sign-up) is its own rule. Not\n * `referral`: that word is the channel group of any outside site, and a channel of the same name\n * would be impossible to tell apart in queries. */\n 'referral_program',\n] as const;\n\nexport type Channel = (typeof CHANNELS)[number];\n\n/** GA4's default channel groups, as identifiers; a dashboard maps them to display names. */\nexport const CHANNEL_GROUPS = [\n 'direct',\n 'paid_search',\n 'paid_social',\n 'paid_other',\n 'display',\n 'organic_search',\n 'organic_social',\n 'organic_video',\n /** not in GA4's default grouping: sessions an AI assistant sent, by referrer or by ChatGPT's utm */\n 'organic_ai',\n 'referral',\n 'email',\n 'affiliate',\n 'unassigned',\n] as const;\n\nexport type ChannelGroup = (typeof CHANNEL_GROUPS)[number];\n\n/** `utm_source` spellings that mean the same channel: Meta's `{{site_source_name}}` values and the usual synonyms. */\nexport const SOURCE_ALIASES = {\n fb: 'meta',\n ig: 'meta',\n an: 'meta',\n msg: 'meta',\n th: 'meta',\n facebook: 'meta',\n instagram: 'meta',\n bing: 'microsoft',\n twitter: 'x',\n 'google ads': 'google',\n googleads: 'google',\n adwords: 'google',\n 'chatgpt.com': 'chatgpt',\n 'chat.openai.com': 'chatgpt',\n openai: 'chatgpt',\n 'perplexity.ai': 'perplexity',\n 'claude.ai': 'claude',\n 'copilot.com': 'copilot',\n 'deepseek.com': 'deepseek',\n 'doubao.com': 'doubao',\n 'kimi.com': 'kimi',\n} as const satisfies Record<string, Channel>;\n\n/**\n * Which click id belongs to which channel, in priority order, and whether the platform puts it on\n * its ad clicks only (`'ads'`) or on other links as well (`'any'`). Every key is a field this SDK\n * collects (`AdvertisingInfo`).\n *\n * The third column decides whether the id can say a click was paid when a `utm_source` names the\n * channel but no `utm_medium` says how (a Reddit ad tagged `utm_source=reddit` and nothing more).\n * `fbclid` is `'any'`: Meta decorates every outbound link with it, organic posts, profile links\n * and shares included. `twclid` and `ko_click_id` are `'any'` until it is clear they never\n * decorate an organic link.\n *\n * Only URL parameters, never the cookies the tags keep beside them (`_fbc`, `_gcl_aw`, … — every\n * underscore key of `AdvertisingInfo`): a click cookie lives up to 90 days after the click, so\n * counting it named every later visit — typed in, from search, from an email — a new ad click.\n */\nexport const CLICK_ID_CHANNELS = [\n ['fbclid', 'meta', 'any'],\n ['gclid', 'google', 'ads'],\n ['gbraid', 'google', 'ads'],\n ['wbraid', 'google', 'ads'],\n ['gad_source', 'google', 'ads'],\n ['gad_campaignid', 'google', 'ads'],\n ['dclid', 'dv360', 'ads'],\n ['msclkid', 'microsoft', 'ads'],\n ['ttclid', 'tiktok', 'ads'],\n ['rdt_cid', 'reddit', 'ads'],\n ['li_fat_id', 'linkedin', 'ads'],\n ['ScCid', 'snapchat', 'ads'],\n ['twclid', 'x', 'any'],\n ['ko_click_id', 'kakao', 'any'],\n ['yclid', 'yandex', 'ads'],\n ['oppref', 'chatgpt', 'ads'],\n ['epik', 'pinterest', 'ads'],\n] as const satisfies readonly (readonly [keyof AdvertisingInfo, Channel, 'ads' | 'any'])[];\n\n/**\n * A landing page reserved for one channel's ads, for ads that lost their parameters:\n * `/lp/<channel>`, with or without host, query and hash; the capture is the channel. A product\n * that names its landing pages differently replaces this.\n */\nexport const AD_LANDING_PAGE = /^(?:https?:\\/\\/[^/]+)?\\/lp\\/([a-z]+)(?:[/?#]|$)/;\n\nexport type ReferrerMedium = 'organic' | 'social' | 'video' | 'ai' | 'email';\n\n/**\n * Referrer hosts that name a channel, as regular expressions over the lower-cased host,\n * with the medium GA4 gives that kind of site. Anything else that refers is `referral`, kept as\n * its host. A country TLD is one or two labels (`google.com`, `google.co.uk`, `google.com.hk`),\n * never an open tail, so `google.com.evil.io` is not Google. In match order: the AI assistants\n * first, because `gemini.google.com`, `copilot.microsoft.com` and `yiyan.baidu.com` would\n * otherwise be Google, Microsoft and Baidu search.\n */\nexport const REFERRER_SITES = [\n ['chatgpt', 'ai', /(^|\\.)(chatgpt\\.com|chat\\.openai\\.com)$/],\n ['perplexity', 'ai', /(^|\\.)perplexity\\.ai$/],\n ['gemini', 'ai', /^gemini\\.google\\.com$/],\n ['claude', 'ai', /(^|\\.)claude\\.ai$/],\n ['copilot', 'ai', /^copilot\\.microsoft\\.com$|(^|\\.)copilot\\.com$/],\n ['grok', 'ai', /(^|\\.)(grok\\.com|x\\.ai)$/],\n ['deepseek', 'ai', /(^|\\.)deepseek\\.com$/],\n ['doubao', 'ai', /(^|\\.)doubao\\.com$/],\n ['kimi', 'ai', /(^|\\.)(kimi\\.com|kimi\\.moonshot\\.cn|moonshot\\.cn)$/],\n ['qwen', 'ai', /^tongyi\\.aliyun\\.com$|(^|\\.)(tongyi\\.com|qianwen\\.com|qwen\\.ai)$/],\n ['yuanbao', 'ai', /^yuanbao\\.tencent\\.com$/],\n ['ernie', 'ai', /^yiyan\\.baidu\\.com$|(^|\\.)ernie\\.baidu\\.com$/],\n ['zhipu', 'ai', /(^|\\.)(chatglm\\.cn|zhipuai\\.cn|bigmodel\\.cn)$/],\n // Before google, whose pattern it would match.\n ['gmail', 'email', /^mail\\.google\\.com$/],\n ['google', 'organic', /(^|\\.)google\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['microsoft', 'organic', /(^|\\.)bing\\.com$/],\n ['yahoo', 'organic', /(^|\\.)yahoo\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['duckduckgo', 'organic', /(^|\\.)duckduckgo\\.com$/],\n ['baidu', 'organic', /(^|\\.)baidu\\.com$/],\n ['yandex', 'organic', /(^|\\.)yandex\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['ecosia', 'organic', /(^|\\.)ecosia\\.org$/],\n ['naver', 'organic', /(^|\\.)naver\\.com$/],\n ['brave', 'organic', /^search\\.brave\\.com$/],\n ['meta', 'social', /(^|\\.)(facebook\\.com|instagram\\.com|fb\\.com|threads\\.net)$/],\n ['x', 'social', /(^|\\.)(twitter\\.com|x\\.com|t\\.co)$/],\n ['linkedin', 'social', /(^|\\.)(linkedin\\.com|lnkd\\.in)$/],\n ['reddit', 'social', /(^|\\.)reddit\\.com$/],\n ['tiktok', 'social', /(^|\\.)tiktok\\.com$/],\n ['pinterest', 'social', /(^|\\.)pinterest\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['snapchat', 'social', /(^|\\.)snapchat\\.com$/],\n ['telegram', 'social', /^(t\\.me|telegram\\.me|(web\\.)?telegram\\.org)$/],\n ['youtube', 'video', /(^|\\.)(youtube\\.com|youtu\\.be)$/],\n] as const satisfies readonly (readonly [Channel, ReferrerMedium, RegExp])[];\n\n/**\n * The site an Android app's referrer stands for. A link opened from an app on Android arrives with\n * `android-app://<package>/` as its referrer rather than the site's URL; the apps below are read as\n * their sites, so the Google app is a Google search and the LinkedIn app is LinkedIn. A package\n * not listed is kept as the referring host, as GA4 shows it; Meta's apps send their mobile host\n * (`android-app://m.facebook.com`), which needs no entry.\n */\nexport const ANDROID_APP_HOSTS: Readonly<Record<string, string>> = {\n 'com.google.android.googlequicksearchbox': 'www.google.com',\n 'com.google.android.gm': 'mail.google.com',\n 'com.google.android.youtube': 'www.youtube.com',\n 'com.facebook.katana': 'www.facebook.com',\n 'com.instagram.android': 'www.instagram.com',\n 'com.linkedin.android': 'www.linkedin.com',\n 'com.reddit.frontpage': 'www.reddit.com',\n 'com.twitter.android': 'x.com',\n 'com.zhiliaoapp.musically': 'www.tiktok.com',\n 'com.ss.android.ugc.trill': 'www.tiktok.com',\n 'com.pinterest': 'www.pinterest.com',\n 'com.snapchat.android': 'www.snapchat.com',\n 'org.telegram.messenger': 't.me',\n};\n\n/**\n * Referrer hosts that are navigation, not acquisition, when a session happens to start on the way\n * back from them: the local dev host; the payment providers a visitor is bounced through; the\n * sign-in providers whose login lives on a host of its own (Google, Apple, Microsoft, WeChat,\n * Kakao, LINE, Naver, Yahoo, Twitch, X's OAuth 1.0a endpoint); and the hosted auth services a\n * product may sit behind (Auth0, Okta, Supabase, Firebase, Clerk). A product appends its own\n * hostnames — a session that starts from an internal link after the session timeout refers to\n * them. Matched before `REFERRER_SITES`, so `nid.naver.com` and `login.yahoo.com` are not read as\n * a search. Providers whose login shares the host with their content — Facebook, X's OAuth 2.0,\n * LinkedIn, GitHub, Discord — cannot be told apart by host (referrer policies strip the path) and\n * are left as the referrers they usually are. Regular expressions over the lower-cased host.\n */\nexport const REFERRERS_NOT_A_TOUCH = [\n /^localhost(:|$)/,\n // payment\n /(^|\\.)stripe\\.com$/,\n /^pay\\.google\\.com$/,\n /(^|\\.)paypal\\.com$/,\n /(^|\\.)alipay\\.com$/,\n /(^|\\.)paddle\\.com$/,\n /(^|\\.)lemonsqueezy\\.com$/,\n // app stores\n /^apps\\.apple\\.com$/,\n /^play\\.google\\.com$/,\n // sign-in providers with a login host of their own\n /^accounts\\.google\\.com$/,\n /^appleid\\.apple\\.com$/,\n /^login\\.(microsoftonline|live)\\.com$/,\n /^open\\.weixin\\.qq\\.com$/,\n /^(kauth|accounts)\\.kakao\\.com$/,\n /^access\\.line\\.me$/,\n /^nid\\.naver\\.com$/,\n /^login\\.yahoo\\.com$/,\n /^id\\.twitch\\.tv$/,\n /^api\\.twitter\\.com$/,\n // hosted auth services\n /(^|\\.)auth0\\.com$/,\n /(^|\\.)okta\\.com$/,\n /(^|\\.)supabase\\.co$/,\n /(^|\\.)firebaseapp\\.com$/,\n /(^|\\.)clerk\\.accounts\\.dev$/,\n] as const;\n\n/** GA4's paid mediums — cpc, cpm, ppc, retargeting, paid_social, … — plus Performance Max. */\nexport const PAID_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|pmax|performance_max)$/;\n/**\n * Meta's `{{placement}}` values, lower-cased, which an ad URL template of `utm_medium={{placement}}`\n * puts in the medium. Only an ad carries one — an organic post arrives as a referrer, with no utm\n * at all — so a medium in this list is a paid Meta click whatever the source says (a template with\n * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so\n * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it\n * is spelled … which means a placement Meta adds later shows up as organic social (or unassigned,\n * under a source that is no social site) until it is added here. Meta's documented values plus the\n * ones seen in the wild (`facebook_mobile_reels`, `facebook_notification`, `an`), and the literal\n * macro for a template that did not expand it.\n *\n * Accepted for the ads already running with that template; new ads should rather send\n * `utm_medium=cpc` and the placement in a `placement` parameter of its own (collected as the\n * `placement` tag): GA4 matches its paid rule on the medium alone, and files a placement medium\n * under Organic Social.\n */\nexport const META_PLACEMENTS = [\n 'facebook_feed',\n 'facebook_instream',\n 'facebook_desktop_feed',\n 'facebook_mobile_feed',\n 'facebook_mobile_reels',\n 'facebook_reels',\n 'facebook_reels_overlay',\n 'facebook_right_column',\n 'facebook_marketplace',\n 'facebook_video_feeds',\n 'facebook_stories',\n 'facebook_search',\n 'facebook_instream_video',\n 'facebook_instant_article',\n 'facebook_groups_feed',\n 'facebook_profile_feed',\n 'facebook_notification',\n 'facebook_notifications',\n 'facebook_business_explore',\n 'instagram_feed',\n 'instagram_stories',\n 'instagram_reels',\n 'instagram_reels_overlay',\n 'instagram_explore',\n 'instagram_explore_home',\n 'instagram_search',\n 'instagram_shop',\n 'instagram_profile_feed',\n 'instagram_profile_reels',\n 'instagram_igtv',\n 'messenger_inbox',\n 'messenger_stories',\n 'messenger_sponsored_messages',\n 'threads_feed',\n 'threads_stream',\n 'whatsapp_status',\n 'audience_network_classic',\n 'audience_network_rewarded_video',\n 'audience_network_native_banner_and_interstitial',\n 'an',\n 'others',\n /** the macro itself, when Meta did not expand it (written in a field it does not substitute, or a\n * preview click): still only ever produced by a Meta ad URL, so still a paid Meta click */\n '{{placement}}',\n] as const;\nexport const DISPLAY_MEDIUMS = ['display', 'banner', 'expandable', 'interstitial'] as const;\n/** GA4's email spellings, as a source or a medium. */\nexport const EMAIL_MEDIUMS = ['email', 'e-mail', 'e_mail', 'e mail'] as const;\n/** A medium that says email or newsletter anywhere in it: `outbound email`, `cold_email`, `newsletter`, and GA4's four. */\nexport const EMAIL_MEDIUM = /(^|[^a-z])(e[-_ ]?mail|newsletter)([^a-z]|$)/;\n\n/** Where a touch came from: a tracked session, or a report by the user or staff. */\nexport const TOUCH_SOURCES = ['tracked', 'reported'] as const;\nexport type TouchSource = (typeof TOUCH_SOURCES)[number];\n\n/**\n * How a reported touch was learned:\n * - survey: the user answered \"how did you hear about us\" (onboarding, checkout)\n * - promo_code: the user redeemed a code that belongs to a channel\n * - phone: a call came in on a channel's dedicated number, marked by staff or the phone system\n * - manual: staff recorded what a customer told them\n */\nexport const REPORTED_TOUCH_KINDS = ['survey', 'promo_code', 'phone', 'manual'] as const;\nexport type ReportedTouchKind = (typeof REPORTED_TOUCH_KINDS)[number];\n\n/**\n * How much a touch is worth against another when attribution has to choose between sessions;\n * lower wins, and the latest wins among equals. Stored on the session when it is classified, so\n * renumbering means reclassifying.\n *\n * This ranks touches against each other. Within one session the rules that name its channel have\n * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —\n * and the first that says something wins; the session's priority is then the tier of that rule\n * (a campaign rule whose touch is organic by its own tag ranking as a referrer, see `classifyTouch`).\n * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the\n * later one is credited, while one session carrying both is named by its utm.\n */\nexport const TOUCH_PRIORITY = {\n /**\n * utm, click id, ad landing page: someone tagged that link (in that order within a session) —\n * unless the tag itself calls the touch organic, a referral or email, which then ranks as `referrer`\n */\n campaign: 1,\n /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */\n verified: 1,\n /** the browser said where the visitor came from; nobody chose it */\n referrer: 2,\n /** a reported touch that is the user's claim: a survey answer, a code that may have been shared */\n claimed: 3,\n} as const satisfies Record<string, number>;\n\n/** Which reported kinds are staff facts and which are the user's claims. */\nexport const REPORTED_TOUCH_PRIORITY = {\n phone: TOUCH_PRIORITY.verified,\n manual: TOUCH_PRIORITY.verified,\n survey: TOUCH_PRIORITY.claimed,\n promo_code: TOUCH_PRIORITY.claimed,\n} as const satisfies Record<ReportedTouchKind, number>;\n"],"mappings":"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"}
@@ -5,6 +5,7 @@ const require_setup_session = require("../setup/session.cjs");
5
5
  const require_track_index = require("../track/index.cjs");
6
6
  const require_hooks_use_previous = require("./use-previous.cjs");
7
7
  const require_web_page_key = require("../web/page-key.cjs");
8
+ const require_web_index = require("../web/index.cjs");
8
9
  let _shware_utils = require("@shware/utils");
9
10
  let react = require("react");
10
11
  //#region src/hooks/use-web-analytics.ts
@@ -13,7 +14,7 @@ function sendFirstVisit(pathname) {
13
14
  require_track_index.track("first_visit", {
14
15
  page_path: pathname,
15
16
  page_title: document.title,
16
- page_referrer: document.referrer,
17
+ page_referrer: require_web_index.getPageReferrer(),
17
18
  page_location: window.location.href
18
19
  });
19
20
  require_setup_index.config.storage.setItem(require_constants_storage.keys.first_visit_time, (/* @__PURE__ */ new Date()).toISOString());
@@ -111,7 +112,7 @@ function useWebAnalytics(pathname, search) {
111
112
  require_track_index.track("page_view", {
112
113
  page_path: pathname,
113
114
  page_title: document.title,
114
- page_referrer: document.referrer,
115
+ page_referrer: require_web_index.getPageReferrer(),
115
116
  page_location: window.location.href,
116
117
  previous_page_path: prevPage === void 0 ? void 0 : prevPathname,
117
118
  engagement_time_msec: prevPage === void 0 ? void 0 : require_setup_session.getSession().flush()
@@ -1 +1 @@
1
- {"version":3,"file":"use-web-analytics.cjs","names":["config","keys","getSession","getPageKey","usePrevious","useRef","throttle"],"sources":["../../src/hooks/use-web-analytics.ts"],"sourcesContent":["import { throttle } from '@shware/utils';\nimport { useEffect, useRef } from 'react';\nimport { keys } from '../constants/storage';\nimport { config } from '../setup/index';\nimport { getSession } from '../setup/session';\nimport { sendBeacon, sendPendingEvents, track } from '../track/index';\nimport { getPageKey } from '../web/page-key';\nimport { usePrevious } from './use-previous';\n\nfunction sendFirstVisit(pathname: string) {\n if (config.storage.getItem(keys.first_visit_time)) return;\n track('first_visit', {\n page_path: pathname,\n page_title: document.title,\n page_referrer: document.referrer,\n page_location: window.location.href,\n });\n config.storage.setItem(keys.first_visit_time, new Date().toISOString());\n}\n\nfunction sendUserEngagement(trigger: 'pagehide' | 'visibilitychange') {\n const engagement_time_msec = getSession().flush();\n if (engagement_time_msec <= 0) return;\n sendBeacon('user_engagement', { engagement_time_msec, trigger });\n}\n\n/** Whether the event was actually sent — a zero-engagement crossing reports nothing. */\nfunction sendScroll(): boolean {\n const engagement_time_msec = getSession().flush();\n if (engagement_time_msec <= 0) return false;\n track('scroll', { engagement_time_msec });\n return true;\n}\n\nfunction getScrollPercent() {\n const scrollTop = window.scrollY || document.documentElement.scrollTop;\n const windowHeight = window.innerHeight;\n const docHeight = document.documentElement.scrollHeight;\n if (docHeight === 0) return 0;\n return ((scrollTop + windowHeight) * 100) / docHeight;\n}\n\n// The queue goes first: a new session's `session_start` is in it, ahead of the engagement that\n// reports for that session. On `hidden` as well as on `pagehide`, as GA4 does: a mobile browser\n// often discards a hidden page without firing `pagehide`.\nfunction onPageHide() {\n getSession().pagehide();\n sendPendingEvents();\n sendUserEngagement('pagehide');\n}\n\nfunction onVisibilityChange() {\n getSession().visibilitychange(document.visibilityState);\n if (document.visibilityState === 'hidden') {\n sendPendingEvents();\n sendUserEngagement('visibilitychange');\n }\n}\n\n/**\n * 1. send session_start event when the page is loaded\n * 2. send scroll event when the user scrolls more than 90% of the page\n * 3. send user_engagement event when the page is hidden or the user is not focused\n * 4. send page_view when the page changes — path or query, see `getPageKey`; `search` is the\n * router's query string (`?a=1`, or `''`), required so no caller forgets that the query is\n * part of what makes a page\n */\nexport function useWebAnalytics(pathname: string, search: string) {\n const page = getPageKey(pathname, search);\n const prevPage = usePrevious(page);\n const prevPathname = usePrevious(pathname);\n\n // reset state when the page changes and send scroll when the user navigates to a new page\n const hasSendScroll = useRef(false);\n useEffect(() => {\n hasSendScroll.current = false;\n }, [page]);\n\n useEffect(() => {\n // One lookup for the whole effect: `addEventListener` and its matching\n // `removeEventListener` have to be handed the very same function.\n const session = getSession();\n\n sendFirstVisit(pathname);\n\n const onScroll = throttle(() => {\n session.updateAccumulator();\n if (hasSendScroll.current) return;\n // only send scroll when the user has scrolled more than 90% of the page\n if (getScrollPercent() < 90) return;\n // A crossing with no engaged time (a restored scroll position in an unfocused window) must\n // not consume the page's one shot: the flag is set only once the event is actually sent.\n hasSendScroll.current = sendScroll();\n }, 500);\n\n const checkpointEvents = ['mousedown', 'keydown', 'touchstart'];\n const checkpoint = throttle(session.updateAccumulator, 1000);\n\n window.addEventListener('focus', session.focus);\n window.addEventListener('blur', session.blur);\n window.addEventListener('scroll', onScroll, { passive: true });\n window.addEventListener('pageshow', session.pageshow);\n window.addEventListener('pagehide', onPageHide);\n document.addEventListener('visibilitychange', onVisibilityChange);\n\n // save checkpoint\n checkpointEvents.forEach((e) => {\n window.addEventListener(e, checkpoint, { passive: true, capture: true });\n });\n\n return () => {\n window.removeEventListener('focus', session.focus);\n window.removeEventListener('blur', session.blur);\n window.removeEventListener('scroll', onScroll);\n window.removeEventListener('pageshow', session.pageshow);\n window.removeEventListener('pagehide', onPageHide);\n document.removeEventListener('visibilitychange', onVisibilityChange);\n checkpointEvents.forEach((e) => window.removeEventListener(e, checkpoint, { capture: true }));\n\n onScroll.cancel();\n checkpoint.cancel();\n };\n }, []);\n\n useEffect(() => {\n // `page_path` stays the path — the query is in `page_location` — and the previous page is\n // named by its path too, so a query-only change reports the same path as its predecessor.\n track('page_view', {\n page_path: pathname,\n page_title: document.title,\n page_referrer: document.referrer,\n page_location: window.location.href,\n previous_page_path: prevPage === undefined ? undefined : prevPathname,\n engagement_time_msec: prevPage === undefined ? undefined : getSession().flush(),\n });\n }, [page]);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"use-web-analytics.cjs","names":["config","keys","getPageReferrer","getSession","getPageKey","usePrevious","useRef","throttle"],"sources":["../../src/hooks/use-web-analytics.ts"],"sourcesContent":["import { throttle } from '@shware/utils';\nimport { useEffect, useRef } from 'react';\nimport { keys } from '../constants/storage';\nimport { config } from '../setup/index';\nimport { getSession } from '../setup/session';\nimport { sendBeacon, sendPendingEvents, track } from '../track/index';\nimport { getPageReferrer } from '../web/index';\nimport { getPageKey } from '../web/page-key';\nimport { usePrevious } from './use-previous';\n\nfunction sendFirstVisit(pathname: string) {\n if (config.storage.getItem(keys.first_visit_time)) return;\n track('first_visit', {\n page_path: pathname,\n page_title: document.title,\n page_referrer: getPageReferrer(),\n page_location: window.location.href,\n });\n config.storage.setItem(keys.first_visit_time, new Date().toISOString());\n}\n\nfunction sendUserEngagement(trigger: 'pagehide' | 'visibilitychange') {\n const engagement_time_msec = getSession().flush();\n if (engagement_time_msec <= 0) return;\n sendBeacon('user_engagement', { engagement_time_msec, trigger });\n}\n\n/** Whether the event was actually sent — a zero-engagement crossing reports nothing. */\nfunction sendScroll(): boolean {\n const engagement_time_msec = getSession().flush();\n if (engagement_time_msec <= 0) return false;\n track('scroll', { engagement_time_msec });\n return true;\n}\n\nfunction getScrollPercent() {\n const scrollTop = window.scrollY || document.documentElement.scrollTop;\n const windowHeight = window.innerHeight;\n const docHeight = document.documentElement.scrollHeight;\n if (docHeight === 0) return 0;\n return ((scrollTop + windowHeight) * 100) / docHeight;\n}\n\n// The queue goes first: a new session's `session_start` is in it, ahead of the engagement that\n// reports for that session. On `hidden` as well as on `pagehide`, as GA4 does: a mobile browser\n// often discards a hidden page without firing `pagehide`.\nfunction onPageHide() {\n getSession().pagehide();\n sendPendingEvents();\n sendUserEngagement('pagehide');\n}\n\nfunction onVisibilityChange() {\n getSession().visibilitychange(document.visibilityState);\n if (document.visibilityState === 'hidden') {\n sendPendingEvents();\n sendUserEngagement('visibilitychange');\n }\n}\n\n/**\n * 1. send session_start event when the page is loaded\n * 2. send scroll event when the user scrolls more than 90% of the page\n * 3. send user_engagement event when the page is hidden or the user is not focused\n * 4. send page_view when the page changes — path or query, see `getPageKey`; `search` is the\n * router's query string (`?a=1`, or `''`), required so no caller forgets that the query is\n * part of what makes a page\n */\nexport function useWebAnalytics(pathname: string, search: string) {\n const page = getPageKey(pathname, search);\n const prevPage = usePrevious(page);\n const prevPathname = usePrevious(pathname);\n\n // reset state when the page changes and send scroll when the user navigates to a new page\n const hasSendScroll = useRef(false);\n useEffect(() => {\n hasSendScroll.current = false;\n }, [page]);\n\n useEffect(() => {\n // One lookup for the whole effect: `addEventListener` and its matching\n // `removeEventListener` have to be handed the very same function.\n const session = getSession();\n\n sendFirstVisit(pathname);\n\n const onScroll = throttle(() => {\n session.updateAccumulator();\n if (hasSendScroll.current) return;\n // only send scroll when the user has scrolled more than 90% of the page\n if (getScrollPercent() < 90) return;\n // A crossing with no engaged time (a restored scroll position in an unfocused window) must\n // not consume the page's one shot: the flag is set only once the event is actually sent.\n hasSendScroll.current = sendScroll();\n }, 500);\n\n const checkpointEvents = ['mousedown', 'keydown', 'touchstart'];\n const checkpoint = throttle(session.updateAccumulator, 1000);\n\n window.addEventListener('focus', session.focus);\n window.addEventListener('blur', session.blur);\n window.addEventListener('scroll', onScroll, { passive: true });\n window.addEventListener('pageshow', session.pageshow);\n window.addEventListener('pagehide', onPageHide);\n document.addEventListener('visibilitychange', onVisibilityChange);\n\n // save checkpoint\n checkpointEvents.forEach((e) => {\n window.addEventListener(e, checkpoint, { passive: true, capture: true });\n });\n\n return () => {\n window.removeEventListener('focus', session.focus);\n window.removeEventListener('blur', session.blur);\n window.removeEventListener('scroll', onScroll);\n window.removeEventListener('pageshow', session.pageshow);\n window.removeEventListener('pagehide', onPageHide);\n document.removeEventListener('visibilitychange', onVisibilityChange);\n checkpointEvents.forEach((e) => window.removeEventListener(e, checkpoint, { capture: true }));\n\n onScroll.cancel();\n checkpoint.cancel();\n };\n }, []);\n\n useEffect(() => {\n // `page_path` stays the path — the query is in `page_location` — and the previous page is\n // named by its path too, so a query-only change reports the same path as its predecessor.\n track('page_view', {\n page_path: pathname,\n page_title: document.title,\n page_referrer: getPageReferrer(),\n page_location: window.location.href,\n previous_page_path: prevPage === undefined ? undefined : prevPathname,\n engagement_time_msec: prevPage === undefined ? undefined : getSession().flush(),\n });\n }, [page]);\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"use-web-analytics.d.cts","names":[],"sources":["../../src/hooks/use-web-analytics.ts"],"mappings":";;;;;;;;;wBAmEgB,gBAAgB,kBAAkB"}
1
+ {"version":3,"file":"use-web-analytics.d.cts","names":[],"sources":["../../src/hooks/use-web-analytics.ts"],"mappings":";;;;;;;;;wBAoEgB,gBAAgB,kBAAkB"}
@@ -1 +1 @@
1
- {"version":3,"file":"use-web-analytics.d.mts","names":[],"sources":["../../src/hooks/use-web-analytics.ts"],"mappings":";;;;;;;;;wBAmEgB,gBAAgB,kBAAkB"}
1
+ {"version":3,"file":"use-web-analytics.d.mts","names":[],"sources":["../../src/hooks/use-web-analytics.ts"],"mappings":";;;;;;;;;wBAoEgB,gBAAgB,kBAAkB"}
@@ -4,6 +4,7 @@ import { getSession } from "../setup/session.mjs";
4
4
  import { sendBeacon, sendPendingEvents, track } from "../track/index.mjs";
5
5
  import { usePrevious } from "./use-previous.mjs";
6
6
  import { getPageKey } from "../web/page-key.mjs";
7
+ import { getPageReferrer } from "../web/index.mjs";
7
8
  import { throttle } from "@shware/utils";
8
9
  import { useEffect, useRef } from "react";
9
10
  //#region src/hooks/use-web-analytics.ts
@@ -12,7 +13,7 @@ function sendFirstVisit(pathname) {
12
13
  track("first_visit", {
13
14
  page_path: pathname,
14
15
  page_title: document.title,
15
- page_referrer: document.referrer,
16
+ page_referrer: getPageReferrer(),
16
17
  page_location: window.location.href
17
18
  });
18
19
  config.storage.setItem(keys.first_visit_time, (/* @__PURE__ */ new Date()).toISOString());
@@ -110,7 +111,7 @@ function useWebAnalytics(pathname, search) {
110
111
  track("page_view", {
111
112
  page_path: pathname,
112
113
  page_title: document.title,
113
- page_referrer: document.referrer,
114
+ page_referrer: getPageReferrer(),
114
115
  page_location: window.location.href,
115
116
  previous_page_path: prevPage === void 0 ? void 0 : prevPathname,
116
117
  engagement_time_msec: prevPage === void 0 ? void 0 : getSession().flush()
@@ -1 +1 @@
1
- {"version":3,"file":"use-web-analytics.mjs","names":[],"sources":["../../src/hooks/use-web-analytics.ts"],"sourcesContent":["import { throttle } from '@shware/utils';\nimport { useEffect, useRef } from 'react';\nimport { keys } from '../constants/storage';\nimport { config } from '../setup/index';\nimport { getSession } from '../setup/session';\nimport { sendBeacon, sendPendingEvents, track } from '../track/index';\nimport { getPageKey } from '../web/page-key';\nimport { usePrevious } from './use-previous';\n\nfunction sendFirstVisit(pathname: string) {\n if (config.storage.getItem(keys.first_visit_time)) return;\n track('first_visit', {\n page_path: pathname,\n page_title: document.title,\n page_referrer: document.referrer,\n page_location: window.location.href,\n });\n config.storage.setItem(keys.first_visit_time, new Date().toISOString());\n}\n\nfunction sendUserEngagement(trigger: 'pagehide' | 'visibilitychange') {\n const engagement_time_msec = getSession().flush();\n if (engagement_time_msec <= 0) return;\n sendBeacon('user_engagement', { engagement_time_msec, trigger });\n}\n\n/** Whether the event was actually sent — a zero-engagement crossing reports nothing. */\nfunction sendScroll(): boolean {\n const engagement_time_msec = getSession().flush();\n if (engagement_time_msec <= 0) return false;\n track('scroll', { engagement_time_msec });\n return true;\n}\n\nfunction getScrollPercent() {\n const scrollTop = window.scrollY || document.documentElement.scrollTop;\n const windowHeight = window.innerHeight;\n const docHeight = document.documentElement.scrollHeight;\n if (docHeight === 0) return 0;\n return ((scrollTop + windowHeight) * 100) / docHeight;\n}\n\n// The queue goes first: a new session's `session_start` is in it, ahead of the engagement that\n// reports for that session. On `hidden` as well as on `pagehide`, as GA4 does: a mobile browser\n// often discards a hidden page without firing `pagehide`.\nfunction onPageHide() {\n getSession().pagehide();\n sendPendingEvents();\n sendUserEngagement('pagehide');\n}\n\nfunction onVisibilityChange() {\n getSession().visibilitychange(document.visibilityState);\n if (document.visibilityState === 'hidden') {\n sendPendingEvents();\n sendUserEngagement('visibilitychange');\n }\n}\n\n/**\n * 1. send session_start event when the page is loaded\n * 2. send scroll event when the user scrolls more than 90% of the page\n * 3. send user_engagement event when the page is hidden or the user is not focused\n * 4. send page_view when the page changes — path or query, see `getPageKey`; `search` is the\n * router's query string (`?a=1`, or `''`), required so no caller forgets that the query is\n * part of what makes a page\n */\nexport function useWebAnalytics(pathname: string, search: string) {\n const page = getPageKey(pathname, search);\n const prevPage = usePrevious(page);\n const prevPathname = usePrevious(pathname);\n\n // reset state when the page changes and send scroll when the user navigates to a new page\n const hasSendScroll = useRef(false);\n useEffect(() => {\n hasSendScroll.current = false;\n }, [page]);\n\n useEffect(() => {\n // One lookup for the whole effect: `addEventListener` and its matching\n // `removeEventListener` have to be handed the very same function.\n const session = getSession();\n\n sendFirstVisit(pathname);\n\n const onScroll = throttle(() => {\n session.updateAccumulator();\n if (hasSendScroll.current) return;\n // only send scroll when the user has scrolled more than 90% of the page\n if (getScrollPercent() < 90) return;\n // A crossing with no engaged time (a restored scroll position in an unfocused window) must\n // not consume the page's one shot: the flag is set only once the event is actually sent.\n hasSendScroll.current = sendScroll();\n }, 500);\n\n const checkpointEvents = ['mousedown', 'keydown', 'touchstart'];\n const checkpoint = throttle(session.updateAccumulator, 1000);\n\n window.addEventListener('focus', session.focus);\n window.addEventListener('blur', session.blur);\n window.addEventListener('scroll', onScroll, { passive: true });\n window.addEventListener('pageshow', session.pageshow);\n window.addEventListener('pagehide', onPageHide);\n document.addEventListener('visibilitychange', onVisibilityChange);\n\n // save checkpoint\n checkpointEvents.forEach((e) => {\n window.addEventListener(e, checkpoint, { passive: true, capture: true });\n });\n\n return () => {\n window.removeEventListener('focus', session.focus);\n window.removeEventListener('blur', session.blur);\n window.removeEventListener('scroll', onScroll);\n window.removeEventListener('pageshow', session.pageshow);\n window.removeEventListener('pagehide', onPageHide);\n document.removeEventListener('visibilitychange', onVisibilityChange);\n checkpointEvents.forEach((e) => window.removeEventListener(e, checkpoint, { capture: true }));\n\n onScroll.cancel();\n checkpoint.cancel();\n };\n }, []);\n\n useEffect(() => {\n // `page_path` stays the path — the query is in `page_location` — and the previous page is\n // named by its path too, so a query-only change reports the same path as its predecessor.\n track('page_view', {\n page_path: pathname,\n page_title: document.title,\n page_referrer: document.referrer,\n page_location: window.location.href,\n previous_page_path: prevPage === undefined ? undefined : prevPathname,\n engagement_time_msec: prevPage === undefined ? undefined : getSession().flush(),\n });\n }, [page]);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"use-web-analytics.mjs","names":[],"sources":["../../src/hooks/use-web-analytics.ts"],"sourcesContent":["import { throttle } from '@shware/utils';\nimport { useEffect, useRef } from 'react';\nimport { keys } from '../constants/storage';\nimport { config } from '../setup/index';\nimport { getSession } from '../setup/session';\nimport { sendBeacon, sendPendingEvents, track } from '../track/index';\nimport { getPageReferrer } from '../web/index';\nimport { getPageKey } from '../web/page-key';\nimport { usePrevious } from './use-previous';\n\nfunction sendFirstVisit(pathname: string) {\n if (config.storage.getItem(keys.first_visit_time)) return;\n track('first_visit', {\n page_path: pathname,\n page_title: document.title,\n page_referrer: getPageReferrer(),\n page_location: window.location.href,\n });\n config.storage.setItem(keys.first_visit_time, new Date().toISOString());\n}\n\nfunction sendUserEngagement(trigger: 'pagehide' | 'visibilitychange') {\n const engagement_time_msec = getSession().flush();\n if (engagement_time_msec <= 0) return;\n sendBeacon('user_engagement', { engagement_time_msec, trigger });\n}\n\n/** Whether the event was actually sent — a zero-engagement crossing reports nothing. */\nfunction sendScroll(): boolean {\n const engagement_time_msec = getSession().flush();\n if (engagement_time_msec <= 0) return false;\n track('scroll', { engagement_time_msec });\n return true;\n}\n\nfunction getScrollPercent() {\n const scrollTop = window.scrollY || document.documentElement.scrollTop;\n const windowHeight = window.innerHeight;\n const docHeight = document.documentElement.scrollHeight;\n if (docHeight === 0) return 0;\n return ((scrollTop + windowHeight) * 100) / docHeight;\n}\n\n// The queue goes first: a new session's `session_start` is in it, ahead of the engagement that\n// reports for that session. On `hidden` as well as on `pagehide`, as GA4 does: a mobile browser\n// often discards a hidden page without firing `pagehide`.\nfunction onPageHide() {\n getSession().pagehide();\n sendPendingEvents();\n sendUserEngagement('pagehide');\n}\n\nfunction onVisibilityChange() {\n getSession().visibilitychange(document.visibilityState);\n if (document.visibilityState === 'hidden') {\n sendPendingEvents();\n sendUserEngagement('visibilitychange');\n }\n}\n\n/**\n * 1. send session_start event when the page is loaded\n * 2. send scroll event when the user scrolls more than 90% of the page\n * 3. send user_engagement event when the page is hidden or the user is not focused\n * 4. send page_view when the page changes — path or query, see `getPageKey`; `search` is the\n * router's query string (`?a=1`, or `''`), required so no caller forgets that the query is\n * part of what makes a page\n */\nexport function useWebAnalytics(pathname: string, search: string) {\n const page = getPageKey(pathname, search);\n const prevPage = usePrevious(page);\n const prevPathname = usePrevious(pathname);\n\n // reset state when the page changes and send scroll when the user navigates to a new page\n const hasSendScroll = useRef(false);\n useEffect(() => {\n hasSendScroll.current = false;\n }, [page]);\n\n useEffect(() => {\n // One lookup for the whole effect: `addEventListener` and its matching\n // `removeEventListener` have to be handed the very same function.\n const session = getSession();\n\n sendFirstVisit(pathname);\n\n const onScroll = throttle(() => {\n session.updateAccumulator();\n if (hasSendScroll.current) return;\n // only send scroll when the user has scrolled more than 90% of the page\n if (getScrollPercent() < 90) return;\n // A crossing with no engaged time (a restored scroll position in an unfocused window) must\n // not consume the page's one shot: the flag is set only once the event is actually sent.\n hasSendScroll.current = sendScroll();\n }, 500);\n\n const checkpointEvents = ['mousedown', 'keydown', 'touchstart'];\n const checkpoint = throttle(session.updateAccumulator, 1000);\n\n window.addEventListener('focus', session.focus);\n window.addEventListener('blur', session.blur);\n window.addEventListener('scroll', onScroll, { passive: true });\n window.addEventListener('pageshow', session.pageshow);\n window.addEventListener('pagehide', onPageHide);\n document.addEventListener('visibilitychange', onVisibilityChange);\n\n // save checkpoint\n checkpointEvents.forEach((e) => {\n window.addEventListener(e, checkpoint, { passive: true, capture: true });\n });\n\n return () => {\n window.removeEventListener('focus', session.focus);\n window.removeEventListener('blur', session.blur);\n window.removeEventListener('scroll', onScroll);\n window.removeEventListener('pageshow', session.pageshow);\n window.removeEventListener('pagehide', onPageHide);\n document.removeEventListener('visibilitychange', onVisibilityChange);\n checkpointEvents.forEach((e) => window.removeEventListener(e, checkpoint, { capture: true }));\n\n onScroll.cancel();\n checkpoint.cancel();\n };\n }, []);\n\n useEffect(() => {\n // `page_path` stays the path — the query is in `page_location` — and the previous page is\n // named by its path too, so a query-only change reports the same path as its predecessor.\n track('page_view', {\n page_path: pathname,\n page_title: document.title,\n page_referrer: getPageReferrer(),\n page_location: window.location.href,\n previous_page_path: prevPage === undefined ? undefined : prevPathname,\n engagement_time_msec: prevPage === undefined ? undefined : getSession().flush(),\n });\n }, [page]);\n}\n"],"mappings":"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"}
@@ -3,8 +3,8 @@ const require_runtime = require("../_virtual/_rolldown/runtime.cjs");
3
3
  const require_setup_index = require("../setup/index.cjs");
4
4
  const require_constants_storage = require("../constants/storage.cjs");
5
5
  const require_link_index = require("../link/index.cjs");
6
- const require_web_page_key = require("./page-key.cjs");
7
6
  const require_es5$1 = require("../node_modules/.pnpm/bowser@2.14.1/node_modules/bowser/es5.cjs");
7
+ const require_web_page_key = require("./page-key.cjs");
8
8
  let uuid = require("uuid");
9
9
  let cookie = require("cookie");
10
10
  //#region src/web/index.ts
@@ -28,14 +28,35 @@ function getDeviceId() {
28
28
  * `useWebAnalytics`): the id exists to link an event to the `page_view` of the page it happened
29
29
  * on, so it must rotate exactly when a `page_view` is sent — path or query change, yes; hash
30
30
  * change or tracking-parameter cleanup, no.
31
+ *
32
+ * `referrer` is the page's referrer as GA4 reports it in a single page app: `document.referrer`
33
+ * for the document's first page, and the previous page's URL after an in-app navigation. The
34
+ * browser never updates `document.referrer` on a `pushState`, so read alone it would name the
35
+ * site the visit came from on every later page — and a session opened there, after 30 minutes
36
+ * idle, would count that site as a second visit from it. With the previous page as its referrer,
37
+ * that session's referrer is the product's own host: no touch, a direct session, which the
38
+ * attribution view then credits to the touch before it.
31
39
  */
32
40
  let pageLoad;
33
- function getPageLoadId(page) {
34
- if (pageLoad?.page !== page) pageLoad = {
41
+ function currentPage() {
42
+ const href = window.location.href;
43
+ const page = require_web_page_key.getPageKey(window.location.pathname, window.location.search);
44
+ if (pageLoad?.page === page) {
45
+ pageLoad.href = href;
46
+ return pageLoad;
47
+ }
48
+ const referrer = pageLoad ? pageLoad.href : document.referrer || void 0;
49
+ pageLoad = {
35
50
  page,
36
- id: randomUUID()
51
+ id: randomUUID(),
52
+ href,
53
+ referrer
37
54
  };
38
- return pageLoad.id;
55
+ return pageLoad;
56
+ }
57
+ /** The current page's referrer: see `pageLoad`. */
58
+ function getPageReferrer() {
59
+ return currentPage().referrer;
39
60
  }
40
61
  const links = /* @__PURE__ */ new Map();
41
62
  /**
@@ -57,8 +78,7 @@ function getCachedLink(id) {
57
78
  let parser;
58
79
  async function getTags() {
59
80
  const page_location = window.location.href;
60
- const page_load_id = getPageLoadId(require_web_page_key.getPageKey(window.location.pathname, window.location.search));
61
- const page_referrer = document.referrer || void 0;
81
+ const { id: page_load_id, referrer: page_referrer } = currentPage();
62
82
  const page_title = document.title;
63
83
  parser ??= import_es5.default.getParser(window.navigator.userAgent);
64
84
  const params = new URLSearchParams(window.location.search);
@@ -160,6 +180,7 @@ const storage = {
160
180
  };
161
181
  //#endregion
162
182
  exports.getDeviceId = getDeviceId;
183
+ exports.getPageReferrer = getPageReferrer;
163
184
  exports.getTags = getTags;
164
185
  exports.storage = storage;
165
186
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["uuidv4","config","keys","getLink","getPageKey","Bowser","parseCookie"],"sources":["../../src/web/index.ts"],"sourcesContent":["import Bowser from 'bowser';\nimport { parseCookie } from 'cookie';\nimport { v4 as uuidv4 } from 'uuid';\nimport { keys } from '../constants/storage';\nimport { type Link, getLink } from '../link/index';\nimport { type Storage, cache, config } from '../setup/index';\nimport type { TrackTags } from '../track/types';\nimport { getPageKey } from './page-key';\n\n// lib.dom types `crypto.randomUUID` as always present, but it is missing in insecure contexts\n// and older browsers, so probe it before use.\nfunction randomUUID(): string {\n return (crypto as Partial<Crypto>).randomUUID ? crypto.randomUUID() : uuidv4();\n}\n\nexport function getDeviceId() {\n const cached = config.storage.getItem(keys.device_id);\n if (cached) return cached;\n const id = randomUUID();\n config.storage.setItem(keys.device_id, id);\n return id;\n}\n\n/**\n * The current page load, keyed by the page it was loaded at. A module variable and nothing more\n * persistent: the id must die with the page (a reload is a new page load, and storage would\n * carry it over — or, shared across tabs, let two pages overwrite each other's).\n *\n * Keyed by `getPageKey` because that is what the SDK's own `page_view` fires on (see\n * `useWebAnalytics`): the id exists to link an event to the `page_view` of the page it happened\n * on, so it must rotate exactly when a `page_view` is sent — path or query change, yes; hash\n * change or tracking-parameter cleanup, no.\n */\nlet pageLoad: { page: string; id: string } | undefined;\n\nfunction getPageLoadId(page: string): string {\n if (pageLoad?.page !== page) pageLoad = { page, id: randomUUID() };\n return pageLoad.id;\n}\n\nconst links = new Map<string, Promise<Link | null>>();\n\n/**\n * `getTags` runs once per event now, and the link a `?s=` id points at cannot change while the\n * page is open, so an uncached lookup would put an identical request on the wire for every\n * event the page sends.\n */\nfunction getCachedLink(id: string) {\n const cached = links.get(id);\n if (cached) return cached;\n const link = getLink(id).then((result) => {\n // A lookup that came back empty must not stick for the lifetime of the page. `getLink`\n // answers null for a network failure as well as for a link that does not exist, and losing\n // the link's utm params for every later event costs more than repeating a rare request.\n if (!result) links.delete(id);\n return result;\n });\n links.set(id, link);\n return link;\n}\n\n/** The user agent cannot change while the page is open, so it is parsed once. */\nlet parser: ReturnType<typeof Bowser.getParser> | undefined;\n\nexport async function getTags() {\n // Read the page before the first await: `getTags` runs when the event happens, and a single\n // page app can navigate while the link lookup below is still in flight.\n const page_location = window.location.href;\n const page_load_id = getPageLoadId(getPageKey(window.location.pathname, window.location.search));\n const page_referrer = document.referrer || undefined;\n const page_title = document.title;\n\n parser ??= Bowser.getParser(window.navigator.userAgent);\n const params = new URLSearchParams(window.location.search);\n const os = parser.getOS();\n const browser = parser.getBrowser();\n const platform = parser.getPlatform();\n const parsed = parseCookie(document.cookie);\n\n const linkId = params.get('s');\n const link = linkId ? await getCachedLink(linkId) : null;\n\n const tags: TrackTags = {\n os: `${os.name} ${os.version}`,\n os_name: os.name,\n os_version: os.version,\n browser: `${browser.name} ${browser.version}`,\n browser_name: browser.name,\n browser_version: browser.version,\n device: platform.model,\n device_id: getDeviceId(),\n device_type: platform.type,\n device_vendor: platform.vendor,\n device_pixel_ratio: window.devicePixelRatio,\n screen_width: window.screen.width,\n screen_height: window.screen.height,\n screen_resolution: `${window.screen.width}x${window.screen.height}`,\n webdriver: navigator.webdriver || undefined,\n release: config.release,\n language: navigator.language,\n time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n page_location,\n page_referrer,\n page_title,\n page_load_id,\n // Ad click ids from the URL, each under its parameter's name: this page's click, which\n // names the visit's channel (`CLICK_ID_CHANNELS`). Never filled from a cookie — a cookie\n // outlives the click; the cookies go below under their own names.\n fbclid: params.get('fbclid') ?? undefined,\n ad_id: params.get('ad_id') ?? undefined,\n ad_name: params.get('ad_name') ?? undefined,\n adset_id: params.get('adset_id') ?? undefined,\n adset_name: params.get('adset_name') ?? undefined,\n campaign_id: params.get('campaign_id') ?? undefined,\n campaign_name: params.get('campaign_name') ?? undefined,\n placement: params.get('placement') ?? undefined,\n gclid: params.get('gclid') ?? undefined,\n gclsrc: params.get('gclsrc') ?? undefined,\n gad_source: params.get('gad_source') ?? undefined,\n gad_campaignid: params.get('gad_campaignid') ?? undefined,\n wbraid: params.get('wbraid') ?? undefined,\n gbraid: params.get('gbraid') ?? undefined,\n dclid: params.get('dclid') ?? undefined,\n msclkid: params.get('msclkid') ?? undefined,\n rdt_cid: params.get('rdt_cid') ?? undefined,\n li_fat_id: params.get('li_fat_id') ?? undefined,\n oppref: params.get('oppref') ?? undefined,\n ko_click_id: params.get('ko_click_id') ?? undefined,\n // Snapchat appends `ScCid`, and URL parameters are case-sensitive.\n ScCid: params.get('ScCid') ?? undefined,\n ttclid: params.get('ttclid') ?? undefined,\n twclid: params.get('twclid') ?? undefined,\n yclid: params.get('yclid') ?? undefined,\n epik: params.get('epik') ?? undefined,\n // The ad platforms' first-party cookies, raw and under the cookie's own name, for the\n // conversion senders only: they carry the click to the later pages and visits where the\n // URL no longer does. `_fbc`, `_rdt_cid`, `_gcl_*` and `_uetmsclkid` are kept alive\n // server-side (see @shware/analytics/server resolveClickIdCookies); the rest are the pixels'.\n _fbc: parsed._fbc || undefined,\n _fbp: parsed._fbp || undefined,\n _gcl_aw: parsed._gcl_aw || undefined,\n _gcl_gb: parsed._gcl_gb || undefined,\n _uetmsclkid: parsed._uetmsclkid || undefined,\n _rdt_cid: parsed._rdt_cid || undefined,\n _rdt_uuid: parsed._rdt_uuid || undefined,\n // The Insight Tag's cookie is itself named `li_fat_id`; renamed so it cannot pass for the\n // URL parameter.\n _li_fat_id: parsed.li_fat_id || undefined,\n __oppref: parsed.__oppref || undefined,\n __obref: parsed.__obref || undefined,\n // utm params\n utm_source: link?.utm_source ?? params.get('utm_source') ?? undefined,\n utm_medium: link?.utm_medium ?? params.get('utm_medium') ?? undefined,\n utm_campaign: link?.utm_campaign ?? params.get('utm_campaign') ?? undefined,\n utm_term: link?.utm_term ?? params.get('utm_term') ?? undefined,\n utm_content: link?.utm_content ?? params.get('utm_content') ?? undefined,\n utm_id: link?.utm_id ?? params.get('utm_id') ?? undefined,\n utm_source_platform:\n link?.utm_source_platform ?? params.get('utm_source_platform') ?? undefined,\n utm_creative_format:\n link?.utm_creative_format ?? params.get('utm_creative_format') ?? undefined,\n utm_marketing_tactic:\n link?.utm_marketing_tactic ?? params.get('utm_marketing_tactic') ?? undefined,\n };\n\n cache.tags = tags;\n return tags;\n}\n\nconst map = new Map<string, string>();\n\nexport const storage: Storage = {\n getItem: (key) => {\n try {\n return localStorage.getItem(key);\n } catch {\n console.error('localStorage is not available');\n return map.get(key) ?? null;\n }\n },\n setItem: (key, value) => {\n try {\n localStorage.setItem(key, value);\n } catch {\n console.error('localStorage is not available');\n map.set(key, value);\n }\n },\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["uuidv4","config","keys","getPageKey","getLink","Bowser","parseCookie"],"sources":["../../src/web/index.ts"],"sourcesContent":["import Bowser from 'bowser';\nimport { parseCookie } from 'cookie';\nimport { v4 as uuidv4 } from 'uuid';\nimport { keys } from '../constants/storage';\nimport { type Link, getLink } from '../link/index';\nimport { type Storage, cache, config } from '../setup/index';\nimport type { TrackTags } from '../track/types';\nimport { getPageKey } from './page-key';\n\n// lib.dom types `crypto.randomUUID` as always present, but it is missing in insecure contexts\n// and older browsers, so probe it before use.\nfunction randomUUID(): string {\n return (crypto as Partial<Crypto>).randomUUID ? crypto.randomUUID() : uuidv4();\n}\n\nexport function getDeviceId() {\n const cached = config.storage.getItem(keys.device_id);\n if (cached) return cached;\n const id = randomUUID();\n config.storage.setItem(keys.device_id, id);\n return id;\n}\n\n/**\n * The current page load, keyed by the page it was loaded at. A module variable and nothing more\n * persistent: the id must die with the page (a reload is a new page load, and storage would\n * carry it over — or, shared across tabs, let two pages overwrite each other's).\n *\n * Keyed by `getPageKey` because that is what the SDK's own `page_view` fires on (see\n * `useWebAnalytics`): the id exists to link an event to the `page_view` of the page it happened\n * on, so it must rotate exactly when a `page_view` is sent — path or query change, yes; hash\n * change or tracking-parameter cleanup, no.\n *\n * `referrer` is the page's referrer as GA4 reports it in a single page app: `document.referrer`\n * for the document's first page, and the previous page's URL after an in-app navigation. The\n * browser never updates `document.referrer` on a `pushState`, so read alone it would name the\n * site the visit came from on every later page — and a session opened there, after 30 minutes\n * idle, would count that site as a second visit from it. With the previous page as its referrer,\n * that session's referrer is the product's own host: no touch, a direct session, which the\n * attribution view then credits to the touch before it.\n */\nlet pageLoad: { page: string; id: string; href: string; referrer: string | undefined } | undefined;\n\nfunction currentPage() {\n const href = window.location.href;\n const page = getPageKey(window.location.pathname, window.location.search);\n if (pageLoad?.page === page) {\n // The same page, its URL maybe cleaned of tracking parameters since: the next page's\n // referrer is the URL as it last was, as the browser's own would be.\n pageLoad.href = href;\n return pageLoad;\n }\n const referrer = pageLoad ? pageLoad.href : document.referrer || undefined;\n pageLoad = { page, id: randomUUID(), href, referrer };\n return pageLoad;\n}\n\n/** The current page's referrer: see `pageLoad`. */\nexport function getPageReferrer(): string | undefined {\n return currentPage().referrer;\n}\n\nconst links = new Map<string, Promise<Link | null>>();\n\n/**\n * `getTags` runs once per event now, and the link a `?s=` id points at cannot change while the\n * page is open, so an uncached lookup would put an identical request on the wire for every\n * event the page sends.\n */\nfunction getCachedLink(id: string) {\n const cached = links.get(id);\n if (cached) return cached;\n const link = getLink(id).then((result) => {\n // A lookup that came back empty must not stick for the lifetime of the page. `getLink`\n // answers null for a network failure as well as for a link that does not exist, and losing\n // the link's utm params for every later event costs more than repeating a rare request.\n if (!result) links.delete(id);\n return result;\n });\n links.set(id, link);\n return link;\n}\n\n/** The user agent cannot change while the page is open, so it is parsed once. */\nlet parser: ReturnType<typeof Bowser.getParser> | undefined;\n\nexport async function getTags() {\n // Read the page before the first await: `getTags` runs when the event happens, and a single\n // page app can navigate while the link lookup below is still in flight.\n const page_location = window.location.href;\n const { id: page_load_id, referrer: page_referrer } = currentPage();\n const page_title = document.title;\n\n parser ??= Bowser.getParser(window.navigator.userAgent);\n const params = new URLSearchParams(window.location.search);\n const os = parser.getOS();\n const browser = parser.getBrowser();\n const platform = parser.getPlatform();\n const parsed = parseCookie(document.cookie);\n\n const linkId = params.get('s');\n const link = linkId ? await getCachedLink(linkId) : null;\n\n const tags: TrackTags = {\n os: `${os.name} ${os.version}`,\n os_name: os.name,\n os_version: os.version,\n browser: `${browser.name} ${browser.version}`,\n browser_name: browser.name,\n browser_version: browser.version,\n device: platform.model,\n device_id: getDeviceId(),\n device_type: platform.type,\n device_vendor: platform.vendor,\n device_pixel_ratio: window.devicePixelRatio,\n screen_width: window.screen.width,\n screen_height: window.screen.height,\n screen_resolution: `${window.screen.width}x${window.screen.height}`,\n webdriver: navigator.webdriver || undefined,\n release: config.release,\n language: navigator.language,\n time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n page_location,\n page_referrer,\n page_title,\n page_load_id,\n // Ad click ids from the URL, each under its parameter's name: this page's click, which\n // names the visit's channel (`CLICK_ID_CHANNELS`). Never filled from a cookie — a cookie\n // outlives the click; the cookies go below under their own names.\n fbclid: params.get('fbclid') ?? undefined,\n ad_id: params.get('ad_id') ?? undefined,\n ad_name: params.get('ad_name') ?? undefined,\n adset_id: params.get('adset_id') ?? undefined,\n adset_name: params.get('adset_name') ?? undefined,\n campaign_id: params.get('campaign_id') ?? undefined,\n campaign_name: params.get('campaign_name') ?? undefined,\n placement: params.get('placement') ?? undefined,\n gclid: params.get('gclid') ?? undefined,\n gclsrc: params.get('gclsrc') ?? undefined,\n gad_source: params.get('gad_source') ?? undefined,\n gad_campaignid: params.get('gad_campaignid') ?? undefined,\n wbraid: params.get('wbraid') ?? undefined,\n gbraid: params.get('gbraid') ?? undefined,\n dclid: params.get('dclid') ?? undefined,\n msclkid: params.get('msclkid') ?? undefined,\n rdt_cid: params.get('rdt_cid') ?? undefined,\n li_fat_id: params.get('li_fat_id') ?? undefined,\n oppref: params.get('oppref') ?? undefined,\n ko_click_id: params.get('ko_click_id') ?? undefined,\n // Snapchat appends `ScCid`, and URL parameters are case-sensitive.\n ScCid: params.get('ScCid') ?? undefined,\n ttclid: params.get('ttclid') ?? undefined,\n twclid: params.get('twclid') ?? undefined,\n yclid: params.get('yclid') ?? undefined,\n epik: params.get('epik') ?? undefined,\n // The ad platforms' first-party cookies, raw and under the cookie's own name, for the\n // conversion senders only: they carry the click to the later pages and visits where the\n // URL no longer does. `_fbc`, `_rdt_cid`, `_gcl_*` and `_uetmsclkid` are kept alive\n // server-side (see @shware/analytics/server resolveClickIdCookies); the rest are the pixels'.\n _fbc: parsed._fbc || undefined,\n _fbp: parsed._fbp || undefined,\n _gcl_aw: parsed._gcl_aw || undefined,\n _gcl_gb: parsed._gcl_gb || undefined,\n _uetmsclkid: parsed._uetmsclkid || undefined,\n _rdt_cid: parsed._rdt_cid || undefined,\n _rdt_uuid: parsed._rdt_uuid || undefined,\n // The Insight Tag's cookie is itself named `li_fat_id`; renamed so it cannot pass for the\n // URL parameter.\n _li_fat_id: parsed.li_fat_id || undefined,\n __oppref: parsed.__oppref || undefined,\n __obref: parsed.__obref || undefined,\n // utm params\n utm_source: link?.utm_source ?? params.get('utm_source') ?? undefined,\n utm_medium: link?.utm_medium ?? params.get('utm_medium') ?? undefined,\n utm_campaign: link?.utm_campaign ?? params.get('utm_campaign') ?? undefined,\n utm_term: link?.utm_term ?? params.get('utm_term') ?? undefined,\n utm_content: link?.utm_content ?? params.get('utm_content') ?? undefined,\n utm_id: link?.utm_id ?? params.get('utm_id') ?? undefined,\n utm_source_platform:\n link?.utm_source_platform ?? params.get('utm_source_platform') ?? undefined,\n utm_creative_format:\n link?.utm_creative_format ?? params.get('utm_creative_format') ?? undefined,\n utm_marketing_tactic:\n link?.utm_marketing_tactic ?? params.get('utm_marketing_tactic') ?? undefined,\n };\n\n cache.tags = tags;\n return tags;\n}\n\nconst map = new Map<string, string>();\n\nexport const storage: Storage = {\n getItem: (key) => {\n try {\n return localStorage.getItem(key);\n } catch {\n console.error('localStorage is not available');\n return map.get(key) ?? null;\n }\n },\n setItem: (key, value) => {\n try {\n localStorage.setItem(key, value);\n } catch {\n console.error('localStorage is not available');\n map.set(key, value);\n }\n },\n};\n"],"mappings":"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"}
@@ -2,6 +2,8 @@ import { TrackTags } from "../track/types.cjs";
2
2
  import { Storage } from "../setup/index.cjs";
3
3
  //#region src/web/index.d.ts
4
4
  export declare function getDeviceId(): string;
5
+ /** The current page's referrer: see `pageLoad`. */
6
+ export declare function getPageReferrer(): string | undefined;
5
7
  export declare function getTags(): Promise<TrackTags>;
6
8
  export declare const storage: Storage;
7
9
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/web/index.ts"],"mappings":";;;wBAegB;wBAiDM,WAAO,QAAA;qBA2GhB,SAAS"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/web/index.ts"],"mappings":";;;wBAegB;;wBA2CA;wBA4BM,WAAO,QAAA;qBA0GhB,SAAS"}
@@ -2,6 +2,8 @@ import { TrackTags } from "../track/types.mjs";
2
2
  import { Storage } from "../setup/index.mjs";
3
3
  //#region src/web/index.d.ts
4
4
  export declare function getDeviceId(): string;
5
+ /** The current page's referrer: see `pageLoad`. */
6
+ export declare function getPageReferrer(): string | undefined;
5
7
  export declare function getTags(): Promise<TrackTags>;
6
8
  export declare const storage: Storage;
7
9
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/web/index.ts"],"mappings":";;;wBAegB;wBAiDM,WAAO,QAAA;qBA2GhB,SAAS"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/web/index.ts"],"mappings":";;;wBAegB;;wBA2CA;wBA4BM,WAAO,QAAA;qBA0GhB,SAAS"}
@@ -2,8 +2,8 @@ import { __toESM } from "../_virtual/_rolldown/runtime.mjs";
2
2
  import { cache, config } from "../setup/index.mjs";
3
3
  import { keys } from "../constants/storage.mjs";
4
4
  import { getLink } from "../link/index.mjs";
5
- import { getPageKey } from "./page-key.mjs";
6
5
  import { require_es5 } from "../node_modules/.pnpm/bowser@2.14.1/node_modules/bowser/es5.mjs";
6
+ import { getPageKey } from "./page-key.mjs";
7
7
  import { v4 } from "uuid";
8
8
  import { parseCookie } from "cookie";
9
9
  //#region src/web/index.ts
@@ -27,14 +27,35 @@ function getDeviceId() {
27
27
  * `useWebAnalytics`): the id exists to link an event to the `page_view` of the page it happened
28
28
  * on, so it must rotate exactly when a `page_view` is sent — path or query change, yes; hash
29
29
  * change or tracking-parameter cleanup, no.
30
+ *
31
+ * `referrer` is the page's referrer as GA4 reports it in a single page app: `document.referrer`
32
+ * for the document's first page, and the previous page's URL after an in-app navigation. The
33
+ * browser never updates `document.referrer` on a `pushState`, so read alone it would name the
34
+ * site the visit came from on every later page — and a session opened there, after 30 minutes
35
+ * idle, would count that site as a second visit from it. With the previous page as its referrer,
36
+ * that session's referrer is the product's own host: no touch, a direct session, which the
37
+ * attribution view then credits to the touch before it.
30
38
  */
31
39
  let pageLoad;
32
- function getPageLoadId(page) {
33
- if (pageLoad?.page !== page) pageLoad = {
40
+ function currentPage() {
41
+ const href = window.location.href;
42
+ const page = getPageKey(window.location.pathname, window.location.search);
43
+ if (pageLoad?.page === page) {
44
+ pageLoad.href = href;
45
+ return pageLoad;
46
+ }
47
+ const referrer = pageLoad ? pageLoad.href : document.referrer || void 0;
48
+ pageLoad = {
34
49
  page,
35
- id: randomUUID()
50
+ id: randomUUID(),
51
+ href,
52
+ referrer
36
53
  };
37
- return pageLoad.id;
54
+ return pageLoad;
55
+ }
56
+ /** The current page's referrer: see `pageLoad`. */
57
+ function getPageReferrer() {
58
+ return currentPage().referrer;
38
59
  }
39
60
  const links = /* @__PURE__ */ new Map();
40
61
  /**
@@ -56,8 +77,7 @@ function getCachedLink(id) {
56
77
  let parser;
57
78
  async function getTags() {
58
79
  const page_location = window.location.href;
59
- const page_load_id = getPageLoadId(getPageKey(window.location.pathname, window.location.search));
60
- const page_referrer = document.referrer || void 0;
80
+ const { id: page_load_id, referrer: page_referrer } = currentPage();
61
81
  const page_title = document.title;
62
82
  parser ??= import_es5.default.getParser(window.navigator.userAgent);
63
83
  const params = new URLSearchParams(window.location.search);
@@ -158,6 +178,6 @@ const storage = {
158
178
  }
159
179
  };
160
180
  //#endregion
161
- export { getDeviceId, getTags, storage };
181
+ export { getDeviceId, getPageReferrer, getTags, storage };
162
182
 
163
183
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["uuidv4","Bowser"],"sources":["../../src/web/index.ts"],"sourcesContent":["import Bowser from 'bowser';\nimport { parseCookie } from 'cookie';\nimport { v4 as uuidv4 } from 'uuid';\nimport { keys } from '../constants/storage';\nimport { type Link, getLink } from '../link/index';\nimport { type Storage, cache, config } from '../setup/index';\nimport type { TrackTags } from '../track/types';\nimport { getPageKey } from './page-key';\n\n// lib.dom types `crypto.randomUUID` as always present, but it is missing in insecure contexts\n// and older browsers, so probe it before use.\nfunction randomUUID(): string {\n return (crypto as Partial<Crypto>).randomUUID ? crypto.randomUUID() : uuidv4();\n}\n\nexport function getDeviceId() {\n const cached = config.storage.getItem(keys.device_id);\n if (cached) return cached;\n const id = randomUUID();\n config.storage.setItem(keys.device_id, id);\n return id;\n}\n\n/**\n * The current page load, keyed by the page it was loaded at. A module variable and nothing more\n * persistent: the id must die with the page (a reload is a new page load, and storage would\n * carry it over — or, shared across tabs, let two pages overwrite each other's).\n *\n * Keyed by `getPageKey` because that is what the SDK's own `page_view` fires on (see\n * `useWebAnalytics`): the id exists to link an event to the `page_view` of the page it happened\n * on, so it must rotate exactly when a `page_view` is sent — path or query change, yes; hash\n * change or tracking-parameter cleanup, no.\n */\nlet pageLoad: { page: string; id: string } | undefined;\n\nfunction getPageLoadId(page: string): string {\n if (pageLoad?.page !== page) pageLoad = { page, id: randomUUID() };\n return pageLoad.id;\n}\n\nconst links = new Map<string, Promise<Link | null>>();\n\n/**\n * `getTags` runs once per event now, and the link a `?s=` id points at cannot change while the\n * page is open, so an uncached lookup would put an identical request on the wire for every\n * event the page sends.\n */\nfunction getCachedLink(id: string) {\n const cached = links.get(id);\n if (cached) return cached;\n const link = getLink(id).then((result) => {\n // A lookup that came back empty must not stick for the lifetime of the page. `getLink`\n // answers null for a network failure as well as for a link that does not exist, and losing\n // the link's utm params for every later event costs more than repeating a rare request.\n if (!result) links.delete(id);\n return result;\n });\n links.set(id, link);\n return link;\n}\n\n/** The user agent cannot change while the page is open, so it is parsed once. */\nlet parser: ReturnType<typeof Bowser.getParser> | undefined;\n\nexport async function getTags() {\n // Read the page before the first await: `getTags` runs when the event happens, and a single\n // page app can navigate while the link lookup below is still in flight.\n const page_location = window.location.href;\n const page_load_id = getPageLoadId(getPageKey(window.location.pathname, window.location.search));\n const page_referrer = document.referrer || undefined;\n const page_title = document.title;\n\n parser ??= Bowser.getParser(window.navigator.userAgent);\n const params = new URLSearchParams(window.location.search);\n const os = parser.getOS();\n const browser = parser.getBrowser();\n const platform = parser.getPlatform();\n const parsed = parseCookie(document.cookie);\n\n const linkId = params.get('s');\n const link = linkId ? await getCachedLink(linkId) : null;\n\n const tags: TrackTags = {\n os: `${os.name} ${os.version}`,\n os_name: os.name,\n os_version: os.version,\n browser: `${browser.name} ${browser.version}`,\n browser_name: browser.name,\n browser_version: browser.version,\n device: platform.model,\n device_id: getDeviceId(),\n device_type: platform.type,\n device_vendor: platform.vendor,\n device_pixel_ratio: window.devicePixelRatio,\n screen_width: window.screen.width,\n screen_height: window.screen.height,\n screen_resolution: `${window.screen.width}x${window.screen.height}`,\n webdriver: navigator.webdriver || undefined,\n release: config.release,\n language: navigator.language,\n time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n page_location,\n page_referrer,\n page_title,\n page_load_id,\n // Ad click ids from the URL, each under its parameter's name: this page's click, which\n // names the visit's channel (`CLICK_ID_CHANNELS`). Never filled from a cookie — a cookie\n // outlives the click; the cookies go below under their own names.\n fbclid: params.get('fbclid') ?? undefined,\n ad_id: params.get('ad_id') ?? undefined,\n ad_name: params.get('ad_name') ?? undefined,\n adset_id: params.get('adset_id') ?? undefined,\n adset_name: params.get('adset_name') ?? undefined,\n campaign_id: params.get('campaign_id') ?? undefined,\n campaign_name: params.get('campaign_name') ?? undefined,\n placement: params.get('placement') ?? undefined,\n gclid: params.get('gclid') ?? undefined,\n gclsrc: params.get('gclsrc') ?? undefined,\n gad_source: params.get('gad_source') ?? undefined,\n gad_campaignid: params.get('gad_campaignid') ?? undefined,\n wbraid: params.get('wbraid') ?? undefined,\n gbraid: params.get('gbraid') ?? undefined,\n dclid: params.get('dclid') ?? undefined,\n msclkid: params.get('msclkid') ?? undefined,\n rdt_cid: params.get('rdt_cid') ?? undefined,\n li_fat_id: params.get('li_fat_id') ?? undefined,\n oppref: params.get('oppref') ?? undefined,\n ko_click_id: params.get('ko_click_id') ?? undefined,\n // Snapchat appends `ScCid`, and URL parameters are case-sensitive.\n ScCid: params.get('ScCid') ?? undefined,\n ttclid: params.get('ttclid') ?? undefined,\n twclid: params.get('twclid') ?? undefined,\n yclid: params.get('yclid') ?? undefined,\n epik: params.get('epik') ?? undefined,\n // The ad platforms' first-party cookies, raw and under the cookie's own name, for the\n // conversion senders only: they carry the click to the later pages and visits where the\n // URL no longer does. `_fbc`, `_rdt_cid`, `_gcl_*` and `_uetmsclkid` are kept alive\n // server-side (see @shware/analytics/server resolveClickIdCookies); the rest are the pixels'.\n _fbc: parsed._fbc || undefined,\n _fbp: parsed._fbp || undefined,\n _gcl_aw: parsed._gcl_aw || undefined,\n _gcl_gb: parsed._gcl_gb || undefined,\n _uetmsclkid: parsed._uetmsclkid || undefined,\n _rdt_cid: parsed._rdt_cid || undefined,\n _rdt_uuid: parsed._rdt_uuid || undefined,\n // The Insight Tag's cookie is itself named `li_fat_id`; renamed so it cannot pass for the\n // URL parameter.\n _li_fat_id: parsed.li_fat_id || undefined,\n __oppref: parsed.__oppref || undefined,\n __obref: parsed.__obref || undefined,\n // utm params\n utm_source: link?.utm_source ?? params.get('utm_source') ?? undefined,\n utm_medium: link?.utm_medium ?? params.get('utm_medium') ?? undefined,\n utm_campaign: link?.utm_campaign ?? params.get('utm_campaign') ?? undefined,\n utm_term: link?.utm_term ?? params.get('utm_term') ?? undefined,\n utm_content: link?.utm_content ?? params.get('utm_content') ?? undefined,\n utm_id: link?.utm_id ?? params.get('utm_id') ?? undefined,\n utm_source_platform:\n link?.utm_source_platform ?? params.get('utm_source_platform') ?? undefined,\n utm_creative_format:\n link?.utm_creative_format ?? params.get('utm_creative_format') ?? undefined,\n utm_marketing_tactic:\n link?.utm_marketing_tactic ?? params.get('utm_marketing_tactic') ?? undefined,\n };\n\n cache.tags = tags;\n return tags;\n}\n\nconst map = new Map<string, string>();\n\nexport const storage: Storage = {\n getItem: (key) => {\n try {\n return localStorage.getItem(key);\n } catch {\n console.error('localStorage is not available');\n return map.get(key) ?? null;\n }\n },\n setItem: (key, value) => {\n try {\n localStorage.setItem(key, value);\n } catch {\n console.error('localStorage is not available');\n map.set(key, value);\n }\n },\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["uuidv4","Bowser"],"sources":["../../src/web/index.ts"],"sourcesContent":["import Bowser from 'bowser';\nimport { parseCookie } from 'cookie';\nimport { v4 as uuidv4 } from 'uuid';\nimport { keys } from '../constants/storage';\nimport { type Link, getLink } from '../link/index';\nimport { type Storage, cache, config } from '../setup/index';\nimport type { TrackTags } from '../track/types';\nimport { getPageKey } from './page-key';\n\n// lib.dom types `crypto.randomUUID` as always present, but it is missing in insecure contexts\n// and older browsers, so probe it before use.\nfunction randomUUID(): string {\n return (crypto as Partial<Crypto>).randomUUID ? crypto.randomUUID() : uuidv4();\n}\n\nexport function getDeviceId() {\n const cached = config.storage.getItem(keys.device_id);\n if (cached) return cached;\n const id = randomUUID();\n config.storage.setItem(keys.device_id, id);\n return id;\n}\n\n/**\n * The current page load, keyed by the page it was loaded at. A module variable and nothing more\n * persistent: the id must die with the page (a reload is a new page load, and storage would\n * carry it over — or, shared across tabs, let two pages overwrite each other's).\n *\n * Keyed by `getPageKey` because that is what the SDK's own `page_view` fires on (see\n * `useWebAnalytics`): the id exists to link an event to the `page_view` of the page it happened\n * on, so it must rotate exactly when a `page_view` is sent — path or query change, yes; hash\n * change or tracking-parameter cleanup, no.\n *\n * `referrer` is the page's referrer as GA4 reports it in a single page app: `document.referrer`\n * for the document's first page, and the previous page's URL after an in-app navigation. The\n * browser never updates `document.referrer` on a `pushState`, so read alone it would name the\n * site the visit came from on every later page — and a session opened there, after 30 minutes\n * idle, would count that site as a second visit from it. With the previous page as its referrer,\n * that session's referrer is the product's own host: no touch, a direct session, which the\n * attribution view then credits to the touch before it.\n */\nlet pageLoad: { page: string; id: string; href: string; referrer: string | undefined } | undefined;\n\nfunction currentPage() {\n const href = window.location.href;\n const page = getPageKey(window.location.pathname, window.location.search);\n if (pageLoad?.page === page) {\n // The same page, its URL maybe cleaned of tracking parameters since: the next page's\n // referrer is the URL as it last was, as the browser's own would be.\n pageLoad.href = href;\n return pageLoad;\n }\n const referrer = pageLoad ? pageLoad.href : document.referrer || undefined;\n pageLoad = { page, id: randomUUID(), href, referrer };\n return pageLoad;\n}\n\n/** The current page's referrer: see `pageLoad`. */\nexport function getPageReferrer(): string | undefined {\n return currentPage().referrer;\n}\n\nconst links = new Map<string, Promise<Link | null>>();\n\n/**\n * `getTags` runs once per event now, and the link a `?s=` id points at cannot change while the\n * page is open, so an uncached lookup would put an identical request on the wire for every\n * event the page sends.\n */\nfunction getCachedLink(id: string) {\n const cached = links.get(id);\n if (cached) return cached;\n const link = getLink(id).then((result) => {\n // A lookup that came back empty must not stick for the lifetime of the page. `getLink`\n // answers null for a network failure as well as for a link that does not exist, and losing\n // the link's utm params for every later event costs more than repeating a rare request.\n if (!result) links.delete(id);\n return result;\n });\n links.set(id, link);\n return link;\n}\n\n/** The user agent cannot change while the page is open, so it is parsed once. */\nlet parser: ReturnType<typeof Bowser.getParser> | undefined;\n\nexport async function getTags() {\n // Read the page before the first await: `getTags` runs when the event happens, and a single\n // page app can navigate while the link lookup below is still in flight.\n const page_location = window.location.href;\n const { id: page_load_id, referrer: page_referrer } = currentPage();\n const page_title = document.title;\n\n parser ??= Bowser.getParser(window.navigator.userAgent);\n const params = new URLSearchParams(window.location.search);\n const os = parser.getOS();\n const browser = parser.getBrowser();\n const platform = parser.getPlatform();\n const parsed = parseCookie(document.cookie);\n\n const linkId = params.get('s');\n const link = linkId ? await getCachedLink(linkId) : null;\n\n const tags: TrackTags = {\n os: `${os.name} ${os.version}`,\n os_name: os.name,\n os_version: os.version,\n browser: `${browser.name} ${browser.version}`,\n browser_name: browser.name,\n browser_version: browser.version,\n device: platform.model,\n device_id: getDeviceId(),\n device_type: platform.type,\n device_vendor: platform.vendor,\n device_pixel_ratio: window.devicePixelRatio,\n screen_width: window.screen.width,\n screen_height: window.screen.height,\n screen_resolution: `${window.screen.width}x${window.screen.height}`,\n webdriver: navigator.webdriver || undefined,\n release: config.release,\n language: navigator.language,\n time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n page_location,\n page_referrer,\n page_title,\n page_load_id,\n // Ad click ids from the URL, each under its parameter's name: this page's click, which\n // names the visit's channel (`CLICK_ID_CHANNELS`). Never filled from a cookie — a cookie\n // outlives the click; the cookies go below under their own names.\n fbclid: params.get('fbclid') ?? undefined,\n ad_id: params.get('ad_id') ?? undefined,\n ad_name: params.get('ad_name') ?? undefined,\n adset_id: params.get('adset_id') ?? undefined,\n adset_name: params.get('adset_name') ?? undefined,\n campaign_id: params.get('campaign_id') ?? undefined,\n campaign_name: params.get('campaign_name') ?? undefined,\n placement: params.get('placement') ?? undefined,\n gclid: params.get('gclid') ?? undefined,\n gclsrc: params.get('gclsrc') ?? undefined,\n gad_source: params.get('gad_source') ?? undefined,\n gad_campaignid: params.get('gad_campaignid') ?? undefined,\n wbraid: params.get('wbraid') ?? undefined,\n gbraid: params.get('gbraid') ?? undefined,\n dclid: params.get('dclid') ?? undefined,\n msclkid: params.get('msclkid') ?? undefined,\n rdt_cid: params.get('rdt_cid') ?? undefined,\n li_fat_id: params.get('li_fat_id') ?? undefined,\n oppref: params.get('oppref') ?? undefined,\n ko_click_id: params.get('ko_click_id') ?? undefined,\n // Snapchat appends `ScCid`, and URL parameters are case-sensitive.\n ScCid: params.get('ScCid') ?? undefined,\n ttclid: params.get('ttclid') ?? undefined,\n twclid: params.get('twclid') ?? undefined,\n yclid: params.get('yclid') ?? undefined,\n epik: params.get('epik') ?? undefined,\n // The ad platforms' first-party cookies, raw and under the cookie's own name, for the\n // conversion senders only: they carry the click to the later pages and visits where the\n // URL no longer does. `_fbc`, `_rdt_cid`, `_gcl_*` and `_uetmsclkid` are kept alive\n // server-side (see @shware/analytics/server resolveClickIdCookies); the rest are the pixels'.\n _fbc: parsed._fbc || undefined,\n _fbp: parsed._fbp || undefined,\n _gcl_aw: parsed._gcl_aw || undefined,\n _gcl_gb: parsed._gcl_gb || undefined,\n _uetmsclkid: parsed._uetmsclkid || undefined,\n _rdt_cid: parsed._rdt_cid || undefined,\n _rdt_uuid: parsed._rdt_uuid || undefined,\n // The Insight Tag's cookie is itself named `li_fat_id`; renamed so it cannot pass for the\n // URL parameter.\n _li_fat_id: parsed.li_fat_id || undefined,\n __oppref: parsed.__oppref || undefined,\n __obref: parsed.__obref || undefined,\n // utm params\n utm_source: link?.utm_source ?? params.get('utm_source') ?? undefined,\n utm_medium: link?.utm_medium ?? params.get('utm_medium') ?? undefined,\n utm_campaign: link?.utm_campaign ?? params.get('utm_campaign') ?? undefined,\n utm_term: link?.utm_term ?? params.get('utm_term') ?? undefined,\n utm_content: link?.utm_content ?? params.get('utm_content') ?? undefined,\n utm_id: link?.utm_id ?? params.get('utm_id') ?? undefined,\n utm_source_platform:\n link?.utm_source_platform ?? params.get('utm_source_platform') ?? undefined,\n utm_creative_format:\n link?.utm_creative_format ?? params.get('utm_creative_format') ?? undefined,\n utm_marketing_tactic:\n link?.utm_marketing_tactic ?? params.get('utm_marketing_tactic') ?? undefined,\n };\n\n cache.tags = tags;\n return tags;\n}\n\nconst map = new Map<string, string>();\n\nexport const storage: Storage = {\n getItem: (key) => {\n try {\n return localStorage.getItem(key);\n } catch {\n console.error('localStorage is not available');\n return map.get(key) ?? null;\n }\n },\n setItem: (key, value) => {\n try {\n localStorage.setItem(key, value);\n } catch {\n console.error('localStorage is not available');\n map.set(key, value);\n }\n },\n};\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/analytics",
3
- "version": "11.3.1",
3
+ "version": "11.4.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",