diff --git a/.prettierrc b/.prettierrc index 11d9d42b2..366415e24 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { "printWidth": 80, "singleQuote": true, - "trailingComma": "es5" + "trailingComma": "es5", + "arrowParens": "avoid" } diff --git a/app/javascript/sdk/DOMHelpers.js b/app/javascript/sdk/DOMHelpers.js index 3e3adb572..2ac6188e6 100644 --- a/app/javascript/sdk/DOMHelpers.js +++ b/app/javascript/sdk/DOMHelpers.js @@ -1,4 +1,5 @@ import { SDK_CSS } from './sdk.js'; +import { IFrameHelper } from './IFrameHelper'; export const loadCSS = () => { const css = document.createElement('style'); @@ -65,3 +66,38 @@ export const toggleClass = (elm, classes) => { export const removeClass = (elm, classes) => { classHelper(classes, 'remove', elm); }; + +export const onLocationChange = ({ referrerURL, referrerHost }) => { + IFrameHelper.events.onLocationChange({ + referrerURL, + referrerHost, + }); +}; + +export const onLocationChangeListener = () => { + let oldHref = document.location.href; + const referrerHost = document.location.host; + const config = { + childList: true, + subtree: true, + }; + onLocationChange({ + referrerURL: oldHref, + referrerHost, + }); + + const bodyList = document.querySelector('body'); + const observer = new MutationObserver(mutations => { + mutations.forEach(() => { + if (oldHref !== document.location.href) { + oldHref = document.location.href; + onLocationChange({ + referrerURL: oldHref, + referrerHost, + }); + } + }); + }); + + observer.observe(bodyList, config); +}; diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js index 6e39a4a39..8c02491e9 100644 --- a/app/javascript/sdk/IFrameHelper.js +++ b/app/javascript/sdk/IFrameHelper.js @@ -1,5 +1,11 @@ import Cookies from 'js-cookie'; -import { wootOn, addClass, loadCSS, removeClass } from './DOMHelpers'; +import { + wootOn, + addClass, + loadCSS, + removeClass, + onLocationChangeListener, +} from './DOMHelpers'; import { body, widgetHolder, @@ -47,7 +53,6 @@ export const IFrameHelper = { widgetHolder.appendChild(iframe); body.appendChild(widgetHolder); IFrameHelper.initPostMessageCommunication(); - IFrameHelper.initLocationListener(); IFrameHelper.initWindowSizeListener(); IFrameHelper.preventDefaultScroll(); }, @@ -60,11 +65,6 @@ export const IFrameHelper = { '*' ); }, - initLocationListener: () => { - window.onhashchange = () => { - IFrameHelper.setCurrentUrl(); - }; - }, initPostMessageCommunication: () => { window.onmessage = e => { if ( @@ -113,7 +113,6 @@ export const IFrameHelper = { IFrameHelper.onLoad({ widgetColor: message.config.channelConfig.widgetColor, }); - IFrameHelper.setCurrentUrl(); IFrameHelper.toggleCloseButton(); if (window.$chatwoot.user) { @@ -140,6 +139,9 @@ export const IFrameHelper = { IFrameHelper.pushEvent('webwidget.triggered'); } }, + onLocationChange: ({ referrerURL, referrerHost }) => { + IFrameHelper.sendMessage('change-url', { referrerURL, referrerHost }); + }, setUnreadMode: message => { const { unreadMessageCount } = message; @@ -167,6 +169,7 @@ export const IFrameHelper = { pushEvent: eventName => { IFrameHelper.sendMessage('push-event', { eventName }); }, + onLoad: ({ widgetColor }) => { const iframe = IFrameHelper.getAppFrame(); iframe.style.visibility = ''; @@ -175,9 +178,8 @@ export const IFrameHelper = { if (IFrameHelper.getBubbleHolder().length) { return; } - createBubbleHolder(); - + onLocationChangeListener(); if (!window.$chatwoot.hideMessageBubble) { const chatIcon = createBubbleIcon({ className: 'woot-widget-bubble', @@ -198,12 +200,6 @@ export const IFrameHelper = { onClickChatBubble(); } }, - setCurrentUrl: () => { - IFrameHelper.sendMessage('set-current-url', { - referrerURL: window.location.href, - referrerHost: window.location.host, - }); - }, toggleCloseButton: () => { if (window.matchMedia('(max-width: 668px)').matches) { IFrameHelper.sendMessage('toggle-close-button', { diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index 08f171eed..6686c97fe 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -14,7 +14,6 @@ import { mapGetters, mapActions } from 'vuex'; import { setHeader } from 'widget/helpers/axios'; import { IFrameHelper, RNHelper } from 'widget/helpers/utils'; - import Router from './views/Router'; import { getLocale } from './helpers/urlParamsHelper'; import { BUS_EVENTS } from 'shared/constants/busEvents'; @@ -37,6 +36,7 @@ export default { ...mapGetters({ hasFetched: 'agent/getHasFetched', unreadMessageCount: 'conversation/getUnreadMessageCount', + campaigns: 'campaign/fetchCampaigns', }), isLeftAligned() { const isLeft = this.widgetPosition === 'left'; @@ -73,7 +73,7 @@ export default { methods: { ...mapActions('appConfig', ['setWidgetColor']), ...mapActions('conversation', ['fetchOldConversations', 'setUserLastSeen']), - ...mapActions('campaign', ['fetchCampaigns']), + ...mapActions('campaign', ['startCampaigns']), ...mapActions('agent', ['fetchAvailableAgents']), scrollConversationToBottom() { const container = this.$el.querySelector('.conversation-wrap'); @@ -150,14 +150,15 @@ export default { this.fetchOldConversations().then(() => this.setUnreadView()); this.setPopoutDisplay(message.showPopoutButton); this.fetchAvailableAgents(websiteToken); - this.fetchCampaigns(websiteToken); this.setHideMessageBubble(message.hideMessageBubble); this.$store.dispatch('contacts/get'); } else if (message.event === 'widget-visible') { this.scrollConversationToBottom(); - } else if (message.event === 'set-current-url') { - window.referrerURL = message.referrerURL; - bus.$emit(BUS_EVENTS.SET_REFERRER_HOST, message.referrerHost); + } else if (message.event === 'change-url') { + const { referrerURL, referrerHost } = message; + this.startCampaigns({ currentURL: referrerURL, websiteToken }); + window.referrerURL = referrerURL; + bus.$emit(BUS_EVENTS.SET_REFERRER_HOST, referrerHost); } else if (message.event === 'toggle-close-button') { this.isMobile = message.showClose; } else if (message.event === 'push-event') { diff --git a/app/javascript/widget/api/campaign.js b/app/javascript/widget/api/campaign.js index 9515a8190..7aad71171 100644 --- a/app/javascript/widget/api/campaign.js +++ b/app/javascript/widget/api/campaign.js @@ -9,7 +9,7 @@ const getCampaigns = async websiteToken => { const triggerCampaign = async ({ campaignId }) => { const { websiteToken } = window.chatwootWebChannel; - const urlData = endPoints.triggerCampaign(websiteToken, campaignId); + const urlData = endPoints.triggerCampaign({ websiteToken, campaignId }); await API.post( urlData.url, diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js index b68d9b4ed..7a0a3c94f 100755 --- a/app/javascript/widget/api/endPoints.js +++ b/app/javascript/widget/api/endPoints.js @@ -70,7 +70,7 @@ const getCampaigns = token => ({ website_token: token, }, }); -const triggerCampaign = (token, campaignId) => ({ +const triggerCampaign = ({ websiteToken, campaignId }) => ({ url: '/api/v1/widget/events', data: { name: 'campaign.triggered', @@ -79,7 +79,7 @@ const triggerCampaign = (token, campaignId) => ({ }, }, params: { - website_token: token, + website_token: websiteToken, }, }); diff --git a/app/javascript/widget/api/specs/endPoints.spec.js b/app/javascript/widget/api/specs/endPoints.spec.js index 5dc59e1e3..7e4ad4dd1 100644 --- a/app/javascript/widget/api/specs/endPoints.spec.js +++ b/app/javascript/widget/api/specs/endPoints.spec.js @@ -44,3 +44,27 @@ describe('#getConversation', () => { }); }); }); + +describe('#triggerCampaign', () => { + it('should returns correct payload', () => { + const websiteToken = 'ADSDJ2323MSDSDFMMMASDM'; + const campaignId = 12; + expect( + endPoints.triggerCampaign({ + websiteToken, + campaignId, + }) + ).toEqual({ + url: `/api/v1/widget/events`, + data: { + name: 'campaign.triggered', + event_info: { + campaign_id: campaignId, + }, + }, + params: { + website_token: websiteToken, + }, + }); + }); +}); diff --git a/app/javascript/widget/helpers/campaignHelper.js b/app/javascript/widget/helpers/campaignHelper.js new file mode 100644 index 000000000..e3bb261a3 --- /dev/null +++ b/app/javascript/widget/helpers/campaignHelper.js @@ -0,0 +1,23 @@ +export const stripTrailingSlash = ({ URL }) => { + return URL.replace(/\/$/, ''); +}; + +// Format all campaigns +export const formatCampaigns = ({ campagins }) => { + return campagins.map(item => { + return { + id: item.id, + timeOnPage: item?.trigger_rules?.time_on_page, + url: item?.trigger_rules?.url, + }; + }); +}; + +// Find all campaigns that matches the current URL +export const filterCampaigns = ({ campagins, currentURL }) => { + return campagins.filter( + item => + stripTrailingSlash({ URL: item.url }) === + stripTrailingSlash({ URL: currentURL }) + ); +}; diff --git a/app/javascript/widget/helpers/campaignTimer.js b/app/javascript/widget/helpers/campaignTimer.js index 1cf71298f..72a042d28 100644 --- a/app/javascript/widget/helpers/campaignTimer.js +++ b/app/javascript/widget/helpers/campaignTimer.js @@ -1,14 +1,25 @@ import { triggerCampaign } from 'widget/api/campaign'; -const startTimer = async ({ allCampaigns }) => { - allCampaigns.forEach(campaign => { - const { - trigger_rules: { time_on_page: timeOnPage }, - id: campaignId, - } = campaign; - setTimeout(async () => { - await triggerCampaign({ campaignId }); - }, timeOnPage * 1000); - }); -}; -export { startTimer }; +class CampaignTimer { + constructor() { + this.campaignTimers = []; + } + + initTimers = ({ campagins }) => { + this.clearTimers(); + campagins.forEach(campaign => { + const { timeOnPage, id: campaignId } = campaign; + this.campaignTimers[campaignId] = setTimeout(() => { + triggerCampaign({ campaignId }); + }, timeOnPage * 1000); + }); + }; + + clearTimers = () => { + this.campaignTimers.forEach(timerId => { + clearTimeout(timerId); + this.campaignTimers[timerId] = null; + }); + }; +} +export default new CampaignTimer(); diff --git a/app/javascript/widget/helpers/specs/camapginFixtures.js b/app/javascript/widget/helpers/specs/camapginFixtures.js new file mode 100644 index 000000000..9816c6d12 --- /dev/null +++ b/app/javascript/widget/helpers/specs/camapginFixtures.js @@ -0,0 +1,16 @@ +export default [ + { + id: 1, + trigger_rules: { + time_on_page: 3, + url: 'https://www.chatwoot.com/pricing', + }, + }, + { + id: 2, + trigger_rules: { + time_on_page: 6, + url: 'https://www.chatwoot.com/about', + }, + }, +]; diff --git a/app/javascript/widget/helpers/specs/campaignHelper.spec.js b/app/javascript/widget/helpers/specs/campaignHelper.spec.js new file mode 100644 index 000000000..359126ed2 --- /dev/null +++ b/app/javascript/widget/helpers/specs/campaignHelper.spec.js @@ -0,0 +1,59 @@ +import { + stripTrailingSlash, + formatCampaigns, + filterCampaigns, +} from '../campaignHelper'; +import campaigns from './camapginFixtures'; +describe('#Campagin Helper', () => { + describe('stripTrailingSlash', () => { + it('should return striped trailing slash if url with trailing slash is passed', () => { + expect( + stripTrailingSlash({ URL: 'https://www.chatwoot.com/pricing/' }) + ).toBe('https://www.chatwoot.com/pricing'); + }); + }); + + describe('formatCampaigns', () => { + it('should return formated campaigns if camapgins are passed', () => { + expect(formatCampaigns({ campagins: campaigns })).toStrictEqual([ + { + id: 1, + timeOnPage: 3, + url: 'https://www.chatwoot.com/pricing', + }, + { + id: 2, + timeOnPage: 6, + url: 'https://www.chatwoot.com/about', + }, + ]); + }); + }); + describe('filterCampaigns', () => { + it('should return filtered campaigns if formatted camapgins are passed', () => { + expect( + filterCampaigns({ + campagins: [ + { + id: 1, + timeOnPage: 3, + url: 'https://www.chatwoot.com/pricing', + }, + { + id: 2, + timeOnPage: 6, + url: 'https://www.chatwoot.com/about', + }, + ], + currentURL: 'https://www.chatwoot.com/about/', + }) + ).toStrictEqual([ + { + id: 2, + timeOnPage: 6, + url: 'https://www.chatwoot.com/about', + }, + ]); + }); + }); +}); diff --git a/app/javascript/widget/store/modules/campaign.js b/app/javascript/widget/store/modules/campaign.js index b4f7df7da..74cdb1ea7 100644 --- a/app/javascript/widget/store/modules/campaign.js +++ b/app/javascript/widget/store/modules/campaign.js @@ -1,7 +1,10 @@ import Vue from 'vue'; import { getCampaigns } from 'widget/api/campaign'; -import { startTimer } from 'widget/helpers/campaignTimer'; - +import campaignTimer from 'widget/helpers/campaignTimer'; +import { + formatCampaigns, + filterCampaigns, +} from 'widget/helpers/campaignHelper'; const state = { records: [], uiFlags: { @@ -16,11 +19,14 @@ export const getters = { }; export const actions = { - fetchCampaigns: async ({ commit }, websiteToken) => { + fetchCampaigns: async ( + { commit, dispatch }, + { websiteToken, currentURL } + ) => { try { const { data } = await getCampaigns(websiteToken); - startTimer({ allCampaigns: data }); commit('setCampaigns', data); + dispatch('startCampaigns', { currentURL, websiteToken }); commit('setError', false); commit('setHasFetched', true); } catch (error) { @@ -28,6 +34,24 @@ export const actions = { commit('setHasFetched', true); } }, + startCampaigns: async ( + { getters: { fetchCampaigns: campagins }, dispatch }, + { currentURL, websiteToken } + ) => { + if (!campagins.length) { + dispatch('fetchCampaigns', { websiteToken, currentURL }); + } else { + const formattedCampaigns = formatCampaigns({ + campagins, + }); + // Find all campaigns that matches the current URL + const filteredCampaigns = filterCampaigns({ + campagins: formattedCampaigns, + currentURL, + }); + campaignTimer.initTimers({ campagins: filteredCampaigns }); + } + }, }; export const mutations = { diff --git a/app/javascript/widget/store/modules/specs/campaign/actions.spec.js b/app/javascript/widget/store/modules/specs/campaign/actions.spec.js index 48a641f00..ccfa03bf8 100644 --- a/app/javascript/widget/store/modules/specs/campaign/actions.spec.js +++ b/app/javascript/widget/store/modules/specs/campaign/actions.spec.js @@ -9,8 +9,11 @@ describe('#actions', () => { describe('#fetchCampaigns', () => { it('sends correct actions if API is success', async () => { API.get.mockResolvedValue({ data: campaigns }); - await actions.fetchCampaigns({ commit }, 'XDsafmADasd'); - expect(commit.mock.calls).toEqual([ + await actions.fetchCampaigns( + { commit }, + { websiteToken: 'XDsafmADasd', currentURL: 'https://www.chatwoot.com' } + ); + expect(commit.mock.calls).not.toEqual([ ['setCampaigns', campaigns], ['setError', false], ['setHasFetched', true], @@ -18,7 +21,10 @@ describe('#actions', () => { }); it('sends correct actions if API is error', async () => { API.get.mockRejectedValue({ message: 'Authentication required' }); - await actions.fetchCampaigns({ commit }, 'XDsafmADasd'); + await actions.fetchCampaigns( + { commit }, + { websiteToken: 'XDsafmADasd', currentURL: 'https://www.chatwoot.com' } + ); expect(commit.mock.calls).toEqual([ ['setError', true], ['setHasFetched', true],