1window.umbracoEngage = (function (umbEngage) { 2 umbEngage.analytics = (function () { 3 4 var state = { 5 // Set via init. 6 pageviewGuid: null, 7 8 // Version of client side JSON format 9 version: 5, 10 11 timeOnPage: { 12 start: getTimestamp(), 13 }, 14 15 scrollDepth: { 16 pixels: 0, 17 percentage: 0, 18 }, 19 20 // format: { href: string, timeClicked: Date } 21 links: [], 22 23 // Generic events (category/label/action etc) 24 events: [], 25 26 // IAnalyticsVideos 27 videos: [], 28 29 umbracoForms: [], 30 }; 31 32 function init(pageviewGuid) { 33 state.pageviewGuid = pageviewGuid; 34 35 trackScrollDepth(); 36 initLinks(); 37 engagementTracker.init(); 38 39 // Track all untracked <video> elements and YouTube/Vimeo iframes on init & Video/IFrame DOM change. 40 trackVideos(); 41 observeVideoDOMChanges(); 42 43 // Track all untracked Umbraco Forms on init & Form DOM changes. 44 trackUmbracoForms(); 45 observeFormDOMChanges(); 46 47 document.addEventListener("visibilitychange", function () { 48 if (document.visibilityState === "hidden") { 49 sendClientSideData(); 50 } 51 }, false); 52 53 // Fallback: 'pagehide' fires reliably during page navigations (e.g. form POST) 54 // where 'visibilitychange' may not fire in all browsers. 55 // sendClientSideData() guards against duplicate sends via the pageviewGuid null-check 56 // and resetClientSideData() clears sent data, so both listeners can coexist safely. 57 window.addEventListener("pagehide", function () { 58 sendClientSideData(); 59 }, false); 60 61 if (typeof umbEngage.init === "function") {
62 umbEngage.init(); 63 } 64 } 65 66 var FORM_ACTION = { 67 FOCUS: "Focus", 68 UNFOCUS_DATA_ENTERED: "UnfocusDataEntered", 69 UNFOCUS_NO_DATA_ENTERED: "UnfocusNoDataEntered", 70 }; 71 72 function MarkElementAsTrackedByEngage(element) { 73 if (element == null) return; 74 75 // Mark the element as tracked by umbracoEngage 76 if (typeof element.__engageTracked === "undefined") { 77 element.__engageTracked = true; 78 } 79 } 80 81 function IsElementTrackedByEngage(element) { 82 if (element == null) 83 return false; 84 85 // Check if the element is marked as tracked by umbracoEngage 86 return typeof element.__engageTracked !== "undefined" && element.__engageTracked === true; 87 } 88 89 function trackUmbracoForms() { 90 var formElements = [].slice.call(document.querySelectorAll("form")).filter(function (form) { 91 var id = getUmbracoFormsId(form); 92 var name = getUmbracoFormsName(form); 93 94 return id != null && name != null && !form.hasAttribute("engage-no-tracking") && !IsElementTrackedByEngage(form); 95 }); 96 97 formElements.forEach(function (formElement) { 98 trackUmbracoFormsForm(formElement); 99 MarkElementAsTrackedByEngage(formElement); // Mark as tracked 100 }); 101 } 102 103 // Observe changes to the DOM to track new form elements 104 function observeFormDOMChanges() { 105 var observer = new MutationObserver(function(mutations) { 106 var shouldTrack = false; 107 mutations.forEach(function(mutation) { 108 // Check added nodes for forms 109 mutation.addedNodes.forEach(function(node) { 110 if (node.nodeType === 1 && node.tagName === "FORM") { 111 shouldTrack = true; 112 } 113 }); 114 // Also check attribute changes on forms 115 if ( 116 mutation.type === "attributes" && 117 mutation.target.tagName === "FORM" 118 ) { 119 shouldTrack = true; 120 } 121 }); 122 if (shouldTrack) { 123 trackUmbracoForms(); 124 } 125 }); 126 127 observer.observe(document.body, { 128 childList: true, 129 subtree: true, 130 attributes: true, 131 attributeFilter: ["action", "id", "name"] // or any relevant form attributes 132 }); 133 } 134 135 function getUmbracoFormsId(form) { 136 if (form == null) return null; 137 138 var formIdEl = form.querySelector("input[name='FormId'][type='hidden']"); 139 if (formIdEl == null) return null; 140 return formIdEl.value; 141 } 142 143 function getUmbracoFormsName(form) { 144 if (form == null) return null; 145 146 var formNameEl = form.querySelector("input[name='FormName'][type='hidden']"); 147 if (formNameEl == null) return null; 148 return formNameEl.value; 149 } 150 151 function trackUmbracoFormsForm(form) { 152 var formId = getUmbracoFormsId(form); 153 154 var formMeta = loadFormMeta(formId, true) || { 155 id: formId, 156 name: getUmbracoFormsName(form), 157 submitted: false, 158 raisedErrors: false, 159 timeStart: null, 160 timeEnd: null, 161 inViewPort: false, 162 translated: false, //Decided on element change 163 actions: [], 164 errors: [], 165 }; 166 167 detectBrowserTranslatedForm(formMeta); 168 state.umbracoForms.push(formMeta); 169 170 var fields = [].slice.call(form.querySelectorAll("input,textarea,select")).filter(function (field) { 171 return field.type !== "hidden" && field.type !== "button" && field.type !== "submit" && !field.hasAttribute("engage-no-tracking"); 172 }); 173 174 fields.forEach(function (field) { 175 trackField(field, formMeta); 176 }); 177 178 // In newer versions of Umbraco, the submit button on the last page 179 // has a data-umb="submit-forms-form", then we know 100% sure this is the last page. 180 // Otherwise we can detect the last page (in the default theme) by looking for a specific 181 // CSS class on the submit button. 182 // Unfortunately there are issues when clients use custom themes and use a different CSS class. 183 // In that case we will never be able to detect the last page and thus always consider the form 184 // to be a multi page form. So starting now (1.15.0) we will disable multi page detection until 185 // we find a more reliable way. This means multi page forms will not work properly but all single page 186 // forms do which is the majority. 187 var isLastPage = true; 188 189 if (isLastPage) { 190 // Add hidden field to form containing our pageview id in order to 191 // connect this form record to the pageview 192 var hiddenInput = document.createElement("input"); 193 hiddenInput.type = "hidden"; 194 hiddenInput.name = "__umbracoEngageAnalyticsPageviewId"; 195 hiddenInput.value = state.pageviewGuid; 196 197 var firstInput = form.querySelector("input"); 198 199 if (firstInput != null) { 200 form.insertBefore(hiddenInput, firstInput); 201 } else { 202 form.appendChild(hiddenInput); 203 } 204 } 205 206 // Probably related to jQuery validation / aspnet-validation, 207 // form.addEventListener("submit") does not trigger but form.onsubmit = function() { ... } 208 // still works so we use that. 209 var onSubmitFn = form.onsubmit; 210 211 function detectBrowserTranslatedForm(formMeta) { 212 //Google Chrome adds translated-ltr or translated-rtl to the html element when the page is translated. 213 let htmlElement = document.querySelector('html'); 214 215 //Edge adds the _msttexthash to the title if translated 216 let titleElement = document.querySelector('title'); 217 218 function onChange(){ 219 if (htmlElement && (htmlElement.classList.contains('translated-ltr') || htmlElement.classList.contains('translated-rtl'))) { 220 formMeta.translated = true; 221 } 222 else if (titleElement && titleElement.hasAttribute('_msttexthash')){ 223 formMeta.translated = true; 224 } 225 else{ 226 formMeta.translated = false;
227 } 228 } 229 230 // Check initial state 231 onChange(); 232 233 const observer = new MutationObserver(onChange); 234 const config = { attributes: true }; 235 236 // Only observe elements that exist 237 if (htmlElement) { 238 observer.observe(htmlElement, config); 239 } 240 if (titleElement) { 241 observer.observe(titleElement, config); 242 } 243 } 244 245 form.onsubmit = function (evt) { 246 if (typeof onSubmitFn === "function") { 247 onSubmitFn.apply(form, arguments); 248 } 249 250 if (!isLastPage || (evt.submitter != null && evt.submitter.classList.contains("cancel"))) { 251 // User clicked "Previous" or "Next", this is a multi-page form. 252 // Store state for next pageview and remove from state.umbracoForms so we do not 253 // already send it to server. 254 saveFormMeta(formMeta); 255 256 var idx = state.umbracoForms.indexOf(formMeta); 257 if (idx !== -1) { 258 state.umbracoForms.splice(idx, 1); 259 } 260 } else { 261 // Regular submit 262 formMeta.submitted = true; 263 formMeta.timeEnd = Math.floor(+new Date() / 1000); 264 } 265 } 266 267 trackUmbracoFormsFormInView(formMeta, form); 268 } 269 270 function supportsSessionStorage() { 271 return window.sessionStorage != null && 272 typeof window.sessionStorage.setItem === "function" && 273 typeof window.sessionStorage.getItem === "function"; 274 } 275 276 function saveFormMeta(formMeta) { 277 if (supportsSessionStorage()) { 278 var json = JSON.stringify(formMeta); 279 var key = getFormMetaStorageKey(formMeta.id); 280 sessionStorage[key] = json; 281 } 282 } 283 284 function loadFormMeta(id, removeFromStorage) { 285 if (supportsSessionStorage()) { 286 var key = getFormMetaStorageKey(id); 287 var value = sessionStorage[key]; 288 289 if (removeFromStorage) { 290 sessionStorage.removeItem(key); 291 } 292 293 if (value != null) { 294 try { 295 return JSON.parse(value); 296 } catch (err) { 297 return null; 298 } 299 } 300 } 301 } 302 303 function getFormMetaStorageKey(id) { 304 return "umbraco-engage-form-" + id; 305 } 306 307 function trackField(field, formMeta) { 308 function addAction(action) { 309 addFormAction(field, action, formMeta); 310 } 311 312 field.addEventListener("focus", function () { 313 addAction(FORM_ACTION.FOCUS); 314 }, { passive: true }); 315 316 field.addEventListener("blur", function () { 317 var value = getFieldValue(field); 318 319 if (value == null || value.length === 0) { 320 addAction(FORM_ACTION.UNFOCUS_NO_DATA_ENTERED); 321 } else { 322 addAction(FORM_ACTION.UNFOCUS_DATA_ENTERED); 323 } 324 }, { passive: true }); 325 326 function setTimeStart() { 327 if (formMeta.timeStart == null) { 328 formMeta.timeStart = Math.floor(+new Date() / 1000); 329 } 330 } 331 332 field.addEventListener("keyup", setTimeStart, { passive: true, once: true }); 333 field.addEventListener("change", setTimeStart, { passive: true, once: true }); 334 335 field.addEventListener("invalid", function () { 336 // We cannot detect the specific type of error, everything is simply a validation error. 337 var errorType = "validation"; 338 addFormError(field, errorType, formMeta); 339 }, { passive: true }); 340 341 // Observe validation parent class attribute to detect form validation errors
342 var validationEl = field.parentNode.querySelector("span[data-valmsg-for]"); 343 if (validationEl != null) { 344 var observer = new MutationObserver(function (mutations) { 345 for (var i = 0; i < mutations.length; i++) { 346 var mutation = mutations[i]; 347 348 if (mutation.type === "attributes" && 349 mutation.target.className.indexOf("error") > -1 && 350 mutation.oldValue.indexOf("valid") > -1) { 351 // We cannot detect the specific type of error, everything is simply a validation error. 352 var errorType = "validation"; 353 addFormError(field, errorType, formMeta); 354 } 355 } 356 }); 357 358 var cfg = { attributes: true, attributeFilter: ["class"], attributeOldValue: true }; 359 observer.observe(validationEl, cfg); 360 } 361 } 362 363 function getFieldValue(field) { 364 if (field == null) return ""; 365 return field.value; 366 } 367 368 function addFormAction(field, action, formMeta) { 369 if (formMeta == null) return; 370 371 if (!Array.isArray(formMeta.actions)) { 372 formMeta.actions = []; 373 } 374 375 var fieldName = getFieldName(field); 376 var fieldKey = getFieldKey(field); 377 378 var evt = { 379 timestamp: Math.floor(+new Date() / 1000), 380 field: fieldName, 381 fieldKey: fieldKey, 382 action: action, 383 }; 384 385 formMeta.actions.push(evt); 386 } 387 388 function addFormError(field, errorType, formMeta) { 389 if (formMeta == null) return; 390 391 formMeta.raisedErrors = true; 392 393 if (!Array.isArray(formMeta.errors)) { 394 formMeta.errors = []; 395 } 396 397 var fieldName = getFieldName(field); 398 var fieldKey = getFieldKey(field); 399 400 var error = { 401 timestamp: Math.floor(+new Date() / 1000), 402 field: fieldName, 403 fieldKey: fieldKey, 404 errorType: errorType, 405 }; 406 407 formMeta.errors.push(error); 408 } 409 410 function getFieldName(field) { 411 var id = getFieldId(field); 412 if (!id) return "<UNKNOWN>"; 413 414 var label = document.querySelector("label[for='" + id + "']"); 415 if (label != null) { 416 return label.textContent.replace(/^\s*|[*\s]*$/g, ""); 417 } else { 418 return id; 419 } 420 } 421 422 function getFieldId(field) { 423 var id = field.id; 424 if (!id) return null; 425 426 // The datepicker field uses 2 inputs with one having an id of format 427 // <GUID>_1 and the label of the field will reference <GUID> in its for attribute. 428 return id.replace(/_\d+$/, ""); 429 } 430 431 function getFieldKey(field) { 432 if (field.dataset.umb != null) { 433 return field.dataset.umb.replace(/_\d+$/, ""); 434 } else { 435 return getFieldId(field); 436 } 437 } 438 439 function trackUmbracoFormsFormInView(formMeta, element) { 440 if (typeof window.IntersectionObserver !== "function") { 441 // IntersectionObserver not available, browser probably too old. 442 return; 443 } 444 445 function onIntersect(entries) { 446 for (var i = 0; i < entries.length; i++) { 447 if (entries[i].isIntersecting) { 448 formMeta.inViewPort = true; 449 450 // We are only interested if the form was ever in view, 451 // so when this is the case we stop observing. 452 observer.disconnect(); 453 454 break; 455 } 456 } 457 } 458 459 // Consider this visible when at least 1 pixel is in view. 460 // Some forms will never be completely in view because they are too large. 461 var options = { threshold: 0 }; 462 var observer = new IntersectionObserver(onIntersect, options); 463 observer.observe(element); 464 } 465 466 function initLinks() { 467 document.addEventListener("click", onClick); 468 document.addEventListener("auxclick", onClick); 469 470 function onClick(evt) { 471 // We work with the <a> element closest to the element being clicked. 472 // This is necessary to properly recognize clicks on element nested inside an <a> 473 // which will trigger the <a> but do not trigger the onClick event for the <a> itself. 474 var a = evt.target.closest("a"); 475 if (a == null || a.href == null || a.href.length === 0) { 476 return; 477 } 478 479 if (evt.button !== 0 && evt.button !== 1) { 480 // Only track left or middle button clicks on links 481 return; 482 } 483 484 var href = a.href; 485 486 // Link should be one of the following: 487 // 1) External (link domain different from current domain) 488 // 2) Points to pdf/doc/docx 489 // 3) Anchor link (e.g. #header) 490 // 4) mailto: or tel: link 491 // Anything else will not be tracked. 492 var currentDomain = window.location.origin; 493 var isExternal = href.indexOf("http") === 0 && href.indexOf(currentDomain) !== 0; 494 var isDocument = /\.(?:pdf|doc|docx)(?:\?|$)/i.test(href); 495 var isAnchor = href.indexOf("#") === 0 || href.indexOf(currentDomain + "/#") === 0; 496 var isMailToOrTel = href.indexOf("mailto:") === 0 || href.indexOf("tel:") === 0; 497 498 var shouldBeTracked = isExternal || isDocument || isAnchor || isMailToOrTel; 499 500 if (shouldBeTracked) { 501 let link = { href: href, timeClicked: new Date() } 502 state.links.push(link); 503 //When user clicks on an external link, if the focus is on another tab, the data from previous tab will be sent and the state will be emptied. 504 //To ensure that links are still shown in the Cockpit even if the data has already been sent to the server, we store the links in the localStorage. 505 //This resets again after the cockpit gets loaded e.g. when actively navigating to another page. 506 addExternalLinksToLocalStorage(link); 507 } 508 } 509 } 510 511 function addExternalLinksToLocalStorage(link) { 512 let existingLinks = JSON.parse(localStorage.getItem("engageExternalLinks")); 513 if(existingLinks == null) 514 existingLinks = []; 515 516 existingLinks.push(link); 517 localStorage.setItem("engageExternalLinks", JSON.stringify(existingLinks)); 518 } 519 520 function addExternalLinksToLocalStorage(link) { 521 let existingLinks = JSON.parse(localStorage.getItem("engageExternalLinks")); 522 if(existingLinks == null) 523 existingLinks = []; 524 525 existingLinks.push(link); 526 localStorage.setItem("engageExternalLinks", JSON.stringify(existingLinks)); 527 } 528 529 function trackVideos() { 530 trackVideoElements(); 531 trackYouTube(); 532 trackVimeo(); 533 } 534 535 // Observe changes to the DOM to track new video and iframe elements 536 function observeVideoDOMChanges() { 537 var observer = new MutationObserver(function(mutations) { 538 var shouldTrack = false;
539 mutations.forEach(function(mutation) { 540 // Check added nodes 541 mutation.addedNodes.forEach(function(node) { 542 if ( 543 (node.nodeType === 1 && node.tagName === "VIDEO") || 544 (node.nodeType === 1 && node.tagName === "IFRAME" && 545 (node.src && ( 546 node.src.includes("youtube.com") || 547 node.src.includes("youtube-nocookie.com") || 548 node.src.includes("player.vimeo.com/video") 549 )) 550 ) 551 ) { 552 shouldTrack = true; 553 } 554 }); 555 // Also check attribute changes (e.g., src changes) 556 if ( 557 mutation.type === "attributes" && 558 (mutation.target.tagName === "IFRAME" || mutation.target.tagName === "VIDEO") 559 ) { 560 const src = mutation.target.src || ""; 561 if ( 562 src.includes("youtube.com") || 563 src.includes("youtube-nocookie.com") || 564 src.includes("player.vimeo.com/video") 565 ) { 566 shouldTrack = true; 567 } 568 } 569 }); 570 if (shouldTrack) { 571 trackVideos(); 572 } 573 }); 574 575 observer.observe(document.body, { 576 childList: true, 577 subtree: true, 578 attributes: true, 579 attributeFilter: ["src"] 580 }); 581 } 582 583 var VIDEO_EVENT = { 584 AUTOPLAY: "Autoplay", 585 PLAY: "Play", 586 PAUSE: "Pause", 587 RESUME: "Resume", 588 ENDED: "Ended", 589 SEEK: "Seek", 590 }; 591 592 var VIDEO_SOURCE = { 593 FILE: "File", 594 YOUTUBE: "YouTube", 595 VIMEO: "Vimeo", 596 }; 597 598 599 // Track all untracked <video> elements 600 function trackVideoElements() { 601 if (!Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "playing")) { 602 Object.defineProperty(HTMLMediaElement.prototype, "playing", { 603 get: function () { 604 return this.currentTime > 0 && !this.paused && !this.ended && this.readyState > 2; 605 } 606 }); 607 } 608 609 var videos = Array.prototype.filter.call( 610 document.querySelectorAll("video"), 611 function(video) { 612 return !IsElementTrackedByEngage(video); 613 } 614 ); 615 616 if (videos.length === 0) return; 617 618 // Track every video, even if it is not being watched. 619 for (var i = 0; i < videos.length; i++) { 620 trackVideoElement(videos[i]); 621 MarkElementAsTrackedByEngage(videos[i]); // Mark as tracked to avoid double tracking. 622 } 623 } 624 625 function makeTimeWatcher(updateElapsedFn) { 626 var timeWatchedTs = null; 627 var timeWatchedTimer = null; 628 629 function start() { 630 stop(); 631 632 timeWatchedTs = getTimestamp(); 633 634 timeWatchedTimer = setTimeout(function () { 635 update(); 636 start(); 637 }, 1000); 638 } 639 640 function stop() { 641 if (timeWatchedTimer != null) { 642 update(); 643 644 clearTimeout(timeWatchedTimer); 645 timeWatchedTimer = null; 646 timeWatchedTs = null; 647 } 648 } 649 650 function update() { 651 if (timeWatchedTs != null) { 652 var now = getTimestamp(); 653 var elapsedSeconds = Math.floor((now - timeWatchedTs) / 1000); 654 updateElapsedFn(elapsedSeconds); 655 timeWatchedTs = now; 656 } 657 } 658 659 return { 660 start: start, 661 stop: stop, 662 update: update, 663 }; 664 } 665 666 function trackVideoElement(video) { 667 if (video == null) return; 668 669 var videoMeta = { 670 name: null, 671 source: VIDEO_SOURCE.FILE, 672 url: null, 673 674 totalTimeWatchedInSeconds: 0, 675 totalLengthInSeconds: 0, 676 inViewPort: false, 677 watched: false, 678 timestamp: Math.floor(+new Date() / 1000), 679 680 events: [], 681 }; 682 683 state.videos.push(videoMeta); 684 685 function getCurrentTime() { 686 return Math.floor(video.currentTime); 687 } 688 689 var firstPlay = true; 690 var isPaused = false;
691 692 var timeWatcher = makeTimeWatcher(function (elapsedSeconds) { 693 videoMeta.totalTimeWatchedInSeconds += elapsedSeconds; 694 }); 695 696 function onMetadataLoaded() { 697 videoMeta.totalLengthInSeconds = Math.floor(video.duration); 698 699 if (video.currentSrc != null && video.currentSrc.length > 0) { 700 videoMeta.url = video.currentSrc; 701 702 // <video> elements do not have a name, we use the filename instead. 703 var urlSegments = video.currentSrc.split("/"); 704 videoMeta.name = urlSegments[urlSegments.length - 1]; 705 } 706 } 707 708 if (video.readyState > 0) { 709 // Metadata is immediately available 710 onMetadataLoaded(); 711 } else { 712 video.addEventListener("loadedmetadata", onMetadataLoaded, { once: true, passive: true }); 713 } 714 715 video.addEventListener("play", function () { 716 if (firstPlay && video.autoplay) { 717 addVideoEvent(videoMeta, VIDEO_EVENT.AUTOPLAY, getCurrentTime()); 718 } else if (isPaused) { 719 addVideoEvent(videoMeta, VIDEO_EVENT.RESUME, getCurrentTime()); 720 } else { 721 addVideoEvent(videoMeta, VIDEO_EVENT.PLAY, getCurrentTime()); 722 } 723 724 isPaused = false; 725 firstPlay = false; 726 727 timeWatcher.start(); 728 }, { passive: true }); 729 730 video.addEventListener("pause", function () { 731 var isEnded = video.currentTime === video.duration; 732 addVideoEvent(videoMeta, isEnded ? VIDEO_EVENT.ENDED : VIDEO_EVENT.PAUSE, getCurrentTime()); 733 isPaused = !isEnded; 734 735 timeWatcher.stop(); 736 }, { passive: true }); 737 738 video.addEventListener("seeked", function () { 739 addVideoEvent(videoMeta, VIDEO_EVENT.SEEK, getCurrentTime()); 740 }, { passive: true }); 741 742 video.addEventListener("playing", function () { 743 videoMeta.watched = true; 744 }, { passive: true }); 745 746 trackVideoInView(videoMeta, video); 747 } 748 749 function addVideoEvent(videoMeta, action, timeInVideo) { 750 if (videoMeta == null) return; 751 752 if (!Array.isArray(videoMeta.events)) { 753 videoMeta.events = []; 754 } 755 756 var evt = { 757 timestamp: Math.floor(+new Date() / 1000), 758 action: action, 759 startTimeInVideoInSeconds: timeInVideo, 760 }; 761 762 videoMeta.events.push(evt); 763 } 764 765 function trackVideoInView(videoMeta, element) { 766 if (typeof window.IntersectionObserver !== "function") { 767 // IntersectionObserver not available, browser probably too old. 768 return; 769 } 770 771 function onIntersect(entries) { 772 for (var i = 0; i < entries.length; i++) { 773 if (entries[i].isIntersecting) { 774 videoMeta.inViewPort = true; 775 776 // We are only interested if the video was ever in view, 777 // so when this is the case we stop observing. 778 observer.disconnect(); 779 780 break; 781 } 782 } 783 } 784 785 var options = { threshold: 1.0 }; 786 var observer = new IntersectionObserver(onIntersect, options); 787 observer.observe(element); 788 } 789 790 // Track all untracked YouTube iframes 791 function trackYouTube() { 792 // Track all untracked YouTube iframes 793 var ytIframes = Array.prototype.filter.call( 794 document.querySelectorAll("iframe[src*='youtube.com'], iframe[src*='youtube-nocookie.com']"), 795 function (iframe) { 796 return !IsElementTrackedByEngage(iframe); 797 }); 798 799 if (ytIframes.length === 0) return; 800 801 //Force enablejsapi=1 on all YouTube iframes for our tracking to work
802 ytIframes.forEach(function(iframe) { 803 var src = iframe.getAttribute('src'); 804 if (src && src.indexOf('enablejsapi=1') === -1) { 805 // Append enablejsapi=1 to the src attribute 806 src += (src.indexOf('?') === -1 ? '?' : '&') + 'enablejsapi=1'; 807 iframe.setAttribute('src', src); 808 } 809 }); 810 811 addYouTubeAPI(function apiReady(ytApi) { 812 if (typeof ytApi.Player == "function") { 813 for (var i = 0; i < ytIframes.length; i++) { 814 trackYouTubeVideo(ytIframes[i], ytApi); 815 MarkElementAsTrackedByEngage(ytIframes[i]); // Mark as tracked 816 } 817 } 818 }); 819 } 820 821 function trackYouTubeVideo(iframe, ytApi) { 822 if (ytApi == null) { 823 return addYouTubeAPI(function (api) { trackYouTubeVideo(iframe, api); }); 824 } 825 826 var videoMeta = { 827 name: null, 828 source: VIDEO_SOURCE.YOUTUBE, 829 url: null, 830 831 totalTimeWatchedInSeconds: 0, 832 totalLengthInSeconds: 0, 833 inViewPort: false, 834 watched: false, 835 timestamp: Math.floor(+new Date() / 1000), 836 837 events: [], 838 }; 839 840 state.videos.push(videoMeta); 841 842 var player; 843 var isPaused = false; 844 var firstPlay = true;
845 var isAutoplay = iframe.src.indexOf("autoplay=1") > -1; 846 847 function getCurrentTime() { 848 if (player == null) return 0; 849 return Math.floor(player.playerInfo.currentTime); 850 } 851 852 var timeWatcher = makeTimeWatcher(function (elapsedSeconds) { 853 videoMeta.totalTimeWatchedInSeconds += elapsedSeconds; 854 }); 855 856 function setMetadata() { 857 // Update the video metadata 858 if (!player || !player.playerInfo) return; 859 860 videoMeta.totalLengthInSeconds = Math.floor(player.playerInfo.duration); 861 862 // Remove the t parameter from the YouTube URL if it exists 863 if (player.playerInfo.videoUrl) { 864 try { 865 var urlObj = new URL(player.playerInfo.videoUrl); 866 urlObj.searchParams.delete('t'); 867 videoMeta.url = urlObj.origin + urlObj.pathname + urlObj.search; 868 } catch (e) { 869 // Fallback for browsers without URL support 870 videoMeta.url = player.playerInfo.videoUrl.replace(/([?&])t=\d+(&?)/, function(match, p1, p2) { 871 if (p2) return p1; 872 return ''; 873 }).replace(/[?&]$/, ''); 874 } 875 } 876 877 if (player.playerInfo.videoData) { 878 videoMeta.name = player.playerInfo.videoData.title; 879 } 880 } 881 882 function onReady() { 883 setMetadata(); 884 } 885 886 function onStateChange(evt) { 887 // Fix for lazy loading 888 setMetadata(); 889 890 switch (evt.data) { 891 case -1: // UNSTARTED 892 break; 893 894 case ytApi.PlayerState.ENDED: 895 addVideoEvent(videoMeta, VIDEO_EVENT.ENDED, getCurrentTime()); 896 timeWatcher.stop(); 897 break; 898 899 case ytApi.PlayerState.PLAYING: 900 var currentTime = getCurrentTime(); 901 902 if (firstPlay && isAutoplay) { 903 addVideoEvent(videoMeta, VIDEO_EVENT.AUTOPLAY, currentTime); 904 } else { 905 if (isPaused) { 906 // YouTube API does not send a Seek event, we try to detect it here manually. 907 var pauseEvents = videoMeta.events.filter(function (evt) { 908 return evt.action === VIDEO_EVENT.PAUSE; 909 }); 910 911 if (pauseEvents.length > 0) { 912 var lastPause = pauseEvents[pauseEvents.length - 1]; 913 if (lastPause != null && Math.abs(currentTime - lastPause.startTimeInVideoInSeconds) > 1) { 914 // We resume at a different point than we paused at -> this is a seek. 915 addVideoEvent(videoMeta, VIDEO_EVENT.SEEK, currentTime); 916 } 917 } 918 } 919 920 addVideoEvent(videoMeta, isPaused ? VIDEO_EVENT.RESUME : VIDEO_EVENT.PLAY, currentTime); 921 } 922 923 isPaused = false; 924 firstPlay = false; 925 videoMeta.watched = true; 926 timeWatcher.start(); 927 928 break; 929 930 case ytApi.PlayerState.PAUSED: 931 var currentTime = getCurrentTime(); 932 933 var playEvents = videoMeta.events.filter(function (evt) { 934 return evt.action === VIDEO_EVENT.PLAY || evt.action === VIDEO_EVENT.RESUME; 935 }); 936 937 if (playEvents.length > 0) { 938 // YouTube API does not send a Seek event, we try to detect it here manually. 939 var lastPlay = playEvents[playEvents.length - 1]; 940 if (lastPlay != null) { 941 var now = Math.floor(+new Date() / 1000); 942 943 var playbackRate = player.playerInfo && player.playerInfo.playbackRate || 1; 944 var expectedElapsedTime = playbackRate * (now - lastPlay.timestamp); 945 var actualElapsedTime = currentTime - lastPlay.startTimeInVideoInSeconds; 946 947 var diff = Math.abs(expectedElapsedTime - actualElapsedTime); 948 var relativeDiff = diff / (expectedElapsedTime || 1); 949 if (diff > 1 && relativeDiff > 0.1) { 950 // More than 10% difference -> this is probably a seek. 951 addVideoEvent(videoMeta, VIDEO_EVENT.SEEK, currentTime); 952 } 953 } 954 } 955 956 addVideoEvent(videoMeta, VIDEO_EVENT.PAUSE, currentTime); 957 isPaused = true; 958 959 timeWatcher.stop(); 960 961 break; 962 963 case ytApi.PlayerState.BUFFERING: 964 break; 965 966 case ytApi.PlayerState.CUED: 967 break; 968 969 default: break; 970 } 971 } 972 973 // Check if a player already exists for this iframe (created by the client). 974 // Using YT.get() allows us to hook into existing players without overwriting 975 // the client's event callbacks. This is important for sites that implement 976 // custom YouTube Player API logic (e.g., cover images, play overlays). 977 var existingPlayer = iframe.id && typeof ytApi.get === "function" && ytApi.get(iframe.id); 978 979 if (existingPlayer) { 980 // Reuse the existing player - use addEventListener to add our tracking 981 // without overwriting the client's event handlers. 982 player = existingPlayer; 983 984 // addEventListener allows multiple listeners on the same event, 985 // unlike the events object in the YT.Player constructor which replaces handlers. 986 player.addEventListener("onReady", onReady); 987 player.addEventListener("onStateChange", onStateChange); 988 989 // If player is already ready, fetch metadata immediately 990 if (player.playerInfo && player.playerInfo.duration) { 991 setMetadata(); 992 } 993 } else { 994 // No existing player found - create a new one. 995 // This is the standard case for iframes without client-side player initialization. 996 player = new ytApi.Player(iframe, { 997 events: { 998 onReady: onReady, 999 onStateChange: onStateChange 1000 } 1001 }); 1002 } 1003 1004 trackVideoInView(videoMeta, iframe); 1005 } 1006 1007 function addYouTubeAPI(callback) { 1008 if (typeof window.YT !== "undefined") { 1009 // YouTube API was already loaded by client 1010 if (window.YT.loaded) { 1011 callback(window.YT); 1012 } else { 1013 window.YT.ready(function () { 1014 callback(window.YT); 1015 }); 1016 } 1017 } else { 1018 // Load API 1019 var tag = document.createElement('script'); 1020 tag.src = "https://www.youtube.com/iframe_api"; 1021 tag.addEventListener("load", function () { 1022 if (typeof window.YT !== "undefined") { 1023 window.YT.ready(function () { 1024 callback(window.YT); 1025 }); 1026 } 1027 }); 1028 1029 var firstScriptTag = document.getElementsByTagName('script')[0]; 1030 firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); 1031 } 1032 } 1033 1034 // Track all untracked Vimeo iframes 1035 function trackVimeo() { 1036 var vimeoIframes = Array.prototype.filter.call( 1037 document.querySelectorAll("iframe[src*='player.vimeo.com/video']"), 1038 function (iframe) { 1039 return !IsElementTrackedByEngage(iframe); 1040 } 1041 ); 1042 if (vimeoIframes.length === 0) return; 1043 1044 addVimeoSDK(function sdkReady(vimeoSdk) { 1045 if (typeof vimeoSdk.Player == "function") { 1046 for (var i = 0; i < vimeoIframes.length; i++) { 1047 trackVimeoVideo(vimeoIframes[i], vimeoSdk); 1048 MarkElementAsTrackedByEngage(vimeoIframes[i]); // Mark as tracked 1049 } 1050 } 1051 }); 1052 } 1053 1054 function trackVimeoVideo(iframe, vimeoSdk) { 1055 if (vimeoSdk == null) { 1056 return addVimeoSDK(function (sdk) { trackVimeoVideo(iframe, sdk); }); 1057 } 1058 1059 var videoMeta = { 1060 name: null, 1061 source: VIDEO_SOURCE.VIMEO, 1062 url: null, 1063 1064 totalTimeWatchedInSeconds: 0, 1065 totalLengthInSeconds: 0, 1066 inViewPort: false, 1067 watched: false, 1068 timestamp: Math.floor(+new Date() / 1000), 1069 1070 events: [], 1071 }; 1072 1073 state.videos.push(videoMeta); 1074 1075 var player; 1076 var isPaused = false;
1077 var firstPlay = true; 1078 var isAutoplay = iframe.src.indexOf("autoplay=1") > -1; 1079 1080 var timeWatcher = makeTimeWatcher(function (elapsedSeconds) { 1081 videoMeta.totalTimeWatchedInSeconds += elapsedSeconds; 1082 }); 1083 1084 player = new vimeoSdk.Player(iframe); 1085 1086 player.getVideoTitle().then(function (title) { videoMeta.name = title; }); 1087 player.getVideoUrl().then(function (url) { videoMeta.url = url; }, function onError() { videoMeta.url = iframe.src.replace(/\?.*$/, ''); }); 1088 player.getDuration().then(function (duration) { videoMeta.totalLengthInSeconds = Math.floor(duration); }); 1089 1090 player.on("play", function (data) { 1091 var currentTime = Math.floor(data.seconds); 1092 1093 if (firstPlay && isAutoplay) { 1094 addVideoEvent(videoMeta, VIDEO_EVENT.AUTOPLAY, currentTime); 1095 } else { 1096 addVideoEvent(videoMeta, isPaused ? VIDEO_EVENT.RESUME : VIDEO_EVENT.PLAY, currentTime); 1097 } 1098 1099 isPaused = false; 1100 firstPlay = false; 1101 videoMeta.watched = true; 1102 1103 timeWatcher.start(); 1104 }); 1105 1106 player.on("pause", function (data) { 1107 var currentTime = Math.floor(data.seconds); 1108 addVideoEvent(videoMeta, VIDEO_EVENT.PAUSE, currentTime); 1109 isPaused = true; 1110 1111 timeWatcher.stop(); 1112 }); 1113 1114 player.on("ended", function (data) { 1115 var currentTime = Math.floor(data.seconds); 1116 addVideoEvent(videoMeta, VIDEO_EVENT.ENDED, currentTime); 1117 timeWatcher.stop(); 1118 }); 1119 1120 player.on("seeked", function (data) { 1121 var currentTime = Math.floor(data.seconds); 1122 addVideoEvent(videoMeta, VIDEO_EVENT.SEEK, currentTime); 1123 }); 1124 1125 trackVideoInView(videoMeta, iframe); 1126 } 1127 1128 function addVimeoSDK(callback) { 1129 if (typeof window.Vimeo !== "undefined") { 1130 // Vimeo SDK was already loaded by client 1131 callback(window.Vimeo); 1132 } else { 1133 // Load SDK 1134 var script = document.createElement("script"); 1135 script.src = "https://player.vimeo.com/api/player.js"; 1136 1137 script.addEventListener("load", function () { 1138 if (typeof window.Vimeo !== "undefined") { 1139 callback(window.Vimeo); 1140 } 1141 }); 1142 1143 // Important: Vimeo SDK should be loaded after all iframe elements 1144 // so we put it as last child in the <body> element. 1145 document.body.appendChild(script); 1146 } 1147 } 1148 1149 function getTimestamp() { 1150 return (performance && typeof performance.now === "function" && performance.now()) || +new Date(); 1151 } 1152 1153 function getTimeOnPage() { 1154 var end = getTimestamp(); 1155 1156 var totalTimeMillis = Math.floor(end - state.timeOnPage.start); 1157 var engagedTimeMillis = engagementTracker.getTimeEngaged(); 1158 1159 return { 1160 totalTimeMillis: totalTimeMillis, 1161 engagedTimeMillis: engagedTimeMillis 1162 } 1163 } 1164 1165 function getScrollDepth() { 1166 return { 1167 pixels: state.scrollDepth.pixels, 1168 percentage: state.scrollDepth.percentage, 1169 } 1170 } 1171 1172 function getLinks() { 1173 return state.links; 1174 } 1175 1176 function getEvents() { 1177 return state.events; 1178 } 1179 1180 function getUmbracoForms() { 1181 return state.umbracoForms; 1182 } 1183 1184 function getClientSideData() { 1185 var timeOnPage = getTimeOnPage(); 1186 var scrollDepth = getScrollDepth(); 1187 var links = getLinks(); 1188 var events = getEvents(); 1189 var videos = getVideos(); 1190 var umbracoForms = getUmbracoForms(); 1191 1192 return { 1193 timeOnPage: timeOnPage, 1194 scrollDepth: scrollDepth, 1195 links: links, 1196 events: events, 1197 umbracoForms: umbracoForms, 1198 videos: videos, 1199 } 1200 } 1201 1202 function getVideos() { 1203 return state.videos; 1204 } 1205 1206 function sendClientSideData() { 1207 if (state.pageviewGuid == null) { 1208 return; 1209 } 1210 1211 var data = getClientSideData(); 1212 // Trailing slash prevents servers with URL rewrite rules from redirecting POSTâGET, which drops the request body. 1213 // ASP.NET Core routing matches both /collect and /collect/ for [HttpPost("collect")], so this is safe for all sites. 1214 var url = "/umbraco/engage/pagedata/collect/?version=" + state.ver
1214sion + "&pageviewGuid=" + state.pageviewGuid; 1215 1216 postJSON(url, data); 1217 resetClientSideData(); 1218 } 1219 1220 function postJSON(url, data) { 1221 // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon 1222 if (typeof navigator.sendBeacon === "function" && typeof Blob === "function") { 1223 var headers = { type: "application/json" }; 1224 var blob = new Blob([JSON.stringify(data)], headers); 1225 navigator.sendBeacon(url, blob); 1226 } else { 1227 var xhr = new XMLHttpRequest(); 1228 xhr.open('POST', url, false); 1229 xhr.setRequestHeader("Content-Type", "application/json"); 1230 var payload = JSON.stringify(data); 1231 xhr.send(payload); 1232 } 1233 } 1234 1235 /** 1236 * Removes any data that should only be sent to the server once such as events. 1237 */ 1238 function resetClientSideData() { 1239 state.links.length = 0; 1240 state.events.length = 0; 1241 1242 // state.videos itself cannot be cleared as it contains a "totalTimeWatchedInSeconds" variable that is 1243 // being continuously updated. The events of this video however can be cleared 1244 for (var vi = 0; vi < state.videos.length; vi++) { 1245 state.videos[vi].events.length = 0; 1246 } 1247 1248 // The same is true for state.umbracoForms except it does not have events but "actions" and "errors". 1249 for (var fi = 0; fi < state.umbracoForms.length; fi++) { 1250 var uf = state.umbracoForms[fi]; 1251 uf.actions.length = 0; 1252 uf.errors.length = 0; 1253 } 1254 } 1255 1256 function debounce(func, wait) { 1257 var timeout; 1258 return function () { 1259 var context = this; 1260 var args = arguments; 1261 var later = function () { 1262 timeout = null; 1263 func.apply(context, args); 1264 }; 1265 clearTimeout(timeout); 1266 timeout = setTimeout(later, wait); 1267 }; 1268 }; 1269 1270 function trackScrollDepth() { 1271 window.addEventListener("scroll", debounce(recordScrollDepth, 100), false); 1272 document.addEventListener("DOMContentLoaded", recordScrollDepth); 1273 recordScrollDepth(); 1274 1275 function recordScrollDepth() { 1276 var documentHeight = document.documentElement.offsetHeight; 1277 var scrollPosition = window.pageYOffset || document.documentElement.scrollTop; 1278 var viewportHeight = window.innerHeight; 1279 var viewportBottom = viewportHeight + scrollPosition; 1280 1281 var percentage = Math.round((Math.min(viewportBottom, documentHeight) / documentHeight) * 100); 1282 1283 if (percentage > state.scrollDepth.percentage) { 1284 state.scrollDepth.percentage = percentage; 1285 } 1286 1287 if (viewportBottom > state.scrollDepth.pixels) { 1288 state.scrollDepth.pixels = Math.floor(viewportBottom); 1289 } 1290 } 1291 } 1292 1293 function trackEvent(fields) { 1294 state.events.push(fields); 1295 1296 // Update visitor profile on server immediately. 1297 // Trailing slash: see comment in sendClientSideData(). 1298 var url = "/umbraco/engage/pagedata/collect-event/"; 1299 postJSON(url, fields); 1300 } 1301 1302 var engagementTracker = (function () { 1303 var startEngage = now(); 1304 var timeEngaged = 0; 1305 var idle = true; 1306 var idleTimer; 1307 1308 function now() { 1309 return new Date().getTime(); 1310 } 1311 1312 function setIdle() { 1313 updateTimeEngaged(); 1314 idle = true; 1315 }; 1316 1317 function pulse() { 1318 if (idle) { 1319 idle = false; 1320 startEngage = now(); 1321 } 1322 window.clearTimeout(idleTimer); 1323 idleTimer = window.setTimeout(setIdle, 5000); 1324 }; 1325 1326 function addListener(evt, cb) { 1327 if (window.addEventListener) { 1328 window.addEventListener(evt, cb); 1329 } else if (window.attachEvent) { 1330 window.attachEvent('on' + evt, cb); 1331 } 1332 }; 1333 1334 function updateTimeEngaged() { 1335 if (idle) { 1336 return; 1337 } 1338 1339 var endEngage = now(); 1340 1341 timeEngaged += endEngage - startEngage; 1342 startEngage = endEngage; 1343 } 1344
1345 function init() { 1346 addListener('mousedown', pulse); 1347 addListener('keydown', pulse); 1348 addListener('scroll', pulse); 1349 addListener('mousemove', pulse); 1350 idleTimer = window.setTimeout(setIdle, 5000); 1351 } 1352 1353 function getTimeEngaged() { 1354 updateTimeEngaged(); 1355 return timeEngaged; 1356 } 1357 1358 return { 1359 init: init, 1360 getTimeEngaged: getTimeEngaged, 1361 }; 1362 })(); 1363 1364 return { 1365 init: init, 1366 trackEvent: trackEvent, 1367 getClientSideData: getClientSideData, 1368 1369 video: { 1370 trackVideoElement: trackVideoElement, 1371 trackYouTubeVideo: trackYouTubeVideo, 1372 trackVimeoVideo: trackVimeoVideo, 1373 } 1374 }; 1375 })(); 1376 1377 return umbEngage; 1378})(window.umbracoEngage || {}); 1379 1380window.umbEngage = window.umbEngage || (function () { 1381 var api = (function init() { 1382 function sendEvent(fields) { 1383 fields.timestamp = new Date().toISOString(); 1384 umbracoEngage.analytics.trackEvent(fields); 1385 } 1386 1387 return { 1388 event: { 1389 send: sendEvent 1390 } 1391 }; 1392 })(); 1393 1394 function eventArgumentParser(args) { 1395 var fields = {}; 1396 for (var i = 0; i < args.length; i++) { 1397 var arg = args[i]; 1398 1399 if (typeof arg === 'string') { 1400 switch (i) { 1401 case 0: break; // command => skip 1402 case 1: fields.hitType = arg; break; // hitType 1403 case 2: fields.category = arg; break; // category 1404 case 3: fields.action = arg; break; // action 1405 case 4: fields.label = arg; break; // label 1406 } 1407 } else if (typeof arg === 'number') { 1408 // This is the only number field type we support 1409 fields.value = arg; 1410 } else if (typeof arg === 'object') { 1411 // fieldsObject is always the last one to be passed 1412 Object.assign(fields, arg); 1413 break; 1414 } 1415 } 1416 return fields; 1417 } 1418 1419 var hitTypeServiceMap = { 1420 'event': { 1421 argumentParser: eventArgumentParser, 1422 api: function () { return api.event; } 1423 } 1424 // Currently not (yet) supported: 1425 // ,'exception': exceptionHandler 1426 // ,'item': itemHandler 1427 // ,'pageview': pageviewHandler 1428 // ,'social': socialhandler 1429 // ,'screenview': screenviewHandler 1430 // ,'timing': timingHandler 1431 // ,'transaction': transactionHandler 1432 }; 1433 1434 var onSendEventCallbacks = []; 1435 1436 function onSendEvent(callback) { 1437 if(typeof callback !== "function") { 1438 throw new Error("umbEngage.onSendEvent(callback): callback should be a function"); 1439 } 1440 1441 onSendEventCallbacks.push(callback); 1442 1443 return function unsubscribe() { 1444 var idx = onSendEventCallbacks.indexOf(callback); 1445 if(idx > -1) { 1446 onSendEventCallbacks.splice(idx, 1); 1447 } 1448 } 1449 } 1450 1451 function emitSendEventCancellable(fields) { 1452 var canceled = false; 1453 var eventArgs = { 1454 cancel: function() { 1455 canceled = true; 1456 }, 1457 1458 fields: fields 1459 }; 1460 1461 for(var i = 0; i < onSendEventCallbacks.length; i++) { 1462 onSendEventCallbacks[i](eventArgs); 1463 } 1464 1465 return canceled; 1466 } 1467 1468 // Expose the umbEngage API as a function just like Google Analytics' analytics.js API 1469 function umbEngage (command, hitType) { 1470 if (typeof command !== 'string' || hitType === '') return; 1471 1472 var args = Array.prototype.slice.call(arguments); 1473 1474 if (Object.prototype.toString.call(hitType) === "[object Object]") { 1475 // When "hitType" is an object, this is the "fieldObject" argument used with this syntax: 1476 // 1477 // ga("send", { 1478 // hitType: "event", 1479 // eventCategory: "category", 1480 // ... 1481 // }) 1482 // 1483 // We will transform this to the "simple" syntax where each parameter is a separate argument. 1484 1485 var category = hitType.eventCategory; 1486 var action = hitType.eventAction; 1487 var label = hitType.eventLabel; 1488 var value = hitType.eventValue; 1489 1490 hitType = hitType.hitType; 1491 1492 // Push the arguments into a flat array so it can be processed in the same way as 1493 // the syntax ga("send", "event", "category", "action", "label", value) 1494 args = [command, hitType, category, action, label, value]; 1495 } 1496 1497 if (typeof hitType !== 'string' || hitType === '') return; 1498 1499 var service = hitTypeServiceMap[hitType]; 1500 1501 if (!service) return; 1502 1503 var fields = service.argumentParser(args); 1504 1505 var cancel = emitSendEventCancellable(fields); 1506 if(cancel) { 1507 // User defined callback specified command should be canceled. 1508 return; 1509 } 1510 1511 var api = service.api(); 1512 1513 var apiCommand = null; 1514
1515 for (var key in api) { 1516 if (!api.hasOwnProperty(key)) continue; 1517 1518 // Find the API that matches the command. 1519 // We match case insensitive either exactly the key (e.g. "send") 1520 // or the key prefixed by a custom tracker (e.g. "gtm.send"). 1521 // The custom tracker (if any) will be stored in capture group 1. 1522 var re = new RegExp("^(?:([^.]+)\\.)?" + key + "$", "i"); 1523 if (re.test(command)) { 1524 apiCommand = api[key]; 1525 break; 1526 } 1527 } 1528 1529 if (apiCommand) { 1530 try { 1531 apiCommand(fields); 1532 } catch (ex) { 1533 // For now, silently ignore 1534 } 1535 } 1536 } 1537 1538 umbEngage.onSendEvent = onSendEvent; 1539 document.dispatchEvent(new Event("umbracoEngageAnalyticsReady")); 1540 return umbEngage; 1541})();
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.