diff --git a/packages/experiment-tag/src/message-bus.ts b/packages/experiment-tag/src/message-bus.ts index 14d27fd7..18a52d2f 100644 --- a/packages/experiment-tag/src/message-bus.ts +++ b/packages/experiment-tag/src/message-bus.ts @@ -17,6 +17,7 @@ export type ElementVisiblePayload = { mutationList: MutationRecord[] }; export type AnalyticsEventPayload = AnalyticsEvent; export type ManualTriggerPayload = { name: string }; export type UrlChangePayload = { updateActivePages?: boolean }; +export type ExitIntentPayload = { durationMs: number }; export type MessagePayloads = { element_appeared: ElementAppearedPayload; @@ -24,6 +25,7 @@ export type MessagePayloads = { url_change: UrlChangePayload; analytics_event: AnalyticsEventPayload; manual: ManualTriggerPayload; + exit_intent: ExitIntentPayload; }; export type MessageType = keyof MessagePayloads; diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index c62c3b9b..193c2d42 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -6,6 +6,7 @@ import { MessagePayloads, ElementAppearedPayload, ManualTriggerPayload, + ExitIntentPayload, MessageType, } from './message-bus'; import { DebouncedMutationManager } from './mutation-manager'; @@ -13,6 +14,7 @@ import { ElementAppearedTriggerValue, ElementVisibleTriggerValue, ManualTriggerValue, + ExitIntentTriggerValue, PageObject, PageObjects, } from './types'; @@ -41,6 +43,7 @@ export class SubscriptionManager { private elementVisibilityState: Map = new Map(); private elementAppearedState: Map = new Map(); private activeElementSelectors: Set = new Set(); + private pageLoadTime: number = Date.now(); constructor( webExperimentClient: DefaultWebExperimentClient, @@ -66,6 +69,7 @@ export class SubscriptionManager { } this.setupMutationObserverPublisher(); this.setupVisibilityPublisher(); + this.setupExitIntentPublisher(); this.setupPageObjectSubscriptions(); this.setupUrlChangeReset(); // Initial check for elements that already exist @@ -182,6 +186,7 @@ export class SubscriptionManager { this.messageBus.subscribe('url_change', () => { this.elementAppearedState.clear(); this.activeElementSelectors.clear(); + this.pageLoadTime = Date.now(); const elementSelectors = this.getElementSelectors(); elementSelectors.forEach((selector) => this.activeElementSelectors.add(selector), @@ -373,6 +378,61 @@ export class SubscriptionManager { }); }; + private setupExitIntentPublisher = () => { + // Get all page objects that use exit_intent trigger + const pages = Object.values(this.pageObjects).flatMap((pages) => + Object.values(pages).filter( + (page) => page.trigger_type === 'exit_intent', + ), + ); + + if (pages.length === 0) { + return; + } + + // Get minimum time requirement (use lowest value so listener activates earliest) + let minTimeOnPageMs = 0; + for (const page of pages) { + const triggerValue = page.trigger_value as ExitIntentTriggerValue; + minTimeOnPageMs = Math.min( + minTimeOnPageMs, + triggerValue.minTimeOnPageMs ?? 0, + ); + } + + // Detect exit intent via mouse movement + const handleMouseLeave = (event: MouseEvent) => { + // Only trigger if: + // 1. Mouse Y position is near top of viewport (leaving towards browser chrome) + // 2. Mouse is leaving the document (relatedTarget is null) + // 3. Not already triggered + if ( + event.clientY <= 50 && // 50px from top + event.relatedTarget === null + ) { + this.messageBus.publish('exit_intent', { + durationMs: Date.now() - this.pageLoadTime, + }); + } + }; + + // Install listener after minimum time requirement + if (minTimeOnPageMs > 0) { + setTimeout(() => { + this.globalScope.document.addEventListener( + 'mouseleave', + handleMouseLeave, + ); + }, minTimeOnPageMs); + } else { + // Install immediately if no time requirement + this.globalScope.document.addEventListener( + 'mouseleave', + handleMouseLeave, + ); + } + }; + private setupLocationChangePublisher = () => { // Add URL change listener for back/forward navigation this.globalScope.addEventListener('popstate', () => { @@ -501,6 +561,15 @@ export class SubscriptionManager { return this.elementVisibilityState.get(observerKey) ?? false; } + case 'exit_intent': { + const durationMs = (message as ExitIntentPayload).durationMs; + const triggerValue = page.trigger_value as ExitIntentTriggerValue; + return ( + triggerValue.minTimeOnPageMs === undefined || + durationMs >= triggerValue.minTimeOnPageMs + ); + } + default: return false; } diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 42639506..7a87a772 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -45,6 +45,10 @@ export interface ManualTriggerValue { name: string; } +export interface ExitIntentTriggerValue { + minTimeOnPageMs?: number; +} + export type PageObject = { id: string; name: string; @@ -54,6 +58,7 @@ export type PageObject = { | ElementAppearedTriggerValue | ElementVisibleTriggerValue | ManualTriggerValue + | ExitIntentTriggerValue | Record; };