UNPKG

@dotcms/analytics

Version:

Official JavaScript library for Content Analytics with DotCMS.

203 lines (202 loc) 9.51 kB
import { getUVEState as m } from "../../../../uve/src/lib/core/core.utils.js"; import "../../../../uve/src/internal/constants.js"; import { getViewportMetrics as d, isElementMeetingVisibilityThreshold as u } from "./dot-analytics.impression.utils.js"; import { DEFAULT_IMPRESSION_CONFIG as l, IMPRESSION_EVENT_TYPE as g } from "../../shared/constants/dot-analytics.constants.js"; import { createPluginLogger as p, isBrowser as a, INITIAL_SCAN_DELAY_MS as f, findContentlets as b, extractContentletIdentifier as c, createContentletObserver as v, extractContentletData as I } from "../../shared/utils/dot-analytics.utils.js"; class E { constructor(e) { this.observer = null, this.mutationObserver = null, this.elementImpressionStates = /* @__PURE__ */ new Map(), this.sessionTrackedImpressions = /* @__PURE__ */ new Set(), this.currentPagePath = "", this.subscribers = /* @__PURE__ */ new Set(), this.logger = p("Impression", e), this.impressionConfig = this.resolveImpressionConfig(e.impressions); } /** * Subscribe to impression events * @param callback - Function called when impression is detected * @returns Subscription object with unsubscribe method */ onImpression(e) { return this.subscribers.add(e), { unsubscribe: () => { this.subscribers.delete(e); } }; } /** Notifies all subscribers of an impression */ notifySubscribers(e, i) { this.subscribers.forEach((t) => { try { t(e, i); } catch (s) { this.logger.error("Error in impression subscriber:", s); } }); } /** Merges user config with defaults */ resolveImpressionConfig(e) { return typeof e != "object" || e === null ? { ...l } : { ...l, ...e }; } /** Initializes tracking: sets up observers, finds contentlets, handles visibility/navigation */ initialize() { if (a()) { if (m()) { this.logger.warn("Impression tracking disabled in editor mode"); return; } this.initializeIntersectionObserver(), typeof window < "u" && setTimeout(() => { this.logger.debug("Running initial scan after timeout..."), this.findAndObserveContentletElements(); }, f), this.initializeDynamicContentDetector(), this.initializePageVisibilityHandler(), this.initializePageNavigationHandler(), this.logger.info("Impression tracking initialized with config:", this.impressionConfig); } } /** Sets up IntersectionObserver with configured visibility threshold */ initializeIntersectionObserver() { const e = { root: null, // Use viewport as root rootMargin: "0px", threshold: this.impressionConfig.visibilityThreshold }; this.observer = new IntersectionObserver((i) => { this.processIntersectionChanges(i); }, e); } /** Finds contentlets in DOM, validates them, and starts observing (respects maxNodes limit) */ findAndObserveContentletElements() { if (!this.observer) return; const e = b(); if (e.length === 0) { this.logger.warn("No contentlets found to track"); return; } const i = Math.min(e.length, this.impressionConfig.maxNodes); let t = 0; for (let s = 0; s < i; s++) { const n = e[s], r = c(n); if (r) { const o = this.shouldSkipElement(n); if (o) { this.logger.debug(`Skipping element ${r} (${o})`); continue; } this.elementImpressionStates.has(r) || (n.dataset.dotAnalyticsDomIndex || (n.dataset.dotAnalyticsDomIndex = String(s)), this.observer.observe(n), this.elementImpressionStates.set(r, { timer: null, visibleSince: null, tracked: this.hasBeenTrackedInSession(r), element: n }), t++); } } this.logger.info(`Observing ${t} contentlets`), e.length > i && this.logger.warn( `${e.length - i} contentlets not tracked (maxNodes limit: ${this.impressionConfig.maxNodes})` ); } /** Watches for new contentlets added to DOM (debounced for performance) */ initializeDynamicContentDetector() { a() && (this.mutationObserver = v(() => { this.findAndObserveContentletElements(); }), this.logger.info("MutationObserver enabled for dynamic content detection")); } /** Cancels all timers when page is hidden (prevents false impressions) */ initializePageVisibilityHandler() { document.addEventListener("visibilitychange", () => { document.visibilityState === "hidden" && (this.elementImpressionStates.forEach((e) => { e.timer !== null && (window.clearTimeout(e.timer), e.timer = null, e.visibleSince = null); }), this.logger.warn("Page hidden, all impression timers cancelled")); }); } /** Resets tracking on SPA navigation (listens to pushState, replaceState, popstate) */ initializePageNavigationHandler() { this.currentPagePath = window.location.pathname; const e = () => { const s = window.location.pathname; s !== this.currentPagePath && (this.logger.warn( `Navigation detected (${this.currentPagePath} → ${s}), resetting impression tracking` ), this.currentPagePath = s, this.sessionTrackedImpressions.clear(), this.elementImpressionStates.forEach((n) => { n.timer !== null && (window.clearTimeout(n.timer), n.timer = null, n.visibleSince = null); }), this.elementImpressionStates.clear()); }; window.addEventListener("popstate", e); const i = history.pushState, t = history.replaceState; history.pushState = function(...s) { i.apply(this, s), e(); }, history.replaceState = function(...s) { t.apply(this, s), e(); }; } /** Handles visibility changes: starts timer on enter, cancels on exit */ processIntersectionChanges(e) { document.visibilityState === "visible" && e.forEach((i) => { const t = i.target, s = c(t); s && (i.isIntersecting ? this.startImpressionDwellTimer(s, t) : this.cancelImpressionDwellTimer(s)); }); } /** Starts dwell timer; fires impression if element still visible when timer expires */ startImpressionDwellTimer(e, i) { const t = this.elementImpressionStates.get(e); t && (t.tracked || this.hasBeenTrackedInSession(e) || t.timer === null && document.visibilityState === "visible" && (t.visibleSince = Date.now(), t.element = i, t.timer = window.setTimeout(() => { this.isElementStillVisible(i) ? this.trackAndSendImpression(e, i) : (this.logger.warn( `Dwell timer expired for ${e} but element no longer visible, skipping impression` ), t.timer = null, t.visibleSince = null); }, this.impressionConfig.dwellMs), this.logger.debug( `Started dwell timer for ${e} (${this.impressionConfig.dwellMs}ms)` ))); } /** Cancels active dwell timer (element left viewport before dwell time) */ cancelImpressionDwellTimer(e) { const i = this.elementImpressionStates.get(e); !i || i.timer === null || (window.clearTimeout(i.timer), i.timer = null, i.visibleSince = null, this.logger.debug(`Cancelled dwell timer for ${e}`)); } /** Fires impression event with content & position data (page data added by enricher plugin) */ trackAndSendImpression(e, i) { const t = this.elementImpressionStates.get(e); if (!t) return; const s = t.visibleSince ? Date.now() - t.visibleSince : 0, n = I(i), r = d(i), o = parseInt(i.dataset.dotAnalyticsDomIndex || "-1", 10), h = { content: { identifier: n.identifier, inode: n.inode, title: n.title, content_type: n.contentType }, position: { viewport_offset_pct: r.offsetPercentage, dom_index: o } }; this.notifySubscribers(g, h), this.markImpressionAsTracked(e), t.timer = null, t.visibleSince = null, t.tracked = !0, this.observer && this.observer.unobserve(i), this.logger.info( `Fired impression for ${e} (dwell: ${s}ms) - element unobserved`, n ); } /** Returns skip reason if element is hidden/too small, null if trackable */ shouldSkipElement(e) { const i = e.getBoundingClientRect(), t = window.getComputedStyle(e); if (i.height === 0 || i.width === 0) return `zero dimensions: ${i.width}x${i.height}`; const s = 10; return i.height < s || i.width < s ? `too small: ${i.width}x${i.height} (minimum: ${s}px)` : t.visibility === "hidden" ? "visibility: hidden" : parseFloat(t.opacity) === 0 ? "opacity: 0" : t.display === "none" ? "display: none" : null; } /** Post-dwell check: verifies element still meets visibility threshold */ isElementStillVisible(e) { return document.visibilityState !== "visible" ? !1 : u( e, this.impressionConfig.visibilityThreshold ); } /** Checks if impression already fired in current page session */ hasBeenTrackedInSession(e) { return this.sessionTrackedImpressions.has(e); } /** Marks impression as tracked (prevents duplicates in same page session) */ markImpressionAsTracked(e) { this.sessionTrackedImpressions.add(e); } /** Cleanup: disconnects observers, clears timers and state */ cleanup() { this.observer && (this.observer.disconnect(), this.observer = null), this.mutationObserver && (this.mutationObserver.disconnect(), this.mutationObserver = null), this.elementImpressionStates.forEach((e) => { e.timer !== null && window.clearTimeout(e.timer); }), this.elementImpressionStates.clear(), this.subscribers.clear(), this.logger.info("Impression tracking cleaned up"); } } export { E as DotCMSImpressionTracker };