Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Journeys In-App Plugin #6

Merged
merged 10 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@segment/analytics.js-video-plugins": "^0.2.1",
"@segment/facade": "^3.4.9",
"@segment/tsub": "1.0.1",
"customerio-gist-web": "^3.6.9",
"dset": "^3.1.2",
"js-cookie": "3.0.1",
"node-fetch": "^2.6.7",
Expand Down
13 changes: 13 additions & 0 deletions packages/browser/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { popSnippetWindowBuffer } from '../core/buffer/snippet'
import { ClassicIntegrationSource } from '../plugins/ajs-destination/types'
import { attachInspector } from '../core/inspector'
import { Stats } from '../core/stats'
import { InAppPluginSettings } from '../plugins/in-app-plugin'

export interface LegacyIntegrationConfiguration {
/* @deprecated - This does not indicate browser types anymore */
Expand Down Expand Up @@ -207,6 +208,14 @@ async function registerPlugins(
tsubMiddleware
).catch(() => [])

const inAppPlugin = options.integrations?.['Customer.io In-App Plugin'] as InAppPluginSettings
? await import(
/* webpackChunkName: "inAppPlugin" */ '../plugins/in-app-plugin'
).then((mod) => {
return mod.InAppPlugin(options.integrations?.['Customer.io In-App Plugin'] as InAppPluginSettings)
})
: undefined

const toRegister = [
validation,
pageEnrichment,
Expand All @@ -219,6 +228,10 @@ async function registerPlugins(
toRegister.push(schemaFilter)
}

if (inAppPlugin) {
toRegister.push(inAppPlugin)
}

const shouldIgnore =
(opts.integrations?.All === false &&
!opts.integrations['Customer.io Data Pipelines']) ||
Expand Down
131 changes: 131 additions & 0 deletions packages/browser/src/plugins/in-app-plugin/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Analytics } from '../../../core/analytics'
import { pageEnrichment } from '../../page-enrichment'
import { CustomerioSettings } from '../../customerio'
import { InAppPlugin, InAppPluginSettings } from '../'
import Gist from 'customerio-gist-web'

describe('Customer.io In-App Plugin', () => {
let analytics: Analytics
let gistMessageShown: Function
let gistMessageAction: Function

beforeEach(async () => {
if(typeof analytics !== 'undefined') {
analytics.reset()
}

jest.resetAllMocks()
jest.restoreAllMocks()

Gist.setup = jest.fn()
Gist.clearUserToken = jest.fn()
Gist.setUserToken = jest.fn()
Gist.setCurrentRoute = jest.fn()
Gist.events = {
on: (name: string, cb: Function) => {
if(name === 'messageShown') {
gistMessageShown = cb
} else if(name === 'messageAction') {
gistMessageAction = cb
}
},
off: jest.fn(),
}

const options: CustomerioSettings = { apiKey: 'foo' }
analytics = new Analytics({ writeKey: options.apiKey })

await analytics.register(InAppPlugin({ siteId: 'siteid'} as InAppPluginSettings), pageEnrichment)
})

it('should setup gist with defaults', async () => {
expect(Gist.setup).toBeCalledTimes(1)
expect(Gist.setup).toBeCalledWith({
env: 'prod',
logging: undefined,
siteId: 'siteid',
})
// We should clear old gist tokens on setup if we're anonymous
expect(Gist.clearUserToken).toBeCalledTimes(1)
})

it('should set gist route on page()', async () => {
await analytics.page('testpage')
expect(Gist.setCurrentRoute).toBeCalledWith('testpage')
})

it('should set gist userToken on identify()', async () => {
await analytics.identify('[email protected]')
expect(Gist.setUserToken).toBeCalledTimes(1)
expect(Gist.setUserToken).toBeCalledWith('[email protected]')
})

it('should clear gist userToken on reset()', async () => {
// Once during setup
expect(Gist.clearUserToken).toBeCalledTimes(1)

await analytics.identify('[email protected]')
expect(Gist.setUserToken).toBeCalledTimes(1)
expect(Gist.setUserToken).toBeCalledWith('[email protected]')

await analytics.reset()

// Once after reset()
expect(Gist.clearUserToken).toBeCalledTimes(2)
})

it('should trigger journey event for open', async () => {
const spy = jest.spyOn(analytics, 'track')
gistMessageShown({
properties: {
gist: {
campaignId: 'testcampaign',
},
},
})
expect(spy).toBeCalledWith('Report Delivery Event', {
deliveryId: 'testcampaign',
metric: 'opened',
})
})

it('should trigger journey event for non-dismiss click', async () => {
const spy = jest.spyOn(analytics, 'track')
gistMessageAction({
message: {
properties: {
messageId: 'a-test-in-app',
gist: {
campaignId: 'testcampaign',
},
},
},
action: 'action value',
name: 'action name',
})
expect(spy).toBeCalledWith('Report Delivery Event', {
deliveryId: 'testcampaign',
metric: 'clicked',
actionName: 'action name',
actionValue: 'action value',
})
})

it('should not trigger journey event for dismiss click', async () => {
const spy = jest.spyOn(analytics, 'track')
gistMessageAction({
message: {
properties: {
messageId: 'a-test-in-app',
gist: {
campaignId: 'testcampaign',
},
},
},
action: 'gist://close',
name: 'action name',
})
expect(spy).toHaveBeenCalledTimes(0)
})

})
33 changes: 33 additions & 0 deletions packages/browser/src/plugins/in-app-plugin/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export enum InAppEvents {
MessageOpened = 'in-app:message-opened',
MessageDismissed = 'in-app:message-dismissed',
MessageError = 'in-app:message-error',
MessageAction = 'in-app:message-action'
}

export const allEvents:string[] = Object.values(InAppEvents);

export enum SemanticEvents {
JourneyMetric = 'Report Delivery Event',
Opened = 'opened',
Clicked = 'clicked',
}

export function newEvent(type:string, detail:any): CustomEvent {
return new CustomEvent(type, { detail })
}

export function gistToCIO(gistEvent:string): string {
switch (gistEvent) {
case 'messageShown':
return InAppEvents.MessageOpened;
case 'messageDismissed':
return InAppEvents.MessageDismissed;
case 'messageError':
return InAppEvents.MessageError;
case 'messageAction':
return InAppEvents.MessageAction;
default:
return "";
}
}
156 changes: 156 additions & 0 deletions packages/browser/src/plugins/in-app-plugin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Analytics } from '../../core/analytics'
import { Context } from '../../core/context'
import { Plugin } from '../../core/plugin'

import { InAppEvents, SemanticEvents, newEvent, allEvents, gistToCIO } from './events'
import Gist from 'customerio-gist-web'

export { InAppEvents }

export type InAppPluginSettings = {
siteId: string | undefined
events: EventListenerOrEventListenerObject | null | undefined

_env: string | undefined
_logging: boolean | undefined
}

export function InAppPlugin(
settings: InAppPluginSettings,
): Plugin {

let _analytics: Analytics;
let _gistLoaded:boolean = false;
let _pluginLoaded:boolean = false;
const _eventTarget:EventTarget = new EventTarget();

function attachListeners() {
if(!_gistLoaded || _pluginLoaded)
return;

_analytics.on('reset', reset);

if(settings.events) {
allEvents.forEach((event) => {
_eventTarget.addEventListener(event, settings?.events as EventListenerOrEventListenerObject);
});
['messageShown', 'messageDismissed', 'messageError'].forEach((event) => {
Gist.events.on(event, (message: any) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the gist sdk have the equivalent of an "unload" function? (to deregister these events)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed this offline, Gist isn't actually using event handlers, just an array of callback functions to call. So we think this is ok.

_eventTarget.dispatchEvent(newEvent(gistToCIO(event), {
messageId: message.messageId,
deliveryId: message.properties?.gist?.campaignId,
}));
});
});
}

Gist.events.on('messageShown', (message: any) => {
const deliveryId:string = message?.properties?.gist?.campaignId;
if (typeof deliveryId != 'undefined' && deliveryId != '') {
_analytics.track(SemanticEvents.JourneyMetric, {
'deliveryId': deliveryId,
'metric': SemanticEvents.Opened,
});
}
});

Gist.events.on('messageAction', (params: any) => {
const deliveryId:string = params?.message?.properties?.gist?.campaignId;
if (params.action != 'gist://close' && typeof deliveryId != 'undefined' && deliveryId != '') {
_analytics.track(SemanticEvents.JourneyMetric, {
'deliveryId': deliveryId,
'metric': SemanticEvents.Clicked,
'actionName': params.name,
'actionValue': params.action,
});
}
settings.events && _eventTarget.dispatchEvent(newEvent(InAppEvents.MessageAction, {
messageId: params.message.messageId,
deliveryId: deliveryId,
action: params.action,
name: params.name,
actionName: params.name,
actionValue: params.action,
message:{
dismiss: function() {
Gist.dismissMessage(params?.message?.instanceId);
}
}
}));
});
}

async function page(ctx: Context): Promise<Context> {
if(!_pluginLoaded)
return ctx;

const page:string = ctx.event?.properties?.name ?? ctx.event?.properties?.url;
if(typeof page === 'string' && page.length > 0) {
Gist.setCurrentRoute(page);
}

return ctx;
}

async function reset(ctx: Context): Promise<Context> {
await Gist.clearUserToken();
return ctx;
}

async function syncUserToken(ctx: Context): Promise<Context> {
if(!_gistLoaded)
return ctx;

const user = _analytics.user().id();
if (typeof user === 'string' && user.length > 0) {
await Gist.setUserToken(user);
} else {
await Gist.clearUserToken();
}
return ctx;
}

const customerio: Plugin = {
name: 'Customer.io In-App Plugin',
type: 'before',
version: '0.0.1',
isLoaded: (): boolean => _pluginLoaded,
load: async (ctx: Context, instance: Analytics) => {
clabland marked this conversation as resolved.
Show resolved Hide resolved
_analytics = instance;

if(settings.siteId == null || settings.siteId == "") {
_error("siteId is required. Can't initialize.")
return ctx;
}

await Gist.setup({
siteId: settings.siteId,
env: settings._env? settings._env : "prod",
logging: settings._logging,
});
_gistLoaded = true;

await syncUserToken(ctx);
attachListeners();

_pluginLoaded = true;

return Promise.resolve();
},
identify: syncUserToken,
page: page,
unload: async () => {
if(settings.events) {
allEvents.forEach((event) => {
_eventTarget.removeEventListener(event, settings?.events as EventListenerOrEventListenerObject);
});
}
},
}

return customerio;
}

function _error(msg: string) {
console.error(`[Customer.io In-App Plugin] ${msg}`)
}
1 change: 1 addition & 0 deletions packages/browser/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const plugins = [
}),
new CircularDependencyPlugin({
failOnError: true,
exclude: /customerio-gist-web/,
}),
]

Expand Down
Loading
Loading