1/** 2 * DW Cookies â frontend banner, YouTube blocking, consent management 3 * 4 * WHAT: Shows/hides the consent banner; blocks YouTube iframes until YouTube 5 * consent is given; saves consent via AJAX; sets the consent cookie. 6 * WHY: GDPR requires consent before non-essential cookies are set. YouTube 7 * embeds must not load until the visitor explicitly allows them. 8 * INPUT: dwcookiesData (wp_localize_script) â ajaxUrl, nonce, categories, 9 * version, cookieName, cookieDays, necessaryOnly, overlay, 10 * ytPlaceholderText, ytAllowBtnText. 11 * OUTPUT: Consent cookie (dwcookies_consent); AJAX POST to server; YouTube 12 * iframes replaced with/restored from placeholders. 13 * DEPENDS: dwcookies.css, DWCookies_Consent::ajax_save_consent (PHP) 14 */ 15 16(function () { 17 'use strict'; 18 19 var data = window.dwcookiesData || {}; 20 var COOKIE = data.cookieName || 'dwcookies_consent'; 21 var DAYS = data.cookieDays || 365; 22 var AJAX = data.ajaxUrl || ''; 23 var NONCE = data.nonce || ''; 24 var VERSION = data.version || ''; 25 var NEC_ONLY = !! data.necessaryOnly; // informational notice mode 26 var OVERLAY = !! data.overlay; // dim page behind banner 27 var POLICY_VERSION = data.policyVersion || ''; // admin-set policy version string 28 var SHOW_MANAGE_LINK = !! data.showManageLink; // always-visible manage-cookies button 29 var FORCE_PREVIEW = !! data.forcePreview; // admin preview: show banner despite consent 30 31 var YT_PLACEHOLDER_TEXT = data.ytPlaceholderText || 'This video is hosted by YouTube. Playing it will allow YouTube to set cookies on your device.'; 32 var YT_ALLOW_BTN_TEXT = data.ytAllowBtnText || 'Allow YouTube & Play'; 33 34 var X_PLACEHOLDER_TEXT = data.xPlaceholderText || 'This post is hosted by X (Twitter). Showing it will allow X to set cookies on your device.'; 35 var X_ALLOW_BTN_TEXT = data.xAllowBtnText || 'Allow X & show post'; 36 var X_LOADING_TEXT = data.xLoadingText || 'Loading postâ¦'; 37 var X_WIDGETS_SRC = data.xWidgetsSrc || 'https://platform.twitter.com/widgets.js'; 38 39 // Settings panel texts 40 var SP_TITLE = data.spTitle || 'Your Cookie Settings'; 41 var SP_INTRO = data.spIntro || 'Manage your cookie preferences below.'; 42 var SP_SAVE_BTN = data.spSaveBtn || 'Save Preferences'; 43 var SP_SAVED = data.spSaved || 'Your preferences have been saved.'; 44 var SP_WITHDRAW = data.spWithdraw || 'Withdraw all consent'; 45 var SP_WITHDRAWN= data.spWithdrawn|| 'All optional consent withdrawn.'; 46 47 // Category texts 48 var CAT_NEC_LABEL = data.catNecessaryLabel || 'Technically Necessary'; 49 var CAT_NEC_DESC = data.catNecessaryDesc || 'Required for the website to function. Cannot be disabled.'; 50 var CAT_NEC_BADGE = data.catNecessaryBadge || 'Always active'; 51 var CAT_YT_LABEL = data.catYoutubeLabel || 'YouTube Videos'; 52 var CAT_YT_DESC = data.catYoutubeDesc || 'Required to play embedded YouTube videos.'; 53 var CAT_X_LABEL = data.catXLabel || 'X (Twitter) Posts'; 54 var CAT_X_DESC = data.catXDesc || 'Required to display embedded X (Twitter) posts.'; 55 56 // ------------------------------------------------------------------------- 57 // Cookie helpers 58 // ------------------------------------------------------------------------- 59 60 function setCookie(name, value, days) { 61 var expires = new Date(); 62 expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); 63 var secure = (location.protocol === 'https:') ? '; Secure' : ''; 64 document.cookie = name + '=' + encodeURIComponent(value) 65 + '; expires=' + expires.toUTCString() 66 + '; path=/' 67 + '; SameSite=Lax' 68 + secure; 69 } 70 71 function getCookie(name) { 72 var prefix = name + '='; 73 var cookies = document.cookie.split(';'); 74 for (var i = 0; i < cookies.length; i++) { 75 var c = cookies[i].trim(); 76 if (c.indexOf(prefix) === 0) { 77 return decodeURIComponent(c.substring(prefix.length)); 78 } 79 } 80 return null; 81 } 82 83 function getConsentData() { 84 var raw = getCookie(COOKIE); 85 if (!raw) return null; 86 try { 87 var parsed = JSON.parse(raw); 88 return (parsed && Array.isArray(parsed.categories)) ? parsed : null; 89 } catch (e) { return null; } 90 } 91 92 function hasValidConsent() { 93 var d = getConsentData(); 94 return d !== null && d.categories.length > 0; 95 } 96 97 function hasYouTubeConsent() { 98 var d = getConsentData(); 99 return d !== null && d.categories.indexOf('youtube') !== -1; 100 } 101 102 function hasXConsent() { 103 var d = getConsentData(); 104 return d !== null && d.categories.indexOf('x') !== -1; 105 } 106 107 // ------------------------------------------------------------------------- 108 // Save consent 109 // ------------------------------------------------------------------------- 110 111 /** 112 * Save a consent decision â sets the browser cookie and logs to the server. 113 * 114 * @param {string[]} categories Accepted category slugs (must include 'necessary'). 115 * @param {string} action One of: granted|withdrawn|updated|acknowledged. 116 * Defaults to 'granted' if omitted. 117 */ 118 function saveConsent(categories, action) { 119 if (categories.indexOf('necessary') === -1) { 120 categories = ['necessary'].concat(categories); 121 } 122 action = action || 'granted'; 123 124 var policyVersion = window.dwcookiesData && window.dwcookiesData.policyVersion 125 ? window.dwcookiesData.policyVersion : ''; 126
127 setCookie(COOKIE, JSON.stringify({ 128 categories: categories, 129 version: VERSION, 130 policyVersion: policyVersion, 131 timestamp: Math.floor(Date.now() / 1000) 132 }), DAYS); 133 134 // Server log (fire-and-forget â use sendBeacon so it survives page navigation). 135 if (AJAX && NONCE) { 136 var fd = new FormData(); 137 fd.append('action', 'dwcookies_save_consent'); 138 fd.append('nonce', NONCE); 139 fd.append('categories', JSON.stringify(categories)); 140 fd.append('consent_action', action); 141 fd.append('policy_version', policyVersion); 142 143 if (navigator.sendBeacon) { 144 var blob = new Blob([new URLSearchParams(fd).toString()], { 145 type: 'application/x-www-form-urlencoded' 146 }); 147 navigator.sendBeacon(AJAX, blob); 148 } else { 149 fetch(AJAX, { method: 'POST', body: fd, keepalive: true }) 150 .catch(function () {}); 151 } 152 } 153 } 154 155 // ------------------------------------------------------------------------- 156 // Banner / overlay 157 // ------------------------------------------------------------------------- 158 159 function showBanner() { 160 var banner = document.getElementById('dwcookies-banner'); 161 if (!banner) return; 162 banner.removeAttribute('hidden'); 163 if (OVERLAY) showOverlay('banner'); 164 // Hide manage link while banner is visible (redundant to show both). 165 hideManageLink(); 166 } 167 168 function hideBanner() { 169 var banner = document.getElementById('dwcookies-banner'); 170 if (banner) banner.setAttribute('hidden', ''); 171 hideOverlay('banner'); 172 // Restore manage link after banner is dismissed. 173 if (SHOW_MANAGE_LINK && !NEC_ONLY) showManageLink(); 174 } 175 176 /** Shows the persistent manage-cookies button (bottom-right corner). */ 177 function showManageLink() { 178 var el = document.getElementById('dwcookies-manage-link'); 179 if (el) el.removeAttribute('hidden'); 180 } 181 182 /** Hides the persistent manage-cookies button. */ 183 function hideManageLink() { 184 var el = document.getElementById('dwcookies-manage-link'); 185 if (el) el.setAttribute('hidden', ''); 186 } 187 188 function showModal() { 189 var modal = document.getElementById('dwcookies-modal'); 190 if (!modal) return; 191 modal.removeAttribute('hidden'); 192 showOverlay('modal'); 193 var first = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); 194 if (first) first.focus(); 195 } 196 197 function hideModal() { 198 var modal = document.getElementById('dwcookies-modal'); 199 if (modal) modal.setAttribute('hidden', ''); 200 hideOverlay('modal'); 201 } 202 203 // The overlay element is shared; track who "owns" it so both banner and modal 204 // can request/release it independently. 205 var _overlayOwners = {}; 206 207 function showOverlay(owner) { 208 _overlayOwners[owner] = true; 209 var el = document.getElementById('dwcookies-overlay'); 210 if (el) el.classList.add('dwcookies-overlay--visible'); 211 } 212 213 function hideOverlay(owner) { 214 delete _overlayOwners[owner]; 215 // Only hide if nobody else needs it. 216 if (Object.keys(_overlayOwners).length === 0) { 217 var el = document.getElementById('dwcookies-overlay'); 218 if (el) el.classList.remove('dwcookies-overlay--visible'); 219 } 220 } 221 222 function injectOverlay() { 223 var overlay = document.createElement('div'); 224 overlay.id = 'dwcookies-overlay'; 225 overlay.className = 'dwcookies-overlay'; 226 // Clicking overlay closes the modal, but does NOT dismiss the banner 227 // (GDPR: user must make an active choice, not click away). 228 overlay.addEventListener('click', function () { 229 hideModal(); 230 }); 231 document.body.appendChild(overlay); 232 } 233 234 // ------------------------------------------------------------------------- 235 // Consent actions 236 // ------------------------------------------------------------------------- 237 238 function acceptAll() { 239 var all = (data.categories || []).slice(); 240 var action = hasValidConsent() ? 'updated' : 'granted'; 241 saveConsent(all, action); 242 hideModal(); 243 hideBanner(); 244 restoreYouTubeIframes(); 245 restoreXEmbeds(); 246 } 247 248 function rejectNonEssential() { 249 var action = hasValidConsent() ? 'updated' : 'granted'; 250 saveConsent(['necessary'], action); 251 hideModal(); 252 hideBanner(); 253 } 254 255 function acknowledge() { 256 // Necessary-only mode: user dismisses the informational notice. 257 saveConsent(['necessary'], 'acknowledged'); 258 hideBanner(); 259 } 260 261 function savePreferences() { 262 var checks = document.querySelectorAll('.dwcookies-cat-check:not([disabled]):checked'); 263 var selected = ['necessary']; 264 for (var i = 0; i < checks.length; i++) { 265 var val = checks[i].value; 266 if (val && selected.indexOf(val) === -1) { 267 selected.push(val); 268 } 269 } 270 var action = hasValidConsent() ? 'updated' : 'granted'; 271 saveConsent(selected, action); 272 hideModal(); 273 hideBanner(); 274 // Each category takes effect immediately â restore on opt-in, re-block on 275 // withdrawal (GDPR Art. 7(3): withdrawal as easy as consent, applied at once). 276 if (selected.indexOf('youtube') !== -1) { 277 restoreYouTubeIframes(); 278 } else { 279 blockYouTubeIframes(); 280 } 281 if (selected.indexOf('x') !== -1) { 282 restoreXEmbeds(); 283 } else { 284 blockXEmbeds(); 285 } 286 } 287 288 // ------------------------------------------------------------------------- 289 // YouTube iframe blocking 290 // ------------------------------------------------------------------------- 291 292 /** 293 * Replaces all YouTube iframes with placeholder divs. 294 * Each placeholder stores the original iframe's outerHTML as a data attribute 295 * so it can be restored exactly when consent is granted. 296 */ 297 function blockYouTubeIframes() { 298 var iframes = document.querySelectorAll('iframe[src*="youtube.com/embed"], iframe[src*="youtube-nocookie.com/embed"]'); 299 for (var i = 0; i < iframes.length; i++) { 300 var iframe = iframes[i]; 301 // Poster = the page's featured image (passed from PHP). Deliberately 302 // NO fallback to img.youtube.com â fetching the YT thumbnail would 303 // contact Google before consent, defeating the whole point of blocking. 304 var thumb = data.ytPlaceholderImage || ''; 305 306 var ph = document.createElement('div'); 307 ph.className = 'dwcookies-yt-placeholder'; 308 ph.dataset.originalHtml = encodeURIComponent(iframe.outerHTML); // URL-encoded; restore decodes 309 ph.style.cssText = 'position:relative;overflow:hidden;background:#000;' 310 + (iframe.width ? 'width:' + iframe.width + 'px;' : 'width:100%;') 311 + (iframe.height ? 'height:' + iframe.height + 'px;' : 'aspect-ratio:16/9;'); 312 313 if (thumb) { 314 var img = document.createElement('img'); 315 img.src = thumb; 316 img.alt = ''; 317 img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0.4;'; 318 ph.appendChild(img); 319 } 320 321 // Notice text 322 var notice = document.createElement('div'); 323 notice.className = 'dwcookies-yt-notice'; 324 notice.textContent = YT_PLACEHOLDER_TEXT; 325 ph.appendChild(notice); 326 327 // Inline allow button â grants YouTube consent and loads THIS video. 328 var btn = document.createElement('button'); 329 btn.type = 'button'; 330 btn.className = 'dwcookies-yt-btn'; 331 btn.textContent = YT_ALLOW_BTN_TEXT; 332 btn.addEventListener('click', onAllowClick); 333 btn.dataset.dwcWired = '1'; 334 ph.appendChild(btn); 335 336 iframe.parentNode.replaceChild(ph, iframe); 337 } 338 } 339 340 /** 341 * Shared handler for any "Allow YouTube & Play" button â grants YouTube 342 * consent, restores every placeholder to its real iframe, and dismisses the 343 * banner if consent is now complete. 344 */ 345 function onAllowClick() { 346 grantYouTubeConsent(); 347 restoreYouTubeIframes(); 348 if (hasValidConsent()) hideBanner(); 349 } 350 351 /** 352 * Wires the "Allow" buttons on SERVER-rendered placeholders (the leak-free 353 * path â PHP emits the placeholder, so there is no live iframe to block).
354 * Idempotent: skips buttons already wired by blockYouTubeIframes(). 355 */ 356 function wirePlaceholderButtons() { 357 var btns = document.querySelectorAll('.dwcookies-yt-placeholder .dwcookies-yt-btn'); 358 for (var i = 0; i < btns.length; i++) { 359 if (btns[i].dataset.dwcWired) continue; 360 btns[i].dataset.dwcWired = '1'; 361 btns[i].addEventListener('click', onAllowClick); 362 } 363 } 364 365 /** 366 * Grants YouTube consent (sets the cookie) without dismissing the main banner. 367 * Used when the visitor clicks "Allow YouTube & Play" on a placeholder. 368 */ 369 function grantYouTubeConsent() { 370 var existing = getConsentData(); 371 var cats = existing ? existing.categories.slice() : ['necessary']; 372 if (cats.indexOf('youtube') === -1) { cats.push('youtube'); } 373 var action = existing ? 'updated' : 'granted'; 374 saveConsent(cats, action); 375 } 376 377 /** 378 * Restores all YouTube placeholders back to their original iframes. 379 * Called after YouTube consent is granted. 380 */ 381 function restoreYouTubeIframes() { 382 var placeholders = document.querySelectorAll('.dwcookies-yt-placeholder'); 383 for (var i = 0; i < placeholders.length; i++) { 384 var ph = placeholders[i]; 385 var raw = ph.dataset.originalHtml; 386 if (!raw) continue; 387 // Stored URL-encoded (by PHP rawurlencode or the JS fallback's 388 // encodeURIComponent) so the iframe markup survives an HTML attribute. 389 var html = decodeURIComponent(raw); 390 var tmp = document.createElement('div'); 391 tmp.innerHTML = html; 392 var restored = tmp.firstChild; 393 if (restored) { 394 ph.parentNode.replaceChild(restored, ph); 395 } 396 } 397 } 398 399 // ------------------------------------------------------------------------- 400 // X (Twitter) embed blocking 401 // ------------------------------------------------------------------------- 402 403 /** 404 * Fallback blocker for any LIVE X embed that bypassed the server filter (e.g. 405 * a manually pasted full embed). Replaces each `blockquote.twitter-tweet` with a 406 * placeholder storing its original HTML, and removes X's widget script so nothing 407 * contacts X. The server filter (filter_x_embeds) already does this for c
407ontent; 408 * this mirrors blockYouTubeIframes() for the widget-HTML path. 409 */ 410 function blockXEmbeds() { 411 // Remove any X widget script so it cannot (re)hydrate blockquotes. 412 var scripts = document.querySelectorAll('script[src*="platform.twitter.com/widgets.js"]'); 413 for (var s = 0; s < scripts.length; s++) { 414 if (scripts[s].parentNode) scripts[s].parentNode.removeChild(scripts[s]); 415 } 416 417 var quotes = document.querySelectorAll('blockquote.twitter-tweet'); 418 for (var i = 0; i < quotes.length; i++) { 419 var quote = quotes[i]; 420 // Skip a blockquote already inside a placeholder (defensive). 421 if (quote.closest && quote.closest('.dwcookies-x-placeholder')) continue; 422 423 var ph = document.createElement('div'); 424 ph.className = 'dwcookies-x-placeholder'; 425 ph.dataset.originalHtml = encodeURIComponent(quote.outerHTML); // URL-encoded; restore decodes 426 427 var notice = document.createElement('div'); 428 notice.className = 'dwcookies-yt-notice'; 429 notice.textContent = X_PLACEHOLDER_TEXT; 430 ph.appendChild(notice); 431 432 var btn = document.createElement('button'); 433 btn.type = 'button'; 434 btn.className = 'dwcookies-yt-btn dwcookies-x-btn'; 435 btn.textContent = X_ALLOW_BTN_TEXT; 436 btn.addEventListener('click', onAllowXClick); 437 btn.dataset.dwcWired = '1'; 438 ph.appendChild(btn); 439 440 quote.parentNode.replaceChild(ph, quote); 441 } 442 } 443 444 /** 445 * Wires the "Allow" buttons on SERVER-rendered X placeholders (the leak-free 446 * path â PHP emits the placeholder, so there is no live blockquote to block). 447 * Idempotent: skips buttons already wired by blockXEmbeds(). 448 */ 449 function wireXPlaceholderButtons() { 450 var btns = document.querySelectorAll('.dwcookies-x-placeholder .dwcookies-x-btn'); 451 for (var i = 0; i < btns.length; i++) { 452 if (btns[i].dataset.dwcWired) continue; 453 btns[i].dataset.dwcWired = '1'; 454 btns[i].addEventListener('click', onAllowXClick); 455 } 456 } 457 458 /** 459 * Shared handler for any "Allow X & show post" button â grants X consent, 460 * restores every placeholder to its real blockquote, loads the widget script, 461 * and dismisses the banner if consent is now complete. 462 */ 463 function onAllowXClick() { 464 grantXConsent(); 465 restoreXEmbeds(); 466 if (hasValidConsent()) hideBanner(); 467 } 468 469 /** 470 * Grants X consent (sets the cookie) without dismissing the main banner. 471 * Used when the visitor clicks "Allow X & show post" on a placeholder. 472 */ 473 function grantXConsent() { 474 var existing = getConsentData(); 475 var cats = existing ? existing.categories.slice() : ['necessary']; 476 if (cats.indexOf('x') === -1) { cats.push('x'); } 477 var action = existing ? 'updated' : 'granted'; 478 saveConsent(cats, action); 479 } 480 481 /** 482 * Restores all X placeholders. Instead of dumping the raw (unstyled) blockquote 483 * and letting it pop, each placeholder becomes a spinner card with the real tweet 484 * hydrating INVISIBLY behind it; once X's widget finishes rendering, the spinner 485 * fades out and the tweet fades in. No flash of unstyled blockquote, no jump. 486 */ 487 function restoreXEmbeds() { 488 var placeholders = document.querySelectorAll('.dwcookies-x-placeholder'); 489 for (var i = 0; i < placeholders.length; i++) { 490 var ph = placeholders[i]; 491 var raw = ph.dataset.originalHtml; 492 if (!raw) continue; 493 var html = decodeURIComponent(raw); 494 495 var wrap = document.createElement('div'); 496 wrap.className = 'dwcookies-x-embed'; 497 498 var loading = document.createElement('div'); 499 loading.className = 'dwcookies-x-loading'; 500 loading.setAttribute('role', 'status'); 501 var spin = document.createElement('span'); 502 spin.className = 'dwcookies-x-spinner'; 503 spin.setAttribute('aria-hidden', 'true'); 504 var ltext = document.createElement('span'); 505 ltext.className = 'dwcookies-x-loading-text'; 506 ltext.textContent = X_LOADING_TEXT; 507 loading.appendChild(spin); 508 loading.appendChild(ltext); 509 510 var host = document.createElement('div'); 511 host.className = 'dwcookies-x-host'; // starts opacity:0 (CSS); tweet hydrates here 512 host.innerHTML = html; 513 514 wrap.appendChild(loading); 515 wrap.appendChild(host); 516 ph.parentNode.replaceChild(wrap, ph); 517 518 hydrateXHost(wrap, host, loading); 519 } 520 } 521 522 /** 523 * Loads X's widget into one host, then crossfades from spinner to tweet. Always 524 * reveals eventually â even if the widget script is blocked/offline â so a visitor
525 * never gets stuck on a spinner. 526 */ 527 function hydrateXHost(wrap, host, loading) { 528 var revealed = false; 529 function reveal() { 530 if (revealed) return; 531 revealed = true; 532 wrap.classList.add('dwcookies-x-ready'); // CSS: fade host in, min-heightâ0 533 setTimeout(function () { 534 if (loading && loading.parentNode) loading.parentNode.removeChild(loading); 535 }, 450); 536 } 537 loadTwitterWidgets(function (twttr) { 538 try { twttr.widgets.load(host); } catch (e) {} 539 // Reveal only once the real tweet IFRAME exists in this host â NOT when 540 // widgets.load()'s promise resolves. That promise fires a couple of frames 541 // before the blockquote is swapped for the iframe, briefly showing the raw 542 // blockquote (theme red quote-border). Poll for the iframe instead. 543 var waited = 0; 544 (function check() { 545 if (host.querySelector('iframe')) { reveal(); return; } 546 waited += 60; 547 if (waited > 6000) { reveal(); return; } // safety: reveal anyway 548 setTimeout(check, 60); 549 })(); 550 }, reveal); // onFail (script blocked) â reveal so the spinner doesn't hang 551 } 552 553 /** 554 * Ensures X's official widget script is present (injected once â NEVER before X 555 * consent, keeping the block leak-free), then calls onReady(twttr) when the API 556 * is available. onFail() fires if the script can't load within the timeout. 557 */ 558 function loadTwitterWidgets(onReady, onFail) { 559 if (!document.getElementById('dwcookies-twitter-wjs')) { 560 var js = document.createElement('script'); 561 js.id = 'dwcookies-twitter-wjs'; 562 js.async = true; 563 js.src = X_WIDGETS_SRC; 564 (document.head || document.body).appendChild(js); 565 } 566 var waited = 0; 567 (function ready() { 568 if (window.twttr && window.twttr.widgets && typeof window.twttr.widgets.load === 'function') { 569 if (typeof onReady === 'function') onReady(window.twttr); 570 return; 571 } 572 waited += 100; 573 if (waited > 8000) { if (typeof onFail === 'function') onFail(); return; } 574 setTimeout(ready, 100); 575 })(); 576 } 577 578 // ------------------------------------------------------------------------- 579 // Keyboard accessibility (focus trap in modal) 580 // ------------------------------------------------------------------------- 581 582 function trapFocus(e) { 583 var modal = document.getElementById('dwcookies-modal'); 584 if (!modal || modal.hasAttribute('hidden')) return; 585 586 var focusable = modal.querySelectorAll( 587 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' 588 ); 589 var first = focusable[0]; 590 var last = focusable[focusable.length - 1]; 591 592 if (e.key === 'Tab') { 593 if (e.shiftKey) { 594 if (document.activeElement === first) { e.preventDefault(); last.focus(); } 595 } else { 596 if (document.activeElement === last) { e.preventDefault(); first.focus(); } 597 } 598 } 599 if (e.key === 'Escape') hideModal(); 600 } 601 602 // ------------------------------------------------------------------------- 603 // Settings panel (shortcode: [dwcookies_settings]) 604 // ------------------------------------------------------------------------- 605 606 /** 607 * Builds and activates the settings panel inside #dwcookies-settings-panel. 608 * Shows current consent state; lets the visitor toggle categories and save. 609 */ 610 function initSettingsPanel() { 611 var panel = document.getElementById('dwcookies-settings-panel'); 612 if (!panel) return; 613 614 var consentData = getConsentData(); 615 var hasYT = consentData && consentData.categories.indexOf('youtube') !== -1; 616 var hasX = consentData && consentData.categories.indexOf('x') !== -1;
617 var hasAnyConsent = consentData !== null; 618 619 // Build panel HTML 620 panel.innerHTML = 621 '<h3 class="dwcookies-sp__title">' + esc(SP_TITLE) + '</h3>' + 622 '<p class="dwcookies-sp__intro">' + esc(SP_INTRO) + '</p>' + 623 624 '<ul class="dwcookies-sp__list">' + 625 626 // Necessary: always on, disabled 627 '<li class="dwcookies-sp__item">' + 628 '<label class="dwcookies-sp__label">' + 629 '<input type="checkbox" checked disabled> ' + 630 '<span>' + esc(CAT_NEC_LABEL) + '</span>' + 631 '<span class="dwcookies-sp__badge">' + esc(CAT_NEC_BADGE) + '</span>' + 632 '</label>' + 633 '<p class="dwcookies-sp__desc">' + esc(CAT_NEC_DESC) + '</p>' + 634 '</li>' + 635 636 // YouTube: toggleable 637 '<li class="dwcookies-sp__item">' + 638 '<label class="dwcookies-sp__label">' + 639 '<input type="checkbox" id="dwcookies-sp-youtube"' + (hasYT ? ' checked' : '') + '> ' + 640 '<span>' + esc(CAT_YT_LABEL) + '</span>' + 641 '</label>' + 642 '<p class="dwcookies-sp__desc">' + esc(CAT_YT_DESC) + '</p>' + 643 '</li>' + 644 645 // X (Twitter): toggleable 646 '<li class="dwcookies-sp__item">' + 647 '<label class="dwcookies-sp__label">' + 648 '<input type="checkbox" id="dwcookies-sp-x"' + (hasX ? ' checked' : '') + '> ' + 649 '<span>' + esc(CAT_X_LABEL) + '</span>' + 650 '</label>' + 651 '<p class="dwcookies-sp__desc">' + esc(CAT_X_DESC) + '</p>' + 652 '</li>' + 653 654 '</ul>' + 655 656 '<div class="dwcookies-sp__actions">' + 657 '<button type="button" id="dwcookies-sp-save" class="dwcookies-btn dwcookies-btn--primary">' + esc(SP_SAVE_BTN) + '</button>' + 658 (hasAnyConsent 659 ? ' <button type="button" id="dwcookies-sp-withdraw" class="dwcookies-btn dwcookies-btn--ghost">' + esc(SP_WITHDRAW) + '</button>' 660 : '') + 661 '</div>' + 662 '<p class="dwcookies-sp__status" id="dwcookies-sp-status" aria-live="polite"></p>'; 663 664 // Save button 665 document.getElementById('dwcookies-sp-save').addEventListener('click', function () { 666 var ytCheck = document.getElementById('dwcookies-sp-youtube'); 667 var xCheck = document.getElementById('dwcookies-sp-x'); 668 var selected = ['necessary']; 669 if (ytCheck && ytCheck.checked) selected.push('youtube'); 670 if (xCheck && xCheck.checked) selected.push('x'); 671 672 saveConsent(selected, hasValidConsent() ? 'updated' : 'granted'); 673 674 // Update embeds immediately, per category. 675 if (selected.indexOf('youtube') !== -1) { 676 restoreYouTubeIframes(); 677 } else { 678 blockYouTubeIframes(); 679 } 680 if (selected.indexOf('x') !== -1) { 681 restoreXEmbeds(); 682 } else { 683 blockXEmbeds(); 684 } 685 686 setStatus(SP_SAVED); 687 688 // Refresh the "Withdraw" button visibility 689 var withdrawBtn = document.getElementById('dwcookies-sp-withdraw'); 690 if (!withdrawBtn) { 691 var actions = panel.querySelector('.dwcookies-sp__actions'); 692 if (actions) { 693 var btn = document.createElement('button'); 694 btn.type = 'button'; 695 btn.id = 'dwcookies-sp-withdraw'; 696 btn.className = 'dwcookies-btn dwcookies-btn--ghost'; 697 btn.textContent = SP_WITHDRAW; 698 actions.appendChild(btn); 699 wireWithdraw(btn); 700 } 701 } 702 }); 703 704 // Withdraw button (if present) 705 var withdrawBtn = document.getElementById('dwcookies-sp-withdraw'); 706 if (withdrawBtn) wireWithdraw(withdrawBtn); 707 } 708 709 function wireWithdraw(btn) { 710 btn.addEventListener('click', function () { 711 saveConsent(['necessary'], 'withdrawn'); 712 blockYouTubeIframes(); 713 blockXEmbeds(); 714 715 // Uncheck the optional toggles 716 var ytCheck = document.getElementById('dwcookies-sp-youtube'); 717 if (ytCheck) ytCheck.checked = false;
718 var xCheck = document.getElementById('dwcookies-sp-x'); 719 if (xCheck) xCheck.checked = false; 720 721 setStatus(SP_WITHDRAWN); 722 723 // Remove the withdraw button (no consent left to withdraw) 724 btn.parentNode.removeChild(btn); 725 }); 726 } 727 728 function setStatus(msg) { 729 var el = document.getElementById('dwcookies-sp-status'); 730 if (!el) return; 731 el.textContent = msg; 732 // Clear after 4 seconds 733 setTimeout(function () { el.textContent = ''; }, 4000); 734 } 735 736 /** Minimal HTML-escape for JS-generated content. */ 737 function esc(str) { 738 return String(str) 739 .replace(/&/g, '&') 740 .replace(/</g, '<') 741 .replace(/>/g, '>') 742 .replace(/"/g, '"'); 743 } 744 745 // ------------------------------------------------------------------------- 746 // Init 747 // ------------------------------------------------------------------------- 748 749 /** 750 * Returns true if the visitor's stored consent cookie is still valid for 751 * the current policy version. A non-empty POLICY_VERSION that differs from 752 * what's in the cookie means the policy changed â the visitor must re-consent. 753 */ 754 function isPolicyVersionCurrent() { 755 if (!POLICY_VERSION) return true; // no policy version configured â always current 756 var d = getConsentData(); 757 if (!d) return false;
758 return (d.policyVersion || '') === POLICY_VERSION; 759 } 760 761 /** 762 * Screenshot bypass: a deliberately low-profile URL param (?dwc_nobanner=1) 763 * suppresses the banner UI so clean screenshots can be taken. PURELY COSMETIC 764 * â it grants and changes NO consent (YouTube stays blocked until a real 765 * Accept). Client-side only, so it never pollutes the page cache. 766 */ 767 function isScreenshotBypass() { 768 try { return /[?&]dwc_nobanner=1(?:&|$)/.test(window.location.search); } 769 catch (e) { return false; } 770 } 771 772 function init() { 773 injectOverlay(); 774 775 // Settings panel: wire up on any page that has the shortcode. 776 initSettingsPanel(); 777 778 // If the policy version changed since the visitor last consented, clear 779 // the stale cookie so the banner shows again (PHP already does this server- 780 // side to suppress the banner HTML, but clear client-side too for consistency). 781 if (hasValidConsent() && !isPolicyVersionCurrent()) { 782 setCookie(COOKIE, '', -1); // expire immediately 783 } 784 785 // YouTube embeds arrive as inert placeholders (PHP the_content filter). 786 // If the visitor already consented, swap them back to live iframes now; 787 // otherwise wire their Allow buttons. blockYouTubeIframes() is a fallback 788 // for any LIVE iframe that bypassed the server filter (e.g. widget HTML). 789 if (!hasYouTubeConsent() || !isPolicyVersionCurrent()) { 790 blockYouTubeIframes(); 791 wirePlaceholderButtons(); 792 } else { 793 restoreYouTubeIframes(); 794 } 795 796 // X (Twitter) embeds arrive as inert placeholders (PHP the_content filter), 797 // handled exactly like YouTube: restore-if-consented, else wire the Allow 798 // buttons. blockXEmbeds() is the fallback for any live blockquote that 799 // bypassed the server filter (e.g. manually pasted widget HTML). 800 if (!hasXConsent() || !isPolicyVersionCurrent()) { 801 blockXEmbeds(); 802 wireXPlaceholderButtons(); 803 } else { 804 restoreXEmbeds(); 805 } 806 807 // Wire modal and manage link regardless of consent state â the modal 808 // is always in the DOM (rendered by render_persistent_elements) and the 809 // manage link must work even after consent has been given. 810 if (!NEC_ONLY) { 811 wireModal(); 812 wireManageLink(); 813 wireOpenLinks(); 814 } 815 816 // If the visitor already has current valid consent, show manage link and stop. 817 // (In admin preview mode we skip this so the banner always shows.) 818 if (!FORCE_PREVIEW && hasValidConsent() && isPolicyVersionCurrent()) { 819 if (SHOW_MANAGE_LINK && !NEC_ONLY) showManageLink(); 820 return; 821 } 822 823 // Screenshot bypass (dev tool): suppress the banner UI for a clean shot. 824 if (isScreenshotBypass()) { hideBanner(); return; } 825 826 showBanner(); // showBanner() also hides the manage link 827 828 if (NEC_ONLY) { 829 // Informational notice mode: just a single dismiss button. 830 var btnAck = document.getElementById('dwcookies-acknowledge'); 831 if (btnAck) btnAck.addEventListener('click', acknowledge); 832 return; // no modal wiring needed 833 } 834 835 // Full consent mode: wire banner buttons. 836 var btnAcceptAll = document.getElementById('dwcookies-accept-all'); 837 if (btnAcceptAll) btnAcceptAll.addEventListener('click', acceptAll); 838 839 var btnReject = document.getElementById('dwcookies-reject'); 840 if (btnReject) btnReject.addEventListener('click', rejectNonEssential); 841 842 var btnCustomize = document.getElementById('dwcookies-customize-open'); 843 if (btnCustomize) btnCustomize.addEventListener('click', showModal); 844 } 845 846 /** 847 * Wires the preferences modal buttons (save, cancel) and keyboard trap. 848 * Called unconditionally on every page in full-consent mode so the modal 849 * works both when opened from the banner and from the manage link. 850 */ 851 function wireModal() { 852 var btnSave = document.getElementById('dwcookies-save-prefs'); 853 if (btnSave) btnSave.addEventListener('click', savePreferences); 854 855 var btnCancel = document.getElementById('dwcookies-modal-cancel'); 856 if (btnCancel) btnCancel.addEventListener('click', hideModal); 857 858 document.addEventListener('keydown', trapFocus); 859 } 860 861 /** 862 * Wires any opt-in "open cookie preferences" element to show the modal. 863 * Matches (via event delegation, so it also covers WordPress menu items â where 864 * the CSS class lands on the <li> and the click originates on the <a> inside â 865 * and any element added after load): 866 * - `.dwcookies-open` â CSS class on any element or its ancestor
867 * - `a[href="#cookie-settings"]` â soft anchor convention for menu/footer links 868 * Works regardless of the "persistent manage link" admin toggle, so a theme or 869 * menu can host the link even when the floating bottom-right button is off. 870 */ 871 function wireOpenLinks() { 872 document.addEventListener('click', function (e) { 873 if (!e.target || !e.target.closest) return; 874 var trigger = e.target.closest('.dwcookies-open, a[href="#cookie-settings"]'); 875 if (!trigger) return; 876 e.preventDefault(); 877 syncModalChecks(); 878 showModal(); 879 }); 880 } 881 882 /** Syncs the modal's optional-category checkboxes to the visitor's current consent. */ 883 function syncModalChecks() { 884 var ytCheck = document.getElementById('dwcookies-cat-youtube'); 885 if (ytCheck) ytCheck.checked = hasYouTubeConsent(); 886 var xCheck = document.getElementById('dwcookies-cat-x'); 887 if (xCheck) xCheck.checked = hasXConsent(); 888 } 889 890 /** 891 * Wires the persistent manage-cookies button to open the modal. 892 * Also syncs the YouTube checkbox state when the modal opens via this path. 893 */ 894 function wireManageLink() { 895 var btn = document.getElementById('dwcookies-manage-link'); 896 if (!btn || !SHOW_MANAGE_LINK) return; 897 898 btn.addEventListener('click', function () { 899 // Sync optional-category checkboxes to current consent before opening. 900 syncModalChecks(); 901 showModal(); 902 }); 903 } 904 905 if (document.readyState === 'loading') { 906 document.addEventListener('DOMContentLoaded', init); 907 } else { 908 init(); 909 } 910 911})();
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.