├── sauce-sample.json ├── src ├── trackers │ ├── index.js │ ├── listenEvents.js │ ├── trackScroll.js │ ├── trackLink.js │ ├── trackTimeOnPage.js │ ├── trackTimeOnSite.js │ └── trackImpression.js ├── variableTypes.js ├── constants.js ├── index.js ├── integrations │ ├── JivoChat.js │ ├── TryFit.js │ ├── VeInteractive.js │ ├── utils │ │ ├── AsyncQueue.js │ │ ├── affiliate.js │ │ └── transliterate.js │ ├── Aidata.js │ ├── Weborama.js │ ├── PushWorld.js │ ├── GoogleTagManager.js │ ├── Renta.js │ ├── Calltouch.js │ ├── K50.js │ ├── OWOXBIStreaming.js │ ├── DoubleClickFloodlight.js │ ├── Adnetic.js │ ├── Mixmarket.js │ ├── Get4Click.js │ ├── Getintent.js │ ├── TradeTracker.js │ ├── Multilead.js │ └── SegmentStream.js ├── polyfill.js ├── enrichments │ ├── IntegrationEnrichment.js │ ├── CustomEnrichmentsCollection.js │ ├── CustomEnrichment.js │ └── CustomEnrichments.js ├── scripts │ ├── CustomScript.js │ └── CustomScripts.js ├── ErrorTracker.js ├── IntegrationUtils.js ├── ConsentManager.js ├── Storage.js ├── testMode.js ├── CookieStorage.js ├── helpers │ └── ValidationHelper.js ├── events │ └── semanticEvents.js ├── snippet.js ├── RollingAttributesHelper.js ├── Handler.js └── availableIntegrations.js ├── test ├── reset.js ├── integrations │ ├── stubs │ │ ├── mindbox │ │ │ └── v3 │ │ │ │ ├── onAddedProduct.stub.js │ │ │ │ └── onRemovedProduct.stub.js │ │ ├── Flocktory │ │ │ ├── onRemovedProduct.stub.js │ │ │ ├── user.stub.js │ │ │ ├── index.js │ │ │ ├── onAddedProduct.stub.js │ │ │ └── onCompletedTransaction.stub.js │ │ ├── MyTarget │ │ │ ├── onAddedProduct.stub.js │ │ │ ├── onViewedProductDetail.stub.js │ │ │ ├── index.js │ │ │ ├── onViewedCart.stub.js │ │ │ └── onCompletedTransaction.stub.js │ │ ├── DynamicYield │ │ │ ├── onViewedCart.stub.js │ │ │ ├── index.js │ │ │ ├── user.stub.js │ │ │ ├── onCompletedTransaction.stub.js │ │ │ ├── onAddedProduct.stub.js │ │ │ └── onViewedProductPage.stub.js │ │ ├── FacebookPixel │ │ │ ├── onAddedProductToWishlist.stub.js │ │ │ ├── onStartedOrder.stub.js │ │ │ ├── index.js │ │ │ ├── onCompletedTransaction.stub.js │ │ │ ├── onAddedProduct.stub.js │ │ │ └── onViewedProductPage.stub.js │ │ └── Target2Sell │ │ │ └── index.js │ ├── Mindbox │ │ └── stubs │ │ │ ├── v2 │ │ │ ├── onLoggedIn.stub.js │ │ │ ├── onUpdateCart.stub.js │ │ │ ├── onViewedPage.stub.js │ │ │ ├── onViewedProductDetail.stub.js │ │ │ ├── onRemovedProduct.stub.js │ │ │ ├── onViewedProductListing.stub.js │ │ │ ├── onAddedProduct.stub.js │ │ │ ├── onSubscribed.stub.js │ │ │ ├── onCompletedTransaction.stub.js │ │ │ ├── index.js │ │ │ └── onRegistered.stub.js │ │ │ ├── v3 │ │ │ ├── onLoggedIn.stub.js │ │ │ ├── onAddedProduct.stub.js │ │ │ ├── onAddedProductToWishlistStub.stub.js │ │ │ ├── onRemovedProduct.stub.js │ │ │ ├── onRemovedProductFromWishlistStub.stub.js │ │ │ ├── onViewedProductListing.stub.js │ │ │ ├── onUpdateCart.stub.js │ │ │ ├── onViewedProductDetail.stub.js │ │ │ ├── onUpdatedProfileInfo.stub.js │ │ │ ├── onViewedPage.stub.js │ │ │ ├── onSubscribed.stub.js │ │ │ ├── index.js │ │ │ └── onRegistered.stub.js │ │ │ └── Mindbox.stub.js │ ├── Vkontakte │ │ └── stubs │ │ │ ├── index.js │ │ │ ├── listingItemsStub.stub.js │ │ │ ├── productStub.stub.js │ │ │ └── cartStub.stub.js │ ├── AdvCake │ │ └── stubs │ │ │ ├── onViewedCartPage.stub.js │ │ │ ├── cartStub.stub.js │ │ │ ├── index.js │ │ │ ├── onViewedHomePage.stub.js │ │ │ ├── onViewedListingPage.stub.js │ │ │ ├── onCompletedTransaction.stub.js │ │ │ └── onViewedProductPage.stub.js │ ├── utils │ │ └── transliterate.spec.js │ ├── Renta.spec.js │ ├── OWOXBIStreaming.spec.js │ ├── Calltouch.spec.js │ ├── Driveback.spec.js │ └── K50.spec.js ├── functions │ ├── argumentsToArray.js │ └── fireEvent.js ├── polyfill.spec.js ├── IntegrationBase.spec.js ├── trackers │ └── trackLink.spec.js ├── snippet.js ├── index.test.js ├── DDStorage.spec.js ├── events │ └── CustomEvents.spec.js └── EventValidator.spec.js ├── .travis.yml ├── docker-compose.yml ├── .gitignore ├── .npmignore ├── .babelrc ├── cloudbuild.yaml ├── cloudbuild.test.yaml ├── Gruntfile.js ├── .gitattributes ├── cloudbuild.master.yaml ├── LICENSE.txt ├── package-scripts.js └── package.json /sauce-sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "enabled": true, 3 | "username": "", 4 | "accessKey": "" 5 | } -------------------------------------------------------------------------------- /src/trackers/index.js: -------------------------------------------------------------------------------- 1 | export { default as trackLink } from './trackLink' 2 | export { default as trackImpression } from './trackImpression' 3 | -------------------------------------------------------------------------------- /test/reset.js: -------------------------------------------------------------------------------- 1 | export default function reset () { 2 | window.digitalData = {} 3 | window.ddListener = undefined 4 | window.ddManager = undefined 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | addons: 5 | sauce_connect: true 6 | script: 7 | - npm run test 8 | branches: 9 | except: 10 | - master 11 | -------------------------------------------------------------------------------- /test/integrations/stubs/mindbox/v3/onAddedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onAddedProductAddProductStub = { 2 | operation: 'AddProduct' 3 | } 4 | 5 | export { onAddedProductAddProductStub } 6 | -------------------------------------------------------------------------------- /src/variableTypes.js: -------------------------------------------------------------------------------- 1 | export const CONSTANT_VAR = 'constant' 2 | export const DIGITALDATA_VAR = 'digitalData' 3 | export const EVENT_VAR = 'event' 4 | export const PRODUCT_VAR = 'product' 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | nginx: 5 | image: nginx:alpine 6 | ports: 7 | - 8008:80 8 | volumes: 9 | - ./dist:/usr/share/nginx/html 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | aws-keys.json 5 | node_modules 6 | npm-debug.log 7 | sauce.json 8 | deploy 9 | sauce_connect.log 10 | build/* 11 | snippet/snippet* 12 | dist 13 | -------------------------------------------------------------------------------- /test/integrations/stubs/mindbox/v3/onRemovedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onRemovedProductRemoveProductStub = { 2 | operation: 'RemoveProduct' 3 | } 4 | 5 | export { onRemovedProductRemoveProductStub } 6 | -------------------------------------------------------------------------------- /test/functions/argumentsToArray.js: -------------------------------------------------------------------------------- 1 | export default function argumentsToArray (args) { 2 | if (args && args[1] === undefined) { 3 | return undefined 4 | } 5 | return Array.prototype.slice.call(args) 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .babelrc 4 | .editorconfig 5 | .eslintrc 6 | .gitattributes 7 | .gitignore 8 | .npmignore 9 | .travis.yml 10 | sauce.json 11 | sauce_connect.log 12 | karma.conf.js 13 | build 14 | test 15 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015-loose"], 3 | "plugins": [ 4 | ["transform-es3-member-expression-literals"], 5 | ["transform-es3-property-literals"], 6 | ["transform-object-rest-spread", { "useBuiltIns": true }] 7 | ] 8 | } -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: node:10.13.0 3 | entrypoint: npm 4 | args: ['install'] 5 | - name: node:10.13.0 6 | entrypoint: npm 7 | args: ['test'] 8 | env: 9 | - 'PHANTOMJS_VERSION=2.1.1' 10 | - 'PHANTOMJS_DIR=/phantomjs' 11 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const SDK_EVENT_SOURCE = 'DDManager SDK' 2 | export const SDK_CHANGE_SOURCE = 'DDManager SDK' 3 | export const CUSTOM_EVENT_SOURCE = 'DDManager Custom Event' 4 | export const CUSTOM_CHANGE_SOURCE = 'DDManager Custom Enrichment' 5 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/onLoggedIn.stub.js: -------------------------------------------------------------------------------- 1 | const onLoggedInLoggedInStub = { 2 | operation: 'EnterWebsite', 3 | identificator: { 4 | provider: 'TestWebsiteId', 5 | identity: '123' 6 | }, 7 | data: {} 8 | } 9 | 10 | export { onLoggedInLoggedInStub } 11 | -------------------------------------------------------------------------------- /test/integrations/Vkontakte/stubs/index.js: -------------------------------------------------------------------------------- 1 | import cartStub from './cartStub.stub' 2 | import productStub from './productStub.stub' 3 | import listingItemsStub from './listingItemsStub.stub' 4 | 5 | export default { 6 | cartStub, 7 | productStub, 8 | listingItemsStub 9 | } 10 | -------------------------------------------------------------------------------- /test/integrations/stubs/Flocktory/onRemovedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onRemovedProductStub = { 2 | in: { 3 | id: '123' 4 | }, 5 | out: { 6 | item: { 7 | id: '123', 8 | count: 2 9 | } 10 | } 11 | } 12 | 13 | export { 14 | onRemovedProductStub 15 | } 16 | -------------------------------------------------------------------------------- /cloudbuild.test.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/npm' 3 | args: ['install'] 4 | - name: 'gcr.io/cloud-builders/npm' 5 | args: ['test'] 6 | env: 7 | - 'SAUCE_ENABLED=true' 8 | - 'SAUCE_USERNAME=${_SAUCE_USERNAME}' 9 | - 'SAUCE_ACCESS_KEY=${_SAUCE_ACCESS_KEY}' 10 | -------------------------------------------------------------------------------- /test/integrations/stubs/Flocktory/user.stub.js: -------------------------------------------------------------------------------- 1 | const userStub = { 2 | in: { 3 | email: 'test@mail.com', 4 | firstName: 'John', 5 | lastName: 'Smith' 6 | }, 7 | out: { 8 | name: 'John Smith', 9 | email: 'test@mail.com' 10 | } 11 | } 12 | 13 | export { 14 | userStub 15 | } 16 | -------------------------------------------------------------------------------- /test/integrations/stubs/MyTarget/onAddedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onAddedProductStub = { 2 | product: { 3 | id: '756', 4 | skuCode: 'sku756', 5 | unitPrice: 55, 6 | unitSalePrice: 51 7 | }, 8 | quantity: 2, 9 | totalValue: 102 10 | } 11 | 12 | export { 13 | onAddedProductStub 14 | } 15 | -------------------------------------------------------------------------------- /test/integrations/stubs/MyTarget/onViewedProductDetail.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedProductDetailStub = { 2 | in: { 3 | id: '123', 4 | skuCode: 'sku123', 5 | unitSalePrice: 150 6 | }, 7 | out: '123', 8 | outGroupedFeed: 'sku123', 9 | outTotal: 150 10 | } 11 | 12 | export { 13 | onViewedProductDetailStub 14 | } 15 | -------------------------------------------------------------------------------- /test/integrations/Vkontakte/stubs/listingItemsStub.stub.js: -------------------------------------------------------------------------------- 1 | const listingItemsStub = { 2 | in: [ 3 | { 4 | id: '123' 5 | }, 6 | { 7 | id: '124' 8 | }, 9 | { 10 | id: '125' 11 | }, 12 | { 13 | id: '126' 14 | } 15 | ], 16 | out: ['123', '124', '125', '126'] 17 | } 18 | 19 | export default listingItemsStub 20 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onLoggedIn.stub.js: -------------------------------------------------------------------------------- 1 | const onLoggedInEnterWebsiteStub = { 2 | operation: 'EnterWebsite', 3 | data: { 4 | customer: { 5 | ids: { 6 | bitrixId: 'user123' 7 | }, 8 | email: 'test@driveback.ru', 9 | mobilePhone: '74957777777' 10 | } 11 | } 12 | } 13 | 14 | export { onLoggedInEnterWebsiteStub } 15 | -------------------------------------------------------------------------------- /test/integrations/stubs/DynamicYield/onViewedCart.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedCartStub = { 2 | in: { 3 | lineItems: [ 4 | { 5 | product: { 6 | id: '123' 7 | } 8 | }, 9 | { 10 | product: { 11 | id: '124' 12 | } 13 | } 14 | ] 15 | }, 16 | out: ['123', '124'] 17 | } 18 | 19 | export { 20 | onViewedCartStub 21 | } 22 | -------------------------------------------------------------------------------- /test/integrations/stubs/Flocktory/index.js: -------------------------------------------------------------------------------- 1 | import { userStub } from './user.stub' 2 | import { onAddedProductStub } from './onAddedProduct.stub' 3 | import { onRemovedProductStub } from './onRemovedProduct.stub' 4 | import { onCompletedTransactionStub } from './onCompletedTransaction.stub' 5 | 6 | export default { 7 | userStub, 8 | onAddedProductStub, 9 | onRemovedProductStub, 10 | onCompletedTransactionStub 11 | } 12 | -------------------------------------------------------------------------------- /test/integrations/stubs/Flocktory/onAddedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onAddedProductStub = { 2 | in: { 3 | id: '123', 4 | categoryId: '1', 5 | unitSalePrice: 100, 6 | manufacturer: 'DB' 7 | }, 8 | out: { 9 | item: { 10 | id: '123', 11 | price: 100, 12 | count: 2, 13 | brand: 'DB', 14 | categoryId: '1' 15 | } 16 | } 17 | } 18 | 19 | export { 20 | onAddedProductStub 21 | } 22 | -------------------------------------------------------------------------------- /test/integrations/stubs/DynamicYield/index.js: -------------------------------------------------------------------------------- 1 | import { onViewedCartStub } from './onViewedCart.stub' 2 | 3 | import { onAddedProductStub } from './onAddedProduct.stub' 4 | 5 | import { onCompletedTransactionStub } from './onCompletedTransaction.stub' 6 | 7 | import { userStub } from './user.stub' 8 | 9 | export default { 10 | onViewedCartStub, 11 | onAddedProductStub, 12 | onCompletedTransactionStub, 13 | userStub 14 | } 15 | -------------------------------------------------------------------------------- /test/integrations/Vkontakte/stubs/productStub.stub.js: -------------------------------------------------------------------------------- 1 | const productStub = { 2 | in: { 3 | id: '123', 4 | unitPrice: 100, 5 | unitSalePrice: 90, 6 | currency: 'USD' 7 | }, 8 | out: { 9 | products: [ 10 | { 11 | id: '123', 12 | price_old: 100, 13 | price: 90 14 | } 15 | ], 16 | total_price: 90, 17 | currency_code: 'USD' 18 | } 19 | } 20 | 21 | export default productStub 22 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function runGrunt (grunt) { 2 | // Project configuration. 3 | grunt.initConfig({ 4 | pkg: grunt.file.readJSON('package.json'), 5 | wrap: { 6 | stage: { 7 | src: ['dist/segmentstream.js'], 8 | dest: 'dist/segmentstream.js', 9 | options: { 10 | wrapper: ['(function () { var define = undefined;', '})();'] 11 | } 12 | } 13 | } 14 | }) 15 | 16 | grunt.loadNpmTasks('grunt-wrap') 17 | } 18 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/onUpdateCart.stub.js: -------------------------------------------------------------------------------- 1 | const onUpdateCartSetCartStub = { 2 | operation: 'SetCart', 3 | data: { 4 | action: { 5 | personalOffers: [ 6 | { 7 | productId: '123', 8 | count: 2, 9 | price: 2000 10 | }, 11 | { 12 | productId: '234', 13 | count: 1, 14 | price: 1000 15 | } 16 | ] 17 | } 18 | } 19 | } 20 | 21 | export { onUpdateCartSetCartStub } 22 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/onViewedPage.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedPageSetCardStub = { 2 | operation: 'SetCart', 3 | data: { 4 | action: { 5 | personalOffers: [ 6 | { 7 | productId: '123', 8 | count: 2, 9 | price: 2000 10 | }, 11 | { 12 | productId: '234', 13 | count: 1, 14 | price: 1000 15 | } 16 | ] 17 | } 18 | } 19 | } 20 | 21 | export { onViewedPageSetCardStub } 22 | -------------------------------------------------------------------------------- /test/functions/fireEvent.js: -------------------------------------------------------------------------------- 1 | export default function fireEvent (el, etype) { 2 | let event 3 | if (document.createEvent) { 4 | event = document.createEvent('HTMLEvents') 5 | event.initEvent(etype, true, true) 6 | } else { 7 | event = document.createEventObject() 8 | event.eventType = etype 9 | } 10 | event.eventName = etype 11 | 12 | if (el.dispatchEvent) { 13 | el.dispatchEvent(event) 14 | } else { 15 | el.fireEvent(`on${etype}`, event) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onAddedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onAddedProductMappedAddProductStub = { 2 | operation: 'AddProduct', 3 | data: { 4 | addProductToList: { 5 | product: { 6 | ids: { 7 | testId: '123' 8 | }, 9 | sku: { 10 | ids: { 11 | testSku: 'sku123' 12 | } 13 | } 14 | }, 15 | price: 2500 16 | } 17 | } 18 | } 19 | 20 | export { 21 | onAddedProductMappedAddProductStub 22 | } 23 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onAddedProductToWishlistStub.stub.js: -------------------------------------------------------------------------------- 1 | const onAddedProductToWishlistStub = { 2 | operation: 'AddProductToWishlist', 3 | data: { 4 | addProductToList: { 5 | product: { 6 | ids: { 7 | testId: '123' 8 | }, 9 | sku: { 10 | ids: { 11 | testSku: 'sku123' 12 | } 13 | } 14 | }, 15 | price: 2500 16 | } 17 | } 18 | } 19 | 20 | export { onAddedProductToWishlistStub } 21 | -------------------------------------------------------------------------------- /test/integrations/stubs/FacebookPixel/onAddedProductToWishlist.stub.js: -------------------------------------------------------------------------------- 1 | const onAddedProductToWishlistStub = { 2 | in: { 3 | id: '123', 4 | name: 'Test Product', 5 | category: ['Category 1', 'Subcategory 1'] 6 | }, 7 | out: { 8 | content_ids: ['123'], 9 | content_type: 'product', 10 | content_name: 'Test Product', 11 | content_category: 'Category 1/Subcategory 1', 12 | paramExample: 'example' 13 | } 14 | } 15 | 16 | export { 17 | onAddedProductToWishlistStub 18 | } 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './polyfill' 2 | import ddManager from './ddManager' 3 | import availableIntegrations from './availableIntegrations' 4 | 5 | const earlyStubsQueue = window.ddManager || window.segmentstream 6 | const { SNIPPET_VERSION } = earlyStubsQueue || {} 7 | 8 | ddManager.SNIPPET_VERSION = SNIPPET_VERSION 9 | 10 | window.ddManager = ddManager 11 | window.segmentstream = ddManager 12 | 13 | ddManager.setAvailableIntegrations(availableIntegrations) 14 | ddManager.processEarlyStubCalls(earlyStubsQueue) 15 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onRemovedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onRemovedProductMappedRemoveProductStub = { 2 | operation: 'RemoveProduct', 3 | data: { 4 | removeProductFromList: { 5 | product: { 6 | ids: { 7 | testId: '123' 8 | }, 9 | sku: { 10 | ids: { 11 | testSku: 'sku123' 12 | } 13 | } 14 | }, 15 | price: 1000 16 | } 17 | } 18 | } 19 | 20 | export { 21 | onRemovedProductMappedRemoveProductStub 22 | } 23 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onRemovedProductFromWishlistStub.stub.js: -------------------------------------------------------------------------------- 1 | const onRemovedProductFromWishlistStub = { 2 | operation: 'RemoveProductFromWishlist', 3 | data: { 4 | removeProductFromList: { 5 | product: { 6 | ids: { 7 | testId: '123' 8 | }, 9 | sku: { 10 | ids: { 11 | testSku: 'sku123' 12 | } 13 | } 14 | }, 15 | price: 2500 16 | } 17 | } 18 | } 19 | 20 | export { onRemovedProductFromWishlistStub } 21 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/onViewedProductDetail.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedProductDetailViewProductStub = { 2 | operation: 'ViewProduct', 3 | data: { 4 | action: { 5 | productId: '123' 6 | } 7 | } 8 | } 9 | 10 | const onViewedProductDetailViewedProductCustomStub = { 11 | operation: 'ViewedProductCustom', 12 | data: { 13 | action: { 14 | productId: '123' 15 | } 16 | } 17 | } 18 | 19 | export { onViewedProductDetailViewProductStub, onViewedProductDetailViewedProductCustomStub } 20 | -------------------------------------------------------------------------------- /src/trackers/listenEvents.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'blur', 3 | 'focus', 4 | 'focusin', 5 | 'focusout', 6 | 'load', 7 | 'resize', 8 | 'scroll', 9 | 'unload', 10 | 'click', 11 | 'dblclick', 12 | 'mousedown', 13 | 'mouseup', 14 | 'mousemove', 15 | 'mouseover', 16 | 'mouseout', 17 | 'mouseenter', 18 | 'mouseleave', 19 | 'change', 20 | 'select', 21 | 'submit', 22 | 'keydown', 23 | 'keypress', 24 | 'keyup', 25 | 'error', 26 | 'touchstart', 27 | 'touchmove', 28 | 'touchend' 29 | ] 30 | -------------------------------------------------------------------------------- /test/integrations/stubs/MyTarget/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | onViewedCartStub 3 | } from './onViewedCart.stub' 4 | 5 | import { 6 | onViewedProductDetailStub 7 | } from './onViewedProductDetail.stub' 8 | 9 | import { 10 | onAddedProductStub 11 | } from './onAddedProduct.stub' 12 | 13 | import { 14 | onCompletedTransactionStub 15 | } from './onCompletedTransaction.stub' 16 | 17 | export default { 18 | onViewedCartStub, 19 | onViewedProductDetailStub, 20 | onAddedProductStub, 21 | onCompletedTransactionStub 22 | } 23 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/onRemovedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onRemovedProductRemoveProductStub = { 2 | operation: 'RemoveProduct', 3 | data: { 4 | action: { 5 | productId: '123', 6 | price: 2500 7 | } 8 | } 9 | } 10 | 11 | const onRemovedProductAddProductCustomStub = { 12 | operation: 'AddProductCustom', 13 | data: { 14 | action: { 15 | productId: '123', 16 | price: 2500 17 | } 18 | } 19 | } 20 | 21 | export { onRemovedProductRemoveProductStub, onRemovedProductAddProductCustomStub } 22 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/onViewedProductListing.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedProductListingCategoryViewStub = { 2 | operation: 'CategoryView', 3 | data: { 4 | action: { 5 | productCategoryId: '123' 6 | } 7 | } 8 | } 9 | 10 | const onViewedProductListingCategoryViewCustomStub = { 11 | operation: 'CategoryViewCustom', 12 | data: { 13 | action: { 14 | productCategoryId: '123' 15 | } 16 | } 17 | } 18 | 19 | export { onViewedProductListingCategoryViewStub, onViewedProductListingCategoryViewCustomStub } 20 | -------------------------------------------------------------------------------- /test/polyfill.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | 3 | describe('Polyfill', () => { 4 | it('Object.values() works in all browsers', () => { 5 | const object = { 6 | 0: 'String 1', 7 | 1: 'String 2' 8 | } 9 | assert.strict.deepEqual(Object.keys(object).map(k => object[k]), ['String 1', 'String 2']) 10 | }) 11 | 12 | it('should not crash on Promises in IE', (done) => { 13 | new Promise((resolve) => { 14 | setTimeout(resolve(true)) 15 | }).then(() => { 16 | done() 17 | }) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/integrations/JivoChat.js: -------------------------------------------------------------------------------- 1 | import Integration from '../Integration' 2 | 3 | class JivoChat extends Integration { 4 | constructor (digitalData, options) { 5 | const optionsWithDefaults = Object.assign({ 6 | widgetId: '' 7 | }, options) 8 | super(digitalData, optionsWithDefaults) 9 | 10 | this.addTag({ 11 | type: 'script', 12 | attr: { 13 | src: `//code.jivosite.com/script/widget/${options.widgetId};` 14 | } 15 | }) 16 | } 17 | 18 | isLoaded () { 19 | return !!window.jivo_version 20 | } 21 | } 22 | 23 | export default JivoChat 24 | -------------------------------------------------------------------------------- /test/integrations/AdvCake/stubs/onViewedCartPage.stub.js: -------------------------------------------------------------------------------- 1 | const basketProducts = [ 2 | { 3 | brand: 'DB', 4 | categoryId: '2', 5 | categoryName: 'Headwear', 6 | id: '123', 7 | name: 'Hat', 8 | price: 100, 9 | quantity: 2 10 | }, 11 | { 12 | brand: 'DB', 13 | categoryId: '1', 14 | categoryName: 'Shirts', 15 | id: '124', 16 | name: 'Shirt', 17 | price: 300, 18 | quantity: 1 19 | } 20 | ] 21 | 22 | const onViewedCartPageStub = { 23 | user: undefined, 24 | basketProducts, 25 | pageType: 4 26 | } 27 | 28 | export { 29 | onViewedCartPageStub 30 | } 31 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Automatically normalize line endings for all text-based files 2 | # http://git-scm.com/docs/gitattributes#_end_of_line_conversion 3 | * text=auto 4 | 5 | # For the following file types, normalize line endings to LF on 6 | # checkin and prevent conversion to CRLF when they are checked out 7 | # (this is required in order to prevent newline related issues like, 8 | # for example, after the build script is run) 9 | .* text eol=lf 10 | *.css text eol=lf 11 | *.html text eol=lf 12 | *.js text eol=lf 13 | *.json text eol=lf 14 | *.md text eol=lf 15 | *.txt text eol=lf 16 | *.xml text eol=lf 17 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onViewedProductListing.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedProductListingCategoryViewStub = { 2 | operation: 'CategoryView', 3 | data: { 4 | productCategory: { 5 | ids: { 6 | bitrixId: '123' 7 | } 8 | } 9 | } 10 | } 11 | const onViewedProductListingCategoryViewCustomStub = { 12 | operation: 'CategoryViewCustom', 13 | data: { 14 | productCategory: { 15 | ids: { 16 | bitrixId: '123' 17 | } 18 | } 19 | } 20 | } 21 | 22 | export { 23 | onViewedProductListingCategoryViewStub, 24 | onViewedProductListingCategoryViewCustomStub 25 | } 26 | -------------------------------------------------------------------------------- /src/polyfill.js: -------------------------------------------------------------------------------- 1 | // import 'core-js/fn/object/create'; // IE8 2 | // import 'core-js/fn/array/is-array'; // IE8 3 | // import 'core-js/fn/array/index-of'; // IE8 4 | // import 'core-js/fn/array/filter'; // IE8 5 | import 'core-js/fn/array/find' 6 | import 'core-js/fn/array/fill' 7 | // import 'core-js/fn/array/map'; // IE8 8 | // import 'core-js/fn/array/some'; // IE8 9 | // import 'core-js/fn/function/bind'; // IE8 10 | import 'core-js/fn/object/assign' 11 | // import 'core-js/fn/string/trim'; // IE8 12 | import 'core-js/fn/string/ends-with' 13 | // import 'core-js/fn/date/to-iso-string'; // IE8 14 | // import 'core-js/fn/date/now'; // IE8 15 | import 'promise-polyfill/dist/polyfill' 16 | import 'whatwg-fetch' 17 | -------------------------------------------------------------------------------- /test/integrations/AdvCake/stubs/cartStub.stub.js: -------------------------------------------------------------------------------- 1 | const cartStub = { 2 | lineItems: [ 3 | { 4 | product: { 5 | id: '123', 6 | name: 'Hat', 7 | unitSalePrice: 100, 8 | manufacturer: 'DB', 9 | categoryId: '2', 10 | category: ['Accessories', 'Headwear'] 11 | }, 12 | quantity: 2, 13 | subtotal: 200 14 | }, 15 | { 16 | product: { 17 | id: '124', 18 | name: 'Shirt', 19 | unitSalePrice: 300, 20 | manufacturer: 'DB', 21 | categoryId: '1', 22 | category: ['Tops', 'Shirts'] 23 | }, 24 | quantity: 1, 25 | subtotal: 300 26 | } 27 | ] 28 | } 29 | 30 | export default cartStub 31 | -------------------------------------------------------------------------------- /cloudbuild.master.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/npm' 3 | args: ['install'] 4 | - name: 'gcr.io/cloud-builders/npm' 5 | args: ['test'] 6 | env: 7 | - 'SAUCE_ENABLED=true' 8 | - 'SAUCE_USERNAME=${_SAUCE_USERNAME}' 9 | - 'SAUCE_ACCESS_KEY=${_SAUCE_ACCESS_KEY}' 10 | - name: 'gcr.io/cloud-builders/npm' 11 | args: ['run', 'dist'] 12 | - name: gcr.io/cloud-builders/gsutil 13 | args: ["cp", "./dist/segmentstream.min.js", "gs://${_DDM_BUCKET_NAME}/sdk/dd-manager.js"] 14 | - name: gcr.io/cloud-builders/gsutil 15 | args: ["cp", "./dist/segmentstream.min.js", "gs://${_DDM_BUCKET_NAME}/sdk/dd-manager.min.js"] 16 | artifacts: 17 | objects: 18 | location: 'gs://${_BUCKET_NAME}/sdk/' 19 | paths: ['dist/segmentstream.js', 'dist/segmentstream.min.js'] 20 | timeout: 960s 21 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/onAddedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onAddedProductAddProductStub = { 2 | operation: 'AddProduct', 3 | data: { 4 | action: { 5 | productId: '123', 6 | price: 2500 7 | } 8 | } 9 | } 10 | 11 | const onAddedProductAddProductSkuStub = { 12 | operation: 'AddProduct', 13 | data: { 14 | action: { 15 | productId: '123', 16 | skuId: 'sku123', 17 | price: 2500 18 | } 19 | } 20 | } 21 | 22 | const onAddedProductAddProductCustomStub = { 23 | operation: 'AddProductCustom', 24 | data: { 25 | action: { 26 | productId: '123', 27 | price: 2500 28 | } 29 | } 30 | } 31 | 32 | export { 33 | onAddedProductAddProductStub, 34 | onAddedProductAddProductSkuStub, 35 | onAddedProductAddProductCustomStub 36 | } 37 | -------------------------------------------------------------------------------- /test/integrations/AdvCake/stubs/index.js: -------------------------------------------------------------------------------- 1 | import cartStub from './cartStub.stub' 2 | 3 | import { 4 | onViewedHomePageUnauthorizedStub, 5 | onViewedHomePageAuthorizedStub 6 | } from './onViewedHomePage.stub' 7 | 8 | import { 9 | onViewedProductPageStub 10 | } from './onViewedProductPage.stub' 11 | 12 | import { 13 | onViewedListingPageStub 14 | } from './onViewedListingPage.stub' 15 | 16 | import { 17 | onViewedCartPageStub 18 | } from './onViewedCartPage.stub' 19 | 20 | import { 21 | onCompletedTransactionStub 22 | } from './onCompletedTransaction.stub' 23 | 24 | export default { 25 | cartStub, 26 | onViewedHomePageUnauthorizedStub, 27 | onViewedHomePageAuthorizedStub, 28 | onViewedProductPageStub, 29 | onViewedListingPageStub, 30 | onViewedCartPageStub, 31 | onCompletedTransactionStub 32 | } 33 | -------------------------------------------------------------------------------- /test/integrations/AdvCake/stubs/onViewedHomePage.stub.js: -------------------------------------------------------------------------------- 1 | const basketProducts = [ 2 | { 3 | brand: 'DB', 4 | categoryId: '2', 5 | categoryName: 'Headwear', 6 | id: '123', 7 | name: 'Hat', 8 | price: 100, 9 | quantity: 2 10 | }, 11 | { 12 | brand: 'DB', 13 | categoryId: '1', 14 | categoryName: 'Shirts', 15 | id: '124', 16 | name: 'Shirt', 17 | price: 300, 18 | quantity: 1 19 | } 20 | ] 21 | 22 | const onViewedHomePageAuthorizedStub = { 23 | user: { 24 | email: 'test@test.com' 25 | }, 26 | basketProducts, 27 | pageType: 1 28 | } 29 | 30 | const onViewedHomePageUnauthorizedStub = { 31 | user: undefined, 32 | basketProducts, 33 | pageType: 1 34 | } 35 | 36 | export { 37 | onViewedHomePageUnauthorizedStub, 38 | onViewedHomePageAuthorizedStub 39 | } 40 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onUpdateCart.stub.js: -------------------------------------------------------------------------------- 1 | const onUpdateCartSetCartStub = { 2 | operation: 'SetCart', 3 | data: { 4 | productList: [ 5 | { 6 | product: { 7 | ids: { 8 | exampleId: '123' 9 | }, 10 | sku: { 11 | ids: { 12 | exampleSku: 'sku123' 13 | } 14 | } 15 | }, 16 | count: 2, 17 | price: 2000 18 | }, 19 | { 20 | product: { 21 | ids: { 22 | exampleId: '234' 23 | }, 24 | sku: { 25 | ids: { 26 | exampleSku: 'sku234' 27 | } 28 | } 29 | }, 30 | count: 1, 31 | price: 1000 32 | } 33 | ] 34 | } 35 | } 36 | 37 | export { onUpdateCartSetCartStub } 38 | -------------------------------------------------------------------------------- /test/integrations/stubs/DynamicYield/user.stub.js: -------------------------------------------------------------------------------- 1 | import sha256 from 'crypto-js/sha256' 2 | 3 | const userStub = { 4 | in: { 5 | userId: '555', 6 | email: 'test@mail.com' 7 | }, 8 | outLoggedIn: { 9 | name: 'Login', 10 | properties: { 11 | dyType: 'login-v1', 12 | hashedEmail: sha256('test@mail.com'), 13 | cuid: '555', 14 | cuidType: 'userId' 15 | } 16 | }, 17 | outRegistered: { 18 | name: 'Signup', 19 | properties: { 20 | dyType: 'signup-v1', 21 | hashedEmail: sha256('test@mail.com'), 22 | cuid: '555', 23 | cuidType: 'userId' 24 | } 25 | }, 26 | outSubscribed: { 27 | name: 'Newsletter Subscription', 28 | properties: { 29 | dyType: 'newsletter-subscription-v1', 30 | hashedEmail: sha256('test@mail.com') 31 | } 32 | } 33 | } 34 | 35 | export { 36 | userStub 37 | } 38 | -------------------------------------------------------------------------------- /test/integrations/utils/transliterate.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import cyrillicToTranslit from '../../../src/integrations/utils/transliterate' 3 | 4 | describe('transliterate', () => { 5 | it('shoult tranliterate correct for ru preset', () => { 6 | const t = cyrillicToTranslit({ preset: 'ru' }) 7 | const from = 'Съешь же ещё этих мягких французских булок, да выпей чаю.' 8 | const to = "Sesh zhe esh'e etih myagkih francuzskih bulok, da vipei chayu." 9 | assert.strict.equal(t.transform(from), to) 10 | }) 11 | 12 | it('shoult tranliterate correct for uk preset', () => { 13 | const t = cyrillicToTranslit({ preset: 'uk' }) 14 | const from = 'В чащах юга жил бы цитрус? Да, но фальшивый экземпляр!' 15 | const to = 'V chashchakh iuha zhyl bi tsytrus? Da, no falshyvii ekzempliar!' 16 | 17 | assert.strict.equal(t.transform(from), to) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/integrations/AdvCake/stubs/onViewedListingPage.stub.js: -------------------------------------------------------------------------------- 1 | 2 | const currentCategory = { 3 | id: '2', 4 | name: 'Headwear' 5 | } 6 | 7 | const products = [ 8 | { 9 | id: '123', 10 | name: 'Hat', 11 | price: 100 12 | }, 13 | { 14 | id: '125', 15 | name: 'Cap', 16 | price: 200 17 | } 18 | ] 19 | 20 | const onViewedListingPageStub = { 21 | in: { 22 | category: ['Accessories', 'Headwear'], 23 | categoryId: '2', 24 | items: [ 25 | { 26 | id: '123', 27 | name: 'Hat', 28 | unitSalePrice: 100 29 | }, 30 | { 31 | id: '125', 32 | name: 'Cap', 33 | unitSalePrice: 200 34 | } 35 | ] 36 | }, 37 | out: { 38 | user: undefined, 39 | currentCategory, 40 | products, 41 | pageType: 3, 42 | basketProducts: [] 43 | } 44 | } 45 | 46 | export { 47 | onViewedListingPageStub 48 | } 49 | -------------------------------------------------------------------------------- /src/integrations/TryFit.js: -------------------------------------------------------------------------------- 1 | import Integration from '../Integration' 2 | import { VIEWED_PRODUCT_DETAIL } from '../events/semanticEvents' 3 | 4 | class TryFit extends Integration { 5 | constructor (digitalData, options) { 6 | const optionsWithDefaults = Object.assign({ 7 | clientId: '' 8 | }, options) 9 | super(digitalData, optionsWithDefaults) 10 | 11 | this.pluginLoaded = false 12 | 13 | this.addTag('plugin', { 14 | type: 'script', 15 | attr: { 16 | id: 'tryfit-plugin', 17 | src: `https://plugin.try.fit/${options.clientId}/tf.js` 18 | } 19 | }) 20 | } 21 | 22 | getSemanticEvents () { 23 | return [VIEWED_PRODUCT_DETAIL] 24 | } 25 | 26 | trackEvent (event) { 27 | if (!this.pluginLoaded && event.name === VIEWED_PRODUCT_DETAIL) { 28 | this.pluginLoaded = true 29 | this.load('plugin') 30 | } 31 | } 32 | } 33 | 34 | export default TryFit 35 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/onSubscribed.stub.js: -------------------------------------------------------------------------------- 1 | const onSubscribedSubscribedStub = { 2 | operation: 'EmailSubscribe', 3 | identificator: { 4 | provider: 'email', 5 | identity: 'test@driveback.ru' 6 | }, 7 | data: { 8 | email: 'test@driveback.ru', 9 | firstName: 'John', 10 | lastName: 'Dow', 11 | subscriptions: [ 12 | { 13 | pointOfContact: 'Email' 14 | } 15 | ] 16 | } 17 | } 18 | 19 | const onSubscribedEmailSubscribeCustomStub = { 20 | operation: 'EmailSubscribeCustom', 21 | identificator: { 22 | provider: 'email', 23 | identity: 'test@driveback.ru' 24 | }, 25 | data: { 26 | email: 'test@driveback.ru', 27 | firstName: 'John', 28 | lastName: 'Dow', 29 | subscriptions: [ 30 | { 31 | pointOfContact: 'Email' 32 | } 33 | ] 34 | } 35 | } 36 | 37 | export { onSubscribedSubscribedStub, onSubscribedEmailSubscribeCustomStub } 38 | -------------------------------------------------------------------------------- /test/integrations/AdvCake/stubs/onCompletedTransaction.stub.js: -------------------------------------------------------------------------------- 1 | const onCompletedTransactionStub = { 2 | in: { 3 | orderId: '123', 4 | lineItems: [ 5 | { 6 | product: { 7 | id: '123', 8 | name: 'Hat', 9 | unitSalePrice: 100, 10 | manufacturer: 'DB', 11 | categoryId: '2', 12 | category: ['Accessories', 'Headwear'] 13 | }, 14 | quantity: 2, 15 | subtotal: 200 16 | } 17 | ], 18 | total: 200 19 | }, 20 | out: { 21 | orderInfo: { 22 | id: '123', 23 | totalPrice: 200 24 | }, 25 | basketProducts: [ 26 | { 27 | id: '123', 28 | name: 'Hat', 29 | price: 100, 30 | quantity: 2, 31 | categoryId: '2', 32 | categoryName: 'Headwear', 33 | brand: 'DB' 34 | } 35 | ], 36 | pageType: 6 37 | } 38 | } 39 | 40 | export { 41 | onCompletedTransactionStub 42 | } 43 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onViewedProductDetail.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedProductDetailViewProductStub = { 2 | operation: 'ViewProduct', 3 | data: { 4 | product: { 5 | ids: { 6 | bitrixId: '123' 7 | } 8 | } 9 | } 10 | } 11 | 12 | const onViewedProductDetailViewProductSkuStub = { 13 | operation: 'ViewProduct', 14 | data: { 15 | product: { 16 | ids: { 17 | bitrixId: '123' 18 | }, 19 | sku: { 20 | ids: { 21 | bitrixId: 'sku123' 22 | } 23 | } 24 | } 25 | } 26 | } 27 | 28 | const onViewedProductDetailViewedProductCustomStub = { 29 | operation: 'ViewedProductCustom', 30 | data: { 31 | product: { 32 | ids: { 33 | bitrixId: '123' 34 | } 35 | } 36 | } 37 | } 38 | 39 | export { 40 | onViewedProductDetailViewProductStub, 41 | onViewedProductDetailViewProductSkuStub, 42 | onViewedProductDetailViewedProductCustomStub 43 | } 44 | -------------------------------------------------------------------------------- /src/enrichments/IntegrationEnrichment.js: -------------------------------------------------------------------------------- 1 | import { error as errorLog } from '@segmentstream/utils/safeConsole' 2 | import { setProp } from '@segmentstream/utils/dotProp' 3 | import isPromise from '@segmentstream/utils/isPromise' 4 | import Handler from '../Handler' 5 | 6 | class IntegrationEnrichment { 7 | constructor (prop, handler, digitalData) { 8 | this.prop = prop 9 | this.handler = handler 10 | this.digitalData = digitalData 11 | 12 | this.done = false 13 | } 14 | 15 | enrich (target) { 16 | const handler = new Handler(this.handler, this.digitalData, [target]) 17 | try { 18 | const value = handler.run() 19 | if (isPromise(value)) { 20 | errorLog('Async integration enrichments are not supported') 21 | } else if (value !== undefined) { 22 | setProp(target, this.prop, value) 23 | } 24 | } catch (e) { 25 | errorLog(e) 26 | } 27 | } 28 | } 29 | 30 | export default IntegrationEnrichment 31 | -------------------------------------------------------------------------------- /test/integrations/stubs/FacebookPixel/onStartedOrder.stub.js: -------------------------------------------------------------------------------- 1 | const onStartedOrderStub = { 2 | in: { 3 | total: 20000, 4 | currency: 'USD', 5 | lineItems: [ 6 | { 7 | product: { 8 | id: '123', 9 | name: 'Test Product', 10 | category: 'Category 1', 11 | currency: 'USD', 12 | unitSalePrice: 10000 13 | }, 14 | quantity: 1, 15 | subtotal: 10000 16 | }, 17 | { 18 | product: { 19 | id: '234', 20 | name: 'Test Product 2', 21 | category: 'Category 1', 22 | currency: 'USD', 23 | unitSalePrice: 5000 24 | }, 25 | quantity: 2, 26 | subtotal: 10000 27 | } 28 | ] 29 | }, 30 | out: { 31 | content_ids: ['123', '234'], 32 | content_type: 'product', 33 | currency: 'USD', 34 | value: 20000, 35 | paramExample: 'example' 36 | } 37 | } 38 | 39 | export { 40 | onStartedOrderStub 41 | } 42 | -------------------------------------------------------------------------------- /test/integrations/stubs/FacebookPixel/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | onViewedProductDetailStub, 3 | onViewedProductDetailStubLegacy, 4 | onViewedProductDetailStubLegacySubcategory 5 | } from './onViewedProductPage.stub' 6 | 7 | import { 8 | onAddedProductToWishlistStub 9 | } from './onAddedProductToWishlist.stub' 10 | 11 | import { 12 | onAddedProductStub, 13 | onAddedProductStubLegacy, 14 | onAddedProductStubLegacySubcategory 15 | } from './onAddedProduct.stub' 16 | 17 | import { 18 | onStartedOrderStub 19 | } from './onStartedOrder.stub' 20 | 21 | import { 22 | onCompletedTransactionStub 23 | } from './onCompletedTransaction.stub' 24 | 25 | export default { 26 | onViewedProductDetailStub, 27 | onViewedProductDetailStubLegacy, 28 | onViewedProductDetailStubLegacySubcategory, 29 | onAddedProductToWishlistStub, 30 | onAddedProductStub, 31 | onAddedProductStubLegacy, 32 | onAddedProductStubLegacySubcategory, 33 | onStartedOrderStub, 34 | onCompletedTransactionStub 35 | } 36 | -------------------------------------------------------------------------------- /test/integrations/stubs/MyTarget/onViewedCart.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedCartStub = { 2 | in: { 3 | lineItems: [ 4 | { 5 | product: { 6 | id: '123', 7 | skuCode: 'sku123', 8 | unitSalePrice: 100 9 | }, 10 | quantity: 1 11 | }, 12 | { 13 | product: { 14 | id: '234', 15 | skuCode: 'sku234', 16 | unitPrice: 100, 17 | unitSalePrice: 50 18 | }, 19 | quantity: 2 20 | }, 21 | { 22 | product: { 23 | id: '345', 24 | skuCode: 'sku345', 25 | unitPrice: 30 26 | } 27 | }, 28 | { 29 | product: { 30 | id: '456', 31 | skuCode: 'sku456' 32 | } 33 | } 34 | ], 35 | total: 230 36 | }, 37 | out: ['123', '234', '345', '456'], 38 | outGroupedFeed: ['sku123', 'sku234', 'sku345', 'sku456'], 39 | outTotal: 230 40 | } 41 | 42 | export { 43 | onViewedCartStub 44 | } 45 | -------------------------------------------------------------------------------- /test/integrations/stubs/FacebookPixel/onCompletedTransaction.stub.js: -------------------------------------------------------------------------------- 1 | const onCompletedTransactionStub = { 2 | in: { 3 | orderId: '123', 4 | total: 20000, 5 | currency: 'USD', 6 | lineItems: [ 7 | { 8 | product: { 9 | id: '123', 10 | name: 'Test Product', 11 | category: 'Category 1', 12 | currency: 'USD', 13 | unitSalePrice: 10000 14 | }, 15 | quantity: 1, 16 | subtotal: 10000 17 | }, 18 | { 19 | product: { 20 | id: '234', 21 | name: 'Test Product 2', 22 | category: 'Category 1', 23 | currency: 'USD', 24 | unitSalePrice: 5000 25 | }, 26 | quantity: 2, 27 | subtotal: 10000 28 | } 29 | ] 30 | }, 31 | out: { 32 | content_ids: ['123', '234'], 33 | content_type: 'product', 34 | currency: 'USD', 35 | value: 20000, 36 | paramExample: 'example' 37 | } 38 | } 39 | 40 | export { 41 | onCompletedTransactionStub 42 | } 43 | -------------------------------------------------------------------------------- /src/integrations/VeInteractive.js: -------------------------------------------------------------------------------- 1 | import Integration from '../Integration' 2 | import { VIEWED_PAGE, COMPLETED_TRANSACTION } from '../events/semanticEvents' 3 | 4 | class VeInteractive extends Integration { 5 | constructor (digitalData, options) { 6 | const optionsWithDefaults = Object.assign({ 7 | javaScriptUrl: '', 8 | pixelUrl: '' 9 | }, options) 10 | 11 | super(digitalData, optionsWithDefaults) 12 | 13 | this.addTag({ 14 | type: 'script', 15 | attr: { 16 | src: options.javaScriptUrl 17 | } 18 | }) 19 | 20 | this.addTag('pixel', { 21 | type: 'img', 22 | attr: { 23 | src: options.pixelUrl 24 | } 25 | }) 26 | } 27 | 28 | getSemanticEvents () { 29 | return [VIEWED_PAGE, COMPLETED_TRANSACTION] 30 | } 31 | 32 | isLoaded () { 33 | return !!window.VeAPI 34 | } 35 | 36 | trackEvent (event) { 37 | if (event.name === COMPLETED_TRANSACTION && this.getOption('pixelUrl')) { 38 | this.load('pixel') 39 | } 40 | } 41 | } 42 | 43 | export default VeInteractive 44 | -------------------------------------------------------------------------------- /test/integrations/stubs/MyTarget/onCompletedTransaction.stub.js: -------------------------------------------------------------------------------- 1 | const onCompletedTransactionStub = { 2 | in: { 3 | lineItems: [ 4 | { 5 | product: { 6 | id: '123', 7 | skuCode: 'sku123', 8 | unitSalePrice: 100 9 | }, 10 | quantity: 1 11 | }, 12 | { 13 | product: { 14 | id: '234', 15 | skuCode: 'sku234', 16 | unitPrice: 100, 17 | unitSalePrice: 50 18 | }, 19 | quantity: 2 20 | }, 21 | { 22 | product: { 23 | id: '345', 24 | skuCode: 'sku345', 25 | unitPrice: 30 26 | } 27 | }, 28 | { 29 | product: { 30 | id: '456', 31 | skuCode: 'sku456' 32 | } 33 | } 34 | ], 35 | orderId: '123', 36 | isFirst: true, 37 | total: 230 38 | }, 39 | out: ['123', '234', '345', '456'], 40 | outGroupedFeed: ['sku123', 'sku234', 'sku345', 'sku456'], 41 | outTotal: 230 42 | } 43 | 44 | export { 45 | onCompletedTransactionStub 46 | } 47 | -------------------------------------------------------------------------------- /src/integrations/utils/AsyncQueue.js: -------------------------------------------------------------------------------- 1 | class AsyncQueue { 2 | constructor (isLoadedDelegate) { 3 | this.isLoadedDelegate = isLoadedDelegate 4 | this.asyncQueue = [] 5 | } 6 | 7 | init () { 8 | // emulate async queue for Ofsys sync script 9 | let invervalCounter = 0 10 | const invervalId = setInterval(() => { 11 | invervalCounter += 1 12 | if (this.isLoadedDelegate()) { 13 | this.flushQueue() 14 | clearInterval(invervalId) 15 | } else if (invervalCounter > 20) { 16 | clearInterval(invervalId) 17 | } 18 | }, 200) 19 | } 20 | 21 | flushQueue () { 22 | let handler = this.asyncQueue.shift() 23 | while (handler && typeof handler === 'function') { 24 | handler() 25 | handler = this.asyncQueue.shift() 26 | } 27 | this.asyncQueue.push = (callback) => { 28 | callback() 29 | } 30 | } 31 | 32 | push (handler) { 33 | if (this.isLoadedDelegate()) { 34 | handler() 35 | } else { 36 | this.asyncQueue.push(handler) 37 | } 38 | } 39 | } 40 | 41 | export default AsyncQueue 42 | -------------------------------------------------------------------------------- /test/integrations/stubs/Target2Sell/index.js: -------------------------------------------------------------------------------- 1 | const productStub = { 2 | in: { 3 | id: '123', 4 | skuCode: '1234' 5 | }, 6 | out: '123', 7 | outGroupedFeed: '1234' 8 | } 9 | 10 | const lineItemsStub = { 11 | in: [ 12 | { 13 | product: { 14 | id: '123', 15 | skuCode: '1234' 16 | }, 17 | subtotal: 400, 18 | quantity: 1 19 | }, 20 | { 21 | product: { 22 | id: '124', 23 | skuCode: '1245' 24 | }, 25 | subtotal: 600, 26 | quantity: 2 27 | } 28 | ], 29 | out: '123|124', 30 | outGroupedFeed: '1234|1245', 31 | outSubtotals: '400|600', 32 | outQuantities: '1|2' 33 | } 34 | 35 | const listingItemsStub = { 36 | in: [ 37 | { 38 | id: '123', 39 | skuCode: '1234' 40 | }, 41 | { 42 | id: '124', 43 | skuCode: '1245' 44 | }, 45 | { 46 | id: '125', 47 | skuCode: '1256' 48 | } 49 | ], 50 | out: '123|124|125', 51 | outGroupedFeed: '1234|1245|1256' 52 | } 53 | 54 | export default { 55 | productStub, 56 | lineItemsStub, 57 | listingItemsStub 58 | } 59 | -------------------------------------------------------------------------------- /src/integrations/Aidata.js: -------------------------------------------------------------------------------- 1 | import Integration from '../Integration' 2 | 3 | class Aidata extends Integration { 4 | constructor (digitalData, options) { 5 | const optionsWithDefaults = Object.assign({ 6 | eventPixels: {} 7 | }, options) 8 | super(digitalData, optionsWithDefaults) 9 | 10 | this.SEMANTIC_EVENTS = Object.keys(this.getOption('eventPixels')) 11 | 12 | this._isLoaded = false 13 | 14 | this.addTag({ 15 | type: 'script', 16 | attr: { 17 | src: `//x01.aidata.io/pixel.js?pixel={{ pixelId }}&v=${Date.now()}` 18 | } 19 | }) 20 | } 21 | 22 | initialize () { 23 | this._isLoaded = true 24 | } 25 | 26 | getSemanticEvents () { 27 | return this.SEMANTIC_EVENTS 28 | } 29 | 30 | isLoaded () { 31 | return this._isLoaded 32 | } 33 | 34 | reset () { 35 | // nothing to reset 36 | } 37 | 38 | trackEvent (event) { 39 | const eventPixels = this.getOption('eventPixels') 40 | const pixelId = eventPixels[event.name] 41 | if (pixelId) { 42 | this.load({ pixelId }) 43 | } 44 | } 45 | } 46 | 47 | export default Aidata 48 | -------------------------------------------------------------------------------- /test/integrations/AdvCake/stubs/onViewedProductPage.stub.js: -------------------------------------------------------------------------------- 1 | const currentCategory = { 2 | id: '2', 3 | name: 'Headwear' 4 | } 5 | 6 | const currentProduct = { 7 | id: '123', 8 | name: 'Hat', 9 | price: 100, 10 | brand: 'DB' 11 | } 12 | 13 | const basketProducts = [ 14 | { 15 | brand: 'DB', 16 | categoryId: '2', 17 | categoryName: 'Headwear', 18 | id: '123', 19 | name: 'Hat', 20 | price: 100, 21 | quantity: 2 22 | }, 23 | { 24 | brand: 'DB', 25 | categoryId: '1', 26 | categoryName: 'Shirts', 27 | id: '124', 28 | name: 'Shirt', 29 | price: 300, 30 | quantity: 1 31 | } 32 | ] 33 | 34 | const onViewedProductPageStub = { 35 | in: { 36 | id: '123', 37 | name: 'Hat', 38 | unitSalePrice: 100, 39 | manufacturer: 'DB', 40 | categoryId: '2', 41 | category: ['Accessories', 'Headwear'] 42 | }, 43 | out: { 44 | user: { 45 | email: 'test@test.com' 46 | }, 47 | currentCategory, 48 | currentProduct, 49 | basketProducts, 50 | pageType: 2 51 | } 52 | } 53 | 54 | export { 55 | onViewedProductPageStub 56 | } 57 | -------------------------------------------------------------------------------- /test/integrations/stubs/DynamicYield/onCompletedTransaction.stub.js: -------------------------------------------------------------------------------- 1 | const onCompletedTransactionStub = { 2 | in: { 3 | orderId: '999', 4 | total: 100, 5 | currency: 'USD', 6 | lineItems: [ 7 | { 8 | product: { 9 | id: '123', 10 | size: 'S', 11 | unitSalePrice: 25 12 | }, 13 | quantity: 2 14 | }, 15 | { 16 | product: { 17 | id: '124', 18 | size: 'M', 19 | unitSalePrice: 50 20 | }, 21 | quantity: 1 22 | } 23 | ] 24 | }, 25 | out: { 26 | name: 'Purchase', 27 | properties: { 28 | dyType: 'purchase-v1', 29 | uniqueTransactionId: '999', 30 | value: 100, 31 | currency: 'USD', 32 | cart: [ 33 | { 34 | productId: '123', 35 | quantity: 2, 36 | itemPrice: 25, 37 | size: 'S' 38 | }, 39 | { 40 | productId: '124', 41 | quantity: 1, 42 | itemPrice: 50, 43 | size: 'M' 44 | } 45 | ] 46 | } 47 | } 48 | } 49 | 50 | export { 51 | onCompletedTransactionStub 52 | } 53 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Driveback LLC (opensource@driveback.ru) | The MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/scripts/CustomScript.js: -------------------------------------------------------------------------------- 1 | import { error as errorLog } from '@segmentstream/utils/safeConsole' 2 | import Handler from '../Handler' 3 | 4 | class CustomScript { 5 | constructor (name, event, handler, fireOnce, runAfterPageLoaded, digitalData) { 6 | this.name = name 7 | this.event = event 8 | this.handler = handler 9 | this.fireOnce = fireOnce || false 10 | this.digitalData = digitalData 11 | this.hasFired = false 12 | this.runAfterPageLoaded = runAfterPageLoaded || false 13 | } 14 | 15 | newHandler (args) { 16 | return new Handler(this.handler, this.digitalData, args) 17 | } 18 | 19 | run (event) { 20 | if (this.fireOnce && this.hasFired) return 21 | 22 | let handler 23 | if (!event && !this.event) { 24 | handler = this.newHandler() 25 | } else if (event.name === this.event) { 26 | handler = this.newHandler([event]) 27 | } 28 | 29 | try { 30 | handler.run() 31 | this.hasFired = true 32 | } catch (e) { 33 | e.message = `DDManager Custom Script "${this.name}" Error\n\n ${e.message}` 34 | errorLog(e) 35 | } 36 | } 37 | } 38 | 39 | export default CustomScript 40 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/onCompletedTransaction.stub.js: -------------------------------------------------------------------------------- 1 | function getCompletedOrderStub () { 2 | return { 3 | operation: 'CompletedOrder', 4 | identificator: { 5 | provider: 'TestWebsiteId', 6 | identity: 'user123' 7 | }, 8 | data: { 9 | order: { 10 | webSiteId: '123', 11 | price: 5000, 12 | deliveryType: 'Courier', 13 | paymentType: 'Visa', 14 | items: [ 15 | { 16 | productId: '123', 17 | skuId: 'sku123', 18 | quantity: 1, 19 | price: 100 20 | }, 21 | { 22 | productId: '234', 23 | skuId: 'sku234', 24 | quantity: 2, 25 | price: 150 26 | } 27 | ] 28 | } 29 | } 30 | } 31 | } 32 | 33 | const onCompletedTransactionCompletedOrderStub = getCompletedOrderStub() 34 | 35 | const onCompletedTransactionCompletedOrderCustomStub = getCompletedOrderStub() 36 | onCompletedTransactionCompletedOrderCustomStub.operation = 'CompletedOrderCustom' 37 | 38 | export { onCompletedTransactionCompletedOrderStub, onCompletedTransactionCompletedOrderCustomStub } 39 | -------------------------------------------------------------------------------- /test/integrations/stubs/DynamicYield/onAddedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onAddedProductStub = { 2 | in: { 3 | cart: { 4 | total: 100, 5 | currency: 'RUB', 6 | lineItems: [ 7 | { 8 | product: { 9 | id: '123', 10 | size: 'S', 11 | unitSalePrice: 25 12 | }, 13 | quantity: 2 14 | }, 15 | { 16 | product: { 17 | id: '124', 18 | size: 'M', 19 | unitSalePrice: 50 20 | }, 21 | quantity: 1 22 | } 23 | ] 24 | }, 25 | product: { 26 | id: '125' 27 | } 28 | }, 29 | out: { 30 | name: 'Add to Cart', 31 | properties: { 32 | dyType: 'add-to-cart-v1', 33 | value: 100, 34 | currency: 'RUB', 35 | productId: '125', 36 | quantity: 2, 37 | cart: [ 38 | { 39 | productId: '123', 40 | quantity: 2, 41 | itemPrice: 25, 42 | size: 'S' 43 | }, 44 | { 45 | productId: '124', 46 | quantity: 1, 47 | itemPrice: 50, 48 | size: 'M' 49 | } 50 | ] 51 | } 52 | } 53 | } 54 | 55 | export { 56 | onAddedProductStub 57 | } 58 | -------------------------------------------------------------------------------- /test/IntegrationBase.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import Integration from '../src/Integration' 3 | 4 | describe('integration base class', () => { 5 | const integration = new Integration() 6 | 7 | describe('script tempates insert', () => { 8 | it('when get empty parameters then expected empty query parameter value with valid script', () => { 9 | const expectedSrc = 'https://www.somedomain.com/landing.js?mode=card' 10 | const script = 'https://www.somedomain.com/landing.js?codes={{ productCodes }}&mode=card' 11 | const codes = '' 12 | 13 | let actualAttr = { src: script } 14 | actualAttr = integration.replaceAttrTemplate({ productCodes: codes }, actualAttr) 15 | 16 | assert.strict.equal(actualAttr.src, expectedSrc) 17 | }) 18 | 19 | it('when get not empty params, expected script url filled value', () => { 20 | const expectedSrc = 'https://www.somedomain.com/landing.js?codes=100&mode=card' 21 | const script = 'https://www.somedomain.com/landing.js?codes={{ productCodes }}&mode=card' 22 | const codes = '100' 23 | 24 | let actualAttr = { src: script } 25 | actualAttr = integration.replaceAttrTemplate({ productCodes: codes }, actualAttr) 26 | 27 | assert.strict.equal(actualAttr.src, expectedSrc) 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /test/trackers/trackLink.spec.js: -------------------------------------------------------------------------------- 1 | import trackLink from './../../src/trackers/trackLink' 2 | import fireEvent from './../functions/fireEvent' 3 | import assert from 'assert' 4 | 5 | describe('trackLink', () => { 6 | describe('#button', () => { 7 | let btn 8 | let div 9 | 10 | beforeEach(() => { 11 | // create button 12 | btn = document.createElement('button') 13 | const t = document.createTextNode('click me') 14 | btn.appendChild(t) 15 | btn.className = 'test-btn' 16 | 17 | // create div 18 | div = document.createElement('div') 19 | div.appendChild(btn) 20 | div.id = 'test-div' 21 | 22 | document.body.appendChild(div) 23 | }) 24 | 25 | afterEach(() => { 26 | document.body.removeChild(div) 27 | }) 28 | 29 | it('should track click by class name', (done) => { 30 | trackLink('.test-btn', (link) => { 31 | assert.strict.equal(typeof link, 'object') 32 | done() 33 | }) 34 | 35 | fireEvent(btn, 'click') 36 | }) 37 | 38 | it('should track click by nested class name', (done) => { 39 | trackLink('#test-div .test-btn', (link) => { 40 | assert.strict.equal(typeof link, 'object') 41 | done() 42 | }) 43 | 44 | fireEvent(btn, 'click') 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /test/integrations/stubs/Flocktory/onCompletedTransaction.stub.js: -------------------------------------------------------------------------------- 1 | const onCompletedTransactionStub = { 2 | in: { 3 | orderId: '123', 4 | total: 20000, 5 | currency: 'USD', 6 | lineItems: [ 7 | { 8 | product: { 9 | id: '123', 10 | name: 'Test Product', 11 | category: 'Category 1', 12 | currency: 'USD', 13 | unitSalePrice: 10000, 14 | imageUrl: '/1.jpg' 15 | }, 16 | quantity: 1, 17 | subtotal: 10000 18 | }, 19 | { 20 | product: { 21 | id: '234', 22 | name: 'Test Product 2', 23 | category: 'Category 1', 24 | currency: 'USD', 25 | unitSalePrice: 5000, 26 | imageUrl: '/2.jpg' 27 | }, 28 | quantity: 2, 29 | subtotal: 10000 30 | } 31 | ] 32 | }, 33 | out: { 34 | id: '123', 35 | items: [ 36 | { 37 | id: '123', 38 | title: 'Test Product', 39 | price: 10000, 40 | image: '/1.jpg', 41 | count: 1 42 | }, 43 | { 44 | id: '234', 45 | title: 'Test Product 2', 46 | price: 5000, 47 | image: '/2.jpg', 48 | count: 2 49 | } 50 | ], 51 | price: 20000 52 | } 53 | } 54 | 55 | export { 56 | onCompletedTransactionStub 57 | } 58 | -------------------------------------------------------------------------------- /test/integrations/Vkontakte/stubs/cartStub.stub.js: -------------------------------------------------------------------------------- 1 | const cartStub = { 2 | in: { 3 | lineItems: [ 4 | { 5 | product: { 6 | id: '123', 7 | skuCode: '1234', 8 | unitPrice: 100, 9 | unitSalePrice: 90, 10 | currency: 'USD' 11 | }, 12 | quantity: 2, 13 | subtotal: 180 14 | }, 15 | { 16 | product: { 17 | id: '124', 18 | skuCode: '1245', 19 | unitPrice: 200, 20 | unitSalePrice: 200, 21 | currency: 'USD' 22 | }, 23 | quantity: 1, 24 | subtotal: 200 25 | } 26 | ], 27 | subtotal: 380, 28 | currency: 'USD' 29 | }, 30 | out: { 31 | products: [ 32 | { 33 | id: '123', 34 | price_old: 100, 35 | price: 90 36 | }, 37 | { 38 | id: '124', 39 | price_old: 200, 40 | price: 200 41 | } 42 | ], 43 | total_price: 380, 44 | currency_code: 'USD' 45 | }, 46 | outGrouped: { 47 | products: [ 48 | { 49 | id: '1234', 50 | price_old: 100, 51 | price: 90 52 | }, 53 | { 54 | id: '1245', 55 | price_old: 200, 56 | price: 200 57 | } 58 | ], 59 | total_price: 380, 60 | currency_code: 'USD' 61 | } 62 | } 63 | 64 | export default cartStub 65 | -------------------------------------------------------------------------------- /src/enrichments/CustomEnrichmentsCollection.js: -------------------------------------------------------------------------------- 1 | // !!! LEGACY LIBRARY @TODO: remove after full propogation of initialization 1.2.9 2 | 3 | import CustomEnrichment from './CustomEnrichment' 4 | 5 | class CustomEnrichmentsCollection { 6 | constructor (event, beforeEvent) { 7 | this.event = event 8 | this.beforeEvent = beforeEvent 9 | this.enrichments = [] 10 | this.enrichmentsIndex = {} 11 | } 12 | 13 | setDigitalData (digitalData) { 14 | this.digitalData = digitalData 15 | } 16 | 17 | getDigitalData () { 18 | return this.digitalData 19 | } 20 | 21 | setDDStorage (ddStorage) { 22 | this.ddStorage = ddStorage 23 | } 24 | 25 | getDDStorage () { 26 | return this.ddStorage 27 | } 28 | 29 | addEnrichment (config) { 30 | const { prop } = config 31 | const enrichment = new CustomEnrichment(config, this) 32 | this.enrichments.push(enrichment) 33 | this.enrichmentsIndex[prop] = enrichment 34 | } 35 | 36 | getEnrichment (prop) { 37 | return this.enrichmentsIndex[prop] 38 | } 39 | 40 | reset () { 41 | this.enrichments.forEach((enrichment) => { 42 | enrichment.reset() 43 | }) 44 | } 45 | 46 | enrich (target, args) { 47 | this.reset() 48 | this.enrichments.forEach((enrichment) => { 49 | enrichment.enrich(target, args) 50 | }) 51 | } 52 | } 53 | 54 | export default CustomEnrichmentsCollection 55 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onUpdatedProfileInfo.stub.js: -------------------------------------------------------------------------------- 1 | const onUpdatedProfileInfoSubscriptionsStub = [ 2 | { 3 | type: 'email', 4 | topic: 'News', 5 | isSubscribed: true 6 | }, 7 | { 8 | type: 'email', 9 | topic: 'Offers', 10 | isSubscribed: false 11 | }, 12 | { 13 | type: 'sms', 14 | isSubscribed: true 15 | } 16 | ] 17 | 18 | const onUpdatedProfileInfoStub = { 19 | operation: 'UpdateProfile', 20 | data: { 21 | customer: { 22 | authenticationTicket: 'xxxxx', 23 | ids: { 24 | bitrixId: 'user123' 25 | }, 26 | firstName: 'John', 27 | lastName: 'Dow', 28 | email: 'test@driveback.ru', 29 | mobilePhone: '79374134389', 30 | customFields: { 31 | source: 'Driveback', 32 | city: 'Moscow', 33 | b2b: true, 34 | childrenNames: [ 35 | 'Helen', 36 | 'Bob' 37 | ] 38 | }, 39 | subscriptions: [ 40 | { 41 | pointOfContact: 'Email', 42 | topic: 'News', 43 | isSubscribed: true 44 | }, 45 | { 46 | pointOfContact: 'Email', 47 | topic: 'Offers', 48 | isSubscribed: false 49 | }, 50 | { 51 | pointOfContact: 'Sms', 52 | isSubscribed: true 53 | } 54 | ] 55 | } 56 | } 57 | } 58 | 59 | export { onUpdatedProfileInfoSubscriptionsStub, onUpdatedProfileInfoStub } 60 | -------------------------------------------------------------------------------- /test/integrations/stubs/FacebookPixel/onAddedProduct.stub.js: -------------------------------------------------------------------------------- 1 | const onAddedProductStub = { 2 | in: { 3 | id: '123', 4 | name: 'Test Product', 5 | category: ['Category 1', 'Subcategory 1'], 6 | currency: 'USD', 7 | unitSalePrice: 10000 8 | }, 9 | out: { 10 | content_ids: ['123'], 11 | content_type: 'product', 12 | content_name: 'Test Product', 13 | content_category: 'Category 1/Subcategory 1', 14 | paramExample: 'example' 15 | } 16 | } 17 | 18 | const onAddedProductStubLegacy = { 19 | in: { 20 | id: '123', 21 | name: 'Test Product', 22 | category: 'Category 1', 23 | currency: 'USD', 24 | unitSalePrice: 10000 25 | }, 26 | out: { 27 | content_ids: ['123'], 28 | content_type: 'product', 29 | content_name: 'Test Product', 30 | content_category: 'Category 1', 31 | paramExample: 'example' 32 | } 33 | } 34 | 35 | const onAddedProductStubLegacySubcategory = { 36 | in: { 37 | id: '123', 38 | name: 'Test Product', 39 | category: 'Category 1', 40 | subcategory: 'Subcategory 1', 41 | currency: 'USD', 42 | unitSalePrice: 10000 43 | }, 44 | out: { 45 | content_ids: ['123'], 46 | content_type: 'product', 47 | content_name: 'Test Product', 48 | content_category: 'Category 1/Subcategory 1', 49 | paramExample: 'example' 50 | } 51 | } 52 | 53 | export { 54 | onAddedProductStub, 55 | onAddedProductStubLegacy, 56 | onAddedProductStubLegacySubcategory 57 | } 58 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onViewedPage.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedPageCartStub = { 2 | lineItems: [ 3 | { 4 | product: { 5 | id: '123', 6 | unitSalePrice: 1000, 7 | skuCode: 'sku123' 8 | }, 9 | quantity: 2 10 | }, 11 | { 12 | product: { 13 | id: '234', 14 | unitSalePrice: 1000, 15 | skuCode: 'sku234' 16 | }, 17 | quantity: 1 18 | } 19 | ] 20 | } 21 | 22 | const productList = [ 23 | { 24 | product: { 25 | ids: { 26 | bitrixId: '123' 27 | }, 28 | sku: { 29 | ids: { 30 | bitrixId: 'sku123' 31 | } 32 | } 33 | }, 34 | count: 2, 35 | price: 2 * 1000 36 | }, 37 | { 38 | product: { 39 | ids: { 40 | bitrixId: '234' 41 | }, 42 | sku: { 43 | ids: { 44 | bitrixId: 'sku234' 45 | } 46 | } 47 | }, 48 | count: 1, 49 | price: 1000 50 | 51 | } 52 | ] 53 | 54 | const onViewedPageSetCartUnauthorizedStub = { 55 | operation: 'SetCart', 56 | data: { 57 | productList 58 | } 59 | } 60 | 61 | const onViewedPageSetCartAuthorizedStub = { 62 | operation: 'SetCart', 63 | data: { 64 | customer: { 65 | ids: { 66 | bitrixId: 'user123' 67 | } 68 | }, 69 | productList 70 | } 71 | } 72 | 73 | export { 74 | onViewedPageSetCartUnauthorizedStub, 75 | onViewedPageSetCartAuthorizedStub, 76 | onViewedPageCartStub 77 | } 78 | -------------------------------------------------------------------------------- /src/ErrorTracker.js: -------------------------------------------------------------------------------- 1 | let errorTrackingEnabled = false 2 | 3 | export function enableErrorTracking (digitalData) { 4 | if (errorTrackingEnabled) return 5 | 6 | const originalWindowErrorCallback = window.onerror 7 | 8 | window.onerror = (errorMessage, url, lineNumber, columnNumber, errorObject) => { 9 | // In case the "errorObject" is available, use its data, else fallback 10 | // on the default "errorMessage" provided: 11 | let exceptionDescription = errorMessage 12 | if (errorObject && typeof errorObject.message !== 'undefined') { 13 | exceptionDescription = errorObject.message 14 | } 15 | 16 | // Format the message to log to Analytics (might also use "errorObject.stack" if defined): 17 | exceptionDescription += ` @ ${url}:${lineNumber}:${columnNumber}` 18 | 19 | digitalData.events.push({ 20 | name: 'Exception', 21 | category: 'JS Errors', 22 | exception: { 23 | message: String(errorMessage), 24 | description: exceptionDescription, 25 | lineNumber, 26 | columnNumber, 27 | isFatal: true 28 | } 29 | }) 30 | 31 | // If the previous "window.onerror" callback can be called, pass it the data: 32 | if (typeof originalWindowErrorCallback === 'function') { 33 | return originalWindowErrorCallback(errorMessage, url, lineNumber, columnNumber, errorObject) 34 | } 35 | // Otherwise, Let the default handler run: 36 | return false 37 | } 38 | 39 | errorTrackingEnabled = true 40 | } 41 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/Mindbox.stub.js: -------------------------------------------------------------------------------- 1 | export const options = { 2 | projectSystemName: 'Test', 3 | brandSystemName: 'drivebackru', 4 | pointOfContactSystemName: 'test-services.mindbox.ru', 5 | projectDomain: 'test.com', 6 | userIdProvider: 'TestWebsiteId', 7 | endpointId: 'endpointId' 8 | } 9 | 10 | export const webPushWithCustomServiceWorkerOptions = { 11 | webpush: true, 12 | useCustomServiceWorkerPath: true, 13 | serviceWorkerPath: '/my-folder/mindbox-services-worker.js', 14 | pushSubscriptionTriggerEvent: 'Viewed Page' 15 | } 16 | 17 | export const webPushWithCustomServiceWorkerScopeOptions = { 18 | webpush: true, 19 | useCustomServiceWorkerPath: true, 20 | serviceWorkerPath: '/my-folder/mindbox-services-worker.js', 21 | serviceWorkerScope: '/my-folder/', 22 | pushSubscriptionTriggerEvent: 'Viewed Page' 23 | } 24 | 25 | export const expectedInitOptions = { 26 | projectSystemName: 'Test', 27 | brandSystemName: 'drivebackru', 28 | pointOfContactSystemName: 'test-services.mindbox.ru', 29 | projectDomain: 'test.com', 30 | firebaseMessagingSenderId: '', 31 | serviceWorkerPath: '/my-folder/mindbox-services-worker.js' 32 | } 33 | 34 | export const expectedInitOptionsWithServiceWorkerScope = { 35 | projectSystemName: 'Test', 36 | brandSystemName: 'drivebackru', 37 | pointOfContactSystemName: 'test-services.mindbox.ru', 38 | projectDomain: 'test.com', 39 | firebaseMessagingSenderId: '', 40 | serviceWorkerPath: '/my-folder/mindbox-services-worker.js', 41 | serviceWorkerScope: '/my-folder/' 42 | } 43 | -------------------------------------------------------------------------------- /src/trackers/trackScroll.js: -------------------------------------------------------------------------------- 1 | import { bind } from '@segmentstream/utils/eventListener' 2 | 3 | let events = [] 4 | 5 | const getScrollPercent = () => { 6 | const html = document.documentElement 7 | const body = document.body 8 | return parseInt((html.scrollTop || body.scrollTop) / ((html.scrollHeight || body.scrollHeight) - html.clientHeight) * 100, 10) 9 | } 10 | 11 | const addEvent = (targetScrollDepth, handler) => { 12 | events.push({ targetScrollDepth, handler, isFired: false }) 13 | } 14 | 15 | const processEvents = () => { 16 | const scrolledPercent = getScrollPercent() 17 | events = events.map((event) => { 18 | if (!event.isFired && event.targetScrollDepth <= scrolledPercent) { 19 | event.handler(event.targetScrollDepth) 20 | return { ...event, isFired: true } 21 | } 22 | return event 23 | }) 24 | } 25 | 26 | bind(window, 'scroll', processEvents, false) 27 | 28 | export const reset = () => { 29 | events = events.map(event => ({ ...event, isFired: false })) 30 | } 31 | 32 | export default (scrollDepth, handler) => { 33 | if (!scrollDepth) return 34 | 35 | if (typeof handler !== 'function') { 36 | throw new TypeError('Must pass function handler to `ddManager.trackScroll`.') 37 | } 38 | 39 | String(scrollDepth) 40 | .replace(/\s+/mg, '') 41 | .split(',') 42 | .forEach((scrollPersentStr) => { 43 | const scrollPersent = parseInt(scrollPersentStr) 44 | if (scrollPersent > 0 && scrollPersent <= 100) { 45 | addEvent(scrollPersent, handler) 46 | } 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /package-scripts.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const { 3 | series, ifWindows, rimraf, mkdirp, 4 | } = require('nps-utils'); 5 | 6 | const buildDir = 'build'; 7 | const distDir = 'dist'; 8 | 9 | const createDist = mkdirp(distDir); 10 | const createBuild = mkdirp(buildDir); 11 | const cleanDist = rimraf(`${distDir}/*`); 12 | const cleanBuild = rimraf(`${buildDir}/*`); 13 | 14 | const browserifyDebug = 'browserify src/index.js -t babelify --debug | exorcist --base=./build build/segmentstream.js.map > build/segmentstream.js'; 15 | const browserifyProd = 'browserify src/index.js -t babelify > dist/segmentstream.js && grunt wrap && uglifyjs dist/segmentstream.js -c -m --output dist/segmentstream.min.js'; 16 | const browserifyTest = 'browserify test/index.test.js -t babelify --debug | exorcist --base=./build build/segmentstream-test.js.map > build/segmentstream-test.js'; 17 | module.exports = { 18 | scripts: { 19 | build: { 20 | default: 21 | { 22 | description: 'This uses for debug purpose. Creates dd.manager.js and its map.', 23 | script: series(cleanBuild, createBuild, browserifyDebug), 24 | }, 25 | prod: 26 | { 27 | description: 'Prepare for deploy. JS + minimize', 28 | script: series(cleanDist, createDist, browserifyProd), 29 | }, 30 | }, 31 | standard: 'standard', 32 | buildTest: series(cleanBuild, createBuild, browserifyTest), 33 | mocha: 'mocha build/segmentstream-test.js', 34 | test: { 35 | default: series.nps('standard', 'buildTest', 'karma') 36 | }, 37 | karma: { 38 | default: 'karma start' 39 | }, 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /test/integrations/Renta.spec.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon' 2 | import noop from '@segmentstream/utils/noop' 3 | import reset from '../reset' 4 | import GoogleAnalytics from '../../src/integrations/GoogleAnalytics' 5 | import Renta from '../../src/integrations/Renta' 6 | import ddManager from '../../src/ddManager' 7 | 8 | describe('Integrations: Renta', () => { 9 | describe('Renta', () => { 10 | let ga 11 | let renta 12 | const options = { trackingId: 'UA-51485228-1', doman: 'auto' } 13 | 14 | beforeEach(() => { 15 | window.digitalData = { events: [] } 16 | ga = new GoogleAnalytics(window.digitalData, options) 17 | renta = new Renta(window.digitalData) 18 | 19 | ga.reset() 20 | renta.reset() 21 | 22 | ddManager.addIntegration('Google Analytics', ga) 23 | ddManager.addIntegration('Renta Streaming', renta) 24 | }) 25 | 26 | afterEach(() => { 27 | ga.reset() 28 | renta.reset() 29 | ddManager.reset() 30 | reset() 31 | }) 32 | 33 | describe('before loading', () => { 34 | beforeEach(() => { 35 | sinon.stub(ga, 'load') 36 | sinon.stub(renta, 'load') 37 | }) 38 | 39 | afterEach(() => { 40 | ga.load.restore() 41 | renta.load.restore() 42 | }) 43 | 44 | describe('#initialize', () => { 45 | it('should require Google Analytics Renta plugin', () => { 46 | window.ga = noop 47 | sinon.stub(window, 'ga') 48 | ddManager.initialize() 49 | ddManager.on('ready', () => { 50 | window.ga.calledWith('require', 'RentaStreaming') 51 | window.ga.restore() 52 | }) 53 | }) 54 | }) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/IntegrationUtils.js: -------------------------------------------------------------------------------- 1 | import each from '@segmentstream/utils/each' 2 | import { getProp } from '@segmentstream/utils/dotProp' 3 | import { DIGITALDATA_VAR } from './variableTypes' 4 | 5 | export function getEnrichableVariableMappingProps (variableMapping) { 6 | const enrichableProps = [] 7 | each(variableMapping, (key, variable) => { 8 | if (variable.type === DIGITALDATA_VAR) { 9 | enrichableProps.push(variable.value) 10 | } 11 | }) 12 | return enrichableProps 13 | } 14 | 15 | /** 16 | * Possible options: 17 | * - booleanToString: true/false (default - false) 18 | * - multipleScopes: true/false (default - false) 19 | */ 20 | export function extractVariableMappingValues (source, variableMapping, options = {}) { 21 | const values = {} 22 | let srcObject = source 23 | each(variableMapping, (key, variable) => { 24 | let value 25 | const vartype = variable.type === DIGITALDATA_VAR ? 'event' : variable.type 26 | if (options.multipleScopes) srcObject = source[vartype] 27 | 28 | if (srcObject) { 29 | if (typeof variable === 'object' && variable.type) { 30 | value = getProp(srcObject, variable.value) 31 | } else { 32 | value = getProp(srcObject, variable) 33 | } 34 | if (value !== undefined) { 35 | if (typeof value === 'boolean' && options.booleanToString) value = value.toString() 36 | values[key] = value 37 | } 38 | } 39 | }) 40 | return values 41 | } 42 | 43 | export function getVariableMappingValue (source, key, variableMapping) { 44 | const variable = variableMapping[key] 45 | if (typeof variable === 'object' && variable.type) { 46 | return getProp(source, variable.value) 47 | } 48 | return getProp(source, variable) 49 | } 50 | -------------------------------------------------------------------------------- /test/snippet.js: -------------------------------------------------------------------------------- 1 | export default function () { 2 | // Create a queue, but don't obliterate an existing one! 3 | var ddManager = window.ddManager = window.ddManager || [] 4 | window.ddListener = window.ddListener || [] 5 | var digitalData = window.digitalData = window.digitalData || {} 6 | digitalData.events = digitalData.events || [] 7 | 8 | // If the real ddManager is already on the page return. 9 | if (ddManager.initialize) return 10 | 11 | // If the snippet was invoked already show an error. 12 | if (ddManager.invoked) { 13 | if (window.console && console.error) { 14 | console.error('Digital Data Manager snippet included twice.') 15 | } 16 | return 17 | } 18 | // Invoked flag, to make sure the snippet 19 | // is never invoked twice. 20 | ddManager.invoked = true 21 | 22 | // A list of the methods in Analytics.js to stub. 23 | ddManager.methods = [ 24 | 'initialize', 25 | 'addIntegration', 26 | 'on', 27 | 'once', 28 | 'off' 29 | ] 30 | 31 | // Define a factory to create stubs. These are placeholders 32 | // for methods in Digital Data Manager so that you never have to wait 33 | // for it to load to actually record data. The `method` is 34 | // stored as the first argument, so we can replay the data. 35 | ddManager.factory = function (method) { 36 | return function () { 37 | var args = Array.prototype.slice.call(arguments) 38 | args.unshift(method) 39 | ddManager.push(args) 40 | return ddManager 41 | } 42 | } 43 | 44 | // For each of our methods, generate a queueing stub. 45 | for (var i = 0; i < ddManager.methods.length; i++) { 46 | var key = ddManager.methods[i] 47 | ddManager[key] = ddManager.factory(key) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/integrations/Weborama.js: -------------------------------------------------------------------------------- 1 | import Integration from '../Integration' 2 | 3 | class Weborama extends Integration { 4 | constructor (digitalData, options) { 5 | const optionsWithDefaults = Object.assign({ 6 | siteId: '', 7 | eventPixels: {} 8 | /* example: 9 | eventPixels: { 10 | 'Viewed Product Detail': '470' 11 | }, 12 | */ 13 | }, options) 14 | 15 | super(digitalData, optionsWithDefaults) 16 | 17 | this.SEMANTIC_EVENTS = Object.keys(this.getOption('eventPixels')) 18 | this._isLoaded = false 19 | 20 | const siteId = this.getOption('siteId') 21 | 22 | if (siteId) { 23 | this.addTag({ 24 | type: 'img', 25 | attr: { 26 | // eslint-disable-next-line max-len 27 | src: `https://rtbprojects.solution.weborama.fr/fcgi-bin/dispatch.fcgi?a.A=co&a.si=${siteId}&a.cp={{ conversionId }}&a.ct=d` 28 | } 29 | }) 30 | } else { 31 | this.addTag('weborama-event-pixel', { 32 | type: 'img', 33 | attr: { 34 | src: '{{ url }}' 35 | } 36 | }) 37 | } 38 | } 39 | 40 | initialize () { 41 | this._isLoaded = true 42 | } 43 | 44 | getSemanticEvents () { 45 | return this.SEMANTIC_EVENTS 46 | } 47 | 48 | isLoaded () { 49 | return this._isLoaded 50 | } 51 | 52 | trackEvent (event) { 53 | const eventPixels = this.getOption('eventPixels') 54 | if (this.getOption('siteId')) { 55 | const conversionId = eventPixels[event.name] 56 | if (conversionId) { 57 | this.load({ conversionId }) 58 | } 59 | } else if (eventPixels[event.name]) { 60 | this.load('weborama-event-pixel', { url: eventPixels[event.name] }) 61 | } 62 | } 63 | } 64 | 65 | export default Weborama 66 | -------------------------------------------------------------------------------- /test/integrations/stubs/DynamicYield/onViewedProductPage.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedProductDetailStub = { 2 | in: { 3 | id: '123', 4 | name: 'Test Product', 5 | category: ['Category 1', 'Subcategory 1'], 6 | currency: 'USD', 7 | unitSalePrice: 10000 8 | }, 9 | out: { 10 | content_ids: ['123'], 11 | content_type: 'product', 12 | content_name: 'Test Product', 13 | content_category: 'Category 1/Subcategory 1', 14 | paramExample: 'example' 15 | }, 16 | outWithValue: { 17 | content_ids: ['123'], 18 | content_type: 'product', 19 | content_name: 'Test Product', 20 | content_category: 'Category 1/Subcategory 1', 21 | value: 10000, 22 | currency: 'USD', 23 | paramExample: 'example' 24 | } 25 | } 26 | 27 | const onViewedProductDetailStubLegacy = { 28 | in: { 29 | id: '123', 30 | name: 'Test Product', 31 | category: 'Category 1', 32 | currency: 'USD', 33 | unitSalePrice: 10000 34 | }, 35 | out: { 36 | content_ids: ['123'], 37 | content_type: 'product', 38 | content_name: 'Test Product', 39 | content_category: 'Category 1', 40 | paramExample: 'example' 41 | } 42 | } 43 | 44 | const onViewedProductDetailStubLegacySubcategory = { 45 | in: { 46 | id: '123', 47 | name: 'Test Product', 48 | category: 'Category 1', 49 | subcategory: 'Subcategory 1', 50 | currency: 'USD', 51 | unitSalePrice: 10000 52 | }, 53 | out: { 54 | content_ids: ['123'], 55 | content_type: 'product', 56 | content_name: 'Test Product', 57 | content_category: 'Category 1/Subcategory 1', 58 | paramExample: 'example' 59 | } 60 | } 61 | 62 | export { 63 | onViewedProductDetailStub, 64 | onViewedProductDetailStubLegacy, 65 | onViewedProductDetailStubLegacySubcategory 66 | } 67 | -------------------------------------------------------------------------------- /test/integrations/stubs/FacebookPixel/onViewedProductPage.stub.js: -------------------------------------------------------------------------------- 1 | const onViewedProductDetailStub = { 2 | in: { 3 | id: '123', 4 | name: 'Test Product', 5 | category: ['Category 1', 'Subcategory 1'], 6 | currency: 'USD', 7 | unitSalePrice: 10000 8 | }, 9 | out: { 10 | content_ids: ['123'], 11 | content_type: 'product', 12 | content_name: 'Test Product', 13 | content_category: 'Category 1/Subcategory 1', 14 | paramExample: 'example' 15 | }, 16 | outWithValue: { 17 | content_ids: ['123'], 18 | content_type: 'product', 19 | content_name: 'Test Product', 20 | content_category: 'Category 1/Subcategory 1', 21 | value: 10000, 22 | currency: 'USD', 23 | paramExample: 'example' 24 | } 25 | } 26 | 27 | const onViewedProductDetailStubLegacy = { 28 | in: { 29 | id: '123', 30 | name: 'Test Product', 31 | category: 'Category 1', 32 | currency: 'USD', 33 | unitSalePrice: 10000 34 | }, 35 | out: { 36 | content_ids: ['123'], 37 | content_type: 'product', 38 | content_name: 'Test Product', 39 | content_category: 'Category 1', 40 | paramExample: 'example' 41 | } 42 | } 43 | 44 | const onViewedProductDetailStubLegacySubcategory = { 45 | in: { 46 | id: '123', 47 | name: 'Test Product', 48 | category: 'Category 1', 49 | subcategory: 'Subcategory 1', 50 | currency: 'USD', 51 | unitSalePrice: 10000 52 | }, 53 | out: { 54 | content_ids: ['123'], 55 | content_type: 'product', 56 | content_name: 'Test Product', 57 | content_category: 'Category 1/Subcategory 1', 58 | paramExample: 'example' 59 | } 60 | } 61 | 62 | export { 63 | onViewedProductDetailStub, 64 | onViewedProductDetailStubLegacy, 65 | onViewedProductDetailStubLegacySubcategory 66 | } 67 | -------------------------------------------------------------------------------- /src/integrations/PushWorld.js: -------------------------------------------------------------------------------- 1 | import cookie from 'js-cookie' 2 | import Integration from '../Integration' 3 | 4 | class PushWorld extends Integration { 5 | constructor (digitalData, options) { 6 | const optionsWithDefaults = Object.assign({ 7 | domain: undefined, 8 | platformCode: undefined 9 | }, options) 10 | 11 | super(digitalData, optionsWithDefaults) 12 | 13 | this.addTag({ 14 | type: 'script', 15 | attr: { 16 | src: `https://${options.domain}.push.world/embed.js` 17 | } 18 | }) 19 | } 20 | 21 | initialize () { 22 | window.pw = { 23 | websiteId: this.getOption('platformCode'), 24 | date: Date.now() 25 | } 26 | this.enrichDigitalData() 27 | document.addEventListener('pw:subscribe:allow', (event) => { 28 | this.digitalData.changes.push(['user.pushNotifications.isSubscribed', true]) 29 | this.digitalData.changes.push(['user.pushNotifications.userId', event.detail.device_id]) 30 | }, false) 31 | document.addEventListener('pw:subscribe:deny', () => { 32 | this.digitalData.changes.push(['user.pushNotifications.isDenied', true]) 33 | }, false) 34 | } 35 | 36 | enrichDigitalData () { 37 | const status = cookie.get(`pw_status_${this.getOption('platformCode')}`) 38 | let isSubscribed = false 39 | switch (status) { 40 | case 'allow': 41 | isSubscribed = true 42 | this.digitalData.changes.push(['user.pushNotifications.userId', cookie.get('pw_deviceid')]) 43 | break 44 | case 'deny': 45 | this.digitalData.changes.push(['user.pushNotifications.isDenied', true]) 46 | break 47 | default: 48 | break 49 | } 50 | this.digitalData.changes.push(['user.pushNotifications.isSubscribed', isSubscribed]) 51 | } 52 | } 53 | 54 | export default PushWorld 55 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onSubscribed.stub.js: -------------------------------------------------------------------------------- 1 | const onSubscribedEmailSubscribeStub = { 2 | operation: 'EmailSubscribe', 3 | data: { 4 | customer: { 5 | email: 'test@driveback.ru', 6 | firstName: 'John', 7 | lastName: 'Dow', 8 | customFields: { 9 | source: 'Driveback' 10 | }, 11 | area: { 12 | ids: { 13 | externalId: 'region123' 14 | } 15 | }, 16 | subscriptions: [ 17 | { 18 | pointOfContact: 'Email' 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | 25 | const onSubscribedEmailSubscribeCustomStub = { 26 | operation: 'EmailSubscribeCustom', 27 | data: { 28 | customer: { 29 | email: 'test@driveback.ru', 30 | firstName: 'John', 31 | lastName: 'Dow', 32 | customFields: { 33 | source: 'Driveback' 34 | }, 35 | subscriptions: [ 36 | { 37 | pointOfContact: 'Email' 38 | } 39 | ] 40 | } 41 | } 42 | } 43 | 44 | const onSubscribedEmailSubscribeAlterCustomStub = { 45 | operation: 'EmailSubscribeCustom', 46 | data: { 47 | customer: { 48 | email: 'test@driveback.ru', 49 | firstName: 'John', 50 | lastName: 'Dow', 51 | mobilePhone: '111111111', 52 | subscriptions: [ 53 | { 54 | pointOfContact: 'Email', 55 | topic: 'News' 56 | }, 57 | { 58 | pointOfContact: 'Email', 59 | topic: 'Special Offers' 60 | }, 61 | { 62 | pointOfContact: 'Sms', 63 | topic: 'Special Offers' 64 | } 65 | ] 66 | }, 67 | pointOfContact: 'Footer Form' 68 | } 69 | } 70 | 71 | export { 72 | onSubscribedEmailSubscribeStub, 73 | onSubscribedEmailSubscribeCustomStub, 74 | onSubscribedEmailSubscribeAlterCustomStub 75 | } 76 | -------------------------------------------------------------------------------- /src/ConsentManager.js: -------------------------------------------------------------------------------- 1 | import { bind, unbind } from '@segmentstream/utils/eventListener' 2 | 3 | import { SDK_CHANGE_SOURCE } from './constants' 4 | 5 | export const COOKIE_CONSENT_NONE = 'none' 6 | export const COOKIE_CONSENT_INFO = 'info' 7 | export const COOKIE_CONSENT_OPTIN = 'optin' 8 | export const COOKIE_CONSENT_OPTOUT = 'optout' 9 | export const CONSENT_NAME = 'cookieConsent' 10 | 11 | const ACCEPT_CONSENT_EVENTS = ['mousemove', 'click', 'keydown', 'scroll', 'wheel', 'touchstart'] 12 | 13 | let _digitalData 14 | let _ddStorage 15 | let _cookieConsent 16 | 17 | const getConsent = () => _ddStorage.get(CONSENT_NAME) 18 | 19 | const setConsent = (consent) => { 20 | _digitalData.changes.push([CONSENT_NAME, consent, SDK_CHANGE_SOURCE]) 21 | _ddStorage.persist(CONSENT_NAME) 22 | } 23 | 24 | export default { 25 | initialize: (cookieConsent, digitalData, ddStorage) => { 26 | _cookieConsent = cookieConsent 27 | _digitalData = digitalData 28 | _ddStorage = ddStorage 29 | 30 | if (getConsent()) return 31 | 32 | const changeConsentListener = () => { 33 | setConsent(true) 34 | 35 | // unbind user action events 36 | ACCEPT_CONSENT_EVENTS.forEach((evt) => { 37 | unbind(window, evt, changeConsentListener) 38 | }) 39 | } 40 | 41 | if (cookieConsent === COOKIE_CONSENT_INFO) { 42 | // bind user action events 43 | ACCEPT_CONSENT_EVENTS.forEach((evt) => { 44 | bind(window, evt, changeConsentListener) 45 | }) 46 | } 47 | }, 48 | 49 | setConsent, 50 | 51 | getConsent, 52 | 53 | isConsentObtained: () => { 54 | if (_cookieConsent === COOKIE_CONSENT_NONE) return true 55 | 56 | const consentValue = getConsent() 57 | 58 | if (_cookieConsent === COOKIE_CONSENT_OPTOUT) { 59 | // user must explicitly deny consent in opt-out 60 | return consentValue !== false 61 | } 62 | 63 | return !!consentValue 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Storage.js: -------------------------------------------------------------------------------- 1 | import store from 'lockr' 2 | 3 | class Storage { 4 | constructor (options = {}) { 5 | this.options = Object.assign({ 6 | prefix: 'ddl:' 7 | }, options) 8 | } 9 | 10 | supportsSubDomains () { 11 | return false 12 | } 13 | 14 | set (key, val, exp) { 15 | key = this.getOption('prefix') + key 16 | if (exp !== undefined) { 17 | store.set(key, { 18 | val, 19 | exp: exp * 1000, 20 | time: Date.now() 21 | }) 22 | } else { 23 | store.set(key, val) 24 | } 25 | } 26 | 27 | get (key) { 28 | key = this.getOption('prefix') + key 29 | 30 | if (!window.localStorage) { // SRP violation, but its ok for this case 31 | // TODO add logging; 32 | return undefined 33 | } 34 | const info = store.get(key) 35 | 36 | if (info instanceof Object) { 37 | if (info.val !== undefined && info.exp && info.time) { 38 | if ((Date.now() - info.time) > info.exp) { 39 | store.rm(key) 40 | return undefined 41 | } 42 | return info.val 43 | } 44 | } 45 | return info 46 | } 47 | 48 | getTtl (key) { 49 | key = this.getOption('prefix') + key 50 | const info = store.get(key) 51 | if (info !== undefined) { 52 | if (info.val !== undefined && info.exp && info.time) { 53 | return info.exp - (Date.now() - info.time) 54 | } 55 | } 56 | return undefined 57 | } 58 | 59 | remove (key) { 60 | key = this.getOption('prefix') + key 61 | return store.rm(key) 62 | } 63 | 64 | isEnabled () { 65 | try { 66 | window.localStorage.setItem('ddm_localstorage_test', 1) 67 | window.localStorage.removeItem('ddm_localstorage_test') 68 | } catch (e) { 69 | return false 70 | } 71 | return true 72 | } 73 | 74 | getOption (name) { 75 | return this.options[name] 76 | } 77 | } 78 | 79 | export default Storage 80 | -------------------------------------------------------------------------------- /src/integrations/GoogleTagManager.js: -------------------------------------------------------------------------------- 1 | import deleteProperty from '@segmentstream/utils/deleteProperty' 2 | import Integration from '../Integration' 3 | 4 | class GoogleTagManager extends Integration { 5 | constructor (digitalData, options) { 6 | const optionsWithDefaults = Object.assign({ 7 | containerId: null, 8 | noConflict: false 9 | }, options) 10 | super(digitalData, optionsWithDefaults) 11 | this.addTag({ 12 | type: 'script', 13 | attr: { 14 | src: `//www.googletagmanager.com/gtm.js?id=${options.containerId}&l=dataLayer` 15 | } 16 | }) 17 | } 18 | 19 | allowCustomEvents () { 20 | return true 21 | } 22 | 23 | getEventValidationConfig (event) { 24 | return { 25 | fields: Object.keys(event) 26 | } 27 | } 28 | 29 | initialize () { 30 | window.dataLayer = window.dataLayer || [] 31 | this.ddManager.on('ready', () => { 32 | window.dataLayer.push({ event: 'DDManager Ready' }) 33 | }) 34 | this.ddManager.on('load', () => { 35 | window.dataLayer.push({ event: 'DDManager Loaded' }) 36 | }) 37 | if (this.getOption('containerId') && this.getOption('noConflict') === false) { 38 | window.dataLayer.push({ 'gtm.start': Number(new Date()), event: 'gtm.js' }) 39 | } 40 | } 41 | 42 | isLoaded () { 43 | return !!(window.google_tag_manager && window.google_tag_manager[this.getOption('containerId')]) 44 | } 45 | 46 | reset () { 47 | deleteProperty(window, 'dataLayer') 48 | deleteProperty(window, 'google_tag_manager') 49 | } 50 | 51 | trackEvent (event) { 52 | const dlEvent = Object.assign({}, event) 53 | const { name, category } = dlEvent 54 | deleteProperty(dlEvent, 'name') 55 | deleteProperty(dlEvent, 'category') 56 | dlEvent.event = name 57 | dlEvent.eventCategory = category 58 | window.dataLayer.push(dlEvent) 59 | } 60 | } 61 | 62 | export default GoogleTagManager 63 | -------------------------------------------------------------------------------- /src/integrations/utils/affiliate.js: -------------------------------------------------------------------------------- 1 | import cookie from 'js-cookie' 2 | import normalizeString from '@segmentstream/utils/normalizeString' 3 | import topDomain from '@segmentstream/utils/topDomain' 4 | 5 | export function isDeduplication (campaign = {}, utmSource = '', deduplicationUtmMedium = []) { 6 | const campaignSource = campaign.source || '' 7 | if (!campaignSource || campaignSource.toLowerCase() !== utmSource.toLowerCase()) { 8 | // last click source is not partner 9 | if (!deduplicationUtmMedium || deduplicationUtmMedium.length === 0) { 10 | // deduplicate with everything 11 | return true 12 | } 13 | const campaignMedium = (campaign.medium || '').toLowerCase() 14 | deduplicationUtmMedium = deduplicationUtmMedium.map(normalizeString) 15 | if (deduplicationUtmMedium.indexOf(campaignMedium) >= 0) { 16 | // last click medium is deduplicated 17 | return true 18 | } 19 | } 20 | return false 21 | } 22 | 23 | export function addAffiliateCookie (cookieName, cookieValue, expires = 90, domain) { 24 | if (window.self !== window.top) { 25 | return // protect from iframe cookie-stuffing 26 | } 27 | if (!domain) { 28 | domain = topDomain(window.location.href) 29 | } 30 | cookie.set(cookieName, cookieValue, { expires, domain }) 31 | } 32 | 33 | export function removeAffiliateCookie (cookieName, domain) { 34 | if (!domain) { 35 | domain = topDomain(window.location.href) 36 | } 37 | cookie.remove(cookieName, { domain }) 38 | } 39 | 40 | export function getAffiliateCookie (cookieName) { 41 | return cookie.get(cookieName) 42 | } 43 | 44 | export function normalizeOptions (options) { 45 | if (options.deduplication) { 46 | if (options.utmSource) { 47 | options.utmSource = normalizeString(options.utmSource) 48 | } 49 | if (options.deduplicationUtmMedium) { 50 | options.deduplicationUtmMedium = options.deduplicationUtmMedium.map(normalizeString) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/trackers/trackLink.js: -------------------------------------------------------------------------------- 1 | import { bind, unbind } from '@segmentstream/utils/eventListener' 2 | import isMeta from '@segmentstream/utils/isMeta' 3 | import preventDefault from '@segmentstream/utils/preventDefault' 4 | import domQuery from '@segmentstream/utils/domQuery' 5 | 6 | function applyHandler (event, el, handler, followLink = true) { 7 | if (event.detail === 0) { 8 | return // prevent incorrect markup clicks propogation 9 | } 10 | 11 | const href = ( 12 | el.tagName === 'A' && 13 | ( 14 | el.getAttribute('href') || 15 | el.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || 16 | el.getAttribute('xlink:href') 17 | ) 18 | ) 19 | 20 | try { 21 | handler(el) 22 | } catch (error) { 23 | // TODO 24 | } 25 | 26 | if ( 27 | followLink && 28 | href && 29 | el.target !== '_blank' && 30 | !isMeta(event) && 31 | !event.defaultPrevented && 32 | event.returnValue !== false 33 | ) { 34 | preventDefault(event) 35 | setTimeout(() => { 36 | window.location.href = href 37 | }, 500) 38 | } 39 | } 40 | 41 | function onClick (el, handler, followLink = true) { 42 | return (event) => { 43 | applyHandler(event, el, handler, followLink) 44 | } 45 | } 46 | 47 | export default function trackLink (selector, handler, followLink = true) { 48 | if (!selector) return 49 | 50 | if (typeof handler !== 'function') { 51 | throw new TypeError('Must pass function handler to `ddManager.trackLink`.') 52 | } 53 | 54 | let trackedLinks = [] 55 | 56 | bind(window.document, 'click', () => { 57 | trackedLinks.forEach((trackedLink) => { 58 | const [el, onClickHandler] = trackedLink 59 | unbind(el, 'click', onClickHandler) 60 | }) 61 | 62 | const links = (window.jQuery) ? window.jQuery(selector).get() : domQuery(selector) 63 | trackedLinks = [] 64 | links.forEach((el) => { 65 | const onClickHandler = onClick(el, handler, followLink) 66 | bind(el, 'click', onClickHandler) 67 | trackedLinks.push([el, onClickHandler]) 68 | }) 69 | }, true) // capturing phase 70 | } 71 | -------------------------------------------------------------------------------- /src/testMode.js: -------------------------------------------------------------------------------- 1 | import { log, group, groupEnd } from '@segmentstream/utils/safeConsole' 2 | import { TYPE_ERROR, TYPE_SUCCESS, TYPE_WARNING } from './EventValidator' 3 | 4 | const validationMessagesColors = { 5 | [TYPE_ERROR]: 'red', 6 | [TYPE_WARNING]: '#ee9a00', 7 | [TYPE_SUCCESS]: 'green' 8 | } 9 | 10 | export function isTestMode () { 11 | return window.localStorage.getItem('_segmentstream_test_mode') === '1' || 12 | window.localStorage.getItem('_ddm_test_mode') === '1' 13 | } 14 | 15 | export function prepareValueForLog (value) { 16 | if (Array.isArray(value)) { 17 | if (value[0] && typeof value[0] === 'object') { 18 | return '[Array of Objects]' 19 | } 20 | return JSON.stringify(value) 21 | } 22 | if (typeof value === 'object') { 23 | return '[Object]' 24 | } 25 | if (typeof value === 'string') { 26 | return `"${value}"` 27 | } 28 | return value 29 | } 30 | 31 | export function showTestModeMessage () { 32 | log('%c SegmentStream: Test Mode', 'color: blue; font-size: 18px') 33 | } 34 | 35 | export function logValidationResult (event, messages) { 36 | messages.forEach(([field, errorMsg, value, resultType]) => { 37 | if (resultType === TYPE_SUCCESS) { 38 | log(`%c[${resultType}] ${field}: ${prepareValueForLog(value)}`, 39 | `color: ${validationMessagesColors[resultType]};`) 40 | } else { 41 | log(`%c[${resultType}] ${field} ${errorMsg}: ${prepareValueForLog(value)}`, 42 | `color: ${validationMessagesColors[resultType]};`) 43 | } 44 | }) 45 | } 46 | 47 | export function logEnrichedIntegrationEvent ( 48 | event, integrationName, result, messages, isInitialized 49 | ) { 50 | if (isInitialized && result) { 51 | group(`[EVENT] ${event.name} -> ${integrationName}`) 52 | } else if (!isInitialized) { 53 | group(`[EVENT] ${event.name} x> ${integrationName} (not initialized)`) 54 | } else { 55 | group(`[EVENT] ${event.name} x> ${integrationName} (not valid)`) 56 | } 57 | 58 | if (messages && messages.length) { 59 | logValidationResult(event, messages, integrationName) 60 | } 61 | 62 | groupEnd() 63 | } 64 | 65 | export default { isTestMode, showTestModeMessage } 66 | -------------------------------------------------------------------------------- /src/CookieStorage.js: -------------------------------------------------------------------------------- 1 | import cookie from 'js-cookie' 2 | import topDomain from '@segmentstream/utils/topDomain' 3 | 4 | class CookieStorage { 5 | constructor (options = {}) { 6 | this.options = Object.assign({ 7 | cookieDomain: topDomain(window.location.href), 8 | cookieMaxAge: 63072000, // default to a 2 years, in seconds 9 | prefix: 'dd_' 10 | }, options) 11 | 12 | // http://curl.haxx.se/rfc/cookie_spec.html 13 | // https://publicsuffix.org/list/effective_tld_names.dat 14 | // 15 | // try setting a dummy cookie with the options 16 | // if the cookie isn't set, it probably means 17 | // that the domain is on the public suffix list 18 | // like myapp.herokuapp.com or localhost / ip. 19 | if (this.getOption('cookieDomain')) { 20 | this.set('__tld__', true) 21 | if (!this.get('__tld__')) { 22 | this.setOption('cookieDomain', null) 23 | } 24 | this.remove('__tld__') 25 | } 26 | } 27 | 28 | supportsSubDomains () { 29 | return true 30 | } 31 | 32 | set (key, val, exp) { 33 | key = this.getOption('prefix') + key 34 | exp = exp || this.getOption('cookieMaxAge') 35 | const expDays = exp / 86400 36 | return cookie.set(key, val, { 37 | expires: expDays, 38 | domain: this.getOption('cookieDomain') 39 | }) 40 | } 41 | 42 | get (key) { 43 | key = this.getOption('prefix') + key 44 | return cookie.getJSON(key) 45 | } 46 | 47 | remove (key) { 48 | key = this.getOption('prefix') + key 49 | cookie.remove(key, { 50 | domain: this.getOption('cookieDomain') 51 | }) 52 | } 53 | 54 | getOption (name) { 55 | return this.options[name] 56 | } 57 | 58 | setOption (name, value) { 59 | this.options[name] = value 60 | } 61 | 62 | clear () { 63 | const cookies = document.cookie.split(';') 64 | for (let i = 0; i < cookies.length; i += 1) { 65 | const cookieVal = cookies[i] 66 | const eqPos = cookieVal.indexOf('=') 67 | const name = eqPos > -1 ? cookieVal.substr(0, eqPos) : cookieVal 68 | document.cookieVal = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT` 69 | } 70 | } 71 | } 72 | 73 | export default CookieStorage 74 | -------------------------------------------------------------------------------- /src/integrations/Renta.js: -------------------------------------------------------------------------------- 1 | import { warn } from '@segmentstream/utils/safeConsole' 2 | import Integration from '../Integration' 3 | 4 | class Renta extends Integration { 5 | // constructor(digitalData, options) { 6 | // super(digitalData, options); 7 | // } 8 | 9 | initialize () { 10 | if (!window.ga) { 11 | warn('Google Analytics integration should be initialized before Renta integration') 12 | return false 13 | } 14 | 15 | window.ga('require', 'RentaStreaming'); 16 | 17 | /* eslint-disable */ 18 | (function(){var trackerData=function(tracker,f){var sendHitTask=tracker.get("sendHitTask"),sender=function(){function ajaxRequest(payload){var success=!1,request;try{window.XMLHttpRequest&&"withCredentials"in(request=new XMLHttpRequest)&&(request.open("POST",url(),!0),a.setRequestHeader("Content-Type","text/plain"),request.send(payload),success=!0)}catch(ex){}return success}function domainRequest(payload){var success=!1,request;try{window.XDomainRequest&&(request=new XDomainRequest,request.open("POST",url(!1,location.protocol.slice(0,-1))),setTimeout(function(){request.send(payload)},0),success=!0)}catch(ex){}return success}function imageRequest(payload){var image,success=!1;try{image=document.createElement("img"),image.src=url(!0)+"?"+payload,success=!0}catch(ex){}return success}function beaconRequest(payload){var success;try{success=navigator.sendBeacon&&navigator.sendBeacon(url(),payload)}catch(ex){}return success}function url(c,protocol){var endpoint;protocol||(protocol="https");endpoint=protocol+"://"+domain+"/collect";c||(endpoint+="?tid="+encodeURIComponent(tracker.get("trackingId")));return endpoint}var domain=f&&f.domain?f.domain:"stream.renta.im";return{send:function(payload){return imageRequest(payload)||beaconRequest(payload)||ajaxRequest(payload)||domainRequest(payload)}}}();tracker.set("sendHitTask",function(data){sendHitTask(data);sender.send(data.get("hitPayload"))})},googleAnalyticsObj=window[window.GoogleAnalyticsObject||"ga"];googleAnalyticsObj&&googleAnalyticsObj("provide","RentaStreaming",trackerData)})(); 19 | /* eslint-enable */ 20 | 21 | this._loaded = true 22 | return true 23 | } 24 | 25 | isLoaded () { 26 | return !!this._loaded 27 | } 28 | } 29 | 30 | export default Renta 31 | -------------------------------------------------------------------------------- /src/helpers/ValidationHelper.js: -------------------------------------------------------------------------------- 1 | import loadScript from '@segmentstream/utils/loadScript' 2 | import { log, group, groupEnd } from '@segmentstream/utils/safeConsole' 3 | import { getProp } from '@segmentstream/utils/dotProp' 4 | import { isTestMode } from '../testMode' 5 | import AsyncQueue from '../integrations/utils/AsyncQueue' 6 | 7 | const isAjvLoaded = () => !!window.Ajv 8 | const asyncQueue = new AsyncQueue(isAjvLoaded) 9 | 10 | let ajvLoadInitiated = false 11 | let ajv 12 | 13 | const ajvValidate = (schema, obj, key) => { 14 | const validate = ajv.compile(schema) 15 | const data = (key) ? getProp(obj, key) : obj 16 | const valid = validate(data) 17 | 18 | let prefix 19 | if (data && data.name && data.timestamp) { 20 | prefix = `"${data.name}" event` 21 | } else { 22 | prefix = `window.digitalData${key ? `.${key}` : ''}` 23 | } 24 | 25 | if (!valid) { 26 | group(`${prefix} validation errors:`) 27 | validate.errors.forEach(error => log(['%c', error.dataPath, error.message].join(' '), 'color: red')) 28 | groupEnd() 29 | } else { 30 | log(`%c ${prefix} is valid!`, 'color: green') 31 | } 32 | } 33 | 34 | export const validate = (schema, obj, key) => { 35 | if (!isTestMode()) return 36 | if (!schema) { 37 | throw new Error('validate() helper requires "schema" as a first argument with object or boolean value') 38 | } 39 | 40 | let originalRequire 41 | let originalDefine 42 | 43 | if (!ajvLoadInitiated) { 44 | asyncQueue.init() 45 | 46 | // Hack for SPA sites with overrided require and define methods 47 | originalRequire = window.require 48 | originalDefine = window.define 49 | window.require = undefined 50 | window.define = undefined 51 | 52 | loadScript({ src: 'https://cdnjs.cloudflare.com/ajax/libs/ajv/6.8.1/ajv.min.js' }) 53 | 54 | ajvLoadInitiated = true 55 | } 56 | asyncQueue.push(() => { 57 | if (!ajv) { 58 | ajv = new window.Ajv({ allErrors: true }) 59 | 60 | // Hack for SPA sites with overrided require and define methods 61 | window.require = originalRequire 62 | window.define = originalDefine 63 | originalRequire = undefined 64 | originalDefine = undefined 65 | } 66 | ajvValidate(schema, obj, key) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-line no-console 2 | 3 | // window.__DEV_MODE__ = true // disable catching exceptions 4 | 5 | import '../src/polyfill' 6 | 7 | // tests 8 | import './polyfill.spec' 9 | 10 | import './RollingAttributesHelper.spec' 11 | import './ddManager.spec' 12 | import './ConsentManager.spec' 13 | import './DDHelper.spec' 14 | import './DDStorage.spec' 15 | import './EventManager.spec' 16 | import './EventDataEnricher.spec' 17 | import './DigitalDataEnricher.spec' 18 | import './EventValidator.spec' 19 | import './IntegrationBase.spec' 20 | 21 | // enrichments & events 22 | import './enrichments/CustomEnrichments.spec' 23 | import './events/CustomEvents.spec' 24 | import './scripts/CustomScripts.spec' 25 | 26 | // trackers 27 | import './trackers/trackLink.spec' 28 | 29 | // integration utils 30 | import './integrations/utils/transliterate.spec' 31 | 32 | // integrations 33 | import './integrations/DDManagerStreaming.spec' 34 | import './integrations/GoogleAnalytics.spec' 35 | import './integrations/GoogleTagManager.spec' 36 | import './integrations/GoogleAdWords.spec' 37 | import './integrations/Driveback.spec' 38 | import './integrations/RetailRocket.spec' 39 | import './integrations/FacebookPixel.spec' 40 | import './integrations/SegmentStream.spec' 41 | import './integrations/SendPulse.spec' 42 | import './integrations/OWOXBIStreaming.spec' 43 | import './integrations/Criteo.spec' 44 | import './integrations/AdvCake/AdvCake.spec' 45 | import './integrations/MyTarget.spec' 46 | import './integrations/YandexMetrica.spec' 47 | import './integrations/Vkontakte/Vkontakte.spec' 48 | import './integrations/Emarsys.spec' 49 | import './integrations/OneSignal.spec' 50 | import './integrations/Sociomantic.spec' 51 | import './integrations/Mindbox/Mindbox.spec' 52 | import './integrations/DoubleClickFloodlight.spec' 53 | import './integrations/RTBHouse.spec' 54 | import './integrations/Soloway.spec' 55 | import './integrations/GdeSlon.spec' 56 | import './integrations/Flocktory.spec' 57 | import './integrations/K50.spec' 58 | import './integrations/Target2Sell.spec' 59 | import './integrations/Calltouch.spec' 60 | import './integrations/DynamicYield.spec' 61 | 62 | window.localStorage.clear() 63 | console.error = () => {} 64 | console.warn = () => {} 65 | -------------------------------------------------------------------------------- /src/enrichments/CustomEnrichment.js: -------------------------------------------------------------------------------- 1 | import { error as errorLog } from '@segmentstream/utils/safeConsole' 2 | import isPromise from '@segmentstream/utils/isPromise' 3 | import Handler from '../Handler' 4 | import { CUSTOM_CHANGE_SOURCE } from '../constants' 5 | 6 | class CustomEnrichment { 7 | constructor (config, collection) { 8 | this.config = config 9 | this.collection = collection 10 | this.digitalData = collection.getDigitalData() 11 | this.ddStorage = collection.getDDStorage() 12 | 13 | this.done = false 14 | this.recursionFreeze = false 15 | } 16 | 17 | hasDependencies () { 18 | return this.config.dependencies && this.config.dependencies.length 19 | } 20 | 21 | getDependencies () { 22 | return this.config.dependencies || [] 23 | } 24 | 25 | enrich (target, args) { 26 | const onValueReceived = (value) => { 27 | if (value !== undefined) { 28 | target.changes.push([this.config.prop, value, CUSTOM_CHANGE_SOURCE]) 29 | if (this.config.persist) { 30 | this.ddStorage.persist(this.config.prop, this.config.persistTtl) 31 | } 32 | } 33 | this.done = true 34 | } 35 | 36 | if (this.recursionFreeze) return 37 | this.recursionFreeze = true 38 | 39 | if (this.isDone()) return 40 | 41 | if (this.hasDependencies()) { 42 | const dependencies = this.getDependencies() 43 | dependencies.forEach((dependencyProp) => { 44 | const enrichment = this.collection.getEnrichment(dependencyProp) 45 | if (enrichment) { 46 | enrichment.enrich(target, args) 47 | } 48 | }) 49 | } 50 | 51 | const handler = new Handler(this.config.handler, this.digitalData, args) 52 | let result 53 | try { 54 | result = handler.run() 55 | if (isPromise(result)) { 56 | result.then((value) => { 57 | onValueReceived(value) 58 | }) 59 | } else { 60 | onValueReceived(result) 61 | } 62 | } catch (e) { 63 | e.message = `DDManager Custom Enrichment "${this.config.prop}" Error\n\n ${e.message}` 64 | errorLog(e) 65 | } 66 | } 67 | 68 | isDone () { 69 | return this.done 70 | } 71 | 72 | reset () { 73 | this.done = false 74 | this.recursionFreeze = false 75 | } 76 | } 77 | 78 | export default CustomEnrichment 79 | -------------------------------------------------------------------------------- /src/integrations/Calltouch.js: -------------------------------------------------------------------------------- 1 | import deleteProperty from '@segmentstream/utils/deleteProperty' 2 | import { extractVariableMappingValues, getEnrichableVariableMappingProps } from '../IntegrationUtils' 3 | import { getProp } from '@segmentstream/utils/dotProp' 4 | import size from '@segmentstream/utils/size' 5 | import cleanObject from '@segmentstream/utils/cleanObject' 6 | import Integration from '../Integration' 7 | import AsyncQueue from './utils/AsyncQueue' 8 | import { VIEWED_PAGE } from '../events/semanticEvents' 9 | 10 | class Calltouch extends Integration { 11 | constructor (digitalData, options) { 12 | const optionsWithDefaults = Object.assign({ 13 | siteId: '', 14 | customParams: undefined 15 | }, options) 16 | super(digitalData, optionsWithDefaults) 17 | 18 | const siteId = this.getOption('siteId') 19 | 20 | this.addTag({ 21 | type: 'script', 22 | attr: { 23 | src: `//mod.calltouch.ru/init.js?id=${siteId}`, 24 | async: true 25 | } 26 | }) 27 | } 28 | 29 | initialize () { 30 | this.asyncQueue = new AsyncQueue(() => this.isLoaded()) 31 | } 32 | 33 | onLoadInitiated () { 34 | this.asyncQueue.init() 35 | } 36 | 37 | isLoaded () { 38 | return !!getProp(window, 'ct_set_attrs') 39 | } 40 | 41 | reset () { 42 | deleteProperty(window, 'ct_set_attrs') 43 | this.pageTracked = false 44 | } 45 | 46 | getSemanticEvents () { 47 | return [VIEWED_PAGE] 48 | } 49 | 50 | getEnrichableEventProps () { 51 | return getEnrichableVariableMappingProps(this.getOption('customParams')) 52 | } 53 | 54 | getCustomParams (event) { 55 | return extractVariableMappingValues(event, this.getOption('customParams')) 56 | } 57 | 58 | trackEvent (event) { 59 | const methods = { 60 | [VIEWED_PAGE]: 'onViewedPage' 61 | } 62 | const method = methods[event.name] 63 | if (method) { 64 | this[method](event) 65 | } 66 | } 67 | 68 | onViewedPage (event) { 69 | this.asyncQueue.push(() => { 70 | if (!this.pageTracked) { 71 | const customParams = cleanObject(this.getCustomParams(event)) 72 | 73 | if (size(customParams)) { 74 | window.ct_set_attrs(JSON.stringify(customParams)) 75 | } 76 | 77 | this.pageTracked = true 78 | } 79 | }) 80 | } 81 | } 82 | 83 | export default Calltouch 84 | -------------------------------------------------------------------------------- /src/scripts/CustomScripts.js: -------------------------------------------------------------------------------- 1 | import { bind } from '@segmentstream/utils/eventListener' 2 | import CustomScript from './CustomScript' 3 | 4 | let storage = {} 5 | 6 | class CustomScripts { 7 | constructor (digitalData, pageLoadTimeout) { 8 | this.digitalData = digitalData 9 | this.pageLoadTimeout = pageLoadTimeout 10 | } 11 | 12 | import (scriptsConfig) { 13 | scriptsConfig = scriptsConfig || [] 14 | scriptsConfig.sort((config1, config2) => { 15 | let priority1 = config1.priority || 0 16 | let priority2 = config2.priority || 0 17 | if (!config1.event) priority1 += 1 // support legacy 18 | if (!config2.event) priority2 += 1 // support legacy 19 | if (priority1 === priority2) return 0 20 | return (priority1 < priority2) ? 1 : -1 21 | }) 22 | scriptsConfig.forEach((scriptConfig) => { 23 | let eventName = scriptConfig.event 24 | let { fireOnce } = scriptConfig 25 | if (!eventName) { 26 | eventName = 'Viewed Page' // support legacy 27 | fireOnce = true 28 | } 29 | const customScript = new CustomScript( 30 | scriptConfig.name, 31 | eventName, 32 | scriptConfig.handler, 33 | fireOnce, 34 | scriptConfig.runAfterPageLoaded, 35 | this.digitalData 36 | ) 37 | 38 | this.prepareCollection(eventName).push(customScript) 39 | }) 40 | } 41 | 42 | prepareCollection (event) { 43 | if (!storage[event]) { 44 | storage[event] = [] 45 | } 46 | return storage[event] 47 | } 48 | 49 | run (event) { 50 | if (event.stopPropagation) return 51 | 52 | const customScripts = storage[event.name] || [] 53 | 54 | customScripts.forEach((customScript) => { 55 | const pageLoaded = window.document.readyState === 'complete' 56 | if (customScript.runAfterPageLoaded && !pageLoaded) { 57 | // set page load timeout 58 | const timeoutId = setTimeout(() => { 59 | customScript.run(event) 60 | }, this.pageLoadTimeout || 3000) 61 | 62 | // wait for page load 63 | bind(window, 'load', () => { 64 | clearTimeout(timeoutId) 65 | customScript.run(event) 66 | }) 67 | } else { 68 | customScript.run(event) 69 | } 70 | }) 71 | } 72 | 73 | reset () { 74 | storage = {} 75 | } 76 | } 77 | 78 | export default CustomScripts 79 | -------------------------------------------------------------------------------- /src/events/semanticEvents.js: -------------------------------------------------------------------------------- 1 | export const VIEWED_PAGE = 'Viewed Page' 2 | export const VIEWED_PRODUCT_DETAIL = 'Viewed Product Detail' 3 | export const VIEWED_PRODUCT_LISTING = 'Viewed Product Listing' 4 | export const SEARCHED_PRODUCTS = 'Searched Products' 5 | export const VIEWED_CART = 'Viewed Cart' 6 | export const COMPLETED_TRANSACTION = 'Completed Transaction' 7 | export const UPDATED_TRANSACTION = 'Updated Transaction' 8 | export const VIEWED_CHECKOUT_STEP = 'Viewed Checkout Step' 9 | export const COMPLETED_CHECKOUT_STEP = 'Completed Checkout Step' 10 | export const REFUNDED_TRANSACTION = 'Refunded Transaction' 11 | export const VIEWED_PRODUCT = 'Viewed Product' 12 | export const CLICKED_PRODUCT = 'Clicked Product' 13 | export const ADDED_PRODUCT = 'Added Product' 14 | export const REMOVED_PRODUCT = 'Removed Product' 15 | export const VIEWED_CAMPAIGN = 'Viewed Campaign' 16 | export const CLICKED_CAMPAIGN = 'Clicked Campaign' 17 | export const LEAD = 'Lead' 18 | export const SUBSCRIBED = 'Subscribed' 19 | export const REGISTERED = 'Registered' 20 | export const LOGGED_IN = 'Logged In' 21 | export const UPDATED_PROFILE_INFO = 'Updated Profile Info' 22 | export const VIEWED_EXPERIMENT = 'Viewed Experiment' 23 | export const ACHIEVED_EXPERIMENT_GOAL = 'Achieved Experiment Goal' 24 | export const ADDED_PRODUCT_TO_WISHLIST = 'Added Product to Wishlist' 25 | export const REMOVED_PRODUCT_FROM_WISHLIST = 'Removed Product from Wishlist' 26 | export const EXCEPTION = 'Exception' 27 | export const SESSION_STARTED = 'Session Started' 28 | export const STARTED_ORDER = 'Started Order' 29 | export const ADDED_PAYMENT_INFO = 'Added Payment Info' 30 | export const INTEGRATION_VALIDATION_FAILED = 'Integration Validation Failed' 31 | export const ALLOWED_PUSH_NOTIFICATIONS = 'Allowed Push Notifications' 32 | export const BLOCKED_PUSH_NOTIFICATIONS = 'Blocked Push Notifications' 33 | export const CLOSED_PUSH_NOTIFICATIONS_PROMPT = 'Closed Push Notifications Prompt' 34 | export const UPDATED_CART = 'Updated Cart' 35 | 36 | // legacy events 37 | export const VIEWED_PRODUCT_CATEGORY = 'Viewed Product Category' 38 | export const SEARCHED = 'Searched' 39 | 40 | // legacy events mapping 41 | const eventMapper = { 42 | [SEARCHED]: SEARCHED_PRODUCTS, 43 | [VIEWED_PRODUCT_CATEGORY]: VIEWED_PRODUCT_LISTING 44 | } 45 | 46 | export function mapEvent (eventName) { 47 | return (eventMapper[eventName]) ? eventMapper[eventName] : eventName 48 | } 49 | -------------------------------------------------------------------------------- /src/trackers/trackTimeOnPage.js: -------------------------------------------------------------------------------- 1 | import { bind, unbind } from '@segmentstream/utils/eventListener' 2 | import listenEvents from './listenEvents' 3 | 4 | const timeout = 10 // 10 seconds 5 | let interval = null 6 | let activeTime = 0 7 | let time = 0 8 | let hasActive = false 9 | let events = [] 10 | let isFirstCall = true 11 | 12 | const addEventsListener = () => { 13 | listenEvents.forEach((eventName) => { 14 | bind(window.document, eventName, setActive, false) 15 | }) 16 | } 17 | 18 | const removeEventsListener = () => { 19 | listenEvents.forEach((eventName) => { 20 | unbind(window.document, eventName, setActive, false) 21 | }) 22 | } 23 | 24 | const processEvents = () => { 25 | if (hasActive) { 26 | activeTime += timeout 27 | hasActive = false 28 | } 29 | 30 | time += timeout 31 | 32 | events = events.map((event) => { 33 | const timeForEvent = event.isActiveTime ? activeTime : time 34 | 35 | if (!event.isFired && event.seconds <= timeForEvent) { 36 | event.handler(timeForEvent) 37 | return { ...event, isFired: true } 38 | } 39 | return event 40 | }) 41 | } 42 | 43 | const setActive = () => { hasActive = true } 44 | 45 | const addEvent = (seconds, handler, isActiveTime) => { 46 | events.push({ seconds, handler, isActiveTime, isFired: false }) 47 | } 48 | 49 | const startTracking = () => { 50 | interval = setInterval(processEvents, timeout * 1000) 51 | addEventsListener() 52 | } 53 | 54 | const stopTracking = () => { 55 | clearInterval(interval) 56 | removeEventsListener() 57 | } 58 | 59 | startTracking() 60 | 61 | export const reset = () => { 62 | if (!isFirstCall) { 63 | if (interval) { 64 | stopTracking() 65 | } 66 | activeTime = 0 67 | time = 0 68 | hasActive = false 69 | events = events.map(event => ({ ...event, isFired: false })) 70 | startTracking() 71 | } 72 | isFirstCall = false 73 | } 74 | 75 | export default (seconds, handler, isActiveTime = false) => { 76 | if (!seconds) return 77 | 78 | if (typeof handler !== 'function') { 79 | throw new TypeError('Must pass function handler to `ddManager.trackTimeOnPage`.') 80 | } 81 | 82 | String(seconds) 83 | .replace(/\s+/mg, '') 84 | .split(',') 85 | .forEach((secondsStr) => { 86 | const second = parseInt(secondsStr) 87 | if (second > 0) { 88 | addEvent(second, handler, isActiveTime) 89 | } 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /src/snippet.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | (function(a, domain) { 3 | domain = domain || 'cdn.segmentstream.com'; 4 | var b = window.segmentstream = window.segmentstream || []; 5 | window.ddListener = window.ddListener || []; 6 | var c = window.digitalData = window.digitalData || {}; 7 | c.events = c.events || []; 8 | c.changes = c.changes || []; 9 | if (!b.initialize) if (b.invoked) window.console && console.error && console.error('SegmentStream snippet included twice.'); else { 10 | b.invoked = !0; 11 | b.methods = 'initialize addIntegration persist unpersist on once off getConsent setConsent'.split(' '); 12 | b.factory = function(a) { 13 | return function() { 14 | var c = Array.prototype.slice.call(arguments); 15 | c.unshift(a); 16 | b.push(c); 17 | return b; 18 | }; 19 | }; 20 | for (c = 0; c < b.methods.length; c++) { 21 | var d = b.methods[c]; 22 | b[d] = b.factory(d); 23 | } 24 | b.load = function(a) { 25 | var b = document.createElement('script'); 26 | b.type = 'text/javascript'; 27 | b.charset = 'utf-8'; 28 | b.async = !0; 29 | b.src = a; 30 | a = document.getElementsByTagName('script')[0]; 31 | a.parentNode.insertBefore(b, a); 32 | }; 33 | b.loadProject = function(a) { 34 | var queryString = window.location.search; 35 | var initUrl; 36 | var testMode; 37 | if (queryString.indexOf('segmentstream_test_mode=1') >= 0) { 38 | try { 39 | testMode = true; 40 | window.localStorage.setItem('_segmentstream_test_mode', '1'); 41 | } catch (e) {} 42 | } else if (queryString.indexOf('segmentstream_test_mode=0') >= 0) { 43 | try { 44 | testMode = false; 45 | window.localStorage.removeItem('_segmentstream_test_mode'); 46 | } catch (e) {} 47 | } else { 48 | try { 49 | testMode = ('1' === window.localStorage.getItem('_segmentstream_test_mode')); 50 | } catch (e) {} 51 | } 52 | if (testMode) { 53 | b.load(window.SEGMENTSTREAM_TESTMODE_INIT_URL 54 | || ('https://api.segmentstream.com/v1/project/' + a + '.js')); 55 | } else { 56 | b.load(window.SEGMENTSTREAM_INIT_URL || ('https://' + domain + '/project/' + a + '.js')); 57 | } 58 | 59 | }; 60 | b.CDN_DOMAIN = domain; 61 | b.SNIPPET_VERSION = '2.0.0'; 62 | b.loadProject(a); 63 | } 64 | })('', ''); 65 | /* eslint-enable */ 66 | -------------------------------------------------------------------------------- /test/DDStorage.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { expect } from 'chai' 3 | import Bowser from 'bowser' 4 | import Storage from '../src/Storage' 5 | import DDStorage from '../src/DDStorage' 6 | 7 | describe('DDStorage', () => { 8 | let _digitalData 9 | const _storage = new Storage() 10 | let _ddStorage 11 | 12 | describe('#persist', () => { 13 | beforeEach(() => { 14 | _digitalData = { 15 | user: { 16 | isSubscribed: true, 17 | email: 'test@email.com', 18 | temp: 'test' 19 | } 20 | } 21 | _ddStorage = new DDStorage(_digitalData, _storage) 22 | }) 23 | 24 | afterEach(() => { 25 | window.localStorage.clear() 26 | _ddStorage.clear() 27 | _ddStorage = undefined 28 | }) 29 | 30 | it('should persist _lastEventTimestamp', () => { 31 | // arrange 32 | const expectedDate = Date.now() 33 | _ddStorage.setLastEventTimestamp(expectedDate) 34 | // act 35 | const actualTimestamp = _ddStorage.getLastEventTimestamp() 36 | 37 | // assert 38 | assert.strict.equal(actualTimestamp, expectedDate) 39 | }) 40 | 41 | it('should return null timestamp when getLastEvent for empty localStorage', () => { 42 | const actualTimestamp = _ddStorage.getLastEventTimestamp() 43 | assert.strict.equal(actualTimestamp, undefined) 44 | }) 45 | 46 | describe('Empty localStorage use cases', () => { 47 | const browser = Bowser.getParser(window.navigator.userAgent) 48 | 49 | const isSafariOld = browser.satisfies({ 50 | macos: { 51 | safari: '<10.0' 52 | } 53 | }) 54 | 55 | if (isSafariOld) return 56 | 57 | const tempStorage = window.localStorage 58 | 59 | beforeEach(() => { 60 | window.localStorage.clear() 61 | }) 62 | 63 | afterEach(() => { 64 | Object.defineProperty(window, 'localStorage', { 65 | value: tempStorage 66 | }) 67 | }) 68 | 69 | it('when get lastEventTimestamp should not throw any error', () => { 70 | expect(() => { _ddStorage.getLastEventTimestamp() }).to.not.throw() 71 | }) 72 | 73 | it('when get any key should does not throw any error', () => { 74 | expect(() => { _ddStorage.get('anyKey') }).to.not.throw() 75 | }) 76 | 77 | it('when set lastEventTimestamp should not throw any error', () => { 78 | expect(() => { _ddStorage.setLastEventTimestamp(Date.now()) }).to.not.throw() 79 | }) 80 | }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/enrichments/CustomEnrichments.js: -------------------------------------------------------------------------------- 1 | import CustomEnrichmentsCollection from './CustomEnrichmentsCollection' 2 | import { VIEWED_PAGE } from '../events/semanticEvents' 3 | 4 | const BEFORE_EVENT = 'beforeEvent' 5 | const AFTER_EVENT = 'afterEvent' 6 | 7 | const storage = { 8 | [BEFORE_EVENT]: {}, 9 | [AFTER_EVENT]: {} 10 | } 11 | 12 | const checkEnrichment = enrichment => ( 13 | typeof enrichment === 'object' && 14 | typeof enrichment.handler === 'function' && 15 | typeof enrichment.prop === 'string' 16 | ) 17 | 18 | class CustomEnrichments { 19 | constructor (digitalData, ddStorage) { 20 | this.ddStorage = ddStorage 21 | this.digitalData = digitalData 22 | } 23 | 24 | import (enrichments) { 25 | if (!enrichments || !Array.isArray(enrichments)) return 26 | enrichments.forEach((enrichment) => { 27 | if (!checkEnrichment(enrichment)) return 28 | this.addEnrichment(enrichment) 29 | }) 30 | } 31 | 32 | addEnrichment (enrichment) { 33 | // TODO: remove later (backward compatibility) 34 | if (enrichment.beforeEvent === undefined) { 35 | if (!enrichment.event) enrichment.event = VIEWED_PAGE 36 | enrichment.beforeEvent = (enrichment.event === VIEWED_PAGE) 37 | } 38 | const collection = this.prepareCollection(enrichment.event, enrichment.beforeEvent) 39 | collection.addEnrichment(enrichment) 40 | } 41 | 42 | enrichDigitalData (digitalData, event, beforeEvent) { 43 | const eventName = event.name 44 | if (!eventName || event.stopPropagation) return 45 | 46 | const collection = this.prepareCollection(event.name, beforeEvent) 47 | collection.enrich(digitalData, [event]) 48 | } 49 | 50 | prepareCollection (event, beforeEvent) { 51 | if (beforeEvent) { 52 | if (!storage[BEFORE_EVENT][event]) { 53 | storage[BEFORE_EVENT][event] = this.newCollection(event, beforeEvent) 54 | } 55 | return storage[BEFORE_EVENT][event] 56 | } 57 | if (!storage[AFTER_EVENT][event]) { 58 | storage[AFTER_EVENT][event] = this.newCollection(event, beforeEvent) 59 | } 60 | return storage[AFTER_EVENT][event] 61 | } 62 | 63 | newCollection (event, beforeEvent) { 64 | const collection = new CustomEnrichmentsCollection(event, beforeEvent) 65 | collection.setDigitalData(this.digitalData) 66 | collection.setDDStorage(this.ddStorage) 67 | 68 | return collection 69 | } 70 | 71 | reset () { 72 | storage[BEFORE_EVENT] = {} 73 | storage[AFTER_EVENT] = {} 74 | } 75 | } 76 | 77 | export default CustomEnrichments 78 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/index.js: -------------------------------------------------------------------------------- 1 | import { onViewedPageSetCardStub } from './onViewedPage.stub' 2 | import { 3 | onViewedProductDetailViewProductStub, 4 | onViewedProductDetailViewedProductCustomStub 5 | } from './onViewedProductDetail.stub' 6 | 7 | import { onUpdateCartSetCartStub } from './onUpdateCart.stub' 8 | import { 9 | onAddedProductAddProductStub, 10 | onAddedProductAddProductSkuStub, 11 | onAddedProductAddProductCustomStub 12 | } from './onAddedProduct.stub' 13 | 14 | import { 15 | onViewedProductListingCategoryViewStub, 16 | onViewedProductListingCategoryViewCustomStub 17 | } from './onViewedProductListing.stub' 18 | 19 | import { onRemovedProductRemoveProductStub, onRemovedProductAddProductCustomStub } from './onRemovedProduct.stub' 20 | import { 21 | onCompletedTransactionCompletedOrderStub, 22 | onCompletedTransactionCompletedOrderCustomStub 23 | } from './onCompletedTransaction.stub' 24 | 25 | import { 26 | onSubscribedSubscribedStub, 27 | onSubscribedEmailSubscribeCustomStub 28 | } from './onSubscribed.stub' 29 | 30 | import { 31 | onRegisteredRegistrationStub, 32 | onRegisteredRegistrationCustomStub, 33 | onRegisteredRegistrationAndSubscriptionStub, 34 | onRegisteredRegistrationAndSubscriptionLegacyStub, 35 | onRegisteredUpdateProfileSubscriptionOnStub, 36 | onRegisteredUpdateProfileSubscriptionOnLegacyStub, 37 | onRegisteredUpdateProfileSubscriptionOffStub 38 | } from './onRegistered.stub' 39 | 40 | import { onLoggedInLoggedInStub } from './onLoggedIn.stub' 41 | 42 | export default { 43 | onViewedPageSetCardStub, 44 | onViewedProductDetailViewProductStub, 45 | onViewedProductDetailViewedProductCustomStub, 46 | onUpdateCartSetCartStub, 47 | onAddedProductAddProductStub, 48 | onAddedProductAddProductSkuStub, 49 | onAddedProductAddProductCustomStub, 50 | onViewedProductListingCategoryViewStub, 51 | onViewedProductListingCategoryViewCustomStub, 52 | onRemovedProductRemoveProductStub, 53 | onRemovedProductAddProductCustomStub, 54 | onCompletedTransactionCompletedOrderStub, 55 | onCompletedTransactionCompletedOrderCustomStub, 56 | onSubscribedSubscribedStub, 57 | onSubscribedEmailSubscribeCustomStub, 58 | onRegisteredRegistrationStub, 59 | onRegisteredRegistrationCustomStub, 60 | onRegisteredRegistrationAndSubscriptionStub, 61 | onRegisteredRegistrationAndSubscriptionLegacyStub, 62 | onRegisteredUpdateProfileSubscriptionOnStub, 63 | onRegisteredUpdateProfileSubscriptionOnLegacyStub, 64 | onRegisteredUpdateProfileSubscriptionOffStub, 65 | onLoggedInLoggedInStub 66 | } 67 | -------------------------------------------------------------------------------- /src/integrations/K50.js: -------------------------------------------------------------------------------- 1 | import deleteProperty from '@segmentstream/utils/deleteProperty' 2 | import getVarValue from '@segmentstream/utils/getVarValue' 3 | import { getProp } from '@segmentstream/utils/dotProp' 4 | import cleanObject from '@segmentstream/utils/cleanObject' 5 | import Integration from '../Integration' 6 | import AsyncQueue from './utils/AsyncQueue' 7 | import { VIEWED_PAGE } from '../events/semanticEvents' 8 | 9 | class K50 extends Integration { 10 | constructor (digitalData, options) { 11 | const optionsWithDefaults = Object.assign({ 12 | siteId: '', 13 | labelVar: undefined 14 | }, options) 15 | super(digitalData, optionsWithDefaults) 16 | 17 | this.addTag({ 18 | type: 'script', 19 | attr: { 20 | src: '//k50-a.akamaihd.net/k50/k50tracker2.js', 21 | async: true 22 | } 23 | }) 24 | } 25 | 26 | initialize () { 27 | this.asyncQueue = new AsyncQueue(() => this.isLoaded()) 28 | } 29 | 30 | onLoadInitiated () { 31 | this.asyncQueue.init() 32 | } 33 | 34 | isLoaded () { 35 | return !!getProp(window, 'k50Tracker.init') 36 | } 37 | 38 | reset () { 39 | deleteProperty(window, 'k50Tracker') 40 | this.pageTracked = false 41 | } 42 | 43 | getSemanticEvents () { 44 | return [VIEWED_PAGE] 45 | } 46 | 47 | getEnrichableEventProps (event) { 48 | let enrichableProps = ['page.url'] 49 | const labelVar = this.getOption('labelVar') 50 | if (labelVar && labelVar.type === 'digitalData') { 51 | enrichableProps.push(labelVar.value) 52 | } 53 | return enrichableProps 54 | } 55 | 56 | getLabel (event) { 57 | const labelVar = this.getOption('labelVar') 58 | return labelVar ? getVarValue(labelVar, event) : undefined 59 | } 60 | 61 | trackEvent (event) { 62 | const methods = { 63 | [VIEWED_PAGE]: 'onViewedPage' 64 | } 65 | const method = methods[event.name] 66 | if (method) { 67 | this[method](event) 68 | } 69 | } 70 | 71 | onViewedPage (event) { 72 | this.asyncQueue.push(() => { 73 | if (this.pageTracked) { 74 | return window.k50Tracker.change( 75 | true, 76 | cleanObject({ 77 | landing: getProp(event, 'page.url'), 78 | label: this.getLabel(event) 79 | }) 80 | ) 81 | } 82 | window.k50Tracker.init( 83 | cleanObject({ 84 | siteId: this.getOption('siteId'), 85 | label: this.getLabel(event) 86 | }) 87 | ) 88 | this.pageTracked = true 89 | }) 90 | } 91 | } 92 | 93 | export default K50 94 | -------------------------------------------------------------------------------- /src/integrations/utils/transliterate.js: -------------------------------------------------------------------------------- 1 | export default function cyrillicToTranslit (config) { 2 | const _preset = config ? config.preset : 'ru' 3 | 4 | const _firstLetterAssociations = { 5 | а: 'a', 6 | б: 'b', 7 | в: 'v', 8 | ґ: 'g', 9 | г: 'g', 10 | д: 'd', 11 | е: 'e', 12 | ё: 'e', 13 | є: 'ye', 14 | ж: 'zh', 15 | з: 'z', 16 | и: 'i', 17 | і: 'i', 18 | ї: 'yi', 19 | й: 'i', 20 | к: 'k', 21 | л: 'l', 22 | м: 'm', 23 | н: 'n', 24 | о: 'o', 25 | п: 'p', 26 | р: 'r', 27 | с: 's', 28 | т: 't', 29 | у: 'u', 30 | ф: 'f', 31 | х: 'h', 32 | ц: 'c', 33 | ч: 'ch', 34 | ш: 'sh', 35 | щ: "sh'", 36 | ъ: '', 37 | ы: 'i', 38 | ь: '', 39 | э: 'e', 40 | ю: 'yu', 41 | я: 'ya' 42 | } 43 | 44 | if (_preset === 'uk') { 45 | Object.assign(_firstLetterAssociations, { 46 | г: 'h', 47 | и: 'y', 48 | й: 'y', 49 | х: 'kh', 50 | ц: 'ts', 51 | щ: 'shch', 52 | "'": '', 53 | '’': '', 54 | ʼ: '' 55 | }) 56 | } 57 | 58 | const _associations = Object.assign({}, _firstLetterAssociations) 59 | 60 | if (_preset === 'uk') { 61 | Object.assign(_associations, { 62 | є: 'ie', 63 | ї: 'i', 64 | й: 'i', 65 | ю: 'iu', 66 | я: 'ia' 67 | }) 68 | } 69 | 70 | function transform (input, spaceReplacement) { 71 | if (!input) { 72 | return '' 73 | } 74 | 75 | let newStr = '' 76 | for (let i = 0; i < input.length; i += 1) { 77 | const isUpperCaseOrWhatever = input[i] === input[i].toUpperCase() 78 | const strLowerCase = input[i].toLowerCase() 79 | if (strLowerCase === ' ' && spaceReplacement) { 80 | newStr += spaceReplacement 81 | } else { 82 | let newLetter 83 | if (_preset === 'uk' && strLowerCase === 'г' && i > 0 && input[i - 1].toLowerCase() === 'з') { 84 | newLetter = 'gh' 85 | } else if (i === 0) { 86 | newLetter = _firstLetterAssociations[strLowerCase] 87 | } else { 88 | newLetter = _associations[strLowerCase] 89 | } 90 | if (newLetter === undefined) { 91 | newStr += isUpperCaseOrWhatever ? strLowerCase.toUpperCase() : strLowerCase 92 | } else { 93 | newStr += isUpperCaseOrWhatever ? newLetter.toUpperCase() : newLetter 94 | } 95 | } 96 | } 97 | return newStr 98 | } 99 | 100 | return { 101 | transform 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/RollingAttributesHelper.js: -------------------------------------------------------------------------------- 1 | import each from '@segmentstream/utils/each' 2 | import { getProp } from '@segmentstream/utils/dotProp' 3 | 4 | const MINUTE = 60 5 | const HOUR = MINUTE * 60 6 | const DAY = HOUR * 24 7 | const MONTH = DAY * 30 8 | 9 | export function objectToTime (obj) { 10 | let time = 0 11 | 12 | if (typeof obj === 'string') { 13 | switch (obj) { 14 | case '1week': obj = { weeks: 1 }; break 15 | case '1month': obj = { months: 1 }; break 16 | case '1year': obj = { days: 365 }; break 17 | default: obj = { days: 1 } 18 | } 19 | } 20 | 21 | each(obj, (key, val) => { 22 | switch (key) { 23 | case 'minutes': time += MINUTE * parseInt(val, 10); break 24 | case 'hours': time += HOUR * parseInt(val, 10); break 25 | case 'days': time += DAY * parseInt(val, 10); break 26 | case 'weeks': time += DAY * 7 * parseInt(val, 10); break 27 | case 'months': time += MONTH * parseInt(val, 10); break 28 | default: 29 | } 30 | }) 31 | return time 32 | } 33 | 34 | export function cleanData (paramStorage, currentTime = Date.now()) { 35 | const { data, ttl, granularity } = paramStorage 36 | if (!data) return {} 37 | const maxTtl = (currentTime / 1000) - ttl 38 | return { 39 | ttl, 40 | granularity, 41 | data: Object.keys(data) 42 | .filter(timestamp => (maxTtl < timestamp)) 43 | .reduce((result, timestamp) => { 44 | result[timestamp] = data[timestamp] 45 | return result 46 | }, {}) 47 | } 48 | } 49 | 50 | /** 51 | * Generated object structure 52 | * { 53 | * ttl: , 54 | * granularity: , 55 | * data: { 56 | * : , 57 | * : , 58 | * ... 59 | * } 60 | * } 61 | */ 62 | export function counterInc (paramName, granularityObj, ttlObj, digitalData) { 63 | const granularity = objectToTime(granularityObj) 64 | const ttl = objectToTime(ttlObj) 65 | const paramStorage = cleanData(getProp(digitalData, paramName) || {}) 66 | const currentTime = (Date.now() / 1000) 67 | const granularityTimestamp = currentTime - (currentTime % granularity) 68 | const data = paramStorage.data || {} 69 | data[granularityTimestamp] = (data[granularityTimestamp] || 0) + 1 70 | 71 | return { ttl, granularity, data } 72 | } 73 | 74 | export function counter (paramName, digitalData) { 75 | const rawParamStorage = getProp(digitalData, paramName) 76 | if (!rawParamStorage) return 0 77 | const paramStorage = cleanData(rawParamStorage) 78 | const data = paramStorage.data || {} 79 | 80 | return Object.keys(data).reduce((acc, timestamp) => (acc + data[timestamp]), 0) 81 | } 82 | -------------------------------------------------------------------------------- /src/Handler.js: -------------------------------------------------------------------------------- 1 | import getQueryParam from '@segmentstream/utils/getQueryParam' 2 | import cookie from 'js-cookie' 3 | import domQuery from '@segmentstream/utils/domQuery' 4 | import loadScript from '@segmentstream/utils/loadScript' 5 | import loadLink from '@segmentstream/utils/loadLink' 6 | import loadIframe from '@segmentstream/utils/loadIframe' 7 | import loadPixel from '@segmentstream/utils/loadPixel' 8 | import { getProp } from '@segmentstream/utils/dotProp' 9 | import getDataLayerProp from '@segmentstream/utils/getDataLayerProp' 10 | import DDHelper from './DDHelper' 11 | import { counterInc, counter } from './RollingAttributesHelper' 12 | import { validate } from './helpers/ValidationHelper' 13 | 14 | class Handler { 15 | constructor (handler, digitalData, args) { 16 | this.handler = handler 17 | this.args = args 18 | this.utils = { 19 | counterInc: (key, granularity, ttl) => counterInc(key, granularity, ttl, digitalData), 20 | counter: key => counter(key, digitalData), 21 | validate: (schema, obj, key) => validate(schema, obj, key), 22 | queryParam: getQueryParam, 23 | cookie: cookie.get, 24 | get: (target, key) => getProp(target, key), 25 | digitalData: key => DDHelper.get(key, digitalData), 26 | domQuery: selector => ((window.jQuery) ? window.jQuery(selector).get() : domQuery(selector)), 27 | global: key => getProp(window, key), 28 | dataLayer: getDataLayerProp, 29 | fetch: (url, options, callback) => { 30 | if (!callback) { 31 | callback = options // arguments shift 32 | options = undefined 33 | } 34 | return new Promise((resolve) => { 35 | window.fetch(url, options).then(response => response.text()).then((text) => { 36 | try { 37 | text = JSON.parse(text) 38 | } catch (error) { 39 | // do nothing 40 | } 41 | resolve(callback(text)) 42 | }) 43 | }) 44 | }, 45 | timeout: (delay, callback) => new Promise((resolve) => { 46 | setTimeout(() => { 47 | resolve(callback()) 48 | }, delay) 49 | }), 50 | retry: (delegate, retriesLeft = 5, delay = 1000) => { 51 | try { 52 | if (retriesLeft > 0) delegate() 53 | } catch (e) { 54 | setTimeout(() => { this.utils.retry(delegate, retriesLeft - 1) }, delay) 55 | } 56 | }, 57 | loadPixel, 58 | loadScript, 59 | loadIframe, 60 | loadLink 61 | } 62 | } 63 | 64 | run () { 65 | const handlerWithUtils = this.handler.bind(this.utils) 66 | return handlerWithUtils(...this.args) 67 | } 68 | } 69 | 70 | export default Handler 71 | -------------------------------------------------------------------------------- /test/integrations/OWOXBIStreaming.spec.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon' 2 | import noop from '@segmentstream/utils/noop' 3 | import reset from './../reset.js' 4 | import GoogleAnalytics from './../../src/integrations/GoogleAnalytics.js' 5 | import OWOXBIStreaming from './../../src/integrations/OWOXBIStreaming.js' 6 | import ddManager from './../../src/ddManager.js' 7 | 8 | describe('Integrations: OWOXBIStreaming', () => { 9 | describe('OWOXBIStreaming', () => { 10 | let ga 11 | let owox 12 | const options = { 13 | trackingId: 'UA-51485228-7', 14 | domain: 'auto' 15 | } 16 | 17 | beforeEach(() => { 18 | window.digitalData = { 19 | events: [] 20 | } 21 | ga = new GoogleAnalytics(window.digitalData, options) 22 | owox = new OWOXBIStreaming(window.digitalData) 23 | 24 | // reset in case GA was loaded 25 | // from previous tests asyncronously 26 | ga.reset() 27 | owox.reset() 28 | 29 | // OWOX should depend on Google Analtics, so this order is for reason 30 | // to test that everything works well even if OWOX BI is added before GA 31 | // TODO: change order and make sure everything works 32 | ddManager.addIntegration('Google Analytics', ga) 33 | ddManager.addIntegration('OWOX BI Streaming', owox) 34 | }) 35 | 36 | afterEach(() => { 37 | ga.reset() 38 | owox.reset() 39 | ddManager.reset() 40 | reset() 41 | }) 42 | 43 | describe('before loading', () => { 44 | beforeEach(() => { 45 | sinon.stub(ga, 'load') 46 | sinon.stub(owox, 'load') 47 | }) 48 | 49 | afterEach(() => { 50 | ga.load.restore() 51 | owox.load.restore() 52 | }) 53 | 54 | describe('#initialize', () => { 55 | it('should require Google Analytics OWOXBIStreaming plugin', () => { 56 | window.ga = noop 57 | sinon.stub(window, 'ga') 58 | ddManager.initialize() 59 | ddManager.on('ready', () => { 60 | window.ga.calledWith('ddl.require', 'OWOXBIStreaming') 61 | window.ga.calledWith('provide', 'OWOXBIStreaming') 62 | window.ga.restore() 63 | }) 64 | }) 65 | 66 | it('should require Google Analytics OWOXBIStreaming plugin', () => { 67 | owox.setOption('sessionStreaming', true) 68 | owox.setOption('sessionIdDimension', 'sessionId') 69 | window.ga = noop 70 | sinon.stub(window, 'ga') 71 | ddManager.initialize() 72 | ddManager.on('ready', () => { 73 | window.ga.calledWith('ddl.require', 'OWOXBIStreaming', { 74 | sessionIdDimension: 'sessionId' 75 | }) 76 | window.ga.restore() 77 | }) 78 | }) 79 | }) 80 | }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/integrations/OWOXBIStreaming.js: -------------------------------------------------------------------------------- 1 | import { warn } from '@segmentstream/utils/safeConsole' 2 | import Integration from '../Integration' 3 | 4 | class OWOXBIStreaming extends Integration { 5 | constructor (digitalData, options) { 6 | const optionsWithDefaults = Object.assign({ 7 | namespace: undefined, 8 | sessionStreaming: false, 9 | sessionIdDimension: undefined 10 | }, options) 11 | 12 | super(digitalData, optionsWithDefaults) 13 | } 14 | 15 | initialize (version) { 16 | this.initVersion = version 17 | 18 | if (!window.ga) { 19 | warn('Google Analytics integration should be initialized before OWOX BI Streaming integration') 20 | return false 21 | } 22 | 23 | // support of legacy version 24 | if (!this.initVersion && !this.getOption('namespace') && this.getOption('namespace') !== false) { 25 | this.setOption('namespace', 'ddl') 26 | } 27 | 28 | if (this.getOption('sessionStreaming')) { 29 | this.ga('require', 'OWOXBIStreaming', { 30 | sessionIdDimension: this.getOption('sessionIdDimension') 31 | }) 32 | } else { 33 | this.ga('require', 'OWOXBIStreaming') 34 | } 35 | 36 | /* eslint-disable */ 37 | (function(){function g(h,b){var f=h.get('sendHitTask'),g=function(){function a(a,e){var d='XDomainRequest'in window?'XDomainRequest':'XMLHttpRequest',c=new window[d];c.open('POST',a,!0);c.onprogress=function(){};c.ontimeout=function(){};c.onerror=function(){};c.onload=function(){};c.setRequestHeader&&c.setRequestHeader('Content-Type','text/plain');'XDomainRequest'==d?setTimeout(function(){c.send(e)},0):c.send(e)}function 38 | f(a,e){var d=new Image;d.onload=function(){};d.src=a+'?'+e}var g=b&&b.domain?b.domain:'google-analytics.bi.owox.com';return{send:function(b){var e=location.protocol+'//'+g+'/collect',d;try{navigator.sendBeacon&&navigator.sendBeacon(d=e+'?tid='+h.get('trackingId'),b)||(2036 { 6 | let _eventManager 7 | let _digitalData 8 | let _ddListener 9 | 10 | let btn 11 | let div 12 | 13 | beforeEach(() => { 14 | _digitalData = { 15 | page: { 16 | categoryId: 1 17 | }, 18 | type: 'product', 19 | events: [{ 20 | name: 'Test Event' 21 | }] 22 | } 23 | _ddListener = [] 24 | _eventManager = new EventManager(_digitalData, _ddListener) 25 | _eventManager.setSendViewedPageEvent(true) 26 | 27 | // create button 28 | btn = document.createElement('button') 29 | const t = document.createTextNode('click me') 30 | btn.appendChild(t) 31 | btn.className = 'test-btn' 32 | 33 | // create div 34 | div = document.createElement('div') 35 | div.appendChild(btn) 36 | div.id = 'test-div' 37 | 38 | document.body.appendChild(div) 39 | }) 40 | 41 | afterEach(() => { 42 | if (_eventManager) { 43 | _eventManager.reset() 44 | _eventManager = undefined 45 | } 46 | 47 | div.parentNode.removeChild(div) 48 | _digitalData.events = [] 49 | }) 50 | 51 | it('should track custom events', (done) => { 52 | let currentAssert = 'Viewed Page' 53 | const nextAssert = { 54 | 'Viewed Page': 'Viewed Product Detail', 55 | 'Viewed Product Detail': 'Clicked Product' 56 | } 57 | _eventManager.addCallback(['on', 'event', (event) => { 58 | assert.strict.equal(event.name, currentAssert) 59 | if (event.name === 'Clicked Product') { 60 | done() 61 | } else { 62 | currentAssert = nextAssert[currentAssert] 63 | } 64 | }]) 65 | _eventManager.import([ 66 | { 67 | name: 'Event: Viewed Product Detail', 68 | trigger: 'event', 69 | event: 'Viewed Page', 70 | handler: function (event) { 71 | assert.strict.equal(event.name, 'Viewed Page') 72 | return { 73 | name: 'Viewed Product Detail' 74 | } 75 | } 76 | }, 77 | { 78 | name: 'Event: Clicked Product', 79 | trigger: 'click', 80 | selector: '.test-btn', 81 | handler: function (element) { 82 | assert.ok(element) 83 | return { 84 | name: 'Clicked Product' 85 | } 86 | } 87 | }, 88 | { 89 | name: 'Test Name', 90 | trigger: 'impression', 91 | selector: '.ddl_product', 92 | handler: function () { 93 | return { 94 | name: 'Viewed Product' 95 | } 96 | } 97 | } 98 | ]) 99 | _eventManager.initialize() 100 | 101 | fireEvent(btn, 'click') 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "digital-data-manager", 3 | "description": "The hassle-free way to integrate Digital Data Layer on your website.", 4 | "author": "Driveback LLC ", 5 | "version": "1.2.262", 6 | "license": "MIT", 7 | "main": "dist/segmentstream.js", 8 | "scripts": { 9 | "start": "nps", 10 | "test": "nps test", 11 | "lint": "standard", 12 | "build": "nps build", 13 | "dist": "nps build.prod", 14 | "build-test": "nps buildTest", 15 | "mocha": "nps mocha", 16 | "lint-fix": "standard --fix" 17 | }, 18 | "standard": { 19 | "env": [ 20 | "mocha", 21 | "chai" 22 | ] 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/driveback/digital-data-manager" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/driveback/digital-data-manager/issues" 30 | }, 31 | "homepage": "https://github.com/driveback/digital-data-manager", 32 | "browser": "./mocha.js", 33 | "browserify": { 34 | "transform": [ 35 | "babelify" 36 | ] 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.5.5", 40 | "assert": "^2.0.0", 41 | "babel": "^6.1.18", 42 | "babel-cli": "^6.26.0", 43 | "babel-plugin-transform-es3-member-expression-literals": "^6.1.18", 44 | "babel-plugin-transform-es3-property-literals": "^6.1.18", 45 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 46 | "babel-plugin-transform-object-set-prototype-of-to-assign": "^6.1.18", 47 | "babel-plugin-transform-proto-to-assign": "^6.26.0", 48 | "babel-preset-es2015": "^6.9.0", 49 | "babel-preset-es2015-loose": "^7.0.0", 50 | "babelify": "^7.2.0", 51 | "bowser": "^2.5.3", 52 | "browserify": "^16.5.0", 53 | "chai": "^4.2.0", 54 | "exorcist": "^0.4.0", 55 | "google-closure-compiler": "^20180805.0.0", 56 | "grunt": "^1.0.4", 57 | "grunt-aws-s3": "^2.0.1", 58 | "grunt-contrib-clean": "^1.0.0", 59 | "grunt-contrib-compress": "^1.5.0", 60 | "grunt-contrib-uglify": "^2.0.0", 61 | "grunt-wrap": "^0.3.1", 62 | "karma": "^1.7.1", 63 | "karma-babel-preprocessor": "^6.0.1", 64 | "karma-browserify": "^5.3.0", 65 | "karma-chrome-launcher": "2.2.0", 66 | "karma-firefox-launcher": "^1.2.0", 67 | "karma-mocha": "^1.3.0", 68 | "karma-mocha-reporter": "^2.2.5", 69 | "karma-phantomjs-launcher": "^1.0.4", 70 | "karma-safari-launcher": "^1.0.0", 71 | "karma-sauce-launcher": "^1.2.0", 72 | "mocha": "^3.5.3", 73 | "nps": "^5.9.5", 74 | "nps-utils": "^1.7.0", 75 | "puppeteer": "^1.19.0", 76 | "sinon": "6.1.4", 77 | "standard": "^12.0.1", 78 | "uglifyify": "^3.0.1" 79 | }, 80 | "dependencies": { 81 | "@segmentstream/utils": "^1.0.12", 82 | "async": "2.1.1", 83 | "component-emitter": "1.1.3", 84 | "core-js": "^2.6.9", 85 | "crypto-js": "^3.1.9-1", 86 | "js-cookie": "^2.2.1", 87 | "lockr": "^0.8.5", 88 | "promise-polyfill": "8.0.0", 89 | "ua-parser-js": "^0.7.20", 90 | "uuid": "^3.3.3", 91 | "whatwg-fetch": "^2.0.4" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /test/EventValidator.spec.js: -------------------------------------------------------------------------------- 1 | import { validateEvent } from './../src/EventValidator' 2 | import assert from 'assert' 3 | 4 | describe('EventValidator', () => { 5 | it('should validate event field with error (no product.id)', () => { 6 | const event = { 7 | name: 'Viewed Product Detail', 8 | product: { 9 | name: 'Test' 10 | } 11 | } 12 | const result = validateEvent(event, { 13 | fields: ['product.id'], 14 | validations: { 15 | 'product.id': { 16 | errors: ['required'] 17 | } 18 | } 19 | }) 20 | 21 | assert.strict.deepEqual(result, [false, [['product.id', 'is required', undefined, 'ERR']]]) 22 | }) 23 | 24 | it('should validate event field with error (no product)', () => { 25 | const event = { 26 | name: 'Viewed Product Detail' 27 | } 28 | const result = validateEvent(event, { 29 | fields: ['product.id'], 30 | validations: { 31 | 'product.id': { 32 | errors: ['required'] 33 | } 34 | } 35 | }) 36 | 37 | assert.strict.deepEqual(result, [false, [['product.id', 'is required', undefined, 'ERR']]]) 38 | }) 39 | 40 | it('should validate event field with success', () => { 41 | const event = { 42 | name: 'Viewed Product Detail', 43 | product: { 44 | id: '123' 45 | } 46 | } 47 | const result = validateEvent(event, [ 48 | [ 'product.id', { required: true } ] 49 | ]) 50 | 51 | assert.strict.equal(result[0], true) 52 | }) 53 | 54 | it('should validate event field with warning', () => { 55 | const event = { 56 | name: 'Viewed Product Detail' 57 | } 58 | const result = validateEvent(event, { 59 | fields: ['product.id'], 60 | validations: { 61 | 'product.id': { 62 | warnings: ['required'] 63 | } 64 | } 65 | }) 66 | 67 | assert.strict.deepEqual(result, [true, [['product.id', 'is required', undefined, 'WARN']]]) 68 | }) 69 | 70 | it('should validate event array field with error (no lineItem)', () => { 71 | const event = { 72 | name: 'Viewed Product Listing' 73 | } 74 | const result = validateEvent(event, { 75 | fields: ['listing.items[].product.id'], 76 | validations: { 77 | 'listing.items[].product.id': { 78 | errors: ['required'] 79 | } 80 | } 81 | }) 82 | 83 | assert.strict.deepEqual(result, [false, [['listing.items[].product.id', 'is required', undefined, 'ERR']]]) 84 | }) 85 | 86 | it('should validate event array field with warning (no lineItem)', () => { 87 | const event = { 88 | name: 'Viewed Product Listing' 89 | } 90 | const result = validateEvent(event, { 91 | fields: ['listing.items[].product.id'], 92 | validations: { 93 | 'listing.items[].product.id': { 94 | warnings: ['required'] 95 | } 96 | } 97 | }) 98 | 99 | assert.strict.deepEqual(result, [true, [['listing.items[].product.id', 'is required', undefined, 'WARN']]]) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /src/trackers/trackTimeOnSite.js: -------------------------------------------------------------------------------- 1 | import { bind, unbind } from '@segmentstream/utils/eventListener' 2 | import listenEvents from './listenEvents' 3 | import Storage from '../Storage' 4 | 5 | const timeout = 10 // 10 seconds 6 | let interval = null 7 | let hasActive = false 8 | let events = [] 9 | const storagePrefix = 'timeOnSite:' 10 | 11 | const storage = new Storage({ prefix: storagePrefix }) 12 | 13 | // Load from storage 14 | let activeTime = storage.get('activeTime') || 0 15 | let time = storage.get('time') || 0 16 | const firedEventsJSON = storage.get('firedEvents') 17 | let firedEvents = firedEventsJSON ? JSON.parse(firedEventsJSON) : [] 18 | 19 | const addEventsListener = () => { 20 | listenEvents.forEach((eventName) => { 21 | bind(window.document, eventName, setActive, false) 22 | }) 23 | } 24 | 25 | const removeEventsListener = () => { 26 | listenEvents.forEach((eventName) => { 27 | unbind(window.document, eventName, setActive, false) 28 | }) 29 | } 30 | 31 | const incActiveTime = () => { 32 | activeTime += timeout 33 | storage.set('activeTime', activeTime) 34 | } 35 | 36 | const incTime = () => { 37 | time += timeout 38 | storage.set('time', time) 39 | } 40 | 41 | const fireEvent = (eventName) => { 42 | firedEvents.push(eventName) 43 | storage.set('firedEvents', JSON.stringify(firedEvents)) 44 | } 45 | 46 | const processEvents = () => { 47 | if (hasActive) { 48 | incActiveTime() 49 | hasActive = false 50 | } 51 | 52 | incTime() 53 | 54 | events.forEach((event) => { 55 | const timeForEvent = event.isActiveTime ? activeTime : time 56 | 57 | if (!firedEvents.includes(`${event.name}:${event.seconds}`) && event.seconds <= timeForEvent) { 58 | event.handler(timeForEvent) 59 | fireEvent(`${event.name}:${event.seconds}`) 60 | } 61 | }) 62 | } 63 | 64 | const setActive = () => { hasActive = true } 65 | 66 | const addEvent = (seconds, handler, eventName, isActiveTime) => { 67 | events.push({ seconds, handler, isActiveTime, name: eventName }) 68 | } 69 | 70 | const startTracking = () => { 71 | interval = setInterval(processEvents, timeout * 1000) 72 | addEventsListener() 73 | } 74 | 75 | const stopTracking = () => { 76 | clearInterval(interval) 77 | removeEventsListener() 78 | } 79 | 80 | startTracking() 81 | 82 | export const reset = () => { 83 | if (interval) { 84 | stopTracking() 85 | } 86 | activeTime = 0 87 | time = 0 88 | hasActive = false 89 | firedEvents = [] 90 | storage.remove('activeTime') 91 | storage.remove('time') 92 | storage.remove('firedEvents') 93 | startTracking() 94 | } 95 | 96 | export default (seconds, handler, eventName, isActiveTime = false) => { 97 | if (!seconds) return 98 | 99 | if (typeof handler !== 'function') { 100 | throw new TypeError('Must pass function handler to `ddManager.trackTimeOnSite`.') 101 | } 102 | 103 | String(seconds) 104 | .replace(/\s+/mg, '') 105 | .split(',') 106 | .forEach((secondsStr) => { 107 | const second = parseInt(secondsStr) 108 | if (second > 0) { 109 | addEvent(second, handler, eventName, isActiveTime) 110 | } 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v2/onRegistered.stub.js: -------------------------------------------------------------------------------- 1 | const onRegisteredRegistrationStub = { 2 | operation: 'Registration', 3 | identificator: { 4 | provider: 'email', 5 | identity: 'test@driveback.ru' 6 | }, 7 | data: { 8 | email: 'test@driveback.ru', 9 | firstName: 'John', 10 | lastName: 'Dow' 11 | } 12 | } 13 | 14 | const onRegisteredRegistrationCustomStub = { 15 | operation: 'RegistrationCustom', 16 | identificator: { 17 | provider: 'email', 18 | identity: 'test@driveback.ru' 19 | }, 20 | data: { 21 | email: 'test@driveback.ru', 22 | firstName: 'John', 23 | lastName: 'Dow' 24 | } 25 | } 26 | 27 | const onRegisteredRegistrationAndSubscriptionLegacyStub = { 28 | operation: 'Registration', 29 | identificator: { 30 | provider: 'email', 31 | identity: 'test@driveback.ru' 32 | }, 33 | data: { 34 | email: 'test@driveback.ru', 35 | firstName: 'John', 36 | lastName: 'Dow', 37 | subscriptions: [ 38 | { 39 | pointOfContact: 'Email', 40 | isSubscribed: true, 41 | valueByDefault: true 42 | }, 43 | { 44 | pointOfContact: 'Sms', 45 | isSubscribed: true, 46 | valueByDefault: true 47 | } 48 | ] 49 | } 50 | } 51 | 52 | const onRegisteredRegistrationAndSubscriptionStub = { 53 | operation: 'Registration', 54 | identificator: { 55 | provider: 'email', 56 | identity: 'test@driveback.ru' 57 | }, 58 | data: { 59 | email: 'test@driveback.ru', 60 | firstName: 'John', 61 | lastName: 'Dow' 62 | } 63 | } 64 | const onRegisteredUpdateProfileSubscriptionOnLegacyStub = { 65 | operation: 'UpdateProfile', 66 | identificator: { 67 | provider: 'email', 68 | identity: 'test@driveback.ru' 69 | }, 70 | data: { 71 | email: 'test@driveback.ru', 72 | firstName: 'John', 73 | lastName: 'Dow', 74 | authenticationTicket: 'xxxxx', 75 | subscriptions: [ 76 | { 77 | pointOfContact: 'Email', 78 | isSubscribed: true 79 | }, 80 | { 81 | pointOfContact: 'Sms', 82 | isSubscribed: true 83 | } 84 | ] 85 | } 86 | } 87 | const onRegisteredUpdateProfileSubscriptionOnStub = { 88 | operation: 'UpdateProfile', 89 | identificator: { 90 | provider: 'email', 91 | identity: 'test@driveback.ru' 92 | }, 93 | data: { 94 | email: 'test@driveback.ru', 95 | firstName: 'John', 96 | lastName: 'Dow', 97 | authenticationTicket: 'xxxxx' 98 | } 99 | } 100 | const onRegisteredUpdateProfileSubscriptionOffStub = { 101 | operation: 'UpdateProfile', 102 | identificator: { 103 | provider: 'email', 104 | identity: 'test@driveback.ru' 105 | }, 106 | data: { 107 | email: 'test@driveback.ru', 108 | firstName: 'John', 109 | lastName: 'Dow' 110 | } 111 | } 112 | 113 | export { 114 | onRegisteredRegistrationStub, 115 | onRegisteredRegistrationCustomStub, 116 | onRegisteredRegistrationAndSubscriptionStub, 117 | onRegisteredRegistrationAndSubscriptionLegacyStub, 118 | onRegisteredUpdateProfileSubscriptionOnStub, 119 | onRegisteredUpdateProfileSubscriptionOnLegacyStub, 120 | onRegisteredUpdateProfileSubscriptionOffStub 121 | } 122 | -------------------------------------------------------------------------------- /src/integrations/DoubleClickFloodlight.js: -------------------------------------------------------------------------------- 1 | import cleanObject from '@segmentstream/utils/cleanObject' 2 | import { 3 | getEnrichableVariableMappingProps, 4 | extractVariableMappingValues 5 | } from '../IntegrationUtils' 6 | import Integration from '../Integration' 7 | 8 | class DoubleClickFloodlight extends Integration { 9 | constructor (digitalData, options) { 10 | const optionsWithDefaults = Object.assign({ 11 | events: [] 12 | }, options) 13 | 14 | super(digitalData, optionsWithDefaults) 15 | this.enrichableEventProps = [] 16 | this.SEMANTIC_EVENTS = [] 17 | 18 | this.getOption('events').forEach((eventOptions) => { 19 | const eventName = eventOptions.event 20 | const customVars = eventOptions.customVars 21 | if (!eventName) return 22 | const variableMappingProps = getEnrichableVariableMappingProps(customVars) 23 | .filter((item, i, ar) => ar.indexOf(item) === i) 24 | this.enrichableEventProps[eventName] = (this.enrichableEventProps[eventName] || []).concat(variableMappingProps) 25 | this.SEMANTIC_EVENTS.push(eventName) 26 | }) 27 | 28 | this.addTag({ 29 | type: 'script', 30 | attr: { 31 | src: 'https://www.googletagmanager.com/gtag/js' 32 | } 33 | }) 34 | } 35 | 36 | initialize () { 37 | window.dataLayer = window.dataLayer || [] 38 | if (!window.gtag) { 39 | window.gtag = function () { window.dataLayer.push(arguments) } 40 | window.gtag('js', new Date()) 41 | } 42 | this.getOption('events') 43 | .map((event) => event.floodlightConfigID) 44 | .filter((item, index, array) => array.indexOf(item) === index) // unique 45 | .forEach((configId) => { 46 | window.gtag('config', `DC-${configId}`) 47 | }) 48 | } 49 | 50 | getSemanticEvents () { 51 | return this.SEMANTIC_EVENTS 52 | } 53 | 54 | getEnrichableEventProps (event) { 55 | return this.enrichableEventProps[event.name] || [] 56 | } 57 | 58 | trackEvent (event) { 59 | const events = this.getOption('events').filter(eventOptions => (eventOptions.event === event.name)) 60 | events.forEach((eventOptions) => { 61 | const { 62 | floodlightConfigID, 63 | activityGroupTagString, 64 | activityTagString, 65 | countingMethod, 66 | customVars 67 | } = eventOptions 68 | const customVariables = extractVariableMappingValues(event, customVars) 69 | const commonTagParams = { 70 | allow_custom_scripts: true, 71 | send_to: `DC-${floodlightConfigID}/${activityGroupTagString}/${activityTagString}+${countingMethod}` 72 | } 73 | 74 | let tagParams = commonTagParams 75 | let tagName = 'conversion' 76 | if (countingMethod === 'transactions') { 77 | tagParams = Object.assign(commonTagParams, this.getPurchaseTagParams(event.transaction)) 78 | tagName = 'purchase' 79 | } 80 | 81 | tagParams = Object.assign(tagParams, customVariables) 82 | window.gtag('event', tagName, tagParams) 83 | }) 84 | } 85 | 86 | getPurchaseTagParams (transaction) { 87 | if (transaction) { 88 | return cleanObject({ 89 | transaction_id: transaction.orderId, 90 | value: transaction.total || transaction.subtotal 91 | }) 92 | } 93 | } 94 | } 95 | 96 | export default DoubleClickFloodlight 97 | -------------------------------------------------------------------------------- /test/integrations/Calltouch.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import sinon from 'sinon' 3 | import reset from '../reset' 4 | import Calltouch from '../../src/integrations/Calltouch' 5 | import ddManager from '../../src/ddManager' 6 | 7 | const CALLTOUCH_LOAD_TIME = 200 8 | 9 | describe('Integrations: Calltouch', () => { 10 | let calltouch 11 | const options = { 12 | siteId: '123123' 13 | } 14 | 15 | beforeEach(() => { 16 | window.digitalData = { 17 | website: {}, 18 | page: { 19 | param: 'value' 20 | }, 21 | events: [] 22 | } 23 | calltouch = new Calltouch(window.digitalData, options) 24 | ddManager.addIntegration('Calltouch', calltouch) 25 | }) 26 | 27 | afterEach(() => { 28 | ddManager.reset() 29 | reset() 30 | }) 31 | 32 | describe('before loading', () => { 33 | describe('#constructor', () => { 34 | it('should add proper options', () => { 35 | assert.strictEqual(options.siteId, calltouch.getOption('siteId')) 36 | }) 37 | }) 38 | }) 39 | 40 | describe('#Tracker', () => { 41 | beforeEach(() => { 42 | sinon.stub(calltouch, 'load').callsFake(() => { 43 | window.ct_set_attrs = sinon.spy() 44 | calltouch.onLoad() 45 | }) 46 | 47 | ddManager.initialize({ sendViewedPageEvent: false }) 48 | }) 49 | 50 | afterEach(() => { 51 | calltouch.reset() 52 | sinon.restore() 53 | }) 54 | 55 | it('should not sent custom parameters', (done) => { 56 | window.digitalData.events.push({ 57 | name: 'Viewed Page', 58 | callback: () => { 59 | setTimeout(() => { 60 | assert.ok(!window.ct_set_attrs.called) 61 | done() 62 | }, CALLTOUCH_LOAD_TIME) 63 | } 64 | }) 65 | }) 66 | 67 | it('should once sent custom parameters from digitalData', (done) => { 68 | calltouch.setOption('customParams', { 69 | test: { 70 | type: 'digitalData', 71 | value: 'page.param' 72 | } 73 | }) 74 | 75 | // First viewed page 76 | window.digitalData.events.push({ 77 | name: 'Viewed Page', 78 | callback: () => { 79 | setTimeout(() => { 80 | assert.ok(window.ct_set_attrs.calledWith('{"test":"value"}')) 81 | 82 | // Second viewed page 83 | window.digitalData.events.push({ 84 | name: 'Viewed Page', 85 | callback: () => { 86 | setTimeout(() => { 87 | assert.ok(window.ct_set_attrs.calledOnce) 88 | done() 89 | }, CALLTOUCH_LOAD_TIME) 90 | } 91 | }) 92 | }, CALLTOUCH_LOAD_TIME) 93 | } 94 | }) 95 | }) 96 | 97 | it('should sent custom parameters from event', (done) => { 98 | calltouch.setOption('customParams', { 99 | test1: { 100 | type: 'event', 101 | value: 'foo' 102 | } 103 | }) 104 | 105 | window.digitalData.events.push({ 106 | name: 'Viewed Page', 107 | foo: 'bar', 108 | callback: () => { 109 | setTimeout(() => { 110 | assert.ok(window.ct_set_attrs.calledWith('{"test1":"bar"}')) 111 | done() 112 | }, CALLTOUCH_LOAD_TIME) 113 | } 114 | }) 115 | }) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | onViewedPageSetCartUnauthorizedStub, 3 | onViewedPageSetCartAuthorizedStub, 4 | onViewedPageCartStub 5 | } from './onViewedPage.stub' 6 | 7 | import { onUpdateCartSetCartStub } from './onUpdateCart.stub' 8 | import { onAddedProductMappedAddProductStub } from './onAddedProduct.stub' 9 | import { onRemovedProductMappedRemoveProductStub } from './onRemovedProduct.stub' 10 | import { onAddedProductToWishlistStub } from './onAddedProductToWishlistStub.stub' 11 | import { onRemovedProductFromWishlistStub } from './onRemovedProductFromWishlistStub.stub' 12 | import { 13 | onViewedProductDetailViewProductStub, 14 | onViewedProductDetailViewProductSkuStub, 15 | onViewedProductDetailViewedProductCustomStub 16 | } from './onViewedProductDetail.stub' 17 | 18 | import { 19 | onViewedProductListingCategoryViewStub, 20 | onViewedProductListingCategoryViewCustomStub 21 | } from './onViewedProductListing.stub' 22 | 23 | import { 24 | onCompletedTransactionTransactionStub, 25 | onCompletedTransactionTransactionVoucherStub, 26 | onCompletedTransactionTransactionSkuStub, 27 | 28 | onCompletedTransactionCheckoutOperationStub, 29 | onCompletedTransactionCheckoutCustomOperationStub, 30 | onCompletedTransactionCheckoutOperationVoucherStub 31 | } from './onCompletedTransaction.stub' 32 | 33 | import { 34 | onSubscribedEmailSubscribeStub, 35 | onSubscribedEmailSubscribeCustomStub, 36 | onSubscribedEmailSubscribeAlterCustomStub 37 | } from './onSubscribed.stub' 38 | 39 | import { 40 | onRegisteredUserStub, 41 | onRegisteredRegistrationStub, 42 | onRegisteredRegistrationCustomStub, 43 | onRegisteredRegistrationWithSubscriptionStub, 44 | onRegisteredRegistrationWithSubscriptionLegacyStub, 45 | onRegisteredRegistrationWithMassSubscriptionsStub 46 | } from './onRegistered.stub' 47 | 48 | import { 49 | onUpdatedProfileInfoSubscriptionsStub, 50 | onUpdatedProfileInfoStub 51 | } from './onUpdatedProfileInfo.stub' 52 | 53 | import { onLoggedInEnterWebsiteStub } from './onLoggedIn.stub' 54 | 55 | export default { 56 | onViewedPageSetCartUnauthorizedStub, 57 | onViewedPageSetCartAuthorizedStub, 58 | onViewedPageCartStub, 59 | onUpdateCartSetCartStub, 60 | onAddedProductMappedAddProductStub, 61 | onRemovedProductMappedRemoveProductStub, 62 | onViewedProductDetailViewProductStub, 63 | onViewedProductDetailViewProductSkuStub, 64 | onViewedProductDetailViewedProductCustomStub, 65 | onViewedProductListingCategoryViewStub, 66 | onViewedProductListingCategoryViewCustomStub, 67 | 68 | onCompletedTransactionTransactionStub, 69 | onCompletedTransactionTransactionVoucherStub, 70 | onCompletedTransactionTransactionSkuStub, 71 | 72 | onCompletedTransactionCheckoutOperationStub, 73 | onCompletedTransactionCheckoutCustomOperationStub, 74 | onCompletedTransactionCheckoutOperationVoucherStub, 75 | 76 | onSubscribedEmailSubscribeStub, 77 | onSubscribedEmailSubscribeCustomStub, 78 | onSubscribedEmailSubscribeAlterCustomStub, 79 | 80 | onRegisteredUserStub, 81 | onRegisteredRegistrationStub, 82 | onRegisteredRegistrationCustomStub, 83 | onRegisteredRegistrationWithSubscriptionStub, 84 | onRegisteredRegistrationWithSubscriptionLegacyStub, 85 | onRegisteredRegistrationWithMassSubscriptionsStub, 86 | 87 | onUpdatedProfileInfoSubscriptionsStub, 88 | onUpdatedProfileInfoStub, 89 | 90 | onLoggedInEnterWebsiteStub, 91 | 92 | onAddedProductToWishlistStub, 93 | onRemovedProductFromWishlistStub 94 | } 95 | -------------------------------------------------------------------------------- /test/integrations/Driveback.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import reset from './../reset.js' 3 | import sinon from 'sinon' 4 | import Driveback from './../../src/integrations/Driveback.js' 5 | import ddManager from './../../src/ddManager.js' 6 | 7 | function emulateDrivebackLoad (driveback) { 8 | sinon.stub(driveback, 'load').callsFake(() => { 9 | window.DrivebackOnLoad = { 10 | push: (fn) => { 11 | fn() 12 | } 13 | } 14 | window.Driveback = {} 15 | window.DriveBack = {} 16 | window.Driveback.Loader = {} 17 | window.dbex = () => {} 18 | driveback.onLoad() 19 | }) 20 | } 21 | 22 | describe('Integrations: Driveback', () => { 23 | let driveback 24 | window.digitalData = { 25 | events: [] 26 | } 27 | const options = { 28 | websiteToken: 'aba543e1-1413-5f77-a8c7-aaf6979208a3' 29 | } 30 | 31 | beforeEach(() => { 32 | driveback = new Driveback(window.digitalData, options) 33 | ddManager.addIntegration('Driveback', driveback) 34 | }) 35 | 36 | afterEach(() => { 37 | driveback.reset() 38 | ddManager.reset() 39 | reset() 40 | }) 41 | 42 | describe('#constructor', () => { 43 | it('should create Driveback integrations with proper options and tags', () => { 44 | assert.strict.equal(options.websiteToken, driveback.getOption('websiteToken')) 45 | assert.strict.equal('script', driveback.getTag().type) 46 | assert.ok(driveback.getTag().attr.src.indexOf('driveback.ru') > 0) 47 | }) 48 | }) 49 | 50 | describe('#load', () => { 51 | it('should load', (done) => { 52 | assert.ok(!driveback.isLoaded()) 53 | emulateDrivebackLoad(driveback) 54 | driveback.once('load', () => { 55 | assert.ok(driveback.isLoaded()) 56 | done() 57 | }) 58 | ddManager.initialize() 59 | }) 60 | }) 61 | 62 | describe('after loading', () => { 63 | beforeEach((done) => { 64 | emulateDrivebackLoad(driveback) 65 | driveback.once('ready', done) 66 | ddManager.initialize() 67 | }) 68 | 69 | it('should initialize all global variables', () => { 70 | assert.ok(window.DrivebackNamespace) 71 | assert.ok(window.Driveback) 72 | assert.ok(window.DrivebackOnLoad.push) 73 | }) 74 | }) 75 | 76 | describe('after loading with experiments', () => { 77 | beforeEach((done) => { 78 | driveback.setOption('experiments', true) 79 | driveback.setOption('experimentsToken', '123123') 80 | emulateDrivebackLoad(driveback) 81 | driveback.once('load', () => { 82 | done() 83 | }) 84 | ddManager.initialize() 85 | }) 86 | 87 | it('should track experiment session', (done) => { 88 | sinon.stub(window, 'dbex') 89 | window.digitalData.events.push({ 90 | name: 'Viewed Experiment', 91 | experiment: '123', 92 | callback: () => { 93 | assert.ok(window.dbex.calledWith('trackSession', '123')) 94 | window.dbex.restore() 95 | done() 96 | } 97 | }) 98 | }) 99 | 100 | it('should track experiment session', (done) => { 101 | sinon.stub(window, 'dbex') 102 | window.digitalData.events.push({ 103 | name: 'Achieved Experiment Goal', 104 | experiment: '123', 105 | callback: () => { 106 | assert.ok(window.dbex.calledWith('trackConversion', '123')) 107 | window.dbex.restore() 108 | done() 109 | } 110 | }) 111 | }) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /src/integrations/Adnetic.js: -------------------------------------------------------------------------------- 1 | import cleanObject from '@segmentstream/utils/cleanObject' 2 | import Integration from '../Integration' 3 | import { 4 | VIEWED_PRODUCT_DETAIL, 5 | ADDED_PRODUCT, 6 | COMPLETED_TRANSACTION 7 | } from '../events/semanticEvents' 8 | 9 | const mapProduct = product => cleanObject({ 10 | id: product.id, 11 | name: product.name, 12 | brand: product.manufacturer || product.brand, 13 | category: Array.isArray(product.category) ? product.category.join('/') : product.category, 14 | variant: product.variant, 15 | currency: product.currency, 16 | price: product.unitSalePrice 17 | }) 18 | 19 | class Adnetic extends Integration { 20 | constructor (digitalData, options) { 21 | super(digitalData, options) 22 | 23 | this.addTag({ 24 | type: 'script', 25 | attr: { 26 | src: 'https://shopnetic.com/js/embed/loader.js' 27 | } 28 | }) 29 | } 30 | 31 | getEnrichableEventProps (event) { 32 | switch (event.name) { 33 | case VIEWED_PRODUCT_DETAIL: 34 | return ['product'] 35 | case COMPLETED_TRANSACTION: 36 | return ['transaction'] 37 | default: 38 | return [] 39 | } 40 | } 41 | 42 | getEventValidationConfig (event) { 43 | const productFields = [ 44 | 'product.id', 45 | 'product.name', 46 | 'product.manufacturer', 47 | 'product.category', 48 | 'product.variant', 49 | 'product.currency', 50 | 'product.unitSalePrice' 51 | ] 52 | const config = { 53 | [VIEWED_PRODUCT_DETAIL]: { 54 | fields: productFields 55 | }, 56 | [ADDED_PRODUCT]: { 57 | fields: productFields 58 | }, 59 | [COMPLETED_TRANSACTION]: { 60 | fields: [ 61 | 'transaction.lineItems[].product.id', 62 | 'transaction.lineItems[].product.name', 63 | 'transaction.lineItems[].product.manufacturer', 64 | 'transaction.lineItems[].product.category', 65 | 'transaction.lineItems[].product.unitSalePrice', 66 | 'transaction.lineItems[].product.currency', 67 | 'transaction.lineItems[].product.variant' 68 | ] 69 | } 70 | } 71 | 72 | return config[event.name] 73 | } 74 | 75 | getSemanticEvents () { 76 | return [ 77 | VIEWED_PRODUCT_DETAIL, 78 | ADDED_PRODUCT, 79 | COMPLETED_TRANSACTION 80 | ] 81 | } 82 | 83 | initialize () { 84 | !function(n,u,t){n[u]||(n[u]={}),n[u][t]||(n[u][t]=function(){n[u].q||(n[u].q=[]),n[u].q.push(arguments)})}(window,'antc','run'); // eslint-disable-line 85 | } 86 | 87 | isLoaded () { 88 | return false 89 | } 90 | 91 | trackEvent (event) { 92 | const methods = { 93 | [VIEWED_PRODUCT_DETAIL]: 'onViewedProductDetail', 94 | [ADDED_PRODUCT]: 'onAddedProduct', 95 | [COMPLETED_TRANSACTION]: 'onCompletedTransaction' 96 | } 97 | 98 | const method = methods[event.name] 99 | if (method) { 100 | this[method](event) 101 | } 102 | } 103 | 104 | onViewedProductDetail (event) { 105 | const product = event.product || {} 106 | window.antc.run('antc.track.ecommerce', 'detail', mapProduct(product)) 107 | } 108 | 109 | onAddedProduct (event) { 110 | const product = event.product || {} 111 | window.antc.run('antc.track.ecommerce', 'add', mapProduct(product)) 112 | } 113 | 114 | onCompletedTransaction (event) { 115 | const transaction = event.transaction || {} 116 | const lineItems = transaction.lineItems || [] 117 | 118 | lineItems.forEach((lineItem) => { 119 | const product = lineItem.product || {} 120 | window.antc.run('antc.track.ecommerce', 'purchase', mapProduct(product)) 121 | }) 122 | } 123 | } 124 | 125 | export default Adnetic 126 | -------------------------------------------------------------------------------- /src/integrations/Mixmarket.js: -------------------------------------------------------------------------------- 1 | import getQueryParam from '@segmentstream/utils/getQueryParam' 2 | import { getProp } from '@segmentstream/utils/dotProp' 3 | import normalizeString from '@segmentstream/utils/normalizeString' 4 | import Integration from '../Integration' 5 | import { COMPLETED_TRANSACTION } from '../events/semanticEvents' 6 | import { isDeduplication, addAffiliateCookie, getAffiliateCookie } from './utils/affiliate' 7 | 8 | const DEFAULT_COOKIE_NAME = 'mixmarket' 9 | const DEFAULT_UTM_SOURCE = 'mixmarket' 10 | 11 | class Mixmarket extends Integration { 12 | constructor (digitalData, options) { 13 | const optionsWithDefaults = Object.assign({ 14 | advertiserId: '', 15 | cookieName: DEFAULT_COOKIE_NAME, 16 | cookieTracking: true, // false - if advertiser wants to track cookies by itself 17 | cookieDomain: '', 18 | cookieTtl: 90, // days 19 | deduplication: false, 20 | utmSource: DEFAULT_UTM_SOURCE, // utm_source for mixmarket leads 21 | deduplicationUtmMedium: [] 22 | }, options) 23 | 24 | super(digitalData, optionsWithDefaults) 25 | 26 | this._isLoaded = false 27 | 28 | this.addTag('trackingPixel', { 29 | type: 'img', 30 | attr: { 31 | // eslint-disable-next-line max-len 32 | src: `//mixmarket.biz/uni/tev.php?id=${options.advertiserId}&r=${escape(window.document.referrer)}&t=${Date.now()}&a1={{ orderId }}&a2={{ total }}` 33 | } 34 | }) 35 | } 36 | 37 | initialize () { 38 | this._isLoaded = true 39 | 40 | if (this.getOption('cookieTracking')) { 41 | const mixmarketUtmSource = normalizeString(this.getOption('utmSource')) 42 | const urlUtmSource = getQueryParam('utm_source') 43 | if (urlUtmSource === mixmarketUtmSource) { 44 | const cookieName = this.getOption('cookieName') 45 | const ttl = this.getOption('cookieTtl') 46 | const domain = this.getOption('cookieDomain') 47 | addAffiliateCookie(cookieName, 1, ttl, domain) 48 | } 49 | } 50 | } 51 | 52 | getSemanticEvents () { 53 | return [COMPLETED_TRANSACTION] 54 | } 55 | 56 | getEnrichableEventProps (event) { 57 | let enrichableProps = [] 58 | 59 | if (event.name === COMPLETED_TRANSACTION) { 60 | enrichableProps = [ 61 | 'transaction', 62 | 'context.campaign' 63 | ] 64 | } 65 | 66 | return enrichableProps 67 | } 68 | 69 | getEventValidationConfig (event) { 70 | const config = { 71 | [COMPLETED_TRANSACTION]: { 72 | fields: [ 73 | 'transaction.orderId', 74 | 'transaction.total', 75 | 'context.campaign.medium' 76 | ], 77 | validations: { 78 | 'transaction.orderId': { 79 | errors: ['required'], 80 | warnings: ['string'] 81 | }, 82 | 'transaction.total': { 83 | errors: ['required'], 84 | warnings: ['numeric'] 85 | } 86 | } 87 | } 88 | } 89 | 90 | return config[event.name] 91 | } 92 | 93 | isLoaded () { 94 | return this._isLoaded 95 | } 96 | 97 | trackEvent (event) { 98 | if (event.name === COMPLETED_TRANSACTION) { 99 | if (!getAffiliateCookie(this.getOption('cookieName'))) return 100 | 101 | const campaign = getProp(event, 'context.campaign') 102 | const utmSource = this.getOption('utmSource') 103 | const deduplicationUtmMedium = this.getOption('deduplicationUtmMedium') 104 | if (isDeduplication(campaign, utmSource, deduplicationUtmMedium)) return 105 | 106 | this.trackSale(event) 107 | } 108 | } 109 | 110 | trackSale (event) { 111 | const { transaction } = event 112 | 113 | if (!transaction || !transaction.orderId || !transaction.total) { 114 | return 115 | } 116 | 117 | const { orderId, total } = transaction 118 | 119 | this.load('trackingPixel', { orderId, total }) 120 | } 121 | } 122 | 123 | export default Mixmarket 124 | -------------------------------------------------------------------------------- /src/integrations/Get4Click.js: -------------------------------------------------------------------------------- 1 | import deleteProperty from '@segmentstream/utils/deleteProperty' 2 | import { getProp } from '@segmentstream/utils/dotProp' 3 | import { stringify } from '@segmentstream/utils/queryString' 4 | import cleanObject from '@segmentstream/utils/cleanObject' 5 | import Integration from '../Integration' 6 | import { 7 | COMPLETED_TRANSACTION 8 | } from '../events/semanticEvents' 9 | 10 | class Get4Click extends Integration { 11 | constructor (digitalData, options) { 12 | const optionsWithDefaults = Object.assign({ 13 | shopId: '', 14 | bannerId: '' 15 | }, options) 16 | 17 | super(digitalData, optionsWithDefaults) 18 | 19 | this.addTag('wrapper', { 20 | type: 'script', 21 | attr: { 22 | src: 'https://get4click.ru/wrapper.php?method=main&jsc=iPromoCpnObj&{{ params }}' 23 | } 24 | }) 25 | } 26 | 27 | initialize () { 28 | window._iPromoBannerObj = function _iPromoBannerObj () { 29 | this.htmlElementId = 'promocode-element-container' 30 | this.gc = function gc () { 31 | return document.getElementById(this.htmlElementId) 32 | } 33 | } 34 | window.iPromoCpnObj = new window._iPromoBannerObj() 35 | this._isLoaded = true 36 | } 37 | 38 | getSemanticEvents () { 39 | return [COMPLETED_TRANSACTION] 40 | } 41 | 42 | getEnrichableEventProps (event) { 43 | switch (event.name) { 44 | case COMPLETED_TRANSACTION: 45 | return [ 46 | 'user.email', 47 | 'user.firstName', 48 | 'user.lastName', 49 | 'user.phone', 50 | 'user.gender', 51 | 'transaction' 52 | ] 53 | default: 54 | return [] 55 | } 56 | } 57 | 58 | getEventValidationConfig (event) { 59 | const config = { 60 | [COMPLETED_TRANSACTION]: { 61 | fields: [ 62 | 'user.email', 63 | 'user.firstName', 64 | 'user.lastName', 65 | 'transaction.orderId', 66 | 'transaction.total', 67 | 'transaction.vouchers' 68 | ], 69 | validations: { 70 | 'user.email': { 71 | warnings: ['required', 'string'] 72 | }, 73 | 'user.firstName': { 74 | errors: ['string'] 75 | }, 76 | 'user.lastName': { 77 | errors: ['string'] 78 | }, 79 | 'transaction.orderId': { 80 | errors: ['required'], 81 | warnings: ['string'] 82 | }, 83 | 'transaction.total': { 84 | errors: ['required'], 85 | warnings: ['numeric'] 86 | } 87 | } 88 | } 89 | } 90 | 91 | return config[event.name] 92 | } 93 | 94 | reset () { 95 | deleteProperty(window, 'iPromoCpnObj') 96 | deleteProperty(window, '_iPromoBannerObj') 97 | } 98 | 99 | trackEvent (event) { 100 | const eventMap = { 101 | [COMPLETED_TRANSACTION]: this.onCompletedTransaction.bind(this) 102 | } 103 | 104 | if (eventMap[event.name]) { 105 | eventMap[event.name](event) 106 | } 107 | } 108 | 109 | onCompletedTransaction (event) { 110 | const vouchers = getProp(event, 'transaction.vouchers') 111 | const params = cleanObject({ 112 | _shopId: this.getOption('shopId'), 113 | _bannerId: this.getOption('bannerId'), 114 | _customerFirstName: getProp(event, 'user.firstName'), 115 | _customerLastName: getProp(event, 'user.lastName'), 116 | _customerEmail: getProp(event, 'user.email'), 117 | _customerPhone: getProp(event, 'user.phone'), 118 | _customerGender: getProp(event, 'user.gender'), 119 | _orderId: getProp(event, 'transaction.orderId'), 120 | _orderValue: getProp(event, 'transaction.total'), 121 | _orderCurrency: getProp(event, 'transaction.currency'), 122 | _usedPromoCode: Array.isArray(vouchers) ? vouchers.toString() : vouchers 123 | }) 124 | 125 | this.load('wrapper', { params: stringify(params) }) 126 | } 127 | } 128 | 129 | export default Get4Click 130 | -------------------------------------------------------------------------------- /src/integrations/Getintent.js: -------------------------------------------------------------------------------- 1 | import { getProp } from '@segmentstream/utils/dotProp' 2 | import Integration from '../Integration' 3 | import { 4 | VIEWED_PAGE, VIEWED_PRODUCT_DETAIL, ADDED_PRODUCT, COMPLETED_TRANSACTION 5 | } from '../events/semanticEvents' 6 | 7 | const ACTION_TYPE_VIEW = 'VIEW' 8 | const ACTION_TYPE_CART_ADD = 'CART_ADD' 9 | const ACTION_TYPE_CONVERSION = 'CONVERSION' 10 | 11 | class Getintent extends Integration { 12 | constructor (digitalData, options) { 13 | const optionsWithDefaults = Object.assign({ 14 | siteId: '', 15 | feedWithGroupedProducts: false 16 | }, options) 17 | super(digitalData, optionsWithDefaults) 18 | 19 | this.addTag({ 20 | type: 'script', 21 | attr: { 22 | src: 'https://px.adhigh.net/p.js' 23 | } 24 | }) 25 | } 26 | 27 | initialize () { 28 | window.__GetI = window.__GetI || [] 29 | } 30 | 31 | getSemanticEvents () { 32 | return [VIEWED_PAGE, VIEWED_PRODUCT_DETAIL, ADDED_PRODUCT, COMPLETED_TRANSACTION] 33 | } 34 | 35 | getEnrichableEventProps (event) { 36 | switch (event.name) { 37 | case [VIEWED_PRODUCT_DETAIL]: 38 | return ['product'] 39 | case [COMPLETED_TRANSACTION]: 40 | return ['transaction'] 41 | default: 42 | return [] 43 | } 44 | } 45 | 46 | trackEvent (event) { 47 | const methods = { 48 | [VIEWED_PAGE]: 'onViewedPage', 49 | [VIEWED_PRODUCT_DETAIL]: 'onViewedProductDetail', 50 | [ADDED_PRODUCT]: 'onAddedProduct', 51 | [COMPLETED_TRANSACTION]: 'onCompletedTransaction' 52 | } 53 | 54 | const method = methods[event.name] 55 | if (method) { 56 | this[method](event) 57 | } 58 | } 59 | 60 | trackGetIntentAction (params) { 61 | window.__GetI.push(params) 62 | this.pageTracked = true 63 | } 64 | 65 | onViewedPage () { 66 | this.pageTracked = false 67 | setTimeout(() => { 68 | if (!this.pageTracked) { 69 | this.trackGetIntentAction({ 70 | type: ACTION_TYPE_VIEW, 71 | site_id: this.getOption('siteId') 72 | }) 73 | } 74 | }, 100) 75 | } 76 | 77 | onViewedProductDetail (event) { 78 | const product = event.product || {} 79 | const feedWithGroupedProducts = this.getOption('feedWithGroupedProducts') 80 | 81 | this.trackGetIntentAction({ 82 | type: ACTION_TYPE_VIEW, 83 | site_id: this.getOption('siteId'), 84 | category_id: product.categoryId, 85 | product_id: (!feedWithGroupedProducts) ? product.id : product.skuCode, 86 | product_price: product.unitSalePrice 87 | }) 88 | } 89 | 90 | onAddedProduct (event) { 91 | const product = event.product || {} 92 | const feedWithGroupedProducts = this.getOption('feedWithGroupedProducts') 93 | 94 | this.trackGetIntentAction({ 95 | type: ACTION_TYPE_CART_ADD, 96 | site_id: this.getOption('siteId'), 97 | order: [ 98 | { 99 | id: (!feedWithGroupedProducts) ? product.id : product.skuCode, 100 | price: product.unitSalePrice, 101 | quantity: event.quantity || 1 102 | } 103 | ] 104 | }) 105 | } 106 | 107 | onCompletedTransaction (event) { 108 | const transaction = event.transaction || {} 109 | const lineItems = transaction.lineItems || [] 110 | const feedWithGroupedProducts = this.getOption('feedWithGroupedProducts') 111 | 112 | this.trackGetIntentAction({ 113 | type: ACTION_TYPE_CONVERSION, 114 | site_id: this.getOption('siteId'), 115 | order: lineItems.map((lineItem) => { 116 | lineItem = lineItem || {} 117 | return { 118 | id: (!feedWithGroupedProducts) ? getProp(lineItem, 'product.id') : getProp(lineItem, 'product.skuCode'), 119 | price: getProp(lineItem, 'product.unitSalePrice'), 120 | quantity: lineItem.quantity || 1 121 | } 122 | }), 123 | transaction_id: transaction.orderId, 124 | revenue: transaction.total 125 | }) 126 | } 127 | } 128 | 129 | export default Getintent 130 | -------------------------------------------------------------------------------- /src/integrations/TradeTracker.js: -------------------------------------------------------------------------------- 1 | import getQueryParam from '@segmentstream/utils/getQueryParam' 2 | import { getProp } from '@segmentstream/utils/dotProp' 3 | import normalizeString from '@segmentstream/utils/normalizeString' 4 | import Integration from '../Integration' 5 | import { COMPLETED_TRANSACTION } from '../events/semanticEvents' 6 | import { isDeduplication, addAffiliateCookie, getAffiliateCookie } from './utils/affiliate' 7 | 8 | const DEFAULT_COOKIE_NAME = 'tradetracker' 9 | const DEFAULT_UTM_SOURCE = 'tradetracker' 10 | 11 | class TradeTracker extends Integration { 12 | constructor (digitalData, options) { 13 | const optionsWithDefaults = Object.assign({ 14 | campaignId: '', 15 | cookieName: DEFAULT_COOKIE_NAME, 16 | cookieTracking: true, // false - if advertiser wants to track cookies by itself 17 | cookieDomain: '', 18 | cookieTtl: 90, // days 19 | deduplication: false, 20 | utmSource: DEFAULT_UTM_SOURCE, // utm_source for tradetracker leads 21 | deduplicationUtmMedium: [] 22 | }, options) 23 | 24 | super(digitalData, optionsWithDefaults) 25 | 26 | this._isLoaded = false 27 | 28 | this.addTag('trackingPixel', { 29 | type: 'img', 30 | attr: { 31 | // eslint-disable-next-line max-len 32 | src: `//ts.tradetracker.net/?cid=${options.campaignId}&tid={{ orderId }}&tam={{ subtotal }}&qty=1&event=sales¤cy={{ currency }}` 33 | } 34 | }) 35 | } 36 | 37 | initialize () { 38 | this._isLoaded = true 39 | 40 | if (this.getOption('cookieTracking')) { 41 | const tradeTrackerUtmSource = normalizeString(this.getOption('utmSource')) 42 | const urlUtmSource = getQueryParam('utm_source') 43 | if (urlUtmSource === tradeTrackerUtmSource) { 44 | const cookieName = this.getOption('cookieName') 45 | const ttl = this.getOption('cookieTtl') 46 | const domain = this.getOption('cookieDomain') 47 | addAffiliateCookie(cookieName, 1, ttl, domain) 48 | } 49 | } 50 | } 51 | 52 | getSemanticEvents () { 53 | return [COMPLETED_TRANSACTION] 54 | } 55 | 56 | getEnrichableEventProps (event) { 57 | let enrichableProps = [] 58 | 59 | if (event.name === COMPLETED_TRANSACTION) { 60 | enrichableProps = [ 61 | 'transaction', 62 | 'context.campaign' 63 | ] 64 | } 65 | 66 | return enrichableProps 67 | } 68 | 69 | getEventValidationConfig (event) { 70 | const config = { 71 | [COMPLETED_TRANSACTION]: { 72 | fields: [ 73 | 'transaction.orderId', 74 | 'transaction.subtotal', 75 | 'transaction.total', 76 | 'transaction.currency', 77 | 'context.campaign.medium' 78 | ], 79 | validations: { 80 | 'transaction.orderId': { 81 | errors: ['required'], 82 | warnings: ['string'] 83 | }, 84 | 'transaction.subtotal': { 85 | warnings: ['required', 'numeric'] 86 | } 87 | } 88 | } 89 | } 90 | 91 | return config[event.name] 92 | } 93 | 94 | isLoaded () { 95 | return this._isLoaded 96 | } 97 | 98 | trackEvent (event) { 99 | if (event.name === COMPLETED_TRANSACTION) { 100 | if (!getAffiliateCookie(this.getOption('cookieName'))) return 101 | 102 | const campaign = getProp(event, 'context.campaign') 103 | const utmSource = this.getOption('utmSource') 104 | const deduplicationUtmMedium = this.getOption('deduplicationUtmMedium') 105 | if (isDeduplication(campaign, utmSource, deduplicationUtmMedium)) return 106 | 107 | this.trackSale(event) 108 | } 109 | } 110 | 111 | trackSale (event) { 112 | const { transaction } = event 113 | 114 | if (!transaction || !transaction.orderId) { 115 | return 116 | } 117 | 118 | const { orderId, currency } = transaction 119 | const subtotal = transaction.subtotal || transaction.total 120 | 121 | this.load('trackingPixel', { orderId, subtotal, currency }) 122 | } 123 | } 124 | 125 | export default TradeTracker 126 | -------------------------------------------------------------------------------- /src/integrations/Multilead.js: -------------------------------------------------------------------------------- 1 | import { getProp } from '@segmentstream/utils/dotProp' 2 | import Integration from '../Integration' 3 | import { 4 | VIEWED_PRODUCT_DETAIL, 5 | COMPLETED_TRANSACTION 6 | } from '../events/semanticEvents' 7 | 8 | const SEMANTIC_EVENTS = [ 9 | VIEWED_PRODUCT_DETAIL, 10 | COMPLETED_TRANSACTION 11 | ] 12 | 13 | class Multilead extends Integration { 14 | constructor (digitalData, options) { 15 | const optionsWithDefaults = Object.assign({ 16 | shopId: '', 17 | afsecure: '', 18 | rbProductPixelId: '', 19 | rbConversionPixelId: '', 20 | utmSource: 'multilead' 21 | }, options) 22 | 23 | super(digitalData, optionsWithDefaults) 24 | 25 | this._isLoaded = false 26 | 27 | this.addTag('productMailRuPixel', { 28 | type: 'img', 29 | attr: { 30 | // eslint-disable-next-line max-len 31 | src: `https://ad.mail.ru/${options.rbProductPixelId}.gif?shop=${options.shopId}&offer={{ productId }}&rnd=${Date.now()}` 32 | } 33 | }) 34 | 35 | this.addTag('conversionMailRuPixel', { 36 | type: 'img', 37 | attr: { 38 | src: `https://rs.mail.ru/${options.rbConversionPixelId}.gif?rnd=${Date.now()}` 39 | } 40 | }) 41 | 42 | this.addTag('conversionPixel', { 43 | type: 'img', 44 | attr: { 45 | // eslint-disable-next-line max-len 46 | src: `https://track.multilead.ru/success.php?afid={{ orderId }}&afprice={{ total }}&afcurrency={{ currency }}&afsecure=${options.afsecure}` 47 | } 48 | }) 49 | } 50 | 51 | getSemanticEvents () { 52 | return SEMANTIC_EVENTS 53 | } 54 | 55 | getEnrichableEventProps (event) { 56 | switch (event.name) { 57 | case VIEWED_PRODUCT_DETAIL: 58 | return ['product.id'] 59 | case COMPLETED_TRANSACTION: 60 | return ['context.campaign', 'transaction'] 61 | default: 62 | return [] 63 | } 64 | } 65 | 66 | getEventValidationConfig (event) { 67 | const config = { 68 | [VIEWED_PRODUCT_DETAIL]: { 69 | fields: ['product.id'], 70 | validations: { 71 | 'product.id': { 72 | errors: ['required'], 73 | warnings: ['string'] 74 | } 75 | } 76 | }, 77 | [COMPLETED_TRANSACTION]: { 78 | fields: [ 79 | 'transaction.orderId', 80 | 'transaction.total', 81 | 'transaction.currency', 82 | 'context.campaign.source' 83 | ], 84 | validation: { 85 | 'transaction.orderId': { 86 | errors: ['required'], 87 | warnings: ['string'] 88 | }, 89 | 'transaction.total': { 90 | errors: ['required'], 91 | warnings: ['numeric'] 92 | } 93 | } 94 | } 95 | } 96 | 97 | const validationConfig = config[event.name] 98 | return validationConfig 99 | } 100 | 101 | initialize () { 102 | this._isLoaded = true 103 | } 104 | 105 | isLoaded () { 106 | return this._isLoaded 107 | } 108 | 109 | trackEvent (event) { 110 | const methods = { 111 | [VIEWED_PRODUCT_DETAIL]: 'onViewedProductDetail', 112 | [COMPLETED_TRANSACTION]: 'onCompletedTransaction' 113 | } 114 | 115 | const method = methods[event.name] 116 | if (method) { 117 | this[method](event) 118 | } 119 | } 120 | 121 | onViewedProductDetail (event) { 122 | const { product } = event 123 | if (product && product.id) { 124 | this.load('productMailRuPixel', { 125 | productId: product.id 126 | }) 127 | } 128 | } 129 | 130 | onCompletedTransaction (event) { 131 | const transaction = event.transaction || {} 132 | const campaign = getProp(event, 'context.campaign') || {} 133 | if (transaction.orderId && campaign.source === this.getOption('utmSource')) { 134 | const { orderId, total } = transaction 135 | const currency = transaction.currency || 'RUB' 136 | this.load('conversionMailRuPixel') 137 | this.load('conversionPixel', { orderId, total, currency }) 138 | } 139 | } 140 | } 141 | 142 | export default Multilead 143 | -------------------------------------------------------------------------------- /src/availableIntegrations.js: -------------------------------------------------------------------------------- 1 | import GoogleAnalytics from './integrations/GoogleAnalytics' 2 | import GoogleTagManager from './integrations/GoogleTagManager' 3 | import GoogleAdWords from './integrations/GoogleAdWords' 4 | import Driveback from './integrations/Driveback' 5 | import RetailRocket from './integrations/RetailRocket' 6 | import FacebookPixel from './integrations/FacebookPixel' 7 | import SegmentStream from './integrations/SegmentStream' 8 | import SendPulse from './integrations/SendPulse' 9 | import OWOXBIStreaming from './integrations/OWOXBIStreaming' 10 | import Criteo from './integrations/Criteo' 11 | import MyTarget from './integrations/MyTarget' 12 | import YandexMetrica from './integrations/YandexMetrica' 13 | import Vkontakte from './integrations/Vkontakte' 14 | import Emarsys from './integrations/Emarsys' 15 | import OneSignal from './integrations/OneSignal' 16 | import Sociomantic from './integrations/Sociomantic' 17 | import Admitad from './integrations/Admitad' 18 | import Actionpay from './integrations/Actionpay' 19 | import Mindbox from './integrations/Mindbox' 20 | import DoubleClickFloodlight from './integrations/DoubleClickFloodlight' 21 | import RTBHouse from './integrations/RTBHouse' 22 | import Ofsys from './integrations/Ofsys' 23 | import Soloway from './integrations/Soloway' 24 | import OneDMC from './integrations/OneDMC' 25 | import AdSpire from './integrations/AdSpire' 26 | import Weborama from './integrations/Weborama' 27 | import CityAds from './integrations/CityAds' 28 | import Aidata from './integrations/Aidata' 29 | import Segmento from './integrations/Segmento' 30 | import Mixmarket from './integrations/Mixmarket' 31 | import GdeSlon from './integrations/GdeSlon' 32 | import RichRelevance from './integrations/RichRelevance' 33 | import Linkprofit from './integrations/Linkprofit' 34 | import Flocktory from './integrations/Flocktory' 35 | import DDManagerStreaming from './integrations/DDManagerStreaming' 36 | import Recreativ from './integrations/Recreativ' 37 | import Multilead from './integrations/Multilead' 38 | import AdvCake from './integrations/AdvCake' 39 | import JivoChat from './integrations/JivoChat' 40 | import Adnetic from './integrations/Adnetic' 41 | import AnyQuery from './integrations/AnyQuery' 42 | import VeInteractive from './integrations/VeInteractive' 43 | import REES46 from './integrations/REES46' 44 | import PushWorld from './integrations/PushWorld' 45 | import Get4Click from './integrations/Get4Click' 46 | import Renta from './integrations/Renta' 47 | import Target2Sell from './integrations/Target2Sell' 48 | import TradeTracker from './integrations/TradeTracker' 49 | import TryFit from './integrations/TryFit' 50 | import Getintent from './integrations/Getintent' 51 | import Glami from './integrations/Glami' 52 | import K50 from './integrations/K50' 53 | import Calltouch from './integrations/Calltouch' 54 | import DynamicYield from './integrations/DynamicYield' 55 | 56 | const integrations = { 57 | 'Google Analytics': GoogleAnalytics, 58 | 'Google Tag Manager': GoogleTagManager, 59 | 'Google AdWords': GoogleAdWords, 60 | 'OWOX BI Streaming': OWOXBIStreaming, 61 | 'Facebook Pixel': FacebookPixel, 62 | Driveback, 63 | 'Retail Rocket': RetailRocket, 64 | SegmentStream, 65 | SendPulse, 66 | Criteo, 67 | myTarget: MyTarget, 68 | 'Yandex Metrica': YandexMetrica, 69 | Vkontakte, 70 | Emarsys, 71 | OneSignal, 72 | Sociomantic, 73 | Admitad, 74 | Actionpay, 75 | Mindbox, 76 | 'DoubleClick Floodlight': DoubleClickFloodlight, 77 | 'RTB House': RTBHouse, 78 | Ofsys, 79 | Soloway, 80 | '1DMC': OneDMC, 81 | AdSpire, 82 | Weborama, 83 | CityAds, 84 | Aidata, 85 | Segmento, 86 | Mixmarket, 87 | GdeSlon, 88 | RichRelevance, 89 | Linkprofit, 90 | Flocktory, 91 | 'DDManager Streaming': DDManagerStreaming, 92 | Recreativ, 93 | Multilead, 94 | AdvCake, 95 | JivoChat, 96 | Adnetic, 97 | AnyQuery, 98 | 'Ve Interactive': VeInteractive, 99 | REES46, 100 | 'Push.world': PushWorld, 101 | Get4Click, 102 | Renta, 103 | Target2Sell, 104 | TradeTracker, 105 | 'Try.Fit': TryFit, 106 | Getintent, 107 | Glami, 108 | K50, 109 | Calltouch, 110 | 'Dynamic Yield': DynamicYield 111 | } 112 | 113 | export default integrations 114 | -------------------------------------------------------------------------------- /test/integrations/K50.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import sinon from 'sinon' 3 | import reset from '../reset' 4 | import K50 from '../../src/integrations/K50' 5 | import htmlGlobals from '@segmentstream/utils/htmlGlobals' 6 | import ddManager from '../../src/ddManager' 7 | 8 | const K50LoadTime = 200 9 | 10 | describe('Integrations: K50', () => { 11 | let k50 12 | const options = { 13 | siteId: '1169056832' 14 | } 15 | 16 | beforeEach(() => { 17 | window.digitalData = { 18 | website: {}, 19 | page: {}, 20 | events: [] 21 | } 22 | k50 = new K50(window.digitalData, options) 23 | ddManager.addIntegration('K50', k50) 24 | }) 25 | 26 | afterEach(() => { 27 | ddManager.reset() 28 | reset() 29 | }) 30 | 31 | describe('before loading', () => { 32 | describe('#constructor', () => { 33 | it('should add proper options', () => { 34 | assert.strictEqual(options.siteId, k50.getOption('siteId')) 35 | }) 36 | }) 37 | }) 38 | 39 | describe('#Tracker', () => { 40 | beforeEach(() => { 41 | sinon.stub(k50, 'load').callsFake(() => { 42 | window.k50Tracker = { init: sinon.spy(), change: sinon.spy() } 43 | k50.onLoad() 44 | }) 45 | ddManager.initialize({ sendViewedPageEvent: false }) 46 | }) 47 | 48 | afterEach(() => { 49 | k50.reset() 50 | sinon.restore() 51 | }) 52 | 53 | it('should init on viewed page', (done) => { 54 | window.digitalData.events.push({ 55 | name: 'Viewed Page', 56 | callback: () => { 57 | setTimeout(() => { 58 | assert.ok(window.k50Tracker.init.calledWith( 59 | { siteId: k50.getOption('siteId') } 60 | )) 61 | done() 62 | }, K50LoadTime) 63 | } 64 | }) 65 | }) 66 | 67 | it('should init on viewed page with constant user label', (done) => { 68 | k50.setOption('labelVar', { 69 | type: 'digitalData', 70 | value: 'example' 71 | }) 72 | window.digitalData.example = 'test' 73 | window.digitalData.events.push({ 74 | name: 'Viewed Page', 75 | callback: () => { 76 | setTimeout(() => { 77 | assert.ok(window.k50Tracker.init.calledWith( 78 | { siteId: k50.getOption('siteId'), label: 'test' } 79 | )) 80 | done() 81 | }, K50LoadTime) 82 | } 83 | }) 84 | }) 85 | 86 | it('should init on viewed page with digitalData user label', (done) => { 87 | k50.setOption('labelVar', { 88 | type: 'constant', 89 | value: 'test' 90 | }) 91 | window.digitalData.events.push({ 92 | name: 'Viewed Page', 93 | callback: () => { 94 | setTimeout(() => { 95 | assert.ok(window.k50Tracker.init.calledWith( 96 | { siteId: k50.getOption('siteId'), label: 'test' } 97 | )) 98 | done() 99 | }, K50LoadTime) 100 | } 101 | }) 102 | }) 103 | 104 | describe('SPA site', () => { 105 | const _location = { 106 | href: 'https://example.com/items/some-item?color=red' 107 | } 108 | 109 | const getCurrentLocation = () => { 110 | return htmlGlobals.getLocation().href 111 | } 112 | 113 | before(() => { 114 | sinon.stub(htmlGlobals, 'getLocation').callsFake(() => _location) 115 | }) 116 | 117 | after(() => { 118 | sinon.restore() 119 | }) 120 | 121 | it('should send change event on viewed page with digitalData user label', (done) => { 122 | k50.setOption('labelVar', { 123 | type: 'constant', 124 | value: 'test' 125 | }) 126 | window.digitalData.events.push({ name: 'Viewed Page' }) 127 | window.digitalData.events.push({ 128 | name: 'Viewed Page', 129 | callback: () => { 130 | setTimeout(() => { 131 | assert.ok(window.k50Tracker.change.calledWith( 132 | true, 133 | { landing: getCurrentLocation(), label: 'test' } 134 | )) 135 | done() 136 | }, K50LoadTime) 137 | } 138 | }) 139 | }) 140 | }) 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /src/integrations/SegmentStream.js: -------------------------------------------------------------------------------- 1 | import deleteProperty from '@segmentstream/utils/deleteProperty' 2 | import each from '@segmentstream/utils/each' 3 | import { getProp } from '@segmentstream/utils/dotProp' 4 | import Integration from '../Integration' 5 | 6 | import { 7 | VIEWED_PAGE, 8 | VIEWED_PRODUCT_DETAIL, 9 | ADDED_PRODUCT 10 | } from '../events/semanticEvents' 11 | 12 | const SEMANTIC_EVENTS = [ 13 | VIEWED_PAGE, 14 | VIEWED_PRODUCT_DETAIL, 15 | ADDED_PRODUCT 16 | ] 17 | 18 | class SegmentStream extends Integration { 19 | constructor (digitalData, options) { 20 | const optionsWithDefaults = Object.assign({ 21 | sessionLength: 1800, // 30 min 22 | storagePrefix: 'ss:' 23 | }, options) 24 | 25 | super(digitalData, optionsWithDefaults) 26 | 27 | this.addTag({ 28 | type: 'script', 29 | attr: { 30 | id: 'segmentstream-sdk', 31 | src: '//cdn.driveback.ru/js/segmentstream.js' 32 | } 33 | }) 34 | } 35 | 36 | getSemanticEvents () { 37 | return SEMANTIC_EVENTS 38 | } 39 | 40 | getEnrichableEventProps (event) { 41 | let enrichableProps = [] 42 | switch (event.name) { 43 | case 'Viewed Product Detail': 44 | enrichableProps = [ 45 | 'product' 46 | ] 47 | break 48 | default: 49 | // do nothing 50 | } 51 | return enrichableProps 52 | } 53 | 54 | initialize () { 55 | const ssApi = window.ssApi = window.ssApi || [] 56 | 57 | if (ssApi.initialize) return 58 | 59 | if (ssApi.invoked) { 60 | throw new Error('SegmentStream snippet included twice.') 61 | } 62 | 63 | ssApi.invoked = true 64 | 65 | ssApi.methods = [ 66 | 'initialize', 67 | 'track', 68 | 'getData', 69 | 'getAnonymousId', 70 | 'pushOnReady' 71 | ] 72 | 73 | ssApi.factory = method => function stub (...args) { 74 | args.unshift(method) 75 | ssApi.push(args) 76 | return ssApi 77 | } 78 | 79 | for (let i = 0; i < ssApi.methods.length; i += 1) { 80 | const key = ssApi.methods[i] 81 | ssApi[key] = ssApi.factory(key) 82 | } 83 | 84 | ssApi.initialize(this._options) 85 | this.enrichDigitalData() 86 | } 87 | 88 | isLoaded () { 89 | return !!(window.ssApi && !Array.isArray(window.ssApi)) 90 | } 91 | 92 | reset () { 93 | deleteProperty(window, 'ssApi') 94 | window.localStorage.clear() 95 | } 96 | 97 | enrichDigitalData () { 98 | function lowercaseFirstLetter (string) { 99 | return string.charAt(0).toLowerCase() + string.slice(1) 100 | } 101 | 102 | window.ssApi.pushOnReady(() => { 103 | const { attributes } = window.ssApi.getData() 104 | const ssAttributes = {} 105 | each(attributes, (name, value) => { 106 | const key = lowercaseFirstLetter(name) 107 | ssAttributes[key] = value 108 | }) 109 | 110 | this.digitalData.user.anonymousId = getProp(this.digitalData, 'user.anonymousId') || window.ssApi.getAnonymousId() 111 | this.digitalData.user.ssAttributes = ssAttributes 112 | this.onEnrich() 113 | }) 114 | } 115 | 116 | trackEvent (event) { 117 | const methods = { 118 | [VIEWED_PAGE]: 'onViewedPage', 119 | [VIEWED_PRODUCT_DETAIL]: 'onViewedProductDetail', 120 | [ADDED_PRODUCT]: 'onAddedProduct' 121 | } 122 | 123 | const method = methods[event.name] 124 | if (method) { 125 | this[method](event) 126 | } 127 | } 128 | 129 | onViewedPage () { 130 | window.ssApi.pushOnReady(() => { 131 | window.ssApi.track('Viewed Page') 132 | this.enrichDigitalData() 133 | }) 134 | } 135 | 136 | onViewedProductDetail (event) { 137 | window.ssApi.pushOnReady(() => { 138 | window.ssApi.track('Viewed Product Detail', { 139 | price: event.product.unitSalePrice || event.product.unitPrice || 0 140 | }) 141 | this.enrichDigitalData() 142 | }) 143 | } 144 | 145 | onAddedProduct (event) { 146 | window.ssApi.pushOnReady(() => { 147 | window.ssApi.track('Added Product', { 148 | price: event.product.unitSalePrice || event.product.unitPrice || 0 149 | }) 150 | this.enrichDigitalData() 151 | }) 152 | } 153 | } 154 | 155 | export default SegmentStream 156 | -------------------------------------------------------------------------------- /src/trackers/trackImpression.js: -------------------------------------------------------------------------------- 1 | import getStyle from '@segmentstream/utils/getStyle' 2 | import domQuery from '@segmentstream/utils/domQuery' 3 | 4 | class Batch { 5 | constructor (handler) { 6 | this.blocks = [] 7 | this.viewedBlocks = [] 8 | this.handler = handler 9 | } 10 | 11 | addViewedBlock (block) { 12 | this.viewedBlocks.push(block) 13 | } 14 | 15 | isViewedBlock (block) { 16 | return !(this.viewedBlocks.indexOf(block) < 0) 17 | } 18 | 19 | setBlocks (blocks) { 20 | this.blocks = blocks 21 | } 22 | } 23 | 24 | class BatchTable { 25 | constructor () { 26 | this.selectors = [] 27 | this.batches = {} 28 | } 29 | 30 | add (selector, handler) { 31 | if (this.selectors.indexOf(selector) < 0) { 32 | this.selectors.push(selector) 33 | this.batches[selector] = [] 34 | } 35 | 36 | const batch = new Batch(handler) 37 | this.batches[selector].push(batch) 38 | } 39 | 40 | update () { 41 | this.selectors.forEach((selector) => { 42 | const batches = this.batches[selector] 43 | const blocks = (window.jQuery) ? window.jQuery(selector).get() : domQuery(selector) 44 | batches.forEach((batch) => { 45 | batch.setBlocks(blocks) 46 | }) 47 | }) 48 | } 49 | 50 | getAll () { 51 | let allBatches = [] 52 | this.selectors.forEach((selector) => { 53 | const batches = this.batches[selector] 54 | allBatches = [...allBatches, ...batches] 55 | }) 56 | 57 | return allBatches 58 | } 59 | } 60 | 61 | const batchTable = new BatchTable() 62 | 63 | let isStarted = false 64 | 65 | /** 66 | * Returns true if element is visible by css 67 | * and at least 3/4 of the element fit user viewport 68 | * 69 | * @param el DOMElement 70 | * @returns boolean 71 | */ 72 | function isVisible (el) { 73 | const docEl = window.document.documentElement 74 | 75 | const elemWidth = el.clientWidth 76 | const elemHeight = el.clientHeight 77 | 78 | const elemTop = el.getBoundingClientRect().top 79 | const elemBottom = elemTop + elemHeight 80 | const elemLeft = el.getBoundingClientRect().left 81 | const elemRight = elemLeft + elemWidth 82 | 83 | const visible = !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) && 84 | Number(getStyle(el, 'opacity')) > 0 && getStyle(el, 'visibility') !== 'hidden' 85 | if (!visible) { 86 | return false 87 | } 88 | 89 | const fitsVertical = ( 90 | ((elemBottom - (elemHeight / 4)) <= docEl.clientHeight) && 91 | ((elemTop + (elemHeight / 4)) >= 0) 92 | ) 93 | 94 | const fitsHorizontal = ( 95 | (elemLeft + (elemWidth / 4) >= 0) && 96 | (elemRight - (elemWidth / 4) <= docEl.clientWidth) 97 | ) 98 | 99 | if (!fitsVertical || !fitsHorizontal) { 100 | return false 101 | } 102 | 103 | let elementFromPoint = document.elementFromPoint( 104 | elemLeft + (elemWidth / 2), 105 | elemTop + (elemHeight / 2) 106 | ) 107 | 108 | while (elementFromPoint && elementFromPoint !== el && elementFromPoint.parentNode !== document) { 109 | elementFromPoint = elementFromPoint.parentNode 110 | } 111 | return (!!elementFromPoint && elementFromPoint === el) 112 | } 113 | 114 | function trackViews () { 115 | batchTable.update() 116 | 117 | const batches = batchTable.getAll() 118 | batches.forEach((batch) => { 119 | const newViewedBlocks = [] 120 | 121 | const { blocks } = batch 122 | blocks.forEach((block) => { 123 | if (isVisible(block) && !batch.isViewedBlock(block)) { 124 | newViewedBlocks.push(block) 125 | batch.addViewedBlock(block) 126 | } 127 | }) 128 | 129 | if (newViewedBlocks.length > 0) { 130 | try { 131 | batch.handler(newViewedBlocks) 132 | } catch (error) { 133 | // TODO 134 | } 135 | } 136 | }) 137 | } 138 | 139 | function startTracking () { 140 | trackViews() 141 | setInterval(() => { 142 | trackViews() 143 | }, 500) 144 | } 145 | 146 | export default function trackImpression (selector, handler) { 147 | if (!selector) return 148 | 149 | if (typeof handler !== 'function') { 150 | throw new TypeError('Must pass function handler to `ddManager.trackImpression`.') 151 | } 152 | 153 | batchTable.add(selector, handler) 154 | 155 | if (!isStarted) { 156 | isStarted = true 157 | startTracking() 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /test/integrations/Mindbox/stubs/v3/onRegistered.stub.js: -------------------------------------------------------------------------------- 1 | const onRegisteredUserStub = { 2 | userId: 'user123', 3 | authenticationTicket: 'xxxxx', 4 | email: 'test@driveback.ru', 5 | phone: '79374134389', 6 | firstName: 'John', 7 | lastName: 'Dow', 8 | childrenNames: ['Helen', 'Bob'], 9 | city: 'Moscow', 10 | b2b: true 11 | } 12 | 13 | const onRegisteredRegistrationStub = { 14 | operation: 'Registration', 15 | data: { 16 | customer: { 17 | ids: { 18 | bitrixId: 'user123' 19 | }, 20 | firstName: 'John', 21 | lastName: 'Dow', 22 | email: 'test@driveback.ru', 23 | mobilePhone: '79374134389', 24 | customFields: { 25 | source: 'Driveback', 26 | city: 'Moscow', 27 | b2b: true, 28 | childrenNames: [ 29 | 'Helen', 30 | 'Bob' 31 | ] 32 | } 33 | } 34 | } 35 | } 36 | 37 | const onRegisteredRegistrationCustomStub = { 38 | operation: 'RegistrationCustom', 39 | data: { 40 | customer: { 41 | ids: { 42 | bitrixId: 'user123' 43 | }, 44 | firstName: 'John', 45 | lastName: 'Dow', 46 | email: 'test@driveback.ru', 47 | mobilePhone: '79374134389', 48 | customFields: { 49 | source: 'Driveback', 50 | city: 'Moscow', 51 | b2b: true, 52 | childrenNames: [ 53 | 'Helen', 54 | 'Bob' 55 | ] 56 | } 57 | } 58 | } 59 | } 60 | 61 | const onRegisteredRegistrationWithSubscriptionLegacyStub = { 62 | operation: 'Registration', 63 | data: { 64 | customer: { 65 | ids: { 66 | bitrixId: 'user123' 67 | }, 68 | firstName: 'John', 69 | lastName: 'Dow', 70 | email: 'test@driveback.ru', 71 | mobilePhone: '79374134389', 72 | customFields: { 73 | source: 'Driveback', 74 | city: 'Moscow', 75 | b2b: true, 76 | childrenNames: [ 77 | 'Helen', 78 | 'Bob' 79 | ] 80 | }, 81 | subscriptions: [ 82 | { 83 | pointOfContact: 'Email', 84 | isSubscribed: true, 85 | valueByDefault: true 86 | }, 87 | { 88 | pointOfContact: 'Sms', 89 | isSubscribed: true, 90 | valueByDefault: true 91 | } 92 | ] 93 | } 94 | } 95 | } 96 | 97 | const onRegisteredRegistrationWithSubscriptionStub = { 98 | operation: 'Registration', 99 | data: { 100 | customer: { 101 | ids: { 102 | bitrixId: 'user123' 103 | }, 104 | firstName: 'John', 105 | lastName: 'Dow', 106 | email: 'test@driveback.ru', 107 | mobilePhone: '79374134389', 108 | customFields: { 109 | source: 'Driveback', 110 | city: 'Moscow', 111 | b2b: true, 112 | childrenNames: [ 113 | 'Helen', 114 | 'Bob' 115 | ] 116 | } 117 | } 118 | } 119 | } 120 | 121 | const onRegisteredRegistrationWithMassSubscriptionsStub = { 122 | operation: 'Registration', 123 | data: { 124 | customer: { 125 | ids: { 126 | bitrixId: 'user123' 127 | }, 128 | firstName: 'John', 129 | lastName: 'Dow', 130 | email: 'test@driveback.ru', 131 | mobilePhone: '79374134389', 132 | customFields: { 133 | source: 'Driveback', 134 | city: 'Moscow', 135 | b2b: true, 136 | childrenNames: [ 137 | 'Helen', 138 | 'Bob' 139 | ] 140 | }, 141 | subscriptions: [ 142 | { 143 | pointOfContact: 'Email', 144 | topic: 'News', 145 | isSubscribed: true, 146 | valueByDefault: true 147 | }, 148 | { 149 | pointOfContact: 'Email', 150 | topic: 'Offers', 151 | isSubscribed: true, 152 | valueByDefault: true 153 | }, 154 | { 155 | pointOfContact: 'Sms', 156 | isSubscribed: true, 157 | valueByDefault: true 158 | } 159 | ] 160 | } 161 | } 162 | } 163 | 164 | export { 165 | onRegisteredUserStub, 166 | onRegisteredRegistrationStub, 167 | onRegisteredRegistrationCustomStub, 168 | onRegisteredRegistrationWithSubscriptionStub, 169 | onRegisteredRegistrationWithMassSubscriptionsStub, 170 | onRegisteredRegistrationWithSubscriptionLegacyStub 171 | } 172 | --------------------------------------------------------------------------------