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:
2026-02-16 05:45:42 +00:00
commit 6bf9f553de
19 changed files with 2726 additions and 0 deletions

272
public/css/banner.css Normal file
View File

@@ -0,0 +1,272 @@
/* ATT Consent - Banner & Modal Styles */
/* Reset box-sizing for our elements */
#att-cc-banner,
#att-cc-banner *,
#att-cc-modal,
#att-cc-modal * {
box-sizing: border-box;
}
/* --- Banner --- */
#att-cc-banner {
position: fixed;
left: 0;
right: 0;
z-index: 999999;
background: var(--att-cc-bg, #1a1a2e);
color: var(--att-cc-text, #ffffff);
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
line-height: 1.5;
transition: transform 0.3s ease, opacity 0.3s ease;
}
#att-cc-banner.att-cc-hidden {
display: none;
}
/* Position variants */
#att-cc-banner.att-cc-position-bottom {
bottom: 0;
}
#att-cc-banner.att-cc-position-top {
top: 0;
}
#att-cc-banner.att-cc-position-center {
top: 50%;
left: 50%;
right: auto;
transform: translate(-50%, -50%);
max-width: 600px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.att-cc-banner__inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.att-cc-position-center .att-cc-banner__inner {
flex-direction: column;
align-items: stretch;
}
.att-cc-banner__content {
flex: 1;
min-width: 280px;
}
.att-cc-banner__heading {
font-size: 16px;
font-weight: 700;
margin: 0 0 6px 0;
color: inherit;
}
.att-cc-banner__message {
margin: 0;
opacity: 0.9;
font-size: 13px;
}
.att-cc-banner__message a {
color: inherit;
text-decoration: underline;
}
.att-cc-banner__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.att-cc-position-center .att-cc-banner__actions {
justify-content: stretch;
}
.att-cc-position-center .att-cc-btn {
flex: 1;
}
/* --- Buttons --- */
.att-cc-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.2s, box-shadow 0.2s;
line-height: 1.4;
font-family: inherit;
}
.att-cc-btn:hover {
opacity: 0.9;
}
.att-cc-btn:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
.att-cc-btn--accept {
background: var(--att-cc-accept-bg, #4CAF50);
color: var(--att-cc-accept-text, #ffffff);
}
.att-cc-btn--reject {
background: var(--att-cc-reject-bg, #555555);
color: var(--att-cc-reject-text, #ffffff);
}
.att-cc-btn--preferences {
background: var(--att-cc-pref-bg, transparent);
color: var(--att-cc-pref-text, #ffffff);
border: 1px solid currentColor;
}
/* --- Preferences Modal --- */
#att-cc-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000000;
display: none;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
line-height: 1.5;
}
#att-cc-modal.att-cc-modal--open {
display: flex;
}
.att-cc-modal__overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
}
.att-cc-modal__dialog {
position: relative;
background: var(--att-cc-bg, #1a1a2e);
color: var(--att-cc-text, #ffffff);
border-radius: 12px;
padding: 28px;
max-width: 540px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.att-cc-modal__heading {
font-size: 18px;
font-weight: 700;
margin: 0 0 20px 0;
color: inherit;
}
.att-cc-modal__category {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 14px 0;
}
.att-cc-modal__category:last-of-type {
border-bottom: none;
}
.att-cc-modal__cat-header {
display: flex;
align-items: center;
}
.att-cc-modal__cat-header label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: inherit;
width: 100%;
}
.att-cc-modal__cat-header input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--att-cc-accept-bg, #4CAF50);
}
.att-cc-always-on {
margin-left: auto;
font-size: 11px;
opacity: 0.6;
font-weight: 400;
}
.att-cc-modal__cat-desc {
margin: 6px 0 0 26px;
font-size: 12px;
opacity: 0.75;
}
.att-cc-modal__actions {
margin-top: 20px;
display: flex;
gap: 8px;
}
.att-cc-modal__actions .att-cc-btn {
flex: 1;
}
/* Prevent body scroll when modal is open */
body.att-cc-modal-active {
overflow: hidden;
}
/* --- Responsive --- */
@media (max-width: 600px) {
.att-cc-banner__inner {
flex-direction: column;
align-items: stretch;
}
.att-cc-banner__actions {
flex-direction: column;
}
.att-cc-btn {
width: 100%;
text-align: center;
}
.att-cc-modal__dialog {
width: 95%;
padding: 20px;
}
}

174
public/js/banner.js Normal file
View 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).
}
});
})();

View 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
};
})();