@webority/analytics 0.5.1 → 0.6.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.
@@ -1 +1 @@
1
- var WeborityAnalytics=(()=>{var m=Object.defineProperty;var M=Object.getOwnPropertyDescriptor;var $=Object.getOwnPropertyNames;var j=Object.prototype.hasOwnProperty;var G=(t,e)=>{for(var n in e)m(t,n,{get:e[n],enumerable:!0})},K=(t,e,n,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of $(e))!j.call(t,r)&&r!==n&&m(t,r,{get:()=>e[r],enumerable:!(o=M(e,r))||o.enumerable});return t};var P=t=>K(m({},"__esModule",{value:!0}),t);var ct={};G(ct,{CONSENT_CHANGE_EVENT:()=>y,CONSENT_COOKIE_NAME:()=>u,captureAttribution:()=>x,getAttribution:()=>R,getConsent:()=>a,initConsent:()=>N,loadTags:()=>q,onConsentChange:()=>c,requiresOptIn:()=>b,saveConsent:()=>L});var p=Object.freeze(["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB","CH"]);function b(t){let e=typeof t=="string"?t.trim().toUpperCase():"";return e.length!==2||e==="XX"||e==="T1"?!0:p.includes(e)}var u="webority_cookie_consent",y="webority-consent-change",H=365,i={initialized:!1,requiresOptIn:!0,cookieDomain:null,saved:null},g=new Set;function N(t){if(typeof t?.requiresOptIn!="boolean")throw new Error("@webority/analytics: initConsent needs requiresOptIn (true or false), decided from the visitor's country.");if(t.cookieDomain!==void 0&&(typeof t.cookieDomain!="string"||!t.cookieDomain.trim()))throw new Error('@webority/analytics: initConsent cookieDomain must be a domain such as ".example.com", or left out.');i.initialized=!0,i.requiresOptIn=t.requiresOptIn,i.cookieDomain=t.cookieDomain?.trim()??null,i.saved=X(),B(),i.saved&&v(i.saved)}function l(){return i.initialized}function a(){k("getConsent");let t=i.saved;if(t)return{essential:!0,analytics:t.analytics,functional:t.functional,marketing:t.marketing,decided:!0};let e=!i.requiresOptIn;return{essential:!0,analytics:e,functional:e,marketing:e,decided:!1}}function L(t){k("saveConsent");let e={essential:!0,analytics:t?.analytics===!0,functional:t?.functional===!0,marketing:t?.marketing===!0,timestamp:new Date().toISOString()};J(e),i.saved=e,v(e),Y(a())}function c(t){return g.add(t),()=>g.delete(t)}function Y(t){for(let e of g)try{e(t)}catch(n){queueMicrotask(()=>{throw n})}typeof window?.dispatchEvent=="function"&&typeof CustomEvent=="function"&&window.dispatchEvent(new CustomEvent(y,{detail:t}))}function k(t){if(!i.initialized)throw new Error(`@webority/analytics: call initConsent before ${t}.`)}function w(){window.dataLayer=window.dataLayer||[],window.dataLayer.push(arguments)}function B(){w("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",functionality_storage:"denied",personalization_storage:"denied",security_storage:"granted",wait_for_update:500,region:[...p]}),w("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted",functionality_storage:"granted",personalization_storage:"granted",security_storage:"granted"})}function v(t){let e=n=>n?"granted":"denied";w("consent","update",{analytics_storage:e(t.analytics),functionality_storage:e(t.functional),personalization_storage:e(t.functional),ad_storage:e(t.marketing),ad_user_data:e(t.marketing),ad_personalization:e(t.marketing)})}function X(){let t=`${u}=`,e=(document.cookie||"").split(";").map(n=>n.trim()).find(n=>n.startsWith(t));if(!e)return null;try{let n=JSON.parse(decodeURIComponent(e.slice(t.length)));return!n||typeof n!="object"?null:{essential:!0,analytics:n.analytics===!0,functional:n.functional===!0,marketing:n.marketing===!0,timestamp:n.timestamp??null}}catch{return null}}function J(t){let e=new Date(Date.now()+H*24*60*60*1e3).toUTCString(),n=i.cookieDomain?`;domain=${i.cookieDomain}`:"",o=window.location?.protocol==="https:"?";Secure":"";document.cookie=`${u}=${encodeURIComponent(JSON.stringify(t))};expires=${e};path=/${n};SameSite=Lax${o}`}var f="webority_attribution",V=2160*60*60*1e3,Q=200,C=500,W=Object.freeze({utm_source:"utmSource",utm_medium:"utmMedium",utm_campaign:"utmCampaign",utm_content:"utmContent",utm_term:"utmTerm",gclid:"gclid",gbraid:"gbraid",wbraid:"wbraid",fbclid:"fbclid",msclkid:"msclkid",li_fat_id:"liFatId"}),A=!1;function x(){U("captureAttribution"),et();let t=Z();if(!t)return;let e=I(_());z(_(),{firstTouch:e?.firstTouch??t,lastTouch:t})}function R(){return U("getAttribution"),I(_())}function Z(){let t=new URLSearchParams(window.location.search),e={};for(let[o,r]of Object.entries(W)){let O=d(t.get(o),Q);O&&(e[r]=O)}let n=d(t.get("referrer"),C)||tt();return n&&(e.referrer=n),Object.keys(e).length===0?null:(e.landingPage=d(window.location.pathname,C),e.capturedDateTimeUtc=new Date().toISOString(),e)}function tt(){let t=document.referrer;if(!t)return"";try{return new URL(t).hostname===window.location.hostname?"":d(t,C)}catch{return""}}function et(){A||(A=!0,c(t=>{let e=t.marketing?T():h(),n=t.marketing?h():T(),o=I(e);nt(e),o&&z(n,o)}))}function _(){return a().marketing?h():T()}function h(){try{return window.localStorage}catch{return null}}function T(){try{return window.sessionStorage}catch{return null}}function I(t){try{let e=t?.getItem(f);if(!e)return null;let n=JSON.parse(e),o=D(n?.firstTouch),r=D(n?.lastTouch);return!o&&!r?(t.removeItem(f),null):{firstTouch:o??r,lastTouch:r??o}}catch{return null}}function z(t,e){try{t?.setItem(f,JSON.stringify(e))}catch{}}function nt(t){try{t?.removeItem(f)}catch{}}function D(t){if(!t||typeof t!="object")return null;let e=Date.parse(t.capturedDateTimeUtc);return Number.isFinite(e)&&Date.now()-e<=V?t:null}function d(t,e){let n=String(t??"").trim();return n?n.slice(0,e):""}function U(t){if(!l())throw new Error(`@webority/analytics: call initConsent before ${t}.`)}var ot=["scroll","mousemove","touchstart","keydown","pointerdown"],F=1500,rt=3e3,it=100,s={loaded:!1,clarityId:null,clarityLoaded:!1};function q(t={}){if(!l())throw new Error("@webority/analytics: call initConsent before loadTags.");if(s.loaded)return;s.loaded=!0;let{gtmId:e,clarityId:n,ahrefsKey:o}=t;o&&S("https://analytics.ahrefs.com/analytics.js",{"data-key":o}),e&&at(()=>{window.dataLayer=window.dataLayer||[],window.dataLayer.push({"gtm.start":Date.now(),event:"gtm.js"}),S(`https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(e)}`)}),n&&(s.clarityId=n,a().analytics&&st(),c(r=>{r.analytics&&E()}))}function at(t){let e=!1,n=()=>{e||(e=!0,t())};for(let o of ot)window.addEventListener(o,n,{once:!0,passive:!0});document.readyState==="complete"?setTimeout(n,F):window.addEventListener("load",()=>setTimeout(n,F),{once:!0})}function st(){let t=()=>{window.scrollY>it&&(window.removeEventListener("scroll",t),E())};window.addEventListener("scroll",t,{passive:!0}),setTimeout(E,rt)}function E(){s.clarityLoaded||!s.clarityId||(s.clarityLoaded=!0,window.clarity=window.clarity||function(){window.clarity.q=window.clarity.q||[],window.clarity.q.push(arguments)},S(`https://www.clarity.ms/tag/${encodeURIComponent(s.clarityId)}`,{crossorigin:"anonymous"}))}function S(t,e={}){let n=document.createElement("script");n.async=!0,n.src=t;for(let[o,r]of Object.entries(e))n.setAttribute(o,r);document.head.appendChild(n)}return P(ct);})();
1
+ var WeborityAnalytics=(()=>{var y=Object.defineProperty;var Y=Object.getOwnPropertyDescriptor;var J=Object.getOwnPropertyNames;var X=Object.prototype.hasOwnProperty;var V=(t,e)=>{for(var n in e)y(t,n,{get:e[n],enumerable:!0})},Q=(t,e,n,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of J(e))!X.call(t,i)&&i!==n&&y(t,i,{get:()=>e[i],enumerable:!(o=Y(e,i))||o.enumerable});return t};var W=t=>Q(y({},"__esModule",{value:!0}),t);var yt={};V(yt,{CONSENT_CHANGE_EVENT:()=>_,CONSENT_COOKIE_NAME:()=>l,captureAttribution:()=>$,getAttribution:()=>j,getConsent:()=>s,initConsent:()=>A,loadTags:()=>B,onConsentChange:()=>c,requiresOptIn:()=>k,saveConsent:()=>R});var w=Object.freeze(["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB","CH"]);function k(t){let e=typeof t=="string"?t.trim().toUpperCase():"";return e.length!==2||e==="XX"||e==="T1"?!0:w.includes(e)}var l="webority_cookie_consent",_="webority-consent-change",L=365,r={initialized:!1,requiresOptIn:!0,strictOptIn:!1,adStorage:!0,cookieDays:L,cookieDomain:null,saved:null},C=new Set;function A(t){let e=t?.strictOptIn??!1;if(typeof e!="boolean")throw new Error("@webority/analytics: initConsent strictOptIn must be true or false, or left out.");if(e){if(t.requiresOptIn!==void 0&&t.requiresOptIn!==!0)throw new Error("@webority/analytics: initConsent strictOptIn asks every visitor to opt in, so requiresOptIn must be true or left out.")}else if(typeof t?.requiresOptIn!="boolean")throw new Error("@webority/analytics: initConsent needs requiresOptIn (true or false), decided from the visitor's country.");if(t.cookieDomain!==void 0&&(typeof t.cookieDomain!="string"||!t.cookieDomain.trim()))throw new Error('@webority/analytics: initConsent cookieDomain must be a domain such as ".example.com", or left out.');if(t.cookieDays!==void 0&&!(Number.isInteger(t.cookieDays)&&t.cookieDays>0))throw new Error("@webority/analytics: initConsent cookieDays must be a whole number of days above zero, or left out.");if(t.adStorage!==void 0&&typeof t.adStorage!="boolean")throw new Error("@webority/analytics: initConsent adStorage must be true or false, or left out.");r.initialized=!0,r.strictOptIn=e,r.requiresOptIn=e||t.requiresOptIn,r.adStorage=t.adStorage??!e,r.cookieDays=t.cookieDays??L,r.cookieDomain=t.cookieDomain?.trim()??null;let{saved:n,entryCount:o}=et();r.saved=n,r.cookieDomain&&o>1&&n&&q(n),tt(),r.saved&&U(r.saved)}function f(){return r.initialized}function v(){return r.strictOptIn}function x(){return r.adStorage}function s(){z("getConsent");let t=r.saved;if(t)return{essential:!0,analytics:t.analytics,functional:t.functional,marketing:t.marketing,decided:!0};let e=!r.requiresOptIn;return{essential:!0,analytics:e,functional:e,marketing:e,decided:!1}}function R(t){z("saveConsent");let e={essential:!0,analytics:t?.analytics===!0,functional:t?.functional===!0,marketing:t?.marketing===!0,timestamp:new Date().toISOString()};q(e),r.saved=e,U(e),Z(s())}function c(t){return C.add(t),()=>C.delete(t)}function Z(t){for(let e of C)try{e(t)}catch(n){queueMicrotask(()=>{throw n})}typeof window?.dispatchEvent=="function"&&typeof CustomEvent=="function"&&window.dispatchEvent(new CustomEvent(_,{detail:t}))}function z(t){if(!r.initialized)throw new Error(`@webority/analytics: call initConsent before ${t}.`)}function d(){window.dataLayer=window.dataLayer||[],window.dataLayer.push(arguments)}function tt(){if(r.strictOptIn){d("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",functionality_storage:"denied",personalization_storage:"denied",security_storage:"granted",wait_for_update:500});return}d("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",functionality_storage:"denied",personalization_storage:"denied",security_storage:"granted",wait_for_update:500,region:[...w]}),d("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted",functionality_storage:"granted",personalization_storage:"granted",security_storage:"granted"})}function U(t){let e=o=>o?"granted":"denied",n=t.marketing&&r.adStorage;d("consent","update",{analytics_storage:e(t.analytics),functionality_storage:e(t.functional),personalization_storage:e(t.functional),ad_storage:e(n),ad_user_data:e(n),ad_personalization:e(n)})}function et(){let t=`${l}=`,e=(document.cookie||"").split(";").map(o=>o.trim()).filter(o=>o.startsWith(t)),n=e.map(o=>nt(o.slice(t.length))).filter(Boolean);return{saved:ot(n),entryCount:e.length}}function nt(t){try{let e=JSON.parse(decodeURIComponent(t));return!e||typeof e!="object"?null:{essential:!0,analytics:e.analytics===!0,functional:e.functional===!0,marketing:e.marketing===!0,timestamp:e.timestamp??null}}catch{return null}}function ot(t){if(t.length===0)return null;let e=t.map(o=>typeof o.timestamp=="string"?Date.parse(o.timestamp):Number.NaN);if(e.some(Number.isNaN))return t.at(-1);let n=0;for(let o=1;o<t.length;o++)e[o]>=e[n]&&(n=o);return t[n]}function q(t){let e=typeof t.timestamp=="string"?Date.parse(t.timestamp):Number.NaN,n=(Number.isNaN(e)?Date.now():e)+r.cookieDays*24*60*60*1e3,o=window.location?.protocol==="https:"?";Secure":"";r.cookieDomain&&D(`${l}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;max-age=0;path=/;SameSite=Lax${o}`);let i=r.cookieDomain?`;domain=${r.cookieDomain}`:"";D(`${l}=${encodeURIComponent(JSON.stringify(t))};expires=${new Date(n).toUTCString()};path=/${i};SameSite=Lax${o}`)}function D(t){document.cookie=t}var p="webority_attribution",rt=2160*60*60*1e3,it=200,I=500,at=Object.freeze({utm_source:"utmSource",utm_medium:"utmMedium",utm_campaign:"utmCampaign",utm_content:"utmContent",utm_term:"utmTerm",gclid:"gclid",gbraid:"gbraid",wbraid:"wbraid",fbclid:"fbclid",msclkid:"msclkid",li_fat_id:"liFatId"}),F=!1;function $(){K("captureAttribution"),ut();let t=st();if(!t)return;let e=T(h());G(h(),{firstTouch:e?.firstTouch??t,lastTouch:t})}function j(){return K("getAttribution"),T(h())}function st(){let t=new URLSearchParams(window.location.search),e={};for(let[o,i]of Object.entries(at)){let a=m(t.get(o),it);a&&(e[i]=a)}let n=m(t.get("referrer"),I)||ct();return n&&(e.referrer=n),Object.keys(e).length===0?null:(e.landingPage=m(window.location.pathname,I),e.capturedDateTimeUtc=new Date().toISOString(),e)}function ct(){let t=document.referrer;if(!t)return"";try{return new URL(t).hostname===window.location.hostname?"":m(t,I)}catch{return""}}function ut(){F||(F=!0,c(t=>{let e=t.marketing?S():E(),n=t.marketing?E():S(),o=T(e);lt(e),o&&G(n,o)}))}function h(){return s().marketing?E():S()}function E(){try{return window.localStorage}catch{return null}}function S(){try{return window.sessionStorage}catch{return null}}function T(t){try{let e=t?.getItem(p);if(!e)return null;let n=JSON.parse(e),o=M(n?.firstTouch),i=M(n?.lastTouch);return!o&&!i?(t.removeItem(p),null):{firstTouch:o??i,lastTouch:i??o}}catch{return null}}function G(t,e){try{t?.setItem(p,JSON.stringify(e))}catch{}}function lt(t){try{t?.removeItem(p)}catch{}}function M(t){if(!t||typeof t!="object")return null;let e=Date.parse(t.capturedDateTimeUtc);return Number.isFinite(e)&&Date.now()-e<=rt?t:null}function m(t,e){let n=String(t??"").trim();return n?n.slice(0,e):""}function K(t){if(!f())throw new Error(`@webority/analytics: call initConsent before ${t}.`)}var dt=["scroll","mousemove","touchstart","keydown","pointerdown"],P=1500,ft=3e3,mt=100,u={loaded:!1,clarityId:null,clarityLoaded:!1};function B(t={}){if(!f())throw new Error("@webority/analytics: call initConsent before loadTags.");if(u.loaded)return;u.loaded=!0;let{gtmId:e,clarityId:n,ahrefsKey:o}=t,i=v();if(o){let a=()=>b("https://analytics.ahrefs.com/analytics.js",{"data-key":o});i?H(g=>g.analytics,a):a()}if(e){let a=()=>pt(()=>{window.dataLayer=window.dataLayer||[],window.dataLayer.push({"gtm.start":Date.now(),event:"gtm.js"}),b(`https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(e)}`)});if(i){let g=x();H(N=>N.analytics||N.marketing&&g,a)}else a()}n&&(u.clarityId=n,s().analytics&&gt(),c(a=>{a.analytics&&O()}))}function H(t,e){if(t(s())){e();return}let n=c(o=>{t(o)&&(n(),e())})}function pt(t){let e=!1,n=()=>{e||(e=!0,t())};for(let o of dt)window.addEventListener(o,n,{once:!0,passive:!0});document.readyState==="complete"?setTimeout(n,P):window.addEventListener("load",()=>setTimeout(n,P),{once:!0})}function gt(){let t=()=>{window.scrollY>mt&&(window.removeEventListener("scroll",t),O())};window.addEventListener("scroll",t,{passive:!0}),setTimeout(O,ft)}function O(){u.clarityLoaded||!u.clarityId||(u.clarityLoaded=!0,window.clarity=window.clarity||function(){window.clarity.q=window.clarity.q||[],window.clarity.q.push(arguments)},b(`https://www.clarity.ms/tag/${encodeURIComponent(u.clarityId)}`,{crossorigin:"anonymous"}))}function b(t,e={}){let n=document.createElement("script");n.async=!0,n.src=t;for(let[o,i]of Object.entries(e))n.setAttribute(o,i);document.head.appendChild(n)}return W(yt);})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webority/analytics",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/consent.js CHANGED
@@ -2,11 +2,14 @@ import { OPT_IN_COUNTRIES } from "./consent-regions.js";
2
2
 
3
3
  export const CONSENT_COOKIE_NAME = "webority_cookie_consent";
4
4
  export const CONSENT_CHANGE_EVENT = "webority-consent-change";
5
- const CONSENT_COOKIE_DAYS = 365;
5
+ const DEFAULT_COOKIE_DAYS = 365;
6
6
 
7
7
  const consentState = {
8
8
  initialized: false,
9
9
  requiresOptIn: true,
10
+ strictOptIn: false,
11
+ adStorage: true,
12
+ cookieDays: DEFAULT_COOKIE_DAYS,
10
13
  cookieDomain: null,
11
14
  saved: null,
12
15
  };
@@ -18,19 +21,50 @@ const listeners = new Set();
18
21
  * knows the visitor's country (requiresOptIn(countryCode)); guessing it in the browser misplaces
19
22
  * VPN users. cookieDomain (".capnix.ai") shares one choice between the website and every portal
20
23
  * subdomain; without it the choice stays on the current host.
24
+ *
25
+ * strictOptIn asks every visitor in every region: Consent Mode is denied everywhere until they
26
+ * choose, loadTags holds Tag Manager and Ahrefs until consent, and requiresOptIn may be left out.
27
+ * adStorage says whether marketing consent also grants Google's ad_* signals: true by default,
28
+ * false under strictOptIn. cookieDays is the saved choice's lifetime, 365 by default.
21
29
  */
22
30
  export function initConsent(options) {
23
- if (typeof options?.requiresOptIn !== "boolean") {
31
+ const strictOptIn = options?.strictOptIn ?? false;
32
+ if (typeof strictOptIn !== "boolean") {
33
+ throw new Error("@webority/analytics: initConsent strictOptIn must be true or false, or left out.");
34
+ }
35
+ if (strictOptIn) {
36
+ if (options.requiresOptIn !== undefined && options.requiresOptIn !== true) {
37
+ throw new Error(
38
+ "@webority/analytics: initConsent strictOptIn asks every visitor to opt in, so requiresOptIn must be true or left out.",
39
+ );
40
+ }
41
+ } else if (typeof options?.requiresOptIn !== "boolean") {
24
42
  throw new Error("@webority/analytics: initConsent needs requiresOptIn (true or false), decided from the visitor's country.");
25
43
  }
26
44
  if (options.cookieDomain !== undefined && (typeof options.cookieDomain !== "string" || !options.cookieDomain.trim())) {
27
45
  throw new Error('@webority/analytics: initConsent cookieDomain must be a domain such as ".example.com", or left out.');
28
46
  }
47
+ if (options.cookieDays !== undefined && !(Number.isInteger(options.cookieDays) && options.cookieDays > 0)) {
48
+ throw new Error("@webority/analytics: initConsent cookieDays must be a whole number of days above zero, or left out.");
49
+ }
50
+ if (options.adStorage !== undefined && typeof options.adStorage !== "boolean") {
51
+ throw new Error("@webority/analytics: initConsent adStorage must be true or false, or left out.");
52
+ }
29
53
 
30
54
  consentState.initialized = true;
31
- consentState.requiresOptIn = options.requiresOptIn;
55
+ consentState.strictOptIn = strictOptIn;
56
+ consentState.requiresOptIn = strictOptIn || options.requiresOptIn;
57
+ consentState.adStorage = options.adStorage ?? !strictOptIn;
58
+ consentState.cookieDays = options.cookieDays ?? DEFAULT_COOKIE_DAYS;
32
59
  consentState.cookieDomain = options.cookieDomain?.trim() ?? null;
33
- consentState.saved = readConsentCookie();
60
+ const { saved, entryCount } = readConsentCookie();
61
+ consentState.saved = saved;
62
+
63
+ // A host-only cookie from before the domain was set sits beside the shared one, and
64
+ // browsers list the older first. Folding them into one stops an old choice resurfacing.
65
+ if (consentState.cookieDomain && entryCount > 1 && saved) {
66
+ writeConsentCookie(saved);
67
+ }
34
68
 
35
69
  pushConsentDefaults();
36
70
  if (consentState.saved) {
@@ -42,6 +76,14 @@ export function isConsentInitialized() {
42
76
  return consentState.initialized;
43
77
  }
44
78
 
79
+ export function isStrictOptIn() {
80
+ return consentState.strictOptIn;
81
+ }
82
+
83
+ export function isAdStorageEnabled() {
84
+ return consentState.adStorage;
85
+ }
86
+
45
87
  /**
46
88
  * The visitor's current consent. Before they choose (decided: false) every optional category
47
89
  * follows the region default: denied where opt-in is required, granted everywhere else.
@@ -117,6 +159,19 @@ function gtag() {
117
159
 
118
160
  // Defaults must reach dataLayer before the Tag Manager container loads, or its tags fire ungated.
119
161
  function pushConsentDefaults() {
162
+ if (consentState.strictOptIn) {
163
+ gtag("consent", "default", {
164
+ ad_storage: "denied",
165
+ ad_user_data: "denied",
166
+ ad_personalization: "denied",
167
+ analytics_storage: "denied",
168
+ functionality_storage: "denied",
169
+ personalization_storage: "denied",
170
+ security_storage: "granted",
171
+ wait_for_update: 500,
172
+ });
173
+ return;
174
+ }
120
175
  gtag("consent", "default", {
121
176
  ad_storage: "denied",
122
177
  ad_user_data: "denied",
@@ -141,28 +196,30 @@ function pushConsentDefaults() {
141
196
 
142
197
  function pushConsentUpdate(saved) {
143
198
  const state = (granted) => (granted ? "granted" : "denied");
199
+ const ads = saved.marketing && consentState.adStorage;
144
200
  gtag("consent", "update", {
145
201
  analytics_storage: state(saved.analytics),
146
202
  functionality_storage: state(saved.functional),
147
203
  personalization_storage: state(saved.functional),
148
- ad_storage: state(saved.marketing),
149
- ad_user_data: state(saved.marketing),
150
- ad_personalization: state(saved.marketing),
204
+ ad_storage: state(ads),
205
+ ad_user_data: state(ads),
206
+ ad_personalization: state(ads),
151
207
  });
152
208
  }
153
209
 
154
210
  function readConsentCookie() {
155
211
  const prefix = `${CONSENT_COOKIE_NAME}=`;
156
- const entry = (document.cookie || "")
212
+ const entries = (document.cookie || "")
157
213
  .split(";")
158
214
  .map((part) => part.trim())
159
- .find((part) => part.startsWith(prefix));
160
- if (!entry) {
161
- return null;
162
- }
215
+ .filter((part) => part.startsWith(prefix));
216
+ const choices = entries.map((entry) => parseConsentEntry(entry.slice(prefix.length))).filter(Boolean);
217
+ return { saved: newestChoice(choices), entryCount: entries.length };
218
+ }
163
219
 
220
+ function parseConsentEntry(value) {
164
221
  try {
165
- const parsed = JSON.parse(decodeURIComponent(entry.slice(prefix.length)));
222
+ const parsed = JSON.parse(decodeURIComponent(value));
166
223
  if (!parsed || typeof parsed !== "object") {
167
224
  return null;
168
225
  }
@@ -179,17 +236,52 @@ function readConsentCookie() {
179
236
  }
180
237
  }
181
238
 
239
+ // Without a readable timestamp on every choice, the last one wins: browsers list equal-path
240
+ // cookies oldest first.
241
+ function newestChoice(choices) {
242
+ if (choices.length === 0) {
243
+ return null;
244
+ }
245
+ const times = choices.map((choice) => (typeof choice.timestamp === "string" ? Date.parse(choice.timestamp) : Number.NaN));
246
+ if (times.some(Number.isNaN)) {
247
+ return choices.at(-1);
248
+ }
249
+ let newest = 0;
250
+ for (let index = 1; index < choices.length; index++) {
251
+ if (times[index] >= times[newest]) {
252
+ newest = index;
253
+ }
254
+ }
255
+ return choices[newest];
256
+ }
257
+
182
258
  function writeConsentCookie(saved) {
183
- const expires = new Date(Date.now() + CONSENT_COOKIE_DAYS * 24 * 60 * 60 * 1000).toUTCString();
184
- const domain = consentState.cookieDomain ? `;domain=${consentState.cookieDomain}` : "";
259
+ // The lifetime runs from the choice, so folding an old choice onto the shared domain does not extend it.
260
+ const chosenAt = typeof saved.timestamp === "string" ? Date.parse(saved.timestamp) : Number.NaN;
261
+ const expiresAt = (Number.isNaN(chosenAt) ? Date.now() : chosenAt) + consentState.cookieDays * 24 * 60 * 60 * 1000;
185
262
  const secure = window.location?.protocol === "https:" ? ";Secure" : "";
263
+ if (consentState.cookieDomain) {
264
+ // Expired before the shared write: a browser that treats the two as one cookie on the
265
+ // apex host then still ends up holding the shared one.
266
+ setCookie(`${CONSENT_COOKIE_NAME}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;max-age=0;path=/;SameSite=Lax${secure}`);
267
+ }
268
+ const domain = consentState.cookieDomain ? `;domain=${consentState.cookieDomain}` : "";
269
+ setCookie(
270
+ `${CONSENT_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(saved))};expires=${new Date(expiresAt).toUTCString()};path=/${domain};SameSite=Lax${secure}`,
271
+ );
272
+ }
273
+
274
+ function setCookie(text) {
186
275
  // biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is missing from browsers our visitors use.
187
- document.cookie = `${CONSENT_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(saved))};expires=${expires};path=/${domain};SameSite=Lax${secure}`;
276
+ document.cookie = text;
188
277
  }
189
278
 
190
279
  export function resetConsentForTests() {
191
280
  consentState.initialized = false;
192
281
  consentState.requiresOptIn = true;
282
+ consentState.strictOptIn = false;
283
+ consentState.adStorage = true;
284
+ consentState.cookieDays = DEFAULT_COOKIE_DAYS;
193
285
  consentState.cookieDomain = null;
194
286
  consentState.saved = null;
195
287
  listeners.clear();
package/src/tags.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getConsent, isConsentInitialized, onConsentChange } from "./consent.js";
1
+ import { getConsent, isAdStorageEnabled, isConsentInitialized, isStrictOptIn, onConsentChange } from "./consent.js";
2
2
 
3
3
  const INTERACTION_EVENTS = ["scroll", "mousemove", "touchstart", "keydown", "pointerdown"];
4
4
  const DEFER_AFTER_LOAD_MS = 1500;
@@ -18,6 +18,11 @@ const tagState = {
18
18
  * analytics consent, and a revoke takes effect from the next page. Ahrefs is cookieless and
19
19
  * loads straight away. GA4 is deliberately absent: it runs inside the Tag Manager container,
20
20
  * and loading it here as well counts every page view twice.
21
+ *
22
+ * Under initConsent's strictOptIn nothing loads before consent: Ahrefs waits for analytics, and
23
+ * Tag Manager for analytics, or for marketing when adStorage lets marketing grant ad signals,
24
+ * whether granted on this page or saved on an earlier one. Marketing alone without adStorage
25
+ * unlocks nothing in the container, and loading it would still let GA4 send cookieless pings.
21
26
  */
22
27
  export function loadTags(options = {}) {
23
28
  if (!isConsentInitialized()) {
@@ -29,17 +34,30 @@ export function loadTags(options = {}) {
29
34
  tagState.loaded = true;
30
35
 
31
36
  const { gtmId, clarityId, ahrefsKey } = options;
37
+ const strict = isStrictOptIn();
32
38
 
33
39
  if (ahrefsKey) {
34
- appendScript("https://analytics.ahrefs.com/analytics.js", { "data-key": ahrefsKey });
40
+ const loadAhrefs = () => appendScript("https://analytics.ahrefs.com/analytics.js", { "data-key": ahrefsKey });
41
+ if (strict) {
42
+ whenConsented((consent) => consent.analytics, loadAhrefs);
43
+ } else {
44
+ loadAhrefs();
45
+ }
35
46
  }
36
47
 
37
48
  if (gtmId) {
38
- runAfterFirstInteraction(() => {
39
- window.dataLayer = window.dataLayer || [];
40
- window.dataLayer.push({ "gtm.start": Date.now(), event: "gtm.js" });
41
- appendScript(`https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(gtmId)}`);
42
- });
49
+ const loadTagManager = () =>
50
+ runAfterFirstInteraction(() => {
51
+ window.dataLayer = window.dataLayer || [];
52
+ window.dataLayer.push({ "gtm.start": Date.now(), event: "gtm.js" });
53
+ appendScript(`https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(gtmId)}`);
54
+ });
55
+ if (strict) {
56
+ const ads = isAdStorageEnabled();
57
+ whenConsented((consent) => consent.analytics || (consent.marketing && ads), loadTagManager);
58
+ } else {
59
+ loadTagManager();
60
+ }
43
61
  }
44
62
 
45
63
  if (clarityId) {
@@ -55,6 +73,19 @@ export function loadTags(options = {}) {
55
73
  }
56
74
  }
57
75
 
76
+ function whenConsented(granted, callback) {
77
+ if (granted(getConsent())) {
78
+ callback();
79
+ return;
80
+ }
81
+ const unsubscribe = onConsentChange((consent) => {
82
+ if (granted(consent)) {
83
+ unsubscribe();
84
+ callback();
85
+ }
86
+ });
87
+ }
88
+
58
89
  function runAfterFirstInteraction(callback) {
59
90
  let ran = false;
60
91
  const run = () => {