frontblocks-cookie-notice.js
293 lines
| 1 | /** |
| 2 | * FrontBlocks Cookie Notice |
| 3 | * |
| 4 | * @package FrontBlocks |
| 5 | * @version 1.0.0 |
| 6 | */ |
| 7 | |
| 8 | (function () { |
| 9 | 'use strict'; |
| 10 | |
| 11 | if (document.readyState === 'loading') { |
| 12 | document.addEventListener('DOMContentLoaded', init); |
| 13 | } else { |
| 14 | init(); |
| 15 | } |
| 16 | |
| 17 | function readCookie(name) { |
| 18 | var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); |
| 19 | |
| 20 | if (!match) { |
| 21 | return ''; |
| 22 | } |
| 23 | |
| 24 | try { |
| 25 | return decodeURIComponent(match[1]); |
| 26 | } catch (e) { |
| 27 | // Malformed percent-encoding: treat it the same as no cookie at all. |
| 28 | return ''; |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | function defineInjectHelper() { |
| 33 | window.frblCookieNoticeInject = window.frblCookieNoticeInject || function (gtmId, ga4Id) { |
| 34 | if (gtmId) { |
| 35 | window.dataLayer = window.dataLayer || []; |
| 36 | window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); |
| 37 | |
| 38 | var gtmScript = document.createElement('script'); |
| 39 | gtmScript.async = true; |
| 40 | gtmScript.src = 'https://www.googletagmanager.com/gtm.js?id=' + encodeURIComponent(gtmId); |
| 41 | document.head.appendChild(gtmScript); |
| 42 | } |
| 43 | |
| 44 | if (ga4Id) { |
| 45 | var ga4Script = document.createElement('script'); |
| 46 | ga4Script.async = true; |
| 47 | ga4Script.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(ga4Id); |
| 48 | document.head.appendChild(ga4Script); |
| 49 | |
| 50 | window.dataLayer = window.dataLayer || []; |
| 51 | window.gtag = window.gtag || function () { |
| 52 | window.dataLayer.push(arguments); |
| 53 | }; |
| 54 | window.gtag('js', new Date()); |
| 55 | window.gtag('config', ga4Id); |
| 56 | } |
| 57 | }; |
| 58 | } |
| 59 | |
| 60 | function fetchAndInjectScripts() { |
| 61 | var formData = new FormData(); |
| 62 | formData.append('action', 'frbl_get_cookie_notice_config'); |
| 63 | |
| 64 | fetch(frblCookieNotice.ajaxUrl, { |
| 65 | method: 'POST', |
| 66 | credentials: 'same-origin', |
| 67 | body: formData |
| 68 | }) |
| 69 | .then(function (response) { |
| 70 | return response.json(); |
| 71 | }) |
| 72 | .then(function (response) { |
| 73 | if (response && response.success && response.data && window.frblCookieNoticeInject) { |
| 74 | window.frblCookieNoticeInject(response.data.gtmId, response.data.ga4Id); |
| 75 | } |
| 76 | }) |
| 77 | .catch(function () { |
| 78 | // Network hiccup: consent is already stored locally; nothing else to do here. |
| 79 | }); |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * For an already-decided visitor, request the tracking scripts (accepted) |
| 84 | * or simply do nothing further (rejected). Normally an inline bootstrap |
| 85 | * script printed on wp_head already does this as early as possible, well |
| 86 | * before this file even loads — this is the fallback for sites whose |
| 87 | * Content Security Policy blocks that unnonced inline script, so tracking |
| 88 | * still starts there too, just later. |
| 89 | * |
| 90 | * Deliberately separate from hiding the banner (see hideBannerIfDecided()): |
| 91 | * that inline copy runs on wp_head, before '#frbl-cookie-notice' exists in |
| 92 | * the DOM at all, so it can never do the hiding itself — only this file, |
| 93 | * running once the DOM is ready, can. |
| 94 | */ |
| 95 | function requestTrackingIfNeeded() { |
| 96 | if (window.frblCookieNoticeBootstrapped) { |
| 97 | return; |
| 98 | } |
| 99 | |
| 100 | defineInjectHelper(); |
| 101 | |
| 102 | if (readCookie(frblCookieNotice.cookieName) === 'accepted') { |
| 103 | fetchAndInjectScripts(); |
| 104 | } |
| 105 | |
| 106 | window.frblCookieNoticeBootstrapped = true; |
| 107 | } |
| 108 | |
| 109 | function hideBannerIfDecided(banner) { |
| 110 | var consent = readCookie(frblCookieNotice.cookieName); |
| 111 | |
| 112 | if (banner && (consent === 'accepted' || consent === 'rejected')) { |
| 113 | banner.style.display = 'none'; |
| 114 | return true; |
| 115 | } |
| 116 | |
| 117 | return false; |
| 118 | } |
| 119 | |
| 120 | function init() { |
| 121 | if (typeof frblCookieNotice === 'undefined') { |
| 122 | return; |
| 123 | } |
| 124 | |
| 125 | requestTrackingIfNeeded(); |
| 126 | |
| 127 | var banner = document.getElementById('frbl-cookie-notice'); |
| 128 | |
| 129 | if (!banner) { |
| 130 | return; |
| 131 | } |
| 132 | |
| 133 | if (hideBannerIfDecided(banner)) { |
| 134 | // Already decided: nothing left to wire up. |
| 135 | return; |
| 136 | } |
| 137 | |
| 138 | var acceptBtn = banner.querySelector('[data-frbl-cookie-action="accept"]'); |
| 139 | var rejectBtn = banner.querySelector('[data-frbl-cookie-action="reject"]'); |
| 140 | var isPopup = banner.classList.contains('frbl-cookie-notice--popup'); |
| 141 | var previouslyFocused = document.activeElement; |
| 142 | |
| 143 | if (isPopup) { |
| 144 | document.body.classList.add('frbl-cookie-notice-lock-scroll'); |
| 145 | |
| 146 | if (acceptBtn) { |
| 147 | acceptBtn.focus({ preventScroll: true }); |
| 148 | } |
| 149 | |
| 150 | document.addEventListener('keydown', trapFocus); |
| 151 | } |
| 152 | |
| 153 | if (acceptBtn) { |
| 154 | acceptBtn.addEventListener('click', function () { |
| 155 | handleDecision('accepted'); |
| 156 | }); |
| 157 | } |
| 158 | |
| 159 | if (rejectBtn) { |
| 160 | rejectBtn.addEventListener('click', function () { |
| 161 | handleDecision('rejected'); |
| 162 | }); |
| 163 | } |
| 164 | |
| 165 | function trapFocus(event) { |
| 166 | if (event.key !== 'Tab') { |
| 167 | return; |
| 168 | } |
| 169 | |
| 170 | var focusable = Array.prototype.slice.call( |
| 171 | banner.querySelectorAll('a[href], button') |
| 172 | ); |
| 173 | |
| 174 | if (!focusable.length) { |
| 175 | return; |
| 176 | } |
| 177 | |
| 178 | var first = focusable[0]; |
| 179 | var last = focusable[focusable.length - 1]; |
| 180 | |
| 181 | if (event.shiftKey && document.activeElement === first) { |
| 182 | event.preventDefault(); |
| 183 | last.focus(); |
| 184 | } else if (!event.shiftKey && document.activeElement === last) { |
| 185 | event.preventDefault(); |
| 186 | first.focus(); |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | var decided = false; |
| 191 | |
| 192 | function handleDecision(decision) { |
| 193 | if (decided) { |
| 194 | return; |
| 195 | } |
| 196 | |
| 197 | decided = true; |
| 198 | |
| 199 | setConsentCookie(decision); |
| 200 | updateConsentMode(decision); |
| 201 | hideBanner(); |
| 202 | dispatchConsentEvent(decision); |
| 203 | logDecision(decision); |
| 204 | |
| 205 | if (decision === 'accepted') { |
| 206 | fetchAndInjectScripts(); |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | function updateConsentMode(decision) { |
| 211 | var granted = decision === 'accepted' ? 'granted' : 'denied'; |
| 212 | |
| 213 | window.dataLayer = window.dataLayer || []; |
| 214 | window.dataLayer.push(['consent', 'update', { |
| 215 | ad_storage: granted, |
| 216 | ad_user_data: granted, |
| 217 | ad_personalization: granted, |
| 218 | analytics_storage: granted |
| 219 | }]); |
| 220 | } |
| 221 | |
| 222 | function setConsentCookie(decision) { |
| 223 | var maxAge = parseInt(frblCookieNotice.expirationDays, 10) * 24 * 60 * 60; |
| 224 | var secure = window.location.protocol === 'https:' ? '; Secure' : ''; |
| 225 | |
| 226 | document.cookie = frblCookieNotice.cookieName + '=' + decision + |
| 227 | '; path=' + frblCookieNotice.cookiePath + '; max-age=' + maxAge + '; SameSite=Lax' + secure; |
| 228 | } |
| 229 | |
| 230 | function hideBanner() { |
| 231 | banner.classList.add('frbl-cookie-notice--hidden'); |
| 232 | document.body.classList.remove('frbl-cookie-notice-lock-scroll'); |
| 233 | document.removeEventListener('keydown', trapFocus); |
| 234 | |
| 235 | if (isPopup && previouslyFocused && typeof previouslyFocused.focus === 'function') { |
| 236 | previouslyFocused.focus({ preventScroll: true }); |
| 237 | } |
| 238 | |
| 239 | window.setTimeout(function () { |
| 240 | if (banner.parentNode) { |
| 241 | banner.parentNode.removeChild(banner); |
| 242 | } |
| 243 | }, 300); |
| 244 | } |
| 245 | |
| 246 | function dispatchConsentEvent(decision) { |
| 247 | var event; |
| 248 | |
| 249 | try { |
| 250 | event = new CustomEvent('frblCookieConsent', { detail: { consent: decision } }); |
| 251 | } catch (e) { |
| 252 | event = document.createEvent('CustomEvent'); |
| 253 | event.initCustomEvent('frblCookieConsent', true, true, { consent: decision }); |
| 254 | } |
| 255 | |
| 256 | document.dispatchEvent(event); |
| 257 | } |
| 258 | |
| 259 | function logDecision(decision) { |
| 260 | var nonceForm = new FormData(); |
| 261 | nonceForm.append('action', 'frbl_get_cookie_notice_log_nonce'); |
| 262 | |
| 263 | fetch(frblCookieNotice.ajaxUrl, { |
| 264 | method: 'POST', |
| 265 | credentials: 'same-origin', |
| 266 | body: nonceForm |
| 267 | }) |
| 268 | .then(function (response) { |
| 269 | return response.json(); |
| 270 | }) |
| 271 | .then(function (response) { |
| 272 | if (!response || !response.success || !response.data) { |
| 273 | return; |
| 274 | } |
| 275 | |
| 276 | var formData = new FormData(); |
| 277 | formData.append('action', 'frbl_log_cookie_consent'); |
| 278 | formData.append('nonce', response.data.nonce); |
| 279 | formData.append('decision', decision); |
| 280 | |
| 281 | return fetch(frblCookieNotice.ajaxUrl, { |
| 282 | method: 'POST', |
| 283 | credentials: 'same-origin', |
| 284 | body: formData |
| 285 | }); |
| 286 | }) |
| 287 | .catch(function () { |
| 288 | // Best-effort: the aggregate stat is not critical to the consent flow. |
| 289 | }); |
| 290 | } |
| 291 | } |
| 292 | })(); |
| 293 |