feat: Add campaigns in web widget (#2227)
* add campaign store(getter, actions and mutations) * add campaign store module * add get campaigns api * add fetch campaign action widget load * add specs * code cleanup * trigger campaig api fixes * integrate campaign trigger action * code cleanup * revert changes * trigger api fixes * review fixes * code beautification * chore: Fix multiple campaigns being send because of race condition * chore: rubocop * chore: Fix specs * disable campaigns Co-authored-by: Nithin David Thomas <webofnithin@gmail.com> Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
parent
db31bfcee4
commit
3fc646f330
15 changed files with 371 additions and 6 deletions
|
@ -5,10 +5,12 @@ class Campaigns::CampaignConversationBuilder
|
|||
@contact_inbox = ContactInbox.find(@contact_inbox_id)
|
||||
@campaign = @contact_inbox.inbox.campaigns.find_by!(display_id: campaign_display_id)
|
||||
|
||||
# We won't send campaigns if a conversation is already present
|
||||
return if @contact_inbox.conversations.present?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@contact_inbox.lock!
|
||||
|
||||
# We won't send campaigns if a conversation is already present
|
||||
return if @contact_inbox.reload.conversations.present?
|
||||
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
|
||||
end
|
||||
|
|
|
@ -73,6 +73,7 @@ export default {
|
|||
methods: {
|
||||
...mapActions('appConfig', ['setWidgetColor']),
|
||||
...mapActions('conversation', ['fetchOldConversations', 'setUserLastSeen']),
|
||||
...mapActions('campaign', ['fetchCampaigns']),
|
||||
...mapActions('agent', ['fetchAvailableAgents']),
|
||||
scrollConversationToBottom() {
|
||||
const container = this.$el.querySelector('.conversation-wrap');
|
||||
|
@ -149,6 +150,7 @@ 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') {
|
||||
|
|
23
app/javascript/widget/api/campaign.js
Normal file
23
app/javascript/widget/api/campaign.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
import endPoints from 'widget/api/endPoints';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
const getCampaigns = async websiteToken => {
|
||||
const urlData = endPoints.getCampaigns(websiteToken);
|
||||
const result = await API.get(urlData.url, { params: urlData.params });
|
||||
return result;
|
||||
};
|
||||
|
||||
const triggerCampaign = async ({ campaignId }) => {
|
||||
const { websiteToken } = window.chatwootWebChannel;
|
||||
const urlData = endPoints.triggerCampaign(websiteToken, campaignId);
|
||||
|
||||
await API.post(
|
||||
urlData.url,
|
||||
{ ...urlData.data },
|
||||
{
|
||||
params: urlData.params,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { getCampaigns, triggerCampaign };
|
|
@ -64,6 +64,24 @@ const getAvailableAgents = token => ({
|
|||
website_token: token,
|
||||
},
|
||||
});
|
||||
const getCampaigns = token => ({
|
||||
url: '/api/v1/widget/campaigns',
|
||||
params: {
|
||||
website_token: token,
|
||||
},
|
||||
});
|
||||
const triggerCampaign = (token, campaignId) => ({
|
||||
url: '/api/v1/widget/events',
|
||||
data: {
|
||||
name: 'campaign.triggered',
|
||||
event_info: {
|
||||
campaign_id: campaignId,
|
||||
},
|
||||
},
|
||||
params: {
|
||||
website_token: token,
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
createConversation,
|
||||
|
@ -72,4 +90,6 @@ export default {
|
|||
getConversation,
|
||||
updateMessage,
|
||||
getAvailableAgents,
|
||||
getCampaigns,
|
||||
triggerCampaign,
|
||||
};
|
||||
|
|
14
app/javascript/widget/helpers/campaignTimer.js
Normal file
14
app/javascript/widget/helpers/campaignTimer.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
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 };
|
|
@ -9,9 +9,9 @@ import conversationLabels from 'widget/store/modules/conversationLabels';
|
|||
import events from 'widget/store/modules/events';
|
||||
import globalConfig from 'shared/store/globalConfig';
|
||||
import message from 'widget/store/modules/message';
|
||||
import campaign from 'widget/store/modules/campaign';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
export default new Vuex.Store({
|
||||
modules: {
|
||||
agent,
|
||||
|
@ -23,5 +23,6 @@ export default new Vuex.Store({
|
|||
events,
|
||||
globalConfig,
|
||||
message,
|
||||
campaign,
|
||||
},
|
||||
});
|
||||
|
|
51
app/javascript/widget/store/modules/campaign.js
Normal file
51
app/javascript/widget/store/modules/campaign.js
Normal file
|
@ -0,0 +1,51 @@
|
|||
import Vue from 'vue';
|
||||
import { getCampaigns } from 'widget/api/campaign';
|
||||
import { startTimer } from 'widget/helpers/campaignTimer';
|
||||
|
||||
const state = {
|
||||
records: [],
|
||||
uiFlags: {
|
||||
isError: false,
|
||||
hasFetched: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getHasFetched: $state => $state.uiFlags.hasFetched,
|
||||
fetchCampaigns: $state => $state.records,
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
fetchCampaigns: async ({ commit }, websiteToken) => {
|
||||
try {
|
||||
const { data } = await getCampaigns(websiteToken);
|
||||
startTimer({ allCampaigns: data });
|
||||
commit('setCampaigns', data);
|
||||
commit('setError', false);
|
||||
commit('setHasFetched', true);
|
||||
} catch (error) {
|
||||
commit('setError', true);
|
||||
commit('setHasFetched', true);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
setCampaigns($state, data) {
|
||||
Vue.set($state, 'records', data);
|
||||
},
|
||||
setError($state, value) {
|
||||
Vue.set($state.uiFlags, 'isError', value);
|
||||
},
|
||||
setHasFetched($state, value) {
|
||||
Vue.set($state.uiFlags, 'hasFetched', value);
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
import { API } from 'widget/helpers/axios';
|
||||
import { actions } from '../../campaign';
|
||||
import { campaigns } from './data';
|
||||
|
||||
const commit = jest.fn();
|
||||
jest.mock('widget/helpers/axios');
|
||||
|
||||
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([
|
||||
['setCampaigns', campaigns],
|
||||
['setError', false],
|
||||
['setHasFetched', true],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
API.get.mockRejectedValue({ message: 'Authentication required' });
|
||||
await actions.fetchCampaigns({ commit }, 'XDsafmADasd');
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['setError', true],
|
||||
['setHasFetched', true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
86
app/javascript/widget/store/modules/specs/campaign/data.js
Normal file
86
app/javascript/widget/store/modules/specs/campaign/data.js
Normal file
|
@ -0,0 +1,86 @@
|
|||
export const campaigns = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Welcome',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
inbox: {
|
||||
id: 37,
|
||||
channel_id: 1,
|
||||
name: 'Chatwoot',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
},
|
||||
sender: {
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
confirmed: true,
|
||||
email: 'sojan@chatwoot.com',
|
||||
available_name: 'Sojan',
|
||||
id: 10,
|
||||
name: 'Sojan',
|
||||
},
|
||||
message: 'Hey, What brings you today',
|
||||
enabled: true,
|
||||
trigger_rules: {
|
||||
url: 'https://github.com',
|
||||
time_on_page: 10,
|
||||
},
|
||||
created_at: '2021-05-03T04:53:36.354Z',
|
||||
updated_at: '2021-05-03T04:53:36.354Z',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: 'Onboarding Campaign',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
inbox: {
|
||||
id: 37,
|
||||
channel_id: 1,
|
||||
name: 'GitX',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
},
|
||||
sender: {
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
confirmed: true,
|
||||
email: 'sojan@chatwoot.com',
|
||||
available_name: 'Sojan',
|
||||
id: 10,
|
||||
},
|
||||
message: 'Begin your onboarding campaign with a welcome message',
|
||||
enabled: true,
|
||||
trigger_rules: {
|
||||
url: 'https://chatwoot.com',
|
||||
time_on_page: '20',
|
||||
},
|
||||
created_at: '2021-05-03T08:15:35.828Z',
|
||||
updated_at: '2021-05-03T08:15:35.828Z',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: 'Thanks',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
inbox: {
|
||||
id: 37,
|
||||
channel_id: 1,
|
||||
name: 'Chatwoot',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
},
|
||||
sender: {
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
confirmed: true,
|
||||
email: 'nithin@chatwoot.com',
|
||||
available_name: 'Nithin',
|
||||
},
|
||||
message: 'Thanks for coming to the show. How may I help you?',
|
||||
enabled: false,
|
||||
trigger_rules: {
|
||||
url: 'https://noshow.com',
|
||||
time_on_page: 10,
|
||||
},
|
||||
created_at: '2021-05-03T10:22:51.025Z',
|
||||
updated_at: '2021-05-03T10:22:51.025Z',
|
||||
},
|
||||
];
|
|
@ -0,0 +1,96 @@
|
|||
import { getters } from '../../campaign';
|
||||
import { campaigns } from './data';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('fetchCampaigns', () => {
|
||||
const state = {
|
||||
records: campaigns,
|
||||
};
|
||||
expect(getters.fetchCampaigns(state)).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Welcome',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
inbox: {
|
||||
id: 37,
|
||||
channel_id: 1,
|
||||
name: 'Chatwoot',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
},
|
||||
sender: {
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
confirmed: true,
|
||||
email: 'sojan@chatwoot.com',
|
||||
available_name: 'Sojan',
|
||||
id: 10,
|
||||
name: 'Sojan',
|
||||
},
|
||||
message: 'Hey, What brings you today',
|
||||
enabled: true,
|
||||
trigger_rules: {
|
||||
url: 'https://github.com',
|
||||
time_on_page: 10,
|
||||
},
|
||||
created_at: '2021-05-03T04:53:36.354Z',
|
||||
updated_at: '2021-05-03T04:53:36.354Z',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: 'Onboarding Campaign',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
inbox: {
|
||||
id: 37,
|
||||
channel_id: 1,
|
||||
name: 'GitX',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
},
|
||||
sender: {
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
confirmed: true,
|
||||
email: 'sojan@chatwoot.com',
|
||||
available_name: 'Sojan',
|
||||
id: 10,
|
||||
},
|
||||
message: 'Begin your onboarding campaign with a welcome message',
|
||||
enabled: true,
|
||||
trigger_rules: {
|
||||
url: 'https://chatwoot.com',
|
||||
time_on_page: '20',
|
||||
},
|
||||
created_at: '2021-05-03T08:15:35.828Z',
|
||||
updated_at: '2021-05-03T08:15:35.828Z',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: 'Thanks',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
inbox: {
|
||||
id: 37,
|
||||
channel_id: 1,
|
||||
name: 'Chatwoot',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
},
|
||||
sender: {
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
confirmed: true,
|
||||
email: 'nithin@chatwoot.com',
|
||||
available_name: 'Nithin',
|
||||
},
|
||||
message: 'Thanks for coming to the show. How may I help you?',
|
||||
enabled: false,
|
||||
trigger_rules: {
|
||||
url: 'https://noshow.com',
|
||||
time_on_page: 10,
|
||||
},
|
||||
created_at: '2021-05-03T10:22:51.025Z',
|
||||
updated_at: '2021-05-03T10:22:51.025Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,28 @@
|
|||
import { mutations } from '../../campaign';
|
||||
import { campaigns } from './data';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#setCampagins', () => {
|
||||
it('set campaign records', () => {
|
||||
const state = { records: [] };
|
||||
mutations.setCampaigns(state, campaigns);
|
||||
expect(state.records).toEqual(campaigns);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setError', () => {
|
||||
it('set error flag', () => {
|
||||
const state = { records: [], uiFlags: {} };
|
||||
mutations.setError(state, true);
|
||||
expect(state.uiFlags.isError).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setHasFetched', () => {
|
||||
it('set fetched flag', () => {
|
||||
const state = { records: [], uiFlags: {} };
|
||||
mutations.setHasFetched(state, true);
|
||||
expect(state.uiFlags.hasFetched).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,7 +6,7 @@ class CampaignListener < BaseListener
|
|||
return if campaign_display_id.blank?
|
||||
|
||||
::Campaigns::CampaignConversationBuilder.new(
|
||||
contact_inbox: contact_inbox.id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
campaign_display_id: campaign_display_id,
|
||||
conversation_additional_attributes: event.data[:event_info].except(:campaign_id)
|
||||
).perform
|
||||
|
|
|
@ -3,9 +3,11 @@ class MessageTemplates::HookExecutionService
|
|||
|
||||
def perform
|
||||
return if inbox.agent_bot_inbox&.active?
|
||||
return if conversation.campaign.present?
|
||||
|
||||
# TODO: let's see whether this is needed and remove this and related logic if not
|
||||
# ::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
|
||||
|
||||
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
|
||||
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if should_send_email_collect?
|
||||
end
|
||||
|
|
|
@ -23,7 +23,7 @@ describe CampaignListener do
|
|||
context 'when params contain campaign id' do
|
||||
it 'triggers campaign conversation builder' do
|
||||
expect(Campaigns::CampaignConversationBuilder).to receive(:new)
|
||||
.with({ contact_inbox: contact_inbox.id, campaign_display_id: campaign.display_id, conversation_additional_attributes: {} }).once
|
||||
.with({ contact_inbox_id: contact_inbox.id, campaign_display_id: campaign.display_id, conversation_additional_attributes: {} }).once
|
||||
listener.campaign_triggered(event)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -39,6 +39,18 @@ describe ::MessageTemplates::HookExecutionService do
|
|||
expect(::MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
|
||||
it 'doesnot calls ::MessageTemplates::Template::EmailCollect on campaign conversations' do
|
||||
contact = create(:contact, email: nil)
|
||||
conversation = create(:conversation, contact: contact, campaign: create(:campaign))
|
||||
|
||||
allow(::MessageTemplates::Template::EmailCollect).to receive(:new).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
|
||||
expect(::MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
|
||||
it 'doesnot calls ::MessageTemplates::Template::Greeting if greeting_message is empty' do
|
||||
contact = create(:contact, email: nil)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
|
|
Loading…
Reference in a new issue