Files
att-consent/public/js/consent-manager.js
Steve Hanlon cde149467a Use 3-day cookie expiry when analytics consent is denied (v1.3.2)
Denied consent was being stored with the full 365-day expiry, preventing
the banner from re-appearing for users who initially declined. Now when
analytics is denied the cookie expires in 3 days so those users are
re-prompted automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 07:57:11 +01:00

321 lines
9.5 KiB
JavaScript

/**
* ATT Consent Manager
*
* Handles Google Consent Mode v2 updates, session attribution replay,
* custom script injection, and consent state persistence.
*
* Phase 1 (consent defaults + attribution capture) runs inline in <head>.
* This file handles Phase 2 (consent updates) and Phase 3 (basic mode tag loading).
*/
var AttConsent = (function() {
'use strict';
var gtag = window.attCCGtag;
var config = window.attCCConfig || {};
/**
* Update consent state.
*
* @param {Object} categories - { functional: bool, analytics: bool, marketing: bool }
*/
function update(categories) {
// Step 1: Replay stored attribution BEFORE consent update.
// This ensures GA4 session_start gets correct source/medium.
if (!sessionStorage.getItem('att_cc_consent_given')) {
replayAttribution();
sessionStorage.setItem('att_cc_consent_given', '1');
}
// Step 2: Send consent update to Google.
gtag('consent', 'update', {
ad_storage: categories.marketing ? 'granted' : 'denied',
analytics_storage: categories.analytics ? 'granted' : 'denied',
ad_user_data: categories.marketing ? 'granted' : 'denied',
ad_personalization: categories.marketing ? 'granted' : 'denied',
functionality_storage: categories.functional ? 'granted' : 'denied',
personalization_storage: categories.functional ? 'granted' : 'denied'
});
// Step 3: Store consent in first-party cookie.
setConsentCookie(categories);
// Step 4: In basic mode, inject tracking script if analytics or marketing consented.
if (config.consent_mode === 'basic' && (categories.analytics || categories.marketing)) {
injectTrackingScript();
}
// Step 5: Execute custom scripts for consented categories.
executeConsentedScripts(categories);
// Step 6: Fire custom event for theme/plugin integration.
try {
document.dispatchEvent(new CustomEvent('att_consent_update', {
detail: categories
}));
} catch (e) {
// IE11 fallback (if ever needed).
var evt = document.createEvent('CustomEvent');
evt.initCustomEvent('att_consent_update', true, true, categories);
document.dispatchEvent(evt);
}
// Step 7: WP Consent API integration.
if (typeof wp_set_consent === 'function') {
wp_set_consent('functional', categories.functional ? 'allow' : 'deny');
wp_set_consent('statistics', categories.analytics ? 'allow' : 'deny');
wp_set_consent('marketing', categories.marketing ? 'allow' : 'deny');
}
}
/**
* Replay stored attribution data via gtag('set').
* Called BEFORE the consent update so GA4 picks up the original
* campaign parameters on the session_start event.
*/
function replayAttribution() {
var stored = sessionStorage.getItem('att_cc_attr');
if (!stored) {
return;
}
try {
var a = JSON.parse(stored);
var params = {};
if (a.us) { params.campaign_source = a.us; }
if (a.um) { params.campaign_medium = a.um; }
if (a.uc) { params.campaign_name = a.uc; }
if (a.ut) { params.campaign_term = a.ut; }
if (a.uo) { params.campaign_content = a.uo; }
// If no UTMs/gclid, derive source/medium from referrer so GA4
// doesn't report (not set). Important for cross-domain tracking
// where the _gl param may have been lost before consent was granted.
if (!a.us && !a.gc && a.r) {
try {
var ref = new URL(a.r);
var linkerDomains = config.linker_domains || [];
var isLinkerDomain = linkerDomains.some(function(d) {
return ref.hostname === d || ref.hostname.endsWith('.' + d);
});
// Only set referral attribution if referrer is from a different
// host and NOT a configured cross-domain linker domain (those
// should be seamless continuations, not referrals).
if (ref.hostname !== window.location.hostname && !isLinkerDomain) {
params.campaign_source = ref.hostname;
params.campaign_medium = 'referral';
}
} catch (e) {
// Invalid referrer URL - skip.
}
}
if (Object.keys(params).length > 0) {
gtag('set', params);
}
} catch (e) {
// Silent failure - don't block consent.
}
}
/**
* Store consent choices in a first-party cookie.
* If analytics is denied, use a short 3-day expiry so the banner re-appears
* automatically rather than locking a denial in for a year.
*
* @param {Object} categories Consent categories.
*/
function setConsentCookie(categories) {
var days = categories.analytics ? (config.consent_expiry || 365) : 3;
var expires = new Date(Date.now() + days * 864e5).toUTCString();
var value = encodeURIComponent(JSON.stringify(categories));
var cookie = 'att_cc_consent=' + value +
'; expires=' + expires +
'; path=/; SameSite=Lax';
if (location.protocol === 'https:') {
cookie += '; Secure';
}
document.cookie = cookie;
}
/**
* Inject the tracking script in basic mode (deferred until consent).
*/
function injectTrackingScript() {
if (!config.tracking_snippet || window.attCCTrackingLoaded) {
return;
}
window.attCCTrackingLoaded = true;
injectHTML(config.tracking_snippet, document.head);
}
/**
* Execute custom scripts for the consented categories.
* Scans the DOM for inert type="text/plain" script tags and template tags
* with matching data-att-cc-category attributes, then activates them.
*
* @param {Object} categories Consent categories.
*/
function executeConsentedScripts(categories) {
var cats = Object.keys(categories).filter(function(cat) {
return categories[cat];
});
if (!cats.length) return;
var selectors = [];
cats.forEach(function(cat) {
selectors.push('script[type="text/plain"][data-att-cc-category="' + cat + '"]');
selectors.push('template[data-att-cc-category="' + cat + '"][data-att-cc-type="html"]');
});
var elements = document.querySelectorAll(selectors.join(','));
Array.prototype.slice.call(elements).forEach(function(el) {
if (el.nodeName === 'SCRIPT') {
activateScript(el);
} else if (el.nodeName === 'TEMPLATE') {
activateTemplate(el);
}
});
}
/**
* Activate an inert script element by creating a fresh <script> tag.
* Copies all attributes except type and data-att-cc-category,
* then removes the original to prevent double execution.
*
* @param {HTMLScriptElement} blocked The inert script element.
*/
function activateScript(blocked) {
var script = document.createElement('script');
Array.prototype.slice.call(blocked.attributes).forEach(function(attr) {
if (attr.name === 'type' || attr.name === 'data-att-cc-category') {
return;
}
script.setAttribute(attr.name, attr.value);
});
if (!blocked.src) {
script.textContent = blocked.textContent;
}
blocked.parentNode.insertBefore(script, blocked);
blocked.parentNode.removeChild(blocked);
}
/**
* Activate an inert template element by injecting its HTML content,
* then removing the template to prevent double execution.
*
* @param {HTMLTemplateElement} tmpl The template element.
*/
function activateTemplate(tmpl) {
var container = tmpl.parentNode;
injectHTML(tmpl.innerHTML, container);
container.removeChild(tmpl);
}
/**
* Inject an HTML string (potentially containing <script> tags) into a container.
* Script tags inserted via innerHTML don't execute, so we create fresh elements.
*
* @param {string} html The HTML/script string to inject.
* @param {HTMLElement} container Target container element.
*/
function injectHTML(html, container) {
var temp = document.createElement('div');
temp.innerHTML = html;
var nodes = Array.prototype.slice.call(temp.childNodes);
nodes.forEach(function(node) {
if (node.nodeType === Node.TEXT_NODE) {
// Skip whitespace-only text nodes.
if (node.textContent.trim()) {
container.appendChild(node.cloneNode(true));
}
return;
}
if (node.nodeName === 'SCRIPT') {
var script = document.createElement('script');
// Copy all attributes.
Array.prototype.slice.call(node.attributes).forEach(function(attr) {
script.setAttribute(attr.name, attr.value);
});
if (!node.src) {
script.textContent = node.textContent;
}
container.appendChild(script);
} else {
container.appendChild(node.cloneNode(true));
}
});
}
/**
* Read the current consent state from the cookie.
*
* @return {Object|null} The consent categories or null if not set.
*/
function getConsent() {
var match = document.cookie.match(/(?:^|; )att_cc_consent=([^;]*)/);
if (match) {
try {
return JSON.parse(decodeURIComponent(match[1]));
} catch (e) {
return null;
}
}
return null;
}
/**
* Check whether the user has already given consent.
*
* @return {boolean}
*/
function hasConsent() {
return !!getConsent();
}
/**
* Revoke consent (clear cookie, reset to denied).
*/
function revoke() {
document.cookie = 'att_cc_consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax';
sessionStorage.removeItem('att_cc_consent_given');
window.attCCHasConsent = false;
gtag('consent', 'update', {
ad_storage: 'denied',
analytics_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
functionality_storage: 'denied',
personalization_storage: 'denied'
});
}
// If consent already exists (return visitor), execute scripts for consented categories.
if (window.attCCHasConsent) {
var existing = getConsent();
if (existing) {
executeConsentedScripts(existing);
}
}
// Public API.
return {
update: update,
getConsent: getConsent,
hasConsent: hasConsent,
revoke: revoke
};
})();