frontblocks-cookie-notice.js
381 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, trackingIntegrations) { |
| 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 | if (!Array.isArray(trackingIntegrations)) { |
| 59 | trackingIntegrations = []; |
| 60 | } |
| 61 | |
| 62 | trackingIntegrations.forEach(function (integration) { |
| 63 | var trackingType = integration && integration.type ? integration.type : ''; |
| 64 | var trackingId = integration && integration.id ? integration.id : ''; |
| 65 | |
| 66 | if (!trackingId) { |
| 67 | return; |
| 68 | } |
| 69 | |
| 70 | if (trackingType === 'clientify_analytics_plus') { |
| 71 | var clientifyPixel = document.createElement('script'); |
| 72 | clientifyPixel.defer = true; |
| 73 | clientifyPixel.src = 'https://analyticsplusdev.clientify.net/analytics_plus/pixel/' + encodeURIComponent(trackingId); |
| 74 | document.head.appendChild(clientifyPixel); |
| 75 | } else if (trackingType === 'clientify_analytics_classic') { |
| 76 | (function (d, w, u, o) { |
| 77 | w[o] = w[o] || function () { |
| 78 | (w[o].q = w[o].q || []).push(arguments); |
| 79 | }; |
| 80 | var a = d.createElement('script'), |
| 81 | m = d.getElementsByTagName('script')[0]; |
| 82 | a.async = 1; a.src = u; |
| 83 | m.parentNode.insertBefore(a, m); |
| 84 | })(document, window, 'https://analytics.clientify.net/tracker.js', 'ana'); |
| 85 | window.ana('setTrackerUrl', 'https://analytics.clientify.net'); |
| 86 | window.ana('setTrackingCode', trackingId); |
| 87 | window.ana('trackPageview'); |
| 88 | } else if (trackingType === 'brevo') { |
| 89 | var brevoScript = document.createElement('script'); |
| 90 | brevoScript.async = true; |
| 91 | brevoScript.src = 'https://cdn.brevo.com/js/sdk-loader.js'; |
| 92 | document.head.appendChild(brevoScript); |
| 93 | |
| 94 | window.Brevo = window.Brevo || []; |
| 95 | window.Brevo.push(['init', { client_key: trackingId }]); |
| 96 | } |
| 97 | }); |
| 98 | }; |
| 99 | } |
| 100 | |
| 101 | function fetchAndInjectScripts() { |
| 102 | var formData = new FormData(); |
| 103 | formData.append('action', 'frbl_get_cookie_notice_config'); |
| 104 | |
| 105 | fetch(frblCookieNotice.ajaxUrl, { |
| 106 | method: 'POST', |
| 107 | credentials: 'same-origin', |
| 108 | body: formData |
| 109 | }) |
| 110 | .then(function (response) { |
| 111 | return response.json(); |
| 112 | }) |
| 113 | .then(function (response) { |
| 114 | if (response && response.success && response.data && window.frblCookieNoticeInject) { |
| 115 | window.frblCookieNoticeInject(response.data.gtmId, response.data.ga4Id, response.data.trackingIntegrations); |
| 116 | } |
| 117 | }) |
| 118 | .catch(function () { |
| 119 | // Network hiccup: consent is already stored locally; nothing else to do here. |
| 120 | }); |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * For an already-decided visitor, request the tracking scripts (accepted) |
| 125 | * or simply do nothing further (rejected). Normally an inline bootstrap |
| 126 | * script printed on wp_head already does this as early as possible, well |
| 127 | * before this file even loads — this is the fallback for sites whose |
| 128 | * Content Security Policy blocks that unnonced inline script, so tracking |
| 129 | * still starts there too, just later. |
| 130 | * |
| 131 | * Deliberately separate from hiding the banner (see hideBannerIfDecided()): |
| 132 | * that inline copy runs on wp_head, before '#frbl-cookie-notice' exists in |
| 133 | * the DOM at all, so it can never do the hiding itself — only this file, |
| 134 | * running once the DOM is ready, can. |
| 135 | */ |
| 136 | function requestTrackingIfNeeded() { |
| 137 | if (window.frblCookieNoticeBootstrapped) { |
| 138 | return; |
| 139 | } |
| 140 | |
| 141 | defineInjectHelper(); |
| 142 | |
| 143 | var isStale = typeof window.frblCookieNoticeIsConsentStale === 'function' && window.frblCookieNoticeIsConsentStale(); |
| 144 | |
| 145 | if (readCookie(frblCookieNotice.cookieName) === 'accepted' && !isStale) { |
| 146 | fetchAndInjectScripts(); |
| 147 | } |
| 148 | |
| 149 | window.frblCookieNoticeBootstrapped = true; |
| 150 | } |
| 151 | |
| 152 | function hideBannerIfDecided(banner) { |
| 153 | var consent = readCookie(frblCookieNotice.cookieName); |
| 154 | |
| 155 | // An add-on tracking per-category consent (analytics vs. marketing) can |
| 156 | // define this to say "the categories cookie is stale — e.g. the site |
| 157 | // admin just added a new integration — so re-prompt even though the |
| 158 | // legacy accepted/rejected cookie here still looks decided." |
| 159 | if (typeof window.frblCookieNoticeIsConsentStale === 'function' && window.frblCookieNoticeIsConsentStale()) { |
| 160 | return false; |
| 161 | } |
| 162 | |
| 163 | if (banner && (consent === 'accepted' || consent === 'rejected')) { |
| 164 | banner.style.display = 'none'; |
| 165 | return true; |
| 166 | } |
| 167 | |
| 168 | return false; |
| 169 | } |
| 170 | |
| 171 | function init() { |
| 172 | if (typeof frblCookieNotice === 'undefined') { |
| 173 | return; |
| 174 | } |
| 175 | |
| 176 | requestTrackingIfNeeded(); |
| 177 | |
| 178 | var banner = document.getElementById('frbl-cookie-notice'); |
| 179 | |
| 180 | if (!banner) { |
| 181 | return; |
| 182 | } |
| 183 | |
| 184 | if (hideBannerIfDecided(banner)) { |
| 185 | // Already decided: nothing left to wire up. |
| 186 | return; |
| 187 | } |
| 188 | |
| 189 | var acceptBtn = banner.querySelector('[data-frbl-cookie-action="accept"]'); |
| 190 | var rejectBtn = banner.querySelector('[data-frbl-cookie-action="reject"]'); |
| 191 | var isPopup = banner.classList.contains('frbl-cookie-notice--popup'); |
| 192 | var previouslyFocused = document.activeElement; |
| 193 | |
| 194 | revealBanner(); |
| 195 | |
| 196 | if (acceptBtn) { |
| 197 | acceptBtn.addEventListener('click', function () { |
| 198 | handleDecision('accepted'); |
| 199 | }); |
| 200 | } |
| 201 | |
| 202 | if (rejectBtn) { |
| 203 | rejectBtn.addEventListener('click', function () { |
| 204 | handleDecision('rejected'); |
| 205 | }); |
| 206 | } |
| 207 | |
| 208 | var customizeBtn = banner.querySelector('[data-frbl-cookie-action="customize"]'); |
| 209 | |
| 210 | if (customizeBtn) { |
| 211 | // Deliberately not routed through handleDecision(): clicking it isn't a |
| 212 | // decision by itself, just a request to see more detail. An add-on |
| 213 | // listens for this to open its own categories dialog. |
| 214 | customizeBtn.addEventListener('click', function () { |
| 215 | var event; |
| 216 | |
| 217 | try { |
| 218 | event = new CustomEvent('frblCookieNoticeCustomize'); |
| 219 | } catch (e) { |
| 220 | event = document.createEvent('CustomEvent'); |
| 221 | event.initCustomEvent('frblCookieNoticeCustomize', true, true, null); |
| 222 | } |
| 223 | |
| 224 | document.dispatchEvent(event); |
| 225 | }); |
| 226 | } |
| 227 | |
| 228 | /** |
| 229 | * Reveal the banner: removes the '--init' class printed by PHP so the |
| 230 | * CSS transition animates it in (slide up for the bar, slide in from |
| 231 | * its anchored edge for the box, scale/fade in for the popup). The |
| 232 | * popup gets a short extra delay first, per its layout's own request, |
| 233 | * so it doesn't feel jarring the instant the page loads. |
| 234 | */ |
| 235 | function revealBanner() { |
| 236 | var delay = isPopup ? 150 : 20; |
| 237 | |
| 238 | window.setTimeout(function () { |
| 239 | banner.classList.remove('frbl-cookie-notice--init'); |
| 240 | |
| 241 | if (isPopup) { |
| 242 | document.body.classList.add('frbl-cookie-notice-lock-scroll'); |
| 243 | |
| 244 | if (acceptBtn) { |
| 245 | acceptBtn.focus({ preventScroll: true }); |
| 246 | } |
| 247 | |
| 248 | document.addEventListener('keydown', trapFocus); |
| 249 | } |
| 250 | }, delay); |
| 251 | } |
| 252 | |
| 253 | function trapFocus(event) { |
| 254 | if (event.key !== 'Tab') { |
| 255 | return; |
| 256 | } |
| 257 | |
| 258 | var focusable = Array.prototype.slice.call( |
| 259 | banner.querySelectorAll('a[href], button') |
| 260 | ); |
| 261 | |
| 262 | if (!focusable.length) { |
| 263 | return; |
| 264 | } |
| 265 | |
| 266 | var first = focusable[0]; |
| 267 | var last = focusable[focusable.length - 1]; |
| 268 | |
| 269 | if (event.shiftKey && document.activeElement === first) { |
| 270 | event.preventDefault(); |
| 271 | last.focus(); |
| 272 | } else if (!event.shiftKey && document.activeElement === last) { |
| 273 | event.preventDefault(); |
| 274 | first.focus(); |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | var decided = false; |
| 279 | |
| 280 | function handleDecision(decision) { |
| 281 | if (decided) { |
| 282 | return; |
| 283 | } |
| 284 | |
| 285 | decided = true; |
| 286 | |
| 287 | setConsentCookie(decision); |
| 288 | updateConsentMode(decision); |
| 289 | hideBanner(); |
| 290 | dispatchConsentEvent(decision); |
| 291 | logDecision(decision); |
| 292 | |
| 293 | if (decision === 'accepted') { |
| 294 | fetchAndInjectScripts(); |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | function updateConsentMode(decision) { |
| 299 | var granted = decision === 'accepted' ? 'granted' : 'denied'; |
| 300 | |
| 301 | window.dataLayer = window.dataLayer || []; |
| 302 | window.dataLayer.push(['consent', 'update', { |
| 303 | ad_storage: granted, |
| 304 | ad_user_data: granted, |
| 305 | ad_personalization: granted, |
| 306 | analytics_storage: granted |
| 307 | }]); |
| 308 | } |
| 309 | |
| 310 | function setConsentCookie(decision) { |
| 311 | var maxAge = parseInt(frblCookieNotice.expirationDays, 10) * 24 * 60 * 60; |
| 312 | var secure = window.location.protocol === 'https:' ? '; Secure' : ''; |
| 313 | |
| 314 | document.cookie = frblCookieNotice.cookieName + '=' + decision + |
| 315 | '; path=' + frblCookieNotice.cookiePath + '; max-age=' + maxAge + '; SameSite=Lax' + secure; |
| 316 | } |
| 317 | |
| 318 | function hideBanner() { |
| 319 | banner.classList.add('frbl-cookie-notice--hidden'); |
| 320 | document.body.classList.remove('frbl-cookie-notice-lock-scroll'); |
| 321 | document.removeEventListener('keydown', trapFocus); |
| 322 | |
| 323 | if (isPopup && previouslyFocused && typeof previouslyFocused.focus === 'function') { |
| 324 | previouslyFocused.focus({ preventScroll: true }); |
| 325 | } |
| 326 | |
| 327 | window.setTimeout(function () { |
| 328 | if (banner.parentNode) { |
| 329 | banner.parentNode.removeChild(banner); |
| 330 | } |
| 331 | }, 300); |
| 332 | } |
| 333 | |
| 334 | function dispatchConsentEvent(decision) { |
| 335 | var event; |
| 336 | |
| 337 | try { |
| 338 | event = new CustomEvent('frblCookieConsent', { detail: { consent: decision } }); |
| 339 | } catch (e) { |
| 340 | event = document.createEvent('CustomEvent'); |
| 341 | event.initCustomEvent('frblCookieConsent', true, true, { consent: decision }); |
| 342 | } |
| 343 | |
| 344 | document.dispatchEvent(event); |
| 345 | } |
| 346 | |
| 347 | function logDecision(decision) { |
| 348 | var nonceForm = new FormData(); |
| 349 | nonceForm.append('action', 'frbl_get_cookie_notice_log_nonce'); |
| 350 | |
| 351 | fetch(frblCookieNotice.ajaxUrl, { |
| 352 | method: 'POST', |
| 353 | credentials: 'same-origin', |
| 354 | body: nonceForm |
| 355 | }) |
| 356 | .then(function (response) { |
| 357 | return response.json(); |
| 358 | }) |
| 359 | .then(function (response) { |
| 360 | if (!response || !response.success || !response.data) { |
| 361 | return; |
| 362 | } |
| 363 | |
| 364 | var formData = new FormData(); |
| 365 | formData.append('action', 'frbl_log_cookie_consent'); |
| 366 | formData.append('nonce', response.data.nonce); |
| 367 | formData.append('decision', decision); |
| 368 | |
| 369 | return fetch(frblCookieNotice.ajaxUrl, { |
| 370 | method: 'POST', |
| 371 | credentials: 'same-origin', |
| 372 | body: formData |
| 373 | }); |
| 374 | }) |
| 375 | .catch(function () { |
| 376 | // Best-effort: the aggregate stat is not critical to the consent flow. |
| 377 | }); |
| 378 | } |
| 379 | } |
| 380 | })(); |
| 381 |