@unifygtm/intent-client
Version:
JavaScript client for interacting with the Unify Intent API in the browser.
390 lines (340 loc) • 12.1 kB
text/typescript
import { UnifyIntentContext } from '../../types';
import { IdentifyActivity, PageActivity } from '../activities';
import { validateEmail } from '../utils/helpers';
import { logUnifyError } from '../utils/logging';
import {
DEFAULT_FORMS_IFRAME_ORIGIN,
NAVATTIC_IFRAME_ORIGIN,
NAVATTIC_USER_EMAIL_KEY,
NAVATTIC_USER_EMAIL_PROPERTY,
} from './constants';
import { DefaultEventData } from './types/default';
import {
NavatticEventData,
NavatticEventType,
NavatticObject,
} from './types/navattic';
import { isDefaultFormEventData } from './utils';
/**
* This class acts as an agent to automatically monitor user
* intent-related activity and log relevant events and data to Unify.
*/
export class UnifyIntentAgent {
private readonly _intentContext: UnifyIntentContext;
private readonly _monitoredInputs: Set<HTMLInputElement>;
private readonly _submittedEmails: Set<string>;
private _autoPage: boolean;
private _autoIdentify: boolean;
private _historyMonitored: boolean;
private _lastLocation?: Location;
constructor(intentContext: UnifyIntentContext) {
this._intentContext = intentContext;
this._monitoredInputs = new Set<HTMLInputElement>();
this._submittedEmails = new Set<string>();
this._autoPage = intentContext.clientConfig.autoPage ?? false;
this._autoIdentify = intentContext.clientConfig.autoIdentify ?? false;
this._historyMonitored = false;
// If auto-page is configured, make sure to track the initial page
if (this._autoPage) {
this.startAutoPage();
this.maybeTrackPage();
}
if (this._autoIdentify) {
this.startAutoIdentify();
}
}
/**
* Tells the Unify Intent Agent to trigger page events when the
* user's current page changes. Note that this will NOT trigger
*/
public startAutoPage = () => {
// We don't start monitoring history until auto-page is turned on
// for the first time.
if (!this._historyMonitored) {
this.monitorHistory();
}
this._autoPage = true;
};
/**
* Tells the Unify Intent Agent to NOT trigger page events when the
* user's current page changes.
*/
public stopAutoPage = () => {
if (this._historyMonitored) {
window.removeEventListener('popstate', this.maybeTrackPage);
}
this._autoPage = false;
};
/**
* Tells the Unify Intent Agent to continuously monitor identity-related
* input elements for changes, and automatically submit identify actions
* to Unify when the user self-identifies.
*/
public startAutoIdentify = () => {
this._autoIdentify = true;
this.refreshMonitoredInputs();
setInterval(this.refreshMonitoredInputs, 2000);
this.subscribeToThirdPartyMessages();
};
/**
* Tells the Unify Intent Agent to stop continously monitoring inputs
* for changes.
*/
public stopAutoIdentify = () => {
this._monitoredInputs.forEach((input) => {
if (input.isConnected) {
input.removeEventListener('blur', this.handleInputBlur);
input.removeEventListener('keydown', this.handleInputKeydown);
}
});
this._monitoredInputs.clear();
this.unsubscribeFromThirdPartyMessages();
this._autoIdentify = false;
};
/**
* This function adds event listeners and overrides to various
* history-related browser functions to automatically track when the
* current page changes. This is important for tracking page changes
* in single page apps because the Unify Intent Client will only
* be instantiated a single time.
*/
private monitorHistory = () => {
// `pushState` is usually triggered to navigate to a new page
const pushState = history.pushState;
history.pushState = (...args) => {
// Update history
pushState.apply(history, args);
// Track page if valid page change
this.maybeTrackPage();
};
// Sometimes `replaceState` is used to navigate to a new page, but
// sometimes it is used to e.g. update query params
const replaceState = history.replaceState;
history.replaceState = (...args) => {
// Update history
replaceState.apply(history, args);
// Track page if valid page change
this.maybeTrackPage();
};
// `popstate` is triggered when the user clicks the back button
window.addEventListener('popstate', this.maybeTrackPage);
this._historyMonitored = true;
};
/**
* Triggers a page event for the current page and context if auto-page
* is currently set to `true` and the page has actually changed.
*/
private maybeTrackPage = () => {
if (!this._autoPage) return;
if (!this._lastLocation || isNewPage(this._lastLocation, window.location)) {
new PageActivity(this._intentContext).track();
this._lastLocation = { ...window.location };
}
};
/**
* Discards inputs no longer in the DOM and adds new inputs in the DOM
* to the set of monitored inputs if they qualify for auto-identity.
*/
private refreshMonitoredInputs = () => {
if (!this._autoIdentify) return;
// Discard input elements no longer in the DOM
this._monitoredInputs.forEach((input) => {
if (!input.isConnected) {
this._monitoredInputs.delete(input);
}
});
// Get all candidate input elements
const inputs = Array.from(document.getElementsByTagName('input')).filter(
(input) =>
!this._monitoredInputs.has(input) && isCandidateIdentityInput(input),
);
// Setup event listeners to monitor the input elements
inputs.forEach((input) => {
input.addEventListener('blur', this.handleInputBlur);
input.addEventListener('keydown', this.handleInputKeydown);
this._monitoredInputs.add(input);
});
};
/**
* Listens for messages posted to the window from third-party
* integrations which communicate via `window.postMessage`. Sets
* up event handlers for each supported integration.
*/
private subscribeToThirdPartyMessages = () => {
window.addEventListener('message', this.handleThirdPartyMessage);
};
/**
* Removes event listeners setup in `subscribeToThirdPartyMessages`.
*/
private unsubscribeFromThirdPartyMessages = () => {
window.removeEventListener('message', this.handleThirdPartyMessage);
};
/**
* Handles an event posted to the window via `window.postMessage` by
* a third-party integration embedded in an iframe, e.g. a Default form.
*
* @param event - the event from `window.postMessage`
*/
private handleThirdPartyMessage = (event: MessageEvent) => {
if (!this._autoIdentify) return;
let thirdParty: string | undefined;
try {
switch (event.origin) {
case DEFAULT_FORMS_IFRAME_ORIGIN: {
thirdParty = 'Default';
this.handleDefaultFormMessage(
event as MessageEvent<DefaultEventData>,
);
break;
}
case NAVATTIC_IFRAME_ORIGIN: {
thirdParty = 'Navattic';
this.handleNavatticDemoMessage(
event as MessageEvent<NavatticEventData>,
);
break;
}
}
} catch (error: any) {
logUnifyError({
message: `Error occurred while handling message from third-party (${thirdParty}): ${error.message}`,
});
}
};
/**
* Message handler for messages posted from embedded Default forms.
*
* @param event - the event from `window.postMessage`
*/
private handleDefaultFormMessage = (
event: MessageEvent<DefaultEventData>,
) => {
if (!this._autoIdentify) return;
if (isDefaultFormEventData(event.data)) {
const email = event.data.payload.email;
if (email) {
this.maybeIdentifyInputEmail(email);
}
}
};
/**
* Message handler for messages posted from embedded Navattic demos.
*
* @param event - the event from `window.postMessage`
*/
private handleNavatticDemoMessage = (
event: MessageEvent<NavatticEventData>,
) => {
if (!this._autoIdentify) return;
if (event.data.type === NavatticEventType.IDENTIFY_USER) {
// Prefer user-supplied email address over other forms of identification
const emailFromForm = event.data.eventAttributes.FORM?.[
NAVATTIC_USER_EMAIL_KEY
] as string | undefined;
// If there is a user email, identify the user
if (emailFromForm) {
this.maybeIdentifyInputEmail(emailFromForm);
}
// Check if email is available from other sources of user attributes
else {
const email = Object.values(event.data.eventAttributes).find(
(attributes) => NAVATTIC_USER_EMAIL_KEY in attributes,
)?.[NAVATTIC_USER_EMAIL_KEY] as string | undefined;
// If there is a user email, identify the user
if (email) {
this.maybeIdentifyInputEmail(email);
}
}
} else {
const eventDataProperties = event.data.properties ?? [];
const endUserEmailProperty = eventDataProperties.find(
({ object, name }) =>
object === NavatticObject.END_USER &&
name === NAVATTIC_USER_EMAIL_PROPERTY,
);
if (endUserEmailProperty) {
this.maybeIdentifyInputEmail(endUserEmailProperty.value);
}
}
};
/**
* Blur event handler for a monitored input element.
*
* @param event - the relevant event to handle
*/
private handleInputBlur = (event: FocusEvent) => {
if (!this._autoIdentify) return;
if (event.target instanceof HTMLInputElement) {
this.maybeIdentifyInputEmail(event.target.value);
}
};
/**
* Keydown event handler for a monitored input element. Only actions
* on the 'Enter' key.
*
* @param event - the relevant event to handle
*/
private handleInputKeydown = (event: KeyboardEvent) => {
if (!this._autoIdentify) return;
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
this.maybeIdentifyInputEmail(event.target.value);
}
};
/**
* This function checks if the current value of a monitored input is a valid
* email address and logs an identity action if it has not already been logged.
*
* @param event - the event object from a monitored input blur or keydown event
*/
private maybeIdentifyInputEmail = (email: string) => {
if (!this._autoIdentify) return;
if (email) {
// Validate that the input is a valid email address and has not already
// been submitted for an identify action.
if (!validateEmail(email) || this._submittedEmails.has(email)) {
return;
}
// Log an identify event
const identifyAction = new IdentifyActivity(this._intentContext, {
email,
});
identifyAction.track();
// Make sure we don't auto-identify this email again later
this._submittedEmails.add(email);
}
};
/**
* DO NOT USE: These methods are exposed only for testing purposes.
*/
__getMonitoredInputs = () => this._monitoredInputs;
/**
* DO NOT USE: These methods are exposed only for testing purposes.
*/
__getSubmittedEmails = () => this._submittedEmails;
}
/**
* Sometimes `history.replaceState` is called to manipulate the current URL,
* but not in a way which qualifies the new URL as a "new page". For example,
* `history.replaceState` is often used to update query params. When this
* happens, we do not want to auto-trigger a page event.
*
* This function compares two URLs to determine whether the second URL
* constitutes a "new page" to auto-trigger a page event.
*/
function isNewPage(oldLocation: Location, newLocation: Location): boolean {
return (
oldLocation.hostname !== newLocation.hostname ||
oldLocation.pathname !== newLocation.pathname
);
}
/**
* Helper function to filter input elements to those which qualify for
* auto-identity.
*
* @param element - the element to check
* @returns `true` if this is an input we can get identity from,
* otherwise `false`
*/
function isCandidateIdentityInput(element: HTMLInputElement) {
return element.type === 'email' || element.type === 'text';
}