PageSourceSearch

https://www.isic.no/Assets/Umbraco.Engage/Scripts/umbracoEngage.analytics.js

js isic.no collected 2026-09-29 08:57:12 UTC 57,512 bytes, 1,541 lines download raw bytes

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.