Initial commit: ATT Consent plugin v1.0.0
Google Consent Mode v2 cookie consent plugin with session attribution preservation, custom script management, and gtag.js/GTM support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
174
public/js/banner.js
Normal file
174
public/js/banner.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* ATT Consent Banner UI
|
||||
*
|
||||
* Handles banner display, preferences modal, focus trapping, and user interactions.
|
||||
* Depends on AttConsent (consent-manager.js) for consent state management.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var banner = document.getElementById('att-cc-banner');
|
||||
var modal = document.getElementById('att-cc-modal');
|
||||
|
||||
if (!banner || !modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If consent already exists, hide the banner immediately.
|
||||
if (AttConsent.hasConsent()) {
|
||||
hideBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Banner buttons ---
|
||||
|
||||
var acceptBtn = banner.querySelector('[data-att-cc="accept-all"]');
|
||||
var rejectBtn = banner.querySelector('[data-att-cc="reject-all"]');
|
||||
var prefsBtn = banner.querySelector('[data-att-cc="preferences"]');
|
||||
|
||||
if (acceptBtn) {
|
||||
acceptBtn.addEventListener('click', function() {
|
||||
AttConsent.update({ functional: true, analytics: true, marketing: true });
|
||||
hideBanner();
|
||||
});
|
||||
}
|
||||
|
||||
if (rejectBtn) {
|
||||
rejectBtn.addEventListener('click', function() {
|
||||
AttConsent.update({ functional: false, analytics: false, marketing: false });
|
||||
hideBanner();
|
||||
});
|
||||
}
|
||||
|
||||
if (prefsBtn) {
|
||||
prefsBtn.addEventListener('click', function() {
|
||||
openModal();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Modal buttons ---
|
||||
|
||||
var saveBtn = modal.querySelector('[data-att-cc="save-preferences"]');
|
||||
var closeBtn = modal.querySelector('[data-att-cc="close-modal"]');
|
||||
var overlay = modal.querySelector('.att-cc-modal__overlay');
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', function() {
|
||||
var categories = {
|
||||
functional: document.getElementById('att-cc-functional').checked,
|
||||
analytics: document.getElementById('att-cc-analytics').checked,
|
||||
marketing: document.getElementById('att-cc-marketing').checked
|
||||
};
|
||||
AttConsent.update(categories);
|
||||
closeModal();
|
||||
hideBanner();
|
||||
});
|
||||
}
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function() {
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
overlay.addEventListener('click', function() {
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
function hideBanner() {
|
||||
banner.setAttribute('aria-hidden', 'true');
|
||||
banner.classList.add('att-cc-hidden');
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
// Pre-fill checkboxes with existing consent if available.
|
||||
var existing = AttConsent.getConsent();
|
||||
if (existing) {
|
||||
setCheckbox('att-cc-functional', existing.functional);
|
||||
setCheckbox('att-cc-analytics', existing.analytics);
|
||||
setCheckbox('att-cc-marketing', existing.marketing);
|
||||
}
|
||||
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
modal.classList.add('att-cc-modal--open');
|
||||
document.body.classList.add('att-cc-modal-active');
|
||||
trapFocus(modal);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
modal.classList.remove('att-cc-modal--open');
|
||||
document.body.classList.remove('att-cc-modal-active');
|
||||
|
||||
// Return focus to the preferences button.
|
||||
if (prefsBtn) {
|
||||
prefsBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function setCheckbox(id, value) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.checked = !!value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trap keyboard focus within an element.
|
||||
*
|
||||
* @param {HTMLElement} element The container to trap focus in.
|
||||
*/
|
||||
function trapFocus(element) {
|
||||
var focusable = element.querySelectorAll(
|
||||
'button, [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (focusable.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
|
||||
first.focus();
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
element.removeEventListener('keydown', handleKeydown);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
last.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
first.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener('keydown', handleKeydown);
|
||||
}
|
||||
|
||||
// Listen for programmatic consent revocation to re-show banner.
|
||||
document.addEventListener('att_consent_update', function(e) {
|
||||
var cats = e.detail;
|
||||
if (!cats.functional && !cats.analytics && !cats.marketing) {
|
||||
// All denied - consent was effectively revoked, but don't re-show
|
||||
// banner immediately (user just clicked reject).
|
||||
}
|
||||
});
|
||||
})();
|
||||
259
public/js/consent-manager.js
Normal file
259
public/js/consent-manager.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 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 (Object.keys(params).length > 0) {
|
||||
gtag('set', params);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - don't block consent.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store consent choices in a first-party cookie.
|
||||
*
|
||||
* @param {Object} categories Consent categories.
|
||||
*/
|
||||
function setConsentCookie(categories) {
|
||||
var days = config.consent_expiry || 365;
|
||||
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.
|
||||
*
|
||||
* @param {Object} categories Consent categories.
|
||||
*/
|
||||
function executeConsentedScripts(categories) {
|
||||
var scripts = config.scripts || {};
|
||||
|
||||
Object.keys(categories).forEach(function(cat) {
|
||||
if (!categories[cat] || !scripts[cat]) {
|
||||
return;
|
||||
}
|
||||
|
||||
['head', 'footer'].forEach(function(placement) {
|
||||
if (!scripts[cat][placement] || !scripts[cat][placement].length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = placement === 'head' ? document.head : document.body;
|
||||
|
||||
scripts[cat][placement].forEach(function(snippet) {
|
||||
injectHTML(snippet, container);
|
||||
});
|
||||
|
||||
// Clear to prevent double execution.
|
||||
scripts[cat][placement] = [];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user