PageSourceSearch

https://umbc.edu/wp-content/themes/umbc-theme-2.0/template-parts/prefs-bar/prefs-bar.js?ver=1790795739

js umbc.edu collected 2026-10-01 07:44:17 UTC 21,325 bytes, 447 lines download raw bytes

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.