diff --git a/package.json b/package.json index 007bb8e..76f0611 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "digital-data-manager", "description": "The hassle-free way to integrate Digital Data Layer on your website.", "author": "Driveback LLC ", - "version": "1.2.31", + "version": "1.2.32", "license": "MIT", "main": "dist/dd-manager.js", "scripts": { diff --git a/src/ddManager.js b/src/ddManager.js index 0ad20ae..da4f282 100644 --- a/src/ddManager.js +++ b/src/ddManager.js @@ -19,6 +19,7 @@ import { VIEWED_PAGE, mapEvent } from './events'; import { validateIntegrationEvent, trackValidationErrors } from './EventValidator'; import { enableErrorTracking } from './ErrorTracker'; import { warn, error as errorLog } from './functions/safeConsole'; +import { trackLink, trackImpression } from './trackers'; let ddManager; @@ -240,7 +241,7 @@ function _initializeIntegrations(settings) { ddManager = { - VERSION: '1.2.31', + VERSION: '1.2.32', setAvailableIntegrations: (availableIntegrations) => { _availableIntegrations = availableIntegrations; @@ -382,6 +383,14 @@ ddManager = { return _eventManager; }, + trackLink: (elements, handler) => { + trackLink(elements, handler); + }, + + trackImpression: (elements, handler) => { + trackImpression(elements, handler); + }, + reset: () => { if (_ddStorage) { _ddStorage.clear(); diff --git a/src/functions/domQuery.js b/src/functions/domQuery.js new file mode 100644 index 0000000..0d96761 --- /dev/null +++ b/src/functions/domQuery.js @@ -0,0 +1,22 @@ +export default function domQuery(selector, context) { + context = context || window.document; + // Redirect simple selectors to the more performant function + if (/^(#?[\w-]+|\.[\w-.]+)$/.test(selector)) { + switch (selector.charAt(0)) { + case '#': + // Handle ID-based selectors + return [context.getElementById(selector.substr(1))]; + case '.': + // Handle class-based selectors + // Query by multiple classes by converting the selector + // string into single spaced class names + var classes = selector.substr(1).replace(/\./g, ' '); + return [].slice.call(context.getElementsByClassName(classes)); + default: + // Handle tag-based selectors + return [].slice.call(context.getElementsByTagName(selector)); + } + } + // Default to `querySelectorAll` + return [].slice.call(context.querySelectorAll(selector)); +} diff --git a/src/functions/eventListener.js b/src/functions/eventListener.js new file mode 100644 index 0000000..2b57112 --- /dev/null +++ b/src/functions/eventListener.js @@ -0,0 +1,35 @@ +const bindName = window.addEventListener ? 'addEventListener' : 'attachEvent'; +const unbindName = window.removeEventListener ? 'removeEventListener' : 'detachEvent'; +const prefix = bindName !== 'addEventListener' ? 'on' : ''; + +/** + * Bind `el` event `type` to `fn`. + * + * @param {Element} el + * @param {String} type + * @param {Function} fn + * @param {Boolean} capture + * @return {Function} + * @api public + */ + +export function bind(el, type, fn, capture) { + el[bindName](prefix + type, fn, capture || false); + return fn; +} + +/** + * Unbind `el` event `type`'s callback `fn`. + * + * @param {Element} el + * @param {String} type + * @param {Function} fn + * @param {Boolean} capture + * @return {Function} + * @api public + */ + +export function undbin(el, type, fn, capture) { + el[unbindName](prefix + type, fn, capture || false); + return fn; +} diff --git a/src/functions/getStyle.js b/src/functions/getStyle.js new file mode 100644 index 0000000..de630ad --- /dev/null +++ b/src/functions/getStyle.js @@ -0,0 +1,8 @@ +export default function getStyle(el, prop) { + if (window.getComputedStyle) { + return window.getComputedStyle(el)[prop]; + } else if (el.currentStyle) { + return el.currentStyle[prop]; + } + return undefined; +} diff --git a/src/functions/isMeta.js b/src/functions/isMeta.js new file mode 100644 index 0000000..fb6eae6 --- /dev/null +++ b/src/functions/isMeta.js @@ -0,0 +1,22 @@ +/** +* Checks whether a DOM click event should open a link in a new tab. +*/ + +export default function isMeta(e) { + if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) { + return true; + } + + // Logic that handles checks for the middle mouse button, based + // on [jQuery](https://github.com/jquery/jquery/blob/master/src/event.js#L466). + const which = e.which; + const button = e.button; + if (!which && button !== undefined) { + // eslint-disable-next-line no-bitwise, no-extra-parens + return (!button & 1) && (!button & 2) && (button & 4); + } else if (which === 2) { + return true; + } + + return false; +} diff --git a/src/functions/preventDefault.js b/src/functions/preventDefault.js new file mode 100644 index 0000000..87370f4 --- /dev/null +++ b/src/functions/preventDefault.js @@ -0,0 +1,4 @@ +export default function preventDefault(e) { + e = e || window.event; + return e.preventDefault ? e.preventDefault() : e.returnValue = false; +} diff --git a/src/trackers/index.js b/src/trackers/index.js new file mode 100644 index 0000000..cb9c22c --- /dev/null +++ b/src/trackers/index.js @@ -0,0 +1,2 @@ +export { default as trackLink } from './trackLink.js'; +export { default as trackImpression } from './trackImpression.js'; diff --git a/src/trackers/trackImpression.js b/src/trackers/trackImpression.js new file mode 100644 index 0000000..93cc9a2 --- /dev/null +++ b/src/trackers/trackImpression.js @@ -0,0 +1,189 @@ +import { bind } from './../functions/eventListener'; +import getStyle from './../functions/getStyle'; +import domQuery from './../functions/domQuery'; + +class Batch { + constructor(handler) { + this.blocks = []; + this.viewedBlocks = []; + this.handler = handler; + } + + addViewedBlock(block) { + this.viewedBlocks.push(block); + } + + isViewedBlock(block) { + return !(this.viewedBlocks.indexOf(block) < 0); + } + + setBlocks(blocks) { + this.blocks = blocks; + } +} + +class BatchTable { + constructor() { + this.selectors = []; + this.batches = {}; + } + + add(selector, handler) { + if (this.selectors.indexOf(selector) < 0) { + this.selectors.push(selector); + this.batches[selector] = []; + } + + const batch = new Batch(handler); + this.batches[selector].push(batch) + } + + update() { + for (const selector of this.selectors) { + const batches = this.batches[selector]; + const blocks = domQuery(selector); + for (const batch of batches) { + batch.setBlocks(blocks); + } + } + } + + getAll() { + let allBatches = []; + for (const selector of this.selectors) { + const batches = this.batches[selector]; + allBatches = [...allBatches, ...batches]; + } + return allBatches; + } +} + +const batchTable = new BatchTable(); + +let isStarted = false; + +let docViewTop; +let docViewBottom; +let docViewLeft; +let docViewRight; + +function defineDocBoundaries(maxWebsiteWidth) { + const _defineDocBoundaries = () => { + const body = window.document.body; + const docEl = window.document.documentElement; + + docViewTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;; + docViewBottom = docViewTop + docEl.clientHeight; + docViewLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft; + docViewRight = docViewLeft + docEl.clientWidth; + + if (maxWebsiteWidth && maxWebsiteWidth < this.docViewRight && this.docViewLeft === 0) { + docViewLeft = (docViewRight - maxWebsiteWidth) / 2; + docViewRight = docViewLeft + maxWebsiteWidth; + } + }; + + _defineDocBoundaries(); + bind(window, 'resize', () => { + _defineDocBoundaries(); + }); + bind(window, 'scroll', () => { + _defineDocBoundaries(); + }); +} + +/** + * Returns true if element is visible by css + * and at least 3/4 of the element fit user viewport + * + * @param el DOMElement + * @returns boolean + */ +function isVisible(el) { + const docEl = window.document.documentElement; + + const elemWidth = el.clientWidth; + const elemHeight = el.clientHeight; + + const elemTop = el.getBoundingClientRect().top; + const elemBottom = elemTop + elemHeight; + const elemLeft = el.getBoundingClientRect().left; + const elemRight = elemLeft + elemWidth; + + const visible = !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) && Number(getStyle(el, 'opacity')) > 0 && getStyle(el, 'visibility') !== 'hidden'; + if (!visible) { + return false; + } + + const fitsVertical = ( + ((elemBottom - elemHeight / 4) <= docEl.clientHeight) && + ((elemTop + elemHeight / 4) >= 0) + ); + + const fitsHorizontal = ( + (elemLeft + elemWidth / 4 >= 0) && + (elemRight - elemWidth / 4 <= docEl.clientWidth) + ); + + if (!fitsVertical || !fitsHorizontal) { + return false; + } + + let elementFromPoint = document.elementFromPoint( + elemLeft + elemWidth / 2, + elemTop + elemHeight / 2 + ); + + while (elementFromPoint && elementFromPoint !== el && elementFromPoint.parentNode !== document) { + elementFromPoint = elementFromPoint.parentNode; + } + return (!!elementFromPoint && elementFromPoint === el); +} + +function trackViews() { + batchTable.update(); + + const batches = batchTable.getAll(); + for (const batch of batches) { + const newViewedBlocks = []; + + const blocks = batch.blocks; + for (const block of blocks) { + if (isVisible(block) && !batch.isViewedBlock(block)) { + newViewedBlocks.push(block); + batch.addViewedBlock(block); + } + } + + if (newViewedBlocks.length > 0) { + try { + batch.handler(newViewedBlocks); + } catch (error) { + // TODO + } + } + } +} + +function startTracking() { + defineDocBoundaries(); + trackViews(); + setInterval(() => { + trackViews(); + }, 500); +} + +export default function trackImpression(selector, handler) { + if (!selector) return; + + if (typeof handler !== 'function') { + throw new TypeError('Must pass function handler to `ddManager.trackImpression`.'); + } + + batchTable.add(selector, handler); + + if (!isStarted) { + isStarted = true; + startTracking(); + } +} diff --git a/src/trackers/trackLink.js b/src/trackers/trackLink.js new file mode 100644 index 0000000..bf0d71a --- /dev/null +++ b/src/trackers/trackLink.js @@ -0,0 +1,53 @@ +import { bind } from './../functions/eventListener'; +import isMeta from './../functions/isMeta'; +import preventDefault from './../functions/preventDefault'; +import domQuery from './../functions/domQuery'; + +function isElement(el) { + return (el && el.nodeType === 1); +} + +function onClick(el, handler) { + return (e) => { + const href = el.getAttribute('href') + || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href') + || el.getAttribute('xlink:href'); + + try { + handler(el); + } catch (error) { + // TODO + } + + if (href && el.target !== '_blank' && !isMeta(e)) { + preventDefault(e); + setTimeout(() => { + window.location.href = href; + }, 500); + } + }; +} + +export default function trackLink(links, handler) { + if (!links) return; + if (typeof links === 'string') { + links = domQuery(links); + } else if (isElement(links)) { + links = [links]; + } else if (links.toArray) { // handles jquery + links = links.toArray(); + } + + if (typeof handler !== 'function') { + throw new TypeError('Must pass function handler to `ddManager.trackLink`.'); + } + + for (const el of links) { + if (!isElement(el)) { + throw new TypeError('Must pass HTMLElement to `ddManager.trackLink`.'); + } + bind(el, 'click', onClick(el, handler)); + } + + return this; +} diff --git a/test/functions/fireEvent.js b/test/functions/fireEvent.js new file mode 100644 index 0000000..dfc1af3 --- /dev/null +++ b/test/functions/fireEvent.js @@ -0,0 +1,17 @@ +export default function fireEvent(el, etype){ + let event; + if (document.createEvent) { + event = document.createEvent("HTMLEvents"); + event.initEvent(etype, true, true); + } else { + event = document.createEventObject(); + event.eventType = etype; + } + event.eventName = etype; + + if (el.dispatchEvent) { + el.dispatchEvent(event); + } else { + el.fireEvent(`on${etype}`, event); + } +} diff --git a/test/index.test.js b/test/index.test.js index 8343183..a860260 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -12,6 +12,9 @@ import './EventDataEnricherSpec.js'; import './DigitalDataEnricherSpec.js'; import './EventValidatorSpec.js'; +// trackers +import './trackers/trackLinkSpec.js'; + // integrations import './integrations/GoogleAnalyticsSpec.js'; import './integrations/GoogleTagManagerSpec.js'; diff --git a/test/trackers/trackLinkSpec.js b/test/trackers/trackLinkSpec.js new file mode 100644 index 0000000..563d3d6 --- /dev/null +++ b/test/trackers/trackLinkSpec.js @@ -0,0 +1,49 @@ +import trackLink from './../../src/trackers/trackLink'; +import fireEvent from './../functions/fireEvent'; +import assert from 'assert'; + +describe('trackLink', () => { + + describe('#button', () => { + let btn; + let div; + + beforeEach(() => { + // create button + btn = document.createElement('button'); + const t = document.createTextNode('click me'); + btn.appendChild(t); + btn.className = 'test-btn'; + + // create div + div = document.createElement('div'); + div.appendChild(btn); + div.id = 'test-div'; + + document.body.appendChild(div); + }); + + afterEach(() => { + document.body.removeChild(div); + }); + + it('should track click by class name', (done) => { + trackLink('.test-btn', (link) => { + assert.equal(typeof link, 'object'); + done(); + }); + + fireEvent(btn, 'click'); + }); + + it('should track click by nested class name', (done) => { + trackLink('#test-div .test-btn', (link) => { + assert.equal(typeof link, 'object'); + done(); + }); + + fireEvent(btn, 'click'); + }); + }) + +});