1/** 2 * UMBC Cookie Consent Banner 3 * 4 * Two models, chosen by window.umbcUseNewConsent (set in header.php): 5 * 6 * LEGACY (live): load nothing until "Accept All". Cookie: all | essential. 7 * TIERED (dev/test): three levels â 8 * 1. Essential â always on, not gated. 9 * 2. Analytics â ON by default (US); first-party. â window.umbcConsentGranted 10 * 3. Advertising â opt-out (US) / opt-in (EU) / off on GPC. â window.umbcAdConsent 11 * Cookie: all | analytics | essential. 12 * Region (window.umbcRegion) is decided in the browser by prefs-head.php, not 13 * PHP. EU: nothing on until opted in; choices also write umbc_consent_meta. 14 * 15 * Effective state is pushed to the dataLayer as `umbc_consent` for GTM (and, 16 * via the server container, for ad-tag gating â see migration plan §12.3). 17 * 18 * See PLAN.md for the human-readable description of all three levels. 19 */ 20 21(function () { 22 'use strict'; 23 24 var CONFIG = { 25 cookieName: 'umbc_cookie_consent', 26 cookieDays: 180, 27 cookiePath: '/', 28 trackerFnName: 'umbcLoadTracking', // GTM loader defined in header.php 29 metaCookie: 'umbc_consent_meta', // EU only: "<policy version>|<ISO date>" record of the choice 30 }; 31 32 // IMPORTANT: read at boot() time, NOT here. cookie-banner.js loads in the 33 // <head> and executes BEFORE header.php's inline script sets 34 // window.umbcUseNewConsent (which prints after wp_head()). Evaluating these 35 // at module load would freeze NEW_MODEL to false. boot() runs at 36 // DOMContentLoaded, by which point the flags are set. 37 var NEW_MODEL = false; 38 var REGION = 'us'; 39 var GPC = false; 40 41 /* ---- cookie helpers ---- */ 42 function getCookie(name) { 43 var m = document.cookie.match( 44 new RegExp('(?:^|;)\\s*' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)') 45 ); 46 return m ? decodeURIComponent(m[1]) : null; 47 } 48 function setCookie(name, value, days, path) { 49 var exp = ''; 50 if (days) { var d = new Date(); d.setTime(d.getTime() + days * 86400000); exp = '; expires=' + d.toUTCString(); } 51 document.cookie = name + '=' + encodeURIComponent(value) + exp + '; path=' + (path || '/') + '; SameSite=Lax'; 52 } 53 54 /* ---- consent state ---- */ 55 // Resolve {analytics, advertising} from a saved cookie value + model/region/GPC. 56 function computeState(saved) { 57 var analytics, advertising; 58 if (saved === 'all') { analytics = true; advertising = true; } 59 else if (saved === 'analytics') { analytics = true; advertising = false; } 60 else if (saved === 'essential') { analytics = false; advertising = false; } 61 else { // no explicit choice yet 62 if (!NEW_MODEL) { analytics = false; advertising = false; } // legacy: nothing until accept 63 else if (REGION === 'eu') { analytics = false; advertising = false; } // EU: opt-in pending 64 else { analytics = true; advertising = true; } // US: default-on 65 } 66 if (GPC) { advertising = false; } // honor Global Privacy Control as an ad opt-out, always 67 return { analytics: analytics, advertising: advertising }; 68 } 69 70 // The saved choice that counts. Outside the EU it is just the cookie. In the 71 // EU a choice only counts if it has a matching consent record (written by 72 // saveAndApply below) â a bare cookie from the old worldwide "Got it" banner 73 // or an older policy version is not valid consent. Mirrors prefs-head.php. 74 function savedChoice() { 75 var saved = getCookie(CONFIG.cookieName); 76 if (REGION === 'eu') { 77 var meta = getCookie(CONFIG.metaCookie) || ''; 78 if (meta.split('|')[0] !== (window.umbcConsentPolicy || 'v1')) { return null; } 79 } 80 return saved; 81 } 82 83 function pushConsent(state) { 84 window.dataLayer = window.dataLayer || []; 85 window.dataLayer.push({ 86 event: 'umbc_consent_update', 87 umbc_consent: { 88 analytics: state.analytics, 89 advertising: state.advertising, 90 region: REGION, 91 gpc: GPC 92 } 93 }); 94 } 95 96 /* ---- mid-page revoke ------------------------------------------------- 97 * GTM's blocking trigger stops NEW ad tags from firing the moment consent 98 * flips, but the platform libraries that already loaded keep beaconing on 99 * their own. Measured 2026-09-17: 5 further requests to facebook.com/tr and 100 * px.ads.linkedin.com in the 9 seconds after an opt-out. The
100se calls tell 101 * the loaded libraries to stop, so the opt-out takes effect on THIS page 102 * rather than only on the next navigation. 103 * -------------------------------------------------------------------- */ 104 105 // prefs-head.php defines window.gtag and pushes the Consent Mode default 106 // before GTM loads; this shim is a fallback for templates without it. 107 // Steers Google tags only. 108 function gtagShim() { 109 window.dataLayer = window.dataLayer || []; 110 if (typeof window.gtag !== 'function') { 111 window.gtag = function () { window.dataLayer.push(arguments); }; 112 } 113 return window.gtag; 114 } 115 116 function revokeAdvertising() { 117 try { 118 gtagShim()('consent', 'update', { 119 ad_storage: 'denied', 120 ad_user_data: 'denied', 121 ad_personalization: 'denied' 122 }); 123 } catch (e) {} 124 125 // Meta: this is the call that actually stops the autonomous sending. 126 try { if (typeof window.fbq === 'function') window.fbq('consent', 'revoke'); } catch (e) {} 127 128 // Bing UET. 129 try { 130 if (window.uetq && typeof window.uetq.push === 'function') { 131 window.uetq.push('consent', 'update', { ad_storage: 'denied' }); 132 } 133 } catch (e) {} 134 135 // LinkedIn publishes no revoke API, and Reddit's is unconfirmed. Replacing 136 // the globals with no-ops kills anything still queued or on a timer. 137 // Requests already in flight will still complete â that residual is 138 // documented rather than claimed to be zero. 139 try { window.lintrk = function () {}; window._linkedin_data_partner_ids = []; } catch (e) {} 140 try { if (window.rdt) { window.rdt = function () {}; } } catch (e) {} 141 142 clearAdCookies(); 143 } 144 145 // First-party advertising identifiers on our own domain. These are what let 146 // an ad platform connect this visitor's later actions back to an ad they 147 // clicked (e.g. _gcl_aw holds the Google Ads click ID). An opt-out means those 148 // identifiers go. Deliberately NOT cleared: _ga / _ga_* (analytics identity, 149 // kept so opt-outs don't show up as new users) and FPID / FPLC (server-set, 150 // likely HttpOnly, so unreachable from script anyway). Cookies on the 151 // platforms' own domains (.facebook.com, .linkedin.com) are not ours to delete. 152 var AD_COOKIE_PREFIXES = ['_gcl_', '_uet', '_rdt_']; 153 var AD_COOKIES = ['_fbp', '_fbc', 'li_fat_id']; 154 155 function clearAdCookies() { 156 try { 157 var names = document.cookie.split(';').map(function (c) { return c.split('=')[0].trim(); }); 158 // A cookie can only be deleted with the exact domain it was set on, and 159 // platforms differ (.umbc.edu on live, .dev-umbc.pantheonsite.io on dev). 160 // Try the host-only copy and every parent level of the hostname; the 161 // browser ignores any level it would never have accepted (e.g. .io). 162 var parts = window.location.hostname.split('.'); 163 var domains = []; 164 for (var i = 0; i < parts.length - 1; i++) { domains.push('.' + parts.slice(i).join('.')); } 165 names.forEach(function (name) { 166 var isAd = AD_COOKIES.indexOf(name) !== -1 || 167 AD_COOKIE_PREFIXES.some(function (p) { return name.indexOf(p) === 0; }); 168 if (!isAd) return; 169 var expire = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; 170 document.cookie = expire; // host-only copy 171 domains.forEach(function (d) { document.cookie = expire + '; domain=' + d; }); 172 }); 173 } catch (e) {} 174 175 // The platforms also keep copies of their IDs in localStorage (found by 176 // live probe 2026-09-23): Bing's _uetvid/_uetsid (+ _exp twins), Google's 177 // _gcl_ls click store, LinkedIn's li_adsId. Bing rebuilds its cookie from 178 // these, so without clearing them an opt-out would not reset ad identity. 179 try { 180 var AD_STORAGE = /^_uet|^_gcl_|^_fbp|^_fbc|^_rdt_|^li_adsId$|^li_fat_id$/; 181 Object.keys(window.localStorage).forEach(function (k) { 182 if (AD_STORAGE.test(k)) window.localStorage.removeItem(k); 183 }); 184 } catch (e) {} 185 } 186 187 function grantAdvertising() { 188 try { 189 gtagShim()('consent', 'update', { 190 ad_storage: 'granted', 191 ad_user_data: 'granted', 192 ad_personalization: 'granted' 193 }); 194 } catch (e) {} 195 try { if (typeof window.fbq === 'function') window.fbq('consent', 'grant'); } catch (e) {} 196 try { 197 if (window.uetq && typeof window.uetq.push === 'function') { 198 window.uetq.push('consent', 'update', { ad_storage: 'granted' }); 199 } 200 } catch (e) {} 201 // LinkedIn and Reddit were stubbed above and cannot be restored in place; 202 // they resume on the next page load. Re-granting mid-page is rare. 203 } 204 205 function denyAnalyticsStorage() { 206 // Note: this makes GA4 send cookieless pings, it does not stop it. Truly 207 // halting GA4 mid-page needs a blocking trigger on the GA4 tags in GTM, 208 // which is a container change and is tracked separately. 209 try { gtagShim()('consent', 'update', { analytics_storage: 'denied' }); } catch (e) {} 210 } 211 212 // The last state we applied, so we can tell a genuine mid-page flip from the 213 // initial application on page load (where nothing has loaded yet to revoke). 214 var appliedState = null; 215 216 // Apply a state: set the gate flags, tell GTM, push the dataLayer signal. 217 function applyState(state) { 218 var prev = appliedState; 219 appliedState = state; 220 221 window.umbcConsentGranted = state.analytics; // gates GTM load (analytics bucket) 222 window.umbcAdConsent = state.advertising; // gates ad tags + Crazy Egg (advertising bucket) 223 224 if (prev) { 225 if (prev.advertising && !state.advertising) { revokeAdvert
225ising(); } 226 else if (!prev.advertising && state.advertising) { grantAdvertising(); } 227 if (prev.analytics && !state.analytics) { denyAnalyticsStorage(); } 228 else if (!prev.analytics && state.analytics) { // e.g. EU visitor accepts 229 try { gtagShim()('consent', 'update', { analytics_storage: 'granted' }); } catch (e) {} 230 } 231 } 232 233 pushConsent(state); 234 if (state.analytics && typeof window[CONFIG.trackerFnName] === 'function') { 235 window[CONFIG.trackerFnName](); 236 } 237 } 238 239 // Session-recording disclosure. prefs-bar.php marks pages with Crazy Egg 240 // ticked (data-recording="1") and adds a recording sentence to the banner. 241 // Visitors who consented to advertising before seeing that sentence get the 242 // banner once more on such a page; any choice made there records that they 243 // have now seen it. Pages without recording are unaffected. 244 var RECORDING_NOTICE_COOKIE = 'umbc_rec_notice'; 245 function isRecordingPage() { var b = el(); return !!b && b.getAttribute('data-recording') === '1'; } 246 247 function saveAndApply(value) { 248 setCookie(CONFIG.cookieName, value, CONFIG.cookieDays, CONFIG.cookiePath); 249 // EU: minimal consent record (policy version + when). Not read by GTM/Consent Mode. 250 if (REGION === 'eu') { 251 setCookie(CONFIG.metaCookie, (window.umbcConsentPolicy || 'v1') + '|' + new Date().toISOString(), CONFIG.cookieDays, CONFIG.cookiePath); 252 } 253 if (isRecordingPage()) { setCookie(RECORDING_NOTICE_COOKIE, '1', CONFIG.cookieDays, CONFIG.cookiePath); } 254 applyState(computeState(value)); 255 hideBanner(); 256 } 257 258 /* ---- banner show / hide ---- */ 259 function el() { return document.getElementById('umbc-cookie-banner'); } 260 // Toggling .umbc-cookie-banner-open on <html> lets CSS reserve 261 // scroll-padding-bottom so keyboard focus is never hidden behind this 262 // fixed bottom banner (WCAG 2.4.12 / 2.4.11 Focus Not Obscured). 263 function hideBanner() { var b = el(); if (b) b.setAttribute('hidden', ''); document.documentElement.classList.remove('umbc-cookie-banner-open'); setBannerHeight(); } 264 function showBanner() { var b = el(); if (b) b.removeAttribute('hidden'); document.documentElement.classList.add('umbc-cookie-banner-open'); setBannerHeight(); } 265 266 /* ---- reserve layout space ---- 267 Publish the banner's real height as --umbc-banner-h so CSS can pad the 268 page bottom by exactly that much. Without this the fixed banner covered 269 whatever sat at the bottom of the viewport. */ 270 function setBannerHeight() { 271 var root = document.documentElement; 272 var b = el(); 273 if (!b || b.hasAttribute('hidden') || root.classList.contains('umbc-has-consent')) { 274 root.style.removeProperty('--umbc-banner-h'); 275 return; 276 } 277 root.style.setProperty('--umbc-banner-h', b.offsetHeight + 'px'); 278 } 279 280 function watchBannerHeight() { 281 var b = el(); 282 if (!b || typeof ResizeObserver !== 'function') return; 283 new ResizeObserver(setBannerHeight).observe(b); 284 } 285 286 /* ---- yield to dialogs ---- 287 While a modal owns the screen the banner must not float above it. We 288 detect the dialogs from here rather than editing each component (the 289 mobile menu ships minified), keeping the banner the sole owner of its own 290 visibility rules. 291 - body.no-scroll -> programs "Request Info" modal (toggleOverlay) 292 - .mobile-menu-overlay.active -> mobile navigation overlay */ 293 function dialogOpen() { 294 if (document.body && document.body.classList.contains('no-scroll')) return true; 295 if (document.querySelector('.mobile-menu-overlay.active')) return true; 296 return false; 297 } 298 299 function syncDialogState() { 300 document.documentElement.classList.toggle('umbc-dialog-open', dialogOpen()); 301 } 302 303 function watchDialogs() { 304 if (typeof MutationObserver !== 'function') return; 305 var mo = new MutationObserver(syncDialogState); 306 if (document.body) mo.observe(document.body, { attributes: true, attributeFilter: ['class'] });
307 var overlay = document.querySelector('.mobile-menu-overlay'); 308 if (overlay) mo.observe(overlay, { attributes: true, attributeFilter: ['class'] }); 309 syncDialogState(); 310 } 311 312 // Re-open the banner from the footer "Cookie settings" control. Clears the 313 // consented-hidden state, reveals the banner, and (tiered model) opens the 314 // preferences panel reflecting the visitor's current choice. 315 function openSettings() { 316 document.documentElement.classList.remove('umbc-has-consent'); 317 showBanner(); 318 var b = el(); 319 var prefs = document.getElementById('umbc-cookie-prefs'); 320 if (b && prefs) { 321 var st = computeState(savedChoice()); 322 setToggle('analytics', st.analytics); 323 setToggle('advertising', st.advertising); 324 prefs.removeAttribute('hidden'); 325 var manageBtn = b.querySelector('[data-action="manage"]'); 326 if (manageBtn) { manageBtn.setAttribute('aria-expanded', 'true'); } 327 } 328 if (b) { 329 var focusable = b.querySelector('button, [href], input, select, textarea'); 330 if (focusable) { focusable.focus(); } 331 } 332 } 333 // Exposed so any control can re-open the cookie settings. 334 window.umbcOpenCookieSettings = openSettings; 335 336 function bindCookieSettingsLinks() { 337 var links = document.querySelectorAll('.umbc-cookie-settings'); 338 for (var i = 0; i < links.length; i++) { 339 links[i].addEventListener('click', function (e) { 340 e.preventDefault(); 341 openSettings(); 342 }); 343 } 344 } 345 346 /* ---- legacy model wiring (live) ---- */ 347 function bindLegacy() { 348 var b = el(); if (!b) return; 349 b.addEventListener('click', function (e) { 350 var btn = e.target.closest('[data-consent]'); 351 if (!btn) return; 352 saveAndApply(btn.dataset.consent); // 'all' | 'essential' 353 }); 354 } 355 356 /* ---- tiered model wiring (dev/test) ---- */ 357 function bindTiered() { 358 var b = el(); if (!b) return; 359 var prefs = document.getElementById('umbc-cookie-prefs'); 360 361 b.addEventListener('click', function (e) { 362 var btn = e.target.closest('[data-action]'); 363 if (!btn) return; 364 var action = btn.dataset.action; 365 366 if (action === 'accept') { // "Got it" (US) / "Accept all" (EU) 367 saveAndApply('all'); 368 } else if (action === 'dismiss') { // X â US only (not rendered in EU) 369 // Identical to "Got it". US visitors already have analytics and 370 // advertising on by default, so closing the notice grants nothing 371 // they did not already have. The X is never rendered in EU, where 372 // dismissal-as-consent would be a dark pattern. 373 saveAndApply('all'); 374 } else if (action === 'reject') { // EU only 375 saveAndApply('essential'); 376 } else if (action === 'manage') { // open preferences panel 377 if (!prefs) return; 378 var open = prefs.hasAttribute('hidden'); 379 if (open) { 380 // reflect current effective state in the toggles 381 var st = computeState(savedChoice()); 382 setToggle('analytics', st.analytics); 383 setToggle('advertising', st.advertising); 384 prefs.removeAttribute('hidden'); 385 } else { 386 prefs.setAttribute('hidden', ''); 387 } 388 btn.setAttribute('aria-expanded', open ? 'true' : 'false'); 389 } else if (action === 'save') { // save the toggles 390 var a = getToggle('analytics'); 391 var ad = getToggle('advertising'); 392 var value = !a ? 'essential' : (ad ? 'all' : 'analytics'); 393 saveAndApply(value); 394 } 395 }); 396 } 397 function toggleEl(bucket) { return document.querySelector('.umbc-cookie-prefs__toggle[data-bucket="' + bucket + '"]'); } 398 function getToggle(bucket) { var t = toggleEl(bucket); return t ? t.checked : false; } 399 function setToggle(bucket, on) { var t = toggleEl(bucket); if (t) t.checked = !!on; } 400 401 /* ---- boot ---- */ 402 function boot() { 403 // Read the consent flags now (DOMContentLoaded) â header.php's inline 404 // script has run by this point, so window.umbcUseNewConsent is set. 405 NEW_MODEL = window.umbcUseNewConsent === true; 406 REGION = window.umbcRegion === 'eu' ? 'eu' : 'us'; 407 GPC = (navigator && navigator.globalPrivacyControl === true); 408 409 var saved = savedChoice(); 410 var bn = el(); 411 if (bn) { bn.setAttribute('data-region', REGION); } 412 413 // Always apply current state on every load (loads analytics-by-default in 414 // the tiered/US model; respects a saved opt-out; does nothing in legacy 415 // until a choice exists). 416 applyState(computeState(saved)); 417 418 // Bind interactions regardless of saved state, so the footer 419 // "Cookie settings" control can re-open a fully working banner later. 420 if (NEW_MODEL) { bindTiered(); } else { bindLegacy(); } 421 bindCookieSettingsLinks(); 422 423 // Keep the banner out of the way of modals, and keep the reserved 424 // bottom space accurate. Bound even when the banner starts hidden, so 425 // the footer "Cookie settings" control gets the same behaviour later. 426 watchDialogs();
427 watchBannerHeight(); 428 429 if (saved) { 430 // Explicit choice already made â leave it hidden, unless this page 431 // records sessions and the visitor (advertising on) hasn't seen that. 432 if (NEW_MODEL && isRecordingPage() && computeState(saved).advertising && 433 getCookie(RECORDING_NOTICE_COOKIE) !== '1') { 434 document.documentElement.classList.remove('umbc-has-consent'); 435 showBanner(); 436 } 437 return; 438 } 439 showBanner(); // first visit â reveal the disclaimer 440 } 441 442 if (document.readyState === 'loading') { 443 document.addEventListener('DOMContentLoaded', boot); 444 } else { 445 boot(); 446 } 447})();
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.