1/* 2 * LMG Consent â client logic. 3 * 4 * Responsibilities: 5 * 1. Read/write consent state (cookie + localStorage, versioned). 6 * 2. Honor Global Privacy Control (GPC) as a browser-level opt-out (CPRA). 7 * 3. Show the banner on first visit; hide once a decision has been made. 8 * 4. Drive the customize modal (render categories, apply toggle state). 9 * 5. Push Google Consent Mode v2 updates (gtag('consent','update',...)). 10 * 6. Log each decision to the REST endpoint (if logging enabled). 11 * 7. Dispatch `m_consent:change` (+ `lmg_consent:change`) for integrators. 12 */ 13(function () { 14 'use strict'; 15 16 if (typeof window.mConsent !== 'object') return; 17 var CFG = window.mConsent; 18 19 var GCM_MAP = { 20 functional: ['functionality_storage', 'personalization_storage'], 21 analytics: ['analytics_storage'], 22 marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'] 23 }; 24 25 /* ---------- Global Privacy Control ---------- */ 26 // CPRA treats a GPC signal as a valid opt-out of sale/sharing. We map it to 27 // a reject of all non-essential categories. 28 function gpcEnabled() { 29 try { 30 if (navigator && navigator.globalPrivacyControl === true) return true; 31 if (window.globalPrivacyControl === true) return true; 32 } catch (e) {} 33 return false; 34 } 35 36 /* ---------- storage helpers ---------- */ 37 function readCookie(name) { 38 var match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.$?*|{}()[\]\\\/+^]/g, '\\$&') + '=([^;]*)')); 39 return match ? decodeURIComponent(match[1]) : null; 40 } 41 function writeCookie(name, value, maxAgeSeconds) { 42 var parts = [ 43 name + '=' + encodeURIComponent(value), 44 'path=/', 45 'max-age=' + Math.floor(maxAgeSeconds), 46 'SameSite=Lax' 47 ]; 48 if (location.protocol === 'https:') parts.push('Secure'); 49 document.cookie = parts.join('; '); 50 } 51 52 function readState() { 53 var raw = readCookie(CFG.cookie) || localStorage.getItem(CFG.cookie); 54 if (!raw) return null; 55 try { 56 var s = JSON.parse(raw); 57 if (!s || s.schema !== CFG.schema) return null; 58 return s; 59 } catch (e) { return null; } 60 } 61 function writeState(state) { 62 var json = JSON.stringify(state); 63 writeCookie(CFG.cookie, json, CFG.cookieTTL); 64 try { localStorage.setItem(CFG.cookie, json); } catch (e) {} 65 } 66 67 /* ---------- id generation ---------- */ 68 function newConsentId() { 69 if (window.crypto && crypto.randomUUID) return crypto.randomUUID(); 70 return 'mc-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); 71 } 72 73 /* ---------- gtag bridge ---------- */ 74 function pushConsent(categories) { 75 if (typeof window.gtag !== 'function') return; 76 var update = {}; 77 Object.keys(GCM_MAP).forEach(function (cat) { 78 var val = categories[cat] ? 'granted' : 'denied'; 79 GCM_MAP[cat].forEach(function (key) { update[key] = val; }); 80 }); 81 window.gtag('consent', 'update', update); 82 } 83 84 /* ---------- reactivate CIPA-blocked loaders ---------- */ 85 // Server-side, LMG_Consent_Blocker rewrote Google loader <script> tags to 86 // type="text/plain" so they neither fetched nor ran. Once the matching 87 // category is granted, clone each into a live <script> to execute it. 88 // Changing an existing script's type does NOT run it â a fresh node must be 89 // inserted (HTML spec: "already started" scripts never re-execute). 90 function activateBlockedScripts(categories) { 91 if (!categories) return; 92 var blocked = document.querySelectorAll('script[type="text/plain"][data-lmg-consent]'); 93 for (var i = 0; i < blocked.length; i++) { 94 var old = blocked[i]; 95 var cat = old.getAttribute('data-lmg-consent'); 96 if (!categories[cat]) continue; // not granted 97 if (old.getAttribute('data-lmg-activated') === '1') continue; // idempotent 98 old.setAttribute('data-lmg-activated', '1'); 99 100 var s = document.createElement('script'); 101 for (var a = 0; a < old.attributes.length; a++) { 102 var attr = old.attributes[a]; 103 if (attr.name === 'type' || 104 attr.name === 'data-lmg-consent' || 105 attr.name === 'data-lmg-activated') continue; 106 s.setAttribute(attr.name, attr.value); 107 } 108 s.type = 'text/javascript'; 109 if (old.textContent) s.text = old.textContent; 110 (old.parentNode || document.head || document.documentElement).insertBefore(s, old); 111 } 112 } 113 114 /* ---------- REST logging ---------- */ 115 function log(action, categories, consentId) { 116 if (!CFG.logEnabled) return; 117 try { 118 fetch(CFG.restUrl, { 119 method: 'POST', 120 credentials: 'same-origin', 121 headers: { 122 'Content-Type': 'application/json', 123 'X-WP-Nonce': CFG.restNonce 124 }, 125 body: JSON.stringify({ 126 action: action, 127 categories: categories, 128 consent_id: consentId, 129 schema: CFG.schema 130 }), 131 keepalive: true 132 }).catch(function(){}); 133 } catch (e) {} 134 } 135 136 /* ---------- DOM ---------- */ 137 var root; 138 139 function show(el) { if (el) el.removeAttribute('hidden'); } 140 function hide(el) { if (el) el.setAttribute('hidden', ''); } 141 142 /* ---------- floating preferences button ---------- */ 143 // Rendered hidden by PHP; reveal it whenever the first-visit banner is NOT on 144 // screen so the two never overlap. It always reopens the modal via the 145 // existing [data-m-consent-open] handler. 146 function floatBtn() { return document.querySelector('[data-m-consent-float]'); } 147 function showFloat() { show(floatBtn()); } 148 function hideFloat() { hide(floatBtn()); } 149 150 function buildCategoryRow(cat) { 151 var row = document.createElement('div'); 152 row.className = 'm-consent__cat'; 153 154 var main = document.createElement('div'); 155 main.className = 'm-consent__cat-main'; 156 157 var h = document.createElement('h3'); 158 h.className = 'm-consent__cat-title'; 159 h.textContent = cat.title; 160 161 var p = document.createElement('p'); 162 p.className = 'm-consent__cat-desc'; 163 p.textContent = cat.desc; 164 165 main.appendChild(h); 166 main.appendChild(p); 167 168 var label = document.createElement('label'); 169 label.className = 'm-consent__toggle'; 170 171 var input = document.createElement('input'); 172 input.type = 'checkbox'; 173 input.setAttribute('data-m-consent-cat', cat.key); 174 if (cat.required) { 175 input.checked = true; 176 input.disabled = true; 177 } 178 179 var slider = document.createElement('span'); 180 slider.className = 'm-consent__toggle-slider'; 181 slider.setAttribute('aria-hidden', 'true'); 182 183 label.appendChild(input); 184 label.appendChild(slider); 185 186 row.appendChild(main); 187 row.appendChild(label); 188 return row; 189 } 190 191 function render() { 192 root = document.getElementById('m-consent'); 193 if (!root) return; 194 195 // Re-parent to <body> so the fixed banner/modal escape any transformed or 196 // clipped ancestor. CSS `position: fixed` resolves against the nearest 197 // ancestor with a transform/filter/perspective (not the viewport), so a 198 // smooth-scroll wrapper like GSAP ScrollSmoother's `.smooth-content` 199 // (which gets a translate3d transform) would otherwise pin the banner 200 // inside the scrolling content instead of to the screen. 201 if (document.body && root.parentNode !== document.body) { 202 document.body.appendChild(root); 203 } 204 205 var body = root.querySelector('[data-m-consent-categories]'); 206 if (body) { 207 while (body.firstChild) body.removeChild(body.firstChild);
208 CFG.categories.forEach(function (cat) { 209 if (!cat.enabled) return; 210 body.appendChild(buildCategoryRow(cat)); 211 }); 212 } 213 214 wireEvents(); 215 216 var state = readState(); 217 if (!state) { 218 // No prior decision. If the browser sends a GPC opt-out, honor it 219 // automatically and don't force interaction with the banner. 220 if (gpcEnabled()) { 221 save('gpc', rejectAll()); 222 return; 223 } 224 show(root); 225 hideFloat(); // banner is on screen â don't overlap it 226 applyTogglesFromCategories(defaultCategories()); 227 } else { 228 // Already decided â keep hidden but apply stored choices to gtag and 229 // reactivate any server-blocked loaders the visitor previously allowed. 230 pushConsent(state.categories); 231 activateBlockedScripts(state.categories); 232 showFloat(); // persistent withdrawal control 233 } 234 } 235 236 function defaultCategories() { 237 var c = {}; 238 CFG.categories.forEach(function (cat) { c[cat.key] = cat.required === true; }); 239 return c; 240 } 241 function fullAccept() { 242 var c = {}; 243 CFG.categories.forEach(function (cat) { c[cat.key] = true; }); 244 return c; 245 } 246 function rejectAll() { 247 return defaultCategories(); 248 } 249 function readToggles() { 250 var c = defaultCategories(); 251 root.querySelectorAll('[data-m-consent-cat]').forEach(function (input) { 252 c[input.getAttribute('data-m-consent-cat')] = !!input.checked; 253 }); 254 return c; 255 } 256 function applyTogglesFromCategories(cats) { 257 root.querySelectorAll('[data-m-consent-cat]').forEach(function (input) { 258 var key = input.getAttribute('data-m-consent-cat'); 259 if (!input.disabled) input.checked = !!cats[key]; 260 }); 261 } 262 263 function save(action, categories) { 264 var prev = readState(); 265 var consentId = (prev && prev.consent_id) || newConsentId(); 266 var state = { 267 schema: CFG.schema, 268 consent_id: consentId, 269 ts: new Date().toISOString(), 270 action: action, 271 categories: categories 272 }; 273 writeState(state); 274 pushConsent(categories); 275 activateBlockedScripts(categories); 276 log(action, categories, consentId); 277 document.dispatchEvent(new CustomEvent('m_consent:change', { detail: state })); 278 document.dispatchEvent(new CustomEvent('lmg_consent:change', { detail: state })); 279 closeBanner(); 280 closeModal(); 281 showFloat(); // a decision exists now â expose the persistent control 282 } 283 284 function openModal() { 285 var modal = root.querySelector('[data-m-consent-modal]'); 286 var state = readState(); 287 applyTogglesFromCategories(state ? state.categories : defaultCategories()); 288 show(root); 289 show(modal); 290 var first = modal.querySelector('input:not([disabled]), button'); 291 if (first) first.focus(); 292 } 293 function closeModal() { 294 var modal = root.querySelector('[data-m-consent-modal]'); 295 hide(modal); 296 if (readState()) hide(root); 297 } 298 function closeBanner() { 299 hide(root); 300 } 301 302 function wireEvents() { 303 root.addEventListener('click', function (e) { 304 var t = e.target.closest('[data-m-consent-accept], [data-m-consent-reject], [data-m-consent-customize], [data-m-consent-save], [data-m-consent-close]'); 305 if (!t) return; 306 if (t.hasAttribute('data-m-consent-accept')) return save('accept_all', fullAccept()); 307 if (t.hasAttribute('data-m-consent-reject')) return save('reject_all', rejectAll()); 308 if (t.hasAttribute('data-m-consent-customize')) return openModal(); 309 if (t.hasAttribute('data-m-consent-save')) return save('custom', readToggles()); 310 if (t.hasAttribute('data-m-consent-close')) return closeModal(); 311 }); 312 313 document.addEventListener('click', function (e) { 314 var t = e.target.closest('[data-m-consent-open]'); 315 if (t) { e.preventDefault(); openModal(); } 316 }); 317 318 document.addEventListener('keydown', function (e) { 319 if (e.key === 'Escape') closeModal(); 320 }); 321 } 322 323 if (document.readyState === 'loading') { 324 document.addEventListener('DOMContentLoaded', render); 325 } else { 326 render(); 327 } 328})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.