Feature: Website SDK (#653)
Add SDK functions Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
parent
7fcd2d0e85
commit
cb22b396eb
38 changed files with 734 additions and 262 deletions
47
app/actions/contact_identify_action.rb
Normal file
47
app/actions/contact_identify_action.rb
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
class ContactIdentifyAction
|
||||||
|
pattr_initialize [:contact!, :params!]
|
||||||
|
|
||||||
|
def perform
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
|
||||||
|
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
|
||||||
|
update_contact
|
||||||
|
end
|
||||||
|
@contact
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def account
|
||||||
|
@account ||= @contact.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def existing_identified_contact
|
||||||
|
return if params[:identifier].blank?
|
||||||
|
|
||||||
|
@existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier])
|
||||||
|
end
|
||||||
|
|
||||||
|
def existing_email_contact
|
||||||
|
return if params[:email].blank?
|
||||||
|
|
||||||
|
@existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email])
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge_contacts?(existing_contact, _contact)
|
||||||
|
existing_contact && existing_contact.id != @contact.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_contact
|
||||||
|
@contact.update!(params.slice(:name, :email, :identifier))
|
||||||
|
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge_contact(base_contact, merge_contact)
|
||||||
|
ContactMergeAction.new(
|
||||||
|
account: account,
|
||||||
|
base_contact: base_contact,
|
||||||
|
mergee_contact: merge_contact
|
||||||
|
).perform
|
||||||
|
end
|
||||||
|
end
|
|
@ -9,6 +9,7 @@ class ContactMergeAction
|
||||||
merge_contact_inboxes
|
merge_contact_inboxes
|
||||||
remove_mergee_contact
|
remove_mergee_contact
|
||||||
end
|
end
|
||||||
|
@base_contact
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
require 'open-uri'
|
|
||||||
|
|
||||||
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
|
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
|
||||||
# Assumptions
|
# Assumptions
|
||||||
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
|
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
|
||||||
|
@ -36,9 +34,7 @@ class Messages::MessageBuilder
|
||||||
return if contact.present?
|
return if contact.present?
|
||||||
|
|
||||||
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
|
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
|
||||||
avatar_resource = LocalResource.new(contact_params[:remote_avatar_url])
|
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url]
|
||||||
@contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
|
|
||||||
|
|
||||||
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
|
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
18
app/controllers/api/v1/widget/contacts_controller.rb
Normal file
18
app/controllers/api/v1/widget/contacts_controller.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||||
|
before_action :set_web_widget
|
||||||
|
before_action :set_contact
|
||||||
|
|
||||||
|
def update
|
||||||
|
contact_identify_action = ContactIdentifyAction.new(
|
||||||
|
contact: @contact,
|
||||||
|
params: permitted_params.to_h.deep_symbolize_keys
|
||||||
|
)
|
||||||
|
render json: contact_identify_action.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:website_token, :identifier, :email, :name, :avatar_url)
|
||||||
|
end
|
||||||
|
end
|
24
app/controllers/api/v1/widget/labels_controller.rb
Normal file
24
app/controllers/api/v1/widget/labels_controller.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
class Api::V1::Widget::LabelsController < Api::V1::Widget::BaseController
|
||||||
|
before_action :set_web_widget
|
||||||
|
before_action :set_contact
|
||||||
|
|
||||||
|
def create
|
||||||
|
conversation.label_list.add(permitted_params[:label])
|
||||||
|
conversation.save!
|
||||||
|
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
conversation.label_list.remove(permitted_params[:id])
|
||||||
|
conversation.save!
|
||||||
|
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:id, :label, :website_token)
|
||||||
|
end
|
||||||
|
end
|
|
@ -17,7 +17,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||||
def update
|
def update
|
||||||
@message.update!(input_submitted_email: contact_email)
|
@message.update!(input_submitted_email: contact_email)
|
||||||
update_contact(contact_email)
|
update_contact(contact_email)
|
||||||
head :no_content
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
|
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
|
||||||
end
|
end
|
||||||
|
@ -96,7 +95,11 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||||
def update_contact(email)
|
def update_contact(email)
|
||||||
contact_with_email = @account.contacts.find_by(email: email)
|
contact_with_email = @account.contacts.find_by(email: email)
|
||||||
if contact_with_email
|
if contact_with_email
|
||||||
::ContactMergeAction.new(account: @account, base_contact: contact_with_email, mergee_contact: @contact).perform
|
@contact = ::ContactMergeAction.new(
|
||||||
|
account: @account,
|
||||||
|
base_contact: contact_with_email,
|
||||||
|
mergee_contact: @contact
|
||||||
|
).perform
|
||||||
else
|
else
|
||||||
@contact.update!(
|
@contact.update!(
|
||||||
email: email,
|
email: email,
|
||||||
|
|
|
@ -1,233 +1,62 @@
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
|
import { IFrameHelper } from '../sdk/IFrameHelper';
|
||||||
|
import { onBubbleClick } from '../sdk/bubbleHelpers';
|
||||||
|
|
||||||
import { SDK_CSS } from '../widget/assets/scss/sdk';
|
const runSDK = ({ baseUrl, websiteToken }) => {
|
||||||
/* eslint-disable no-param-reassign */
|
const chatwootSettings = window.chatwootSettings || {};
|
||||||
const bubbleImg =
|
window.$chatwoot = {
|
||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg==';
|
baseUrl,
|
||||||
|
hasLoaded: false,
|
||||||
|
hideMessageBubble: chatwootSettings.hideMessageBubble || false,
|
||||||
|
isOpen: false,
|
||||||
|
position: chatwootSettings.position || 'right',
|
||||||
|
websiteToken,
|
||||||
|
|
||||||
const body = document.getElementsByTagName('body')[0];
|
toggle() {
|
||||||
const holder = document.createElement('div');
|
onBubbleClick();
|
||||||
|
|
||||||
const bubbleHolder = document.createElement('div');
|
|
||||||
const chatBubble = document.createElement('div');
|
|
||||||
const closeBubble = document.createElement('div');
|
|
||||||
|
|
||||||
const notification_bubble = document.createElement('span');
|
|
||||||
const bodyOverFlowStyle = document.body.style.overflow;
|
|
||||||
|
|
||||||
function loadCSS() {
|
|
||||||
const css = document.createElement('style');
|
|
||||||
css.type = 'text/css';
|
|
||||||
css.innerHTML = `${SDK_CSS}`;
|
|
||||||
document.body.appendChild(css);
|
|
||||||
}
|
|
||||||
|
|
||||||
function wootOn(elm, event, fn) {
|
|
||||||
if (document.addEventListener) {
|
|
||||||
elm.addEventListener(event, fn, false);
|
|
||||||
} else if (document.attachEvent) {
|
|
||||||
// <= IE 8 loses scope so need to apply, we add this to object so we
|
|
||||||
// can detach later (can't detach anonymous functions)
|
|
||||||
// eslint-disable-next-line
|
|
||||||
elm[event + fn] = function() {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
return fn.apply(elm, arguments);
|
|
||||||
};
|
|
||||||
elm.attachEvent(`on${event}`, elm[event + fn]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function classHelper(classes, action, elm) {
|
|
||||||
let search;
|
|
||||||
let replace;
|
|
||||||
let i;
|
|
||||||
let has = false;
|
|
||||||
if (classes) {
|
|
||||||
// Trim any whitespace
|
|
||||||
const classarray = classes.split(/\s+/);
|
|
||||||
for (i = 0; i < classarray.length; i += 1) {
|
|
||||||
search = new RegExp(`\\b${classarray[i]}\\b`, 'g');
|
|
||||||
replace = new RegExp(` *${classarray[i]}\\b`, 'g');
|
|
||||||
if (action === 'remove') {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
elm.className = elm.className.replace(replace, '');
|
|
||||||
} else if (action === 'toggle') {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
elm.className = elm.className.match(search)
|
|
||||||
? elm.className.replace(replace, '')
|
|
||||||
: `${elm.className} ${classarray[i]}`;
|
|
||||||
} else if (action === 'has') {
|
|
||||||
if (elm.className.match(search)) {
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return has;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addClass(elm, classes) {
|
|
||||||
if (classes) {
|
|
||||||
elm.className += ` ${classes}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle class
|
|
||||||
function toggleClass(elm, classes) {
|
|
||||||
classHelper(classes, 'toggle', elm);
|
|
||||||
}
|
|
||||||
|
|
||||||
const createBubbleIcon = ({ className, src, target }) => {
|
|
||||||
target.className = className;
|
|
||||||
const bubbleIcon = document.createElement('img');
|
|
||||||
bubbleIcon.src = src;
|
|
||||||
target.appendChild(bubbleIcon);
|
|
||||||
return target;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createBubbleHolder() {
|
|
||||||
addClass(bubbleHolder, 'woot--bubble-holder');
|
|
||||||
body.appendChild(bubbleHolder);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNotificationBubble() {
|
|
||||||
addClass(notification_bubble, 'woot--notification');
|
|
||||||
return notification_bubble;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bubbleClickCallback() {
|
|
||||||
toggleClass(chatBubble, 'woot--hide');
|
|
||||||
toggleClass(closeBubble, 'woot--hide');
|
|
||||||
toggleClass(holder, 'woot--hide');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClickChatBubble() {
|
|
||||||
wootOn(bubbleHolder, 'click', bubbleClickCallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableScroll() {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
}
|
|
||||||
|
|
||||||
function enableScroll() {
|
|
||||||
document.body.style.overflow = bodyOverFlowStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IFrameHelper = {
|
|
||||||
createFrame: ({ baseUrl, websiteToken }) => {
|
|
||||||
const iframe = document.createElement('iframe');
|
|
||||||
const cwCookie = Cookies.get('cw_conversation');
|
|
||||||
let widgetUrl = `${baseUrl}/widget?website_token=${websiteToken}`;
|
|
||||||
if (cwCookie) {
|
|
||||||
widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`;
|
|
||||||
}
|
|
||||||
iframe.src = widgetUrl;
|
|
||||||
|
|
||||||
iframe.id = 'chatwoot_live_chat_widget';
|
|
||||||
iframe.style.visibility = 'hidden';
|
|
||||||
holder.className = 'woot-widget-holder woot--hide';
|
|
||||||
holder.appendChild(iframe);
|
|
||||||
body.appendChild(holder);
|
|
||||||
IFrameHelper.initPostMessageCommunication();
|
|
||||||
IFrameHelper.initLocationListener();
|
|
||||||
IFrameHelper.initWindowSizeListener();
|
|
||||||
},
|
|
||||||
getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'),
|
|
||||||
sendMessage: (key, value) => {
|
|
||||||
const element = IFrameHelper.getAppFrame();
|
|
||||||
element.contentWindow.postMessage(
|
|
||||||
`chatwoot-widget:${JSON.stringify({ event: key, ...value })}`,
|
|
||||||
'*'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
loaded: message => {
|
|
||||||
Cookies.set('cw_conversation', message.config.authToken);
|
|
||||||
IFrameHelper.sendMessage('config-set', {});
|
|
||||||
IFrameHelper.onLoad(message.config.channelConfig);
|
|
||||||
IFrameHelper.setCurrentUrl();
|
|
||||||
IFrameHelper.toggleCloseButton();
|
|
||||||
},
|
},
|
||||||
set_auth_token: message => {
|
|
||||||
Cookies.set('cw_conversation', message.authToken);
|
setUser(identifier, user) {
|
||||||
},
|
if (typeof identifier === 'string' || typeof identifier === 'number') {
|
||||||
toggleBubble: () => {
|
window.$chatwoot.identifier = identifier;
|
||||||
bubbleClickCallback();
|
window.$chatwoot.user = user || {};
|
||||||
},
|
IFrameHelper.sendMessage('set-user', {
|
||||||
},
|
identifier,
|
||||||
initPostMessageCommunication: () => {
|
user: window.$chatwoot.user,
|
||||||
window.onmessage = e => {
|
});
|
||||||
if (
|
} else {
|
||||||
typeof e.data !== 'string' ||
|
throw new Error('Identifier should be a string or a number');
|
||||||
e.data.indexOf('chatwoot-widget:') !== 0
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
|
},
|
||||||
if (typeof IFrameHelper.events[message.event] === 'function') {
|
|
||||||
IFrameHelper.events[message.event](message);
|
setLabel(label = '') {
|
||||||
|
IFrameHelper.sendMessage('set-label', { label });
|
||||||
|
},
|
||||||
|
|
||||||
|
removeLabel(label = '') {
|
||||||
|
IFrameHelper.sendMessage('remove-label', { label });
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
if (window.$chatwoot.isOpen) {
|
||||||
|
onBubbleClick();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
},
|
|
||||||
initLocationListener: () => {
|
|
||||||
window.onhashchange = () => {
|
|
||||||
IFrameHelper.setCurrentUrl();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
initWindowSizeListener: () => {
|
|
||||||
wootOn(window, 'resize', () => {
|
|
||||||
IFrameHelper.toggleCloseButton();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onLoad: ({ widget_color: widgetColor }) => {
|
|
||||||
const iframe = IFrameHelper.getAppFrame();
|
|
||||||
iframe.style.visibility = '';
|
|
||||||
iframe.setAttribute('id', `chatwoot_live_chat_widget`);
|
|
||||||
iframe.onmouseenter = disableScroll;
|
|
||||||
iframe.onmouseleave = enableScroll;
|
|
||||||
|
|
||||||
loadCSS();
|
Cookies.remove('cw_conversation');
|
||||||
createBubbleHolder();
|
const iframe = IFrameHelper.getAppFrame();
|
||||||
|
iframe.src = IFrameHelper.getUrl({
|
||||||
|
baseUrl: window.$chatwoot.baseUrl,
|
||||||
|
websiteToken: window.$chatwoot.websiteToken,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const chatIcon = createBubbleIcon({
|
|
||||||
className: 'woot-widget-bubble',
|
|
||||||
src: bubbleImg,
|
|
||||||
target: chatBubble,
|
|
||||||
});
|
|
||||||
|
|
||||||
const closeIcon = closeBubble;
|
|
||||||
closeIcon.className = 'woot-widget-bubble woot--close woot--hide';
|
|
||||||
|
|
||||||
chatIcon.style.background = widgetColor;
|
|
||||||
closeIcon.style.background = widgetColor;
|
|
||||||
|
|
||||||
bubbleHolder.appendChild(chatIcon);
|
|
||||||
bubbleHolder.appendChild(closeIcon);
|
|
||||||
bubbleHolder.appendChild(createNotificationBubble());
|
|
||||||
onClickChatBubble();
|
|
||||||
},
|
|
||||||
setCurrentUrl: () => {
|
|
||||||
IFrameHelper.sendMessage('set-current-url', {
|
|
||||||
refererURL: window.location.href,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
toggleCloseButton: () => {
|
|
||||||
if (window.matchMedia('(max-width: 668px)').matches) {
|
|
||||||
IFrameHelper.sendMessage('toggle-close-button', { showClose: true });
|
|
||||||
} else {
|
|
||||||
IFrameHelper.sendMessage('toggle-close-button', { showClose: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function loadIframe({ baseUrl, websiteToken }) {
|
|
||||||
IFrameHelper.createFrame({
|
IFrameHelper.createFrame({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
websiteToken,
|
websiteToken,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
window.chatwootSDK = {
|
window.chatwootSDK = {
|
||||||
run: loadIframe,
|
run: runSDK,
|
||||||
};
|
};
|
||||||
|
|
63
app/javascript/sdk/DOMHelpers.js
Normal file
63
app/javascript/sdk/DOMHelpers.js
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { SDK_CSS } from '../widget/assets/scss/sdk';
|
||||||
|
|
||||||
|
export const loadCSS = () => {
|
||||||
|
const css = document.createElement('style');
|
||||||
|
css.type = 'text/css';
|
||||||
|
css.innerHTML = `${SDK_CSS}`;
|
||||||
|
document.body.appendChild(css);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const wootOn = (elm, event, fn) => {
|
||||||
|
if (document.addEventListener) {
|
||||||
|
elm.addEventListener(event, fn, false);
|
||||||
|
} else if (document.attachEvent) {
|
||||||
|
// <= IE 8 loses scope so need to apply, we add this to object so we
|
||||||
|
// can detach later (can't detach anonymous functions)
|
||||||
|
// eslint-disable-next-line
|
||||||
|
elm[event + fn] = function() {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
return fn.apply(elm, arguments);
|
||||||
|
};
|
||||||
|
elm.attachEvent(`on${event}`, elm[event + fn]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const classHelper = (classes, action, elm) => {
|
||||||
|
let search;
|
||||||
|
let replace;
|
||||||
|
let i;
|
||||||
|
let has = false;
|
||||||
|
if (classes) {
|
||||||
|
// Trim any whitespace
|
||||||
|
const classarray = classes.split(/\s+/);
|
||||||
|
for (i = 0; i < classarray.length; i += 1) {
|
||||||
|
search = new RegExp(`\\b${classarray[i]}\\b`, 'g');
|
||||||
|
replace = new RegExp(` *${classarray[i]}\\b`, 'g');
|
||||||
|
if (action === 'remove') {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
elm.className = elm.className.replace(replace, '');
|
||||||
|
} else if (action === 'toggle') {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
elm.className = elm.className.match(search)
|
||||||
|
? elm.className.replace(replace, '')
|
||||||
|
: `${elm.className} ${classarray[i]}`;
|
||||||
|
} else if (action === 'has') {
|
||||||
|
if (elm.className.match(search)) {
|
||||||
|
has = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return has;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addClass = (elm, classes) => {
|
||||||
|
if (classes) {
|
||||||
|
elm.className += ` ${classes}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleClass = (elm, classes) => {
|
||||||
|
classHelper(classes, 'toggle', elm);
|
||||||
|
};
|
134
app/javascript/sdk/IFrameHelper.js
Normal file
134
app/javascript/sdk/IFrameHelper.js
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import { wootOn, loadCSS } from './DOMHelpers';
|
||||||
|
import {
|
||||||
|
body,
|
||||||
|
widgetHolder,
|
||||||
|
createBubbleHolder,
|
||||||
|
disableScroll,
|
||||||
|
enableScroll,
|
||||||
|
createBubbleIcon,
|
||||||
|
bubbleImg,
|
||||||
|
chatBubble,
|
||||||
|
closeBubble,
|
||||||
|
bubbleHolder,
|
||||||
|
createNotificationBubble,
|
||||||
|
onClickChatBubble,
|
||||||
|
onBubbleClick,
|
||||||
|
} from './bubbleHelpers';
|
||||||
|
|
||||||
|
export const IFrameHelper = {
|
||||||
|
getUrl({ baseUrl, websiteToken }) {
|
||||||
|
return `${baseUrl}/widget?website_token=${websiteToken}`;
|
||||||
|
},
|
||||||
|
createFrame: ({ baseUrl, websiteToken }) => {
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
const cwCookie = Cookies.get('cw_conversation');
|
||||||
|
let widgetUrl = IFrameHelper.getUrl({ baseUrl, websiteToken });
|
||||||
|
if (cwCookie) {
|
||||||
|
widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`;
|
||||||
|
}
|
||||||
|
iframe.src = widgetUrl;
|
||||||
|
|
||||||
|
iframe.id = 'chatwoot_live_chat_widget';
|
||||||
|
iframe.style.visibility = 'hidden';
|
||||||
|
widgetHolder.className = 'woot-widget-holder woot--hide';
|
||||||
|
widgetHolder.appendChild(iframe);
|
||||||
|
body.appendChild(widgetHolder);
|
||||||
|
IFrameHelper.initPostMessageCommunication();
|
||||||
|
IFrameHelper.initLocationListener();
|
||||||
|
IFrameHelper.initWindowSizeListener();
|
||||||
|
},
|
||||||
|
getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'),
|
||||||
|
sendMessage: (key, value) => {
|
||||||
|
const element = IFrameHelper.getAppFrame();
|
||||||
|
element.contentWindow.postMessage(
|
||||||
|
`chatwoot-widget:${JSON.stringify({ event: key, ...value })}`,
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initLocationListener: () => {
|
||||||
|
window.onhashchange = () => {
|
||||||
|
IFrameHelper.setCurrentUrl();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
initPostMessageCommunication: () => {
|
||||||
|
window.onmessage = e => {
|
||||||
|
if (
|
||||||
|
typeof e.data !== 'string' ||
|
||||||
|
e.data.indexOf('chatwoot-widget:') !== 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
|
||||||
|
if (typeof IFrameHelper.events[message.event] === 'function') {
|
||||||
|
IFrameHelper.events[message.event](message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
initWindowSizeListener: () => {
|
||||||
|
wootOn(window, 'resize', () => {
|
||||||
|
IFrameHelper.toggleCloseButton();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
loaded: message => {
|
||||||
|
Cookies.set('cw_conversation', message.config.authToken, {
|
||||||
|
expires: 365,
|
||||||
|
});
|
||||||
|
window.$chatwoot.hasLoaded = true;
|
||||||
|
IFrameHelper.sendMessage('config-set', {});
|
||||||
|
IFrameHelper.onLoad(message.config.channelConfig);
|
||||||
|
IFrameHelper.setCurrentUrl();
|
||||||
|
IFrameHelper.toggleCloseButton();
|
||||||
|
|
||||||
|
if (window.$chatwoot.user) {
|
||||||
|
IFrameHelper.sendMessage('set-user', window.$chatwoot.user);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleBubble: () => {
|
||||||
|
onBubbleClick();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onLoad: ({ widget_color: widgetColor }) => {
|
||||||
|
const iframe = IFrameHelper.getAppFrame();
|
||||||
|
iframe.style.visibility = '';
|
||||||
|
iframe.setAttribute('id', `chatwoot_live_chat_widget`);
|
||||||
|
iframe.onmouseenter = disableScroll;
|
||||||
|
iframe.onmouseleave = enableScroll;
|
||||||
|
|
||||||
|
loadCSS();
|
||||||
|
createBubbleHolder();
|
||||||
|
|
||||||
|
if (!window.$chatwoot.hideMessageBubble) {
|
||||||
|
const chatIcon = createBubbleIcon({
|
||||||
|
className: 'woot-widget-bubble',
|
||||||
|
src: bubbleImg,
|
||||||
|
target: chatBubble,
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeIcon = closeBubble;
|
||||||
|
closeIcon.className = 'woot-widget-bubble woot--close woot--hide';
|
||||||
|
|
||||||
|
chatIcon.style.background = widgetColor;
|
||||||
|
closeIcon.style.background = widgetColor;
|
||||||
|
|
||||||
|
bubbleHolder.appendChild(chatIcon);
|
||||||
|
bubbleHolder.appendChild(closeIcon);
|
||||||
|
bubbleHolder.appendChild(createNotificationBubble());
|
||||||
|
onClickChatBubble();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setCurrentUrl: () => {
|
||||||
|
IFrameHelper.sendMessage('set-current-url', {
|
||||||
|
refererURL: window.location.href,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toggleCloseButton: () => {
|
||||||
|
if (window.matchMedia('(max-width: 668px)').matches) {
|
||||||
|
IFrameHelper.sendMessage('toggle-close-button', { showClose: true });
|
||||||
|
} else {
|
||||||
|
IFrameHelper.sendMessage('toggle-close-button', { showClose: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
51
app/javascript/sdk/bubbleHelpers.js
Normal file
51
app/javascript/sdk/bubbleHelpers.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { addClass, toggleClass, wootOn } from './DOMHelpers';
|
||||||
|
|
||||||
|
export const bubbleImg =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
export const body = document.getElementsByTagName('body')[0];
|
||||||
|
export const widgetHolder = document.createElement('div');
|
||||||
|
|
||||||
|
export const bubbleHolder = document.createElement('div');
|
||||||
|
export const chatBubble = document.createElement('div');
|
||||||
|
export const closeBubble = document.createElement('div');
|
||||||
|
|
||||||
|
export const notificationBubble = document.createElement('span');
|
||||||
|
const bodyOverFlowStyle = document.body.style.overflow;
|
||||||
|
|
||||||
|
export const createBubbleIcon = ({ className, src, target }) => {
|
||||||
|
target.className = className;
|
||||||
|
const bubbleIcon = document.createElement('img');
|
||||||
|
bubbleIcon.src = src;
|
||||||
|
target.appendChild(bubbleIcon);
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBubbleHolder = () => {
|
||||||
|
addClass(bubbleHolder, 'woot--bubble-holder');
|
||||||
|
body.appendChild(bubbleHolder);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNotificationBubble = () => {
|
||||||
|
addClass(notificationBubble, 'woot--notification');
|
||||||
|
return notificationBubble;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onBubbleClick = () => {
|
||||||
|
window.$chatwoot.isOpen = !window.$chatwoot.isOpen;
|
||||||
|
toggleClass(chatBubble, 'woot--hide');
|
||||||
|
toggleClass(closeBubble, 'woot--hide');
|
||||||
|
toggleClass(widgetHolder, 'woot--hide');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onClickChatBubble = () => {
|
||||||
|
wootOn(bubbleHolder, 'click', onBubbleClick);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disableScroll = () => {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enableScroll = () => {
|
||||||
|
document.body.style.overflow = bodyOverFlowStyle;
|
||||||
|
};
|
|
@ -2,8 +2,8 @@ import { createConsumer } from '@rails/actioncable';
|
||||||
|
|
||||||
class BaseActionCableConnector {
|
class BaseActionCableConnector {
|
||||||
constructor(app, pubsubToken) {
|
constructor(app, pubsubToken) {
|
||||||
const consumer = createConsumer();
|
this.consumer = createConsumer();
|
||||||
consumer.subscriptions.create(
|
this.consumer.subscriptions.create(
|
||||||
{
|
{
|
||||||
channel: 'RoomChannel',
|
channel: 'RoomChannel',
|
||||||
pubsub_token: pubsubToken,
|
pubsub_token: pubsubToken,
|
||||||
|
@ -16,6 +16,10 @@ class BaseActionCableConnector {
|
||||||
this.events = {};
|
this.events = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.consumer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
onReceived = ({ event, data } = {}) => {
|
onReceived = ({ event, data } = {}) => {
|
||||||
if (this.events[event] && typeof this.events[event] === 'function') {
|
if (this.events[event] && typeof this.events[event] === 'function') {
|
||||||
this.events[event](data);
|
this.events[event](data);
|
||||||
|
|
|
@ -47,6 +47,12 @@ export default {
|
||||||
window.refererURL = message.refererURL;
|
window.refererURL = message.refererURL;
|
||||||
} else if (message.event === 'toggle-close-button') {
|
} else if (message.event === 'toggle-close-button') {
|
||||||
this.isMobile = message.showClose;
|
this.isMobile = message.showClose;
|
||||||
|
} else if (message.event === 'set-label') {
|
||||||
|
this.$store.dispatch('conversationLabels/create', message.label);
|
||||||
|
} else if (message.event === 'remove-label') {
|
||||||
|
this.$store.dispatch('conversationLabels/destroy', message.label);
|
||||||
|
} else if (message.event === 'set-user') {
|
||||||
|
this.$store.dispatch('contacts/update', message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
import authEndPoint from 'widget/api/endPoints';
|
|
||||||
import { API } from 'widget/helpers/axios';
|
|
||||||
|
|
||||||
export const updateContact = async ({ messageId, email }) => {
|
|
||||||
const urlData = authEndPoint.updateContact(messageId);
|
|
||||||
const result = await API.patch(urlData.url, {
|
|
||||||
contact: { email },
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
};
|
|
12
app/javascript/widget/api/contacts.js
Normal file
12
app/javascript/widget/api/contacts.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { API } from 'widget/helpers/axios';
|
||||||
|
|
||||||
|
const buildUrl = endPoint => `/api/v1/${endPoint}${window.location.search}`;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
update(identifier, userObject) {
|
||||||
|
return API.patch(buildUrl('widget/contact'), {
|
||||||
|
identifier,
|
||||||
|
...userObject,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
12
app/javascript/widget/api/conversationLabels.js
Normal file
12
app/javascript/widget/api/conversationLabels.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { API } from 'widget/helpers/axios';
|
||||||
|
|
||||||
|
const buildUrl = endPoint => `/api/v1/${endPoint}${window.location.search}`;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
create(label) {
|
||||||
|
return API.post(buildUrl('widget/labels'), { label });
|
||||||
|
},
|
||||||
|
destroy(label) {
|
||||||
|
return API.delete(buildUrl(`widget/labels/${label}`));
|
||||||
|
},
|
||||||
|
};
|
11
app/javascript/widget/api/message.js
Executable file
11
app/javascript/widget/api/message.js
Executable file
|
@ -0,0 +1,11 @@
|
||||||
|
import authEndPoint from 'widget/api/endPoints';
|
||||||
|
import { API } from 'widget/helpers/axios';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
update: ({ messageId, email }) => {
|
||||||
|
const urlData = authEndPoint.updateContact(messageId);
|
||||||
|
return API.patch(urlData.url, {
|
||||||
|
contact: { email },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -53,7 +53,7 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
uiFlags: 'contact/getUIFlags',
|
uiFlags: 'message/getUIFlags',
|
||||||
widgetColor: 'appConfig/getWidgetColor',
|
widgetColor: 'appConfig/getWidgetColor',
|
||||||
}),
|
}),
|
||||||
hasSubmitted() {
|
hasSubmitted() {
|
||||||
|
@ -71,7 +71,7 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
this.$store.dispatch('contact/updateContactAttributes', {
|
this.$store.dispatch('message/updateContactAttributes', {
|
||||||
email: this.email,
|
email: this.email,
|
||||||
messageId: this.messageId,
|
messageId: this.messageId,
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,4 +13,13 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const refreshActionCableConnector = pubsubToken => {
|
||||||
|
window.chatwootPubsubToken = pubsubToken;
|
||||||
|
window.actionCable.disconnect();
|
||||||
|
window.actionCable = new ActionCableConnector(
|
||||||
|
window.WOOT_WIDGET,
|
||||||
|
window.chatwootPubsubToken
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ActionCableConnector;
|
export default ActionCableConnector;
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import Vuex from 'vuex';
|
import Vuex from 'vuex';
|
||||||
import appConfig from 'widget/store/modules/appConfig';
|
|
||||||
import contact from 'widget/store/modules/contact';
|
|
||||||
import conversation from 'widget/store/modules/conversation';
|
|
||||||
import agent from 'widget/store/modules/agent';
|
import agent from 'widget/store/modules/agent';
|
||||||
|
import appConfig from 'widget/store/modules/appConfig';
|
||||||
|
import contacts from 'widget/store/modules/contacts';
|
||||||
|
import conversation from 'widget/store/modules/conversation';
|
||||||
|
import conversationLabels from 'widget/store/modules/conversationLabels';
|
||||||
|
import message from 'widget/store/modules/message';
|
||||||
|
|
||||||
Vue.use(Vuex);
|
Vue.use(Vuex);
|
||||||
|
|
||||||
export default new Vuex.Store({
|
export default new Vuex.Store({
|
||||||
modules: {
|
modules: {
|
||||||
appConfig,
|
|
||||||
contact,
|
|
||||||
conversation,
|
|
||||||
agent,
|
agent,
|
||||||
|
appConfig,
|
||||||
|
message,
|
||||||
|
contacts,
|
||||||
|
conversation,
|
||||||
|
conversationLabels,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
28
app/javascript/widget/store/modules/contacts.js
Normal file
28
app/javascript/widget/store/modules/contacts.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import ContactsAPI from '../../api/contacts';
|
||||||
|
import { refreshActionCableConnector } from '../../helpers/actionCable';
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
update: async (_, { identifier, user: userObject }) => {
|
||||||
|
try {
|
||||||
|
const user = {
|
||||||
|
email: userObject.email,
|
||||||
|
name: userObject.name,
|
||||||
|
avatar_url: userObject.avatar_url,
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
data: { pubsub_token: pubsubToken },
|
||||||
|
} = await ContactsAPI.update(identifier, user);
|
||||||
|
refreshActionCableConnector(pubsubToken);
|
||||||
|
} catch (error) {
|
||||||
|
// Ingore error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {},
|
||||||
|
getters: {},
|
||||||
|
actions,
|
||||||
|
mutations: {},
|
||||||
|
};
|
32
app/javascript/widget/store/modules/conversationLabels.js
Normal file
32
app/javascript/widget/store/modules/conversationLabels.js
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import conversationLabels from '../../api/conversationLabels';
|
||||||
|
|
||||||
|
const state = {};
|
||||||
|
|
||||||
|
export const getters = {};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
create: async (_, label) => {
|
||||||
|
try {
|
||||||
|
await conversationLabels.create(label);
|
||||||
|
} catch (error) {
|
||||||
|
// Ingore error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy: async (_, label) => {
|
||||||
|
try {
|
||||||
|
await conversationLabels.destroy(label);
|
||||||
|
} catch (error) {
|
||||||
|
// Ingore error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mutations = {};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state,
|
||||||
|
getters,
|
||||||
|
actions,
|
||||||
|
mutations,
|
||||||
|
};
|
|
@ -1,4 +1,5 @@
|
||||||
import { updateContact } from 'widget/api/contact';
|
import MessageAPI from 'widget/api/message';
|
||||||
|
import { refreshActionCableConnector } from '../../helpers/actionCable';
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
uiFlags: {
|
uiFlags: {
|
||||||
|
@ -14,7 +15,11 @@ const actions = {
|
||||||
updateContactAttributes: async ({ commit }, { email, messageId }) => {
|
updateContactAttributes: async ({ commit }, { email, messageId }) => {
|
||||||
commit('toggleUpdateStatus', true);
|
commit('toggleUpdateStatus', true);
|
||||||
try {
|
try {
|
||||||
await updateContact({ email, messageId });
|
const {
|
||||||
|
data: {
|
||||||
|
contact: { pubsub_token: pubsubToken },
|
||||||
|
},
|
||||||
|
} = await MessageAPI.update({ email, messageId });
|
||||||
commit(
|
commit(
|
||||||
'conversation/updateMessage',
|
'conversation/updateMessage',
|
||||||
{
|
{
|
||||||
|
@ -23,6 +28,7 @@ const actions = {
|
||||||
},
|
},
|
||||||
{ root: true }
|
{ root: true }
|
||||||
);
|
);
|
||||||
|
refreshActionCableConnector(pubsubToken);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
}
|
}
|
8
app/jobs/contact_avatar_job.rb
Normal file
8
app/jobs/contact_avatar_job.rb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
class ContactAvatarJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(contact, avatar_url)
|
||||||
|
avatar_resource = LocalResource.new(avatar_url)
|
||||||
|
contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,7 +3,7 @@
|
||||||
# Table name: accounts
|
# Table name: accounts
|
||||||
#
|
#
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# locale :integer default("English")
|
# locale :integer default("eng")
|
||||||
# name :string not null
|
# name :string not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# additional_attributes :jsonb
|
# additional_attributes :jsonb
|
||||||
# email :string
|
# email :string
|
||||||
|
# identifier :string
|
||||||
# name :string
|
# name :string
|
||||||
# phone_number :string
|
# phone_number :string
|
||||||
# pubsub_token :string
|
# pubsub_token :string
|
||||||
|
@ -14,8 +15,10 @@
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_contacts_on_account_id (account_id)
|
# index_contacts_on_account_id (account_id)
|
||||||
# index_contacts_on_pubsub_token (pubsub_token) UNIQUE
|
# index_contacts_on_pubsub_token (pubsub_token) UNIQUE
|
||||||
|
# uniq_email_per_account_contact (email,account_id) UNIQUE
|
||||||
|
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
|
||||||
#
|
#
|
||||||
|
|
||||||
class Contact < ApplicationRecord
|
class Contact < ApplicationRecord
|
||||||
|
@ -23,6 +26,8 @@ class Contact < ApplicationRecord
|
||||||
include Avatarable
|
include Avatarable
|
||||||
include AvailabilityStatusable
|
include AvailabilityStatusable
|
||||||
validates :account_id, presence: true
|
validates :account_id, presence: true
|
||||||
|
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false }
|
||||||
|
validates :identifier, allow_blank: true, uniqueness: { scope: [:account_id] }
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
has_many :conversations, dependent: :destroy
|
has_many :conversations, dependent: :destroy
|
||||||
|
@ -30,6 +35,8 @@ class Contact < ApplicationRecord
|
||||||
has_many :inboxes, through: :contact_inboxes
|
has_many :inboxes, through: :contact_inboxes
|
||||||
has_many :messages, dependent: :destroy
|
has_many :messages, dependent: :destroy
|
||||||
|
|
||||||
|
before_validation :downcase_email
|
||||||
|
|
||||||
def get_source_id(inbox_id)
|
def get_source_id(inbox_id)
|
||||||
contact_inboxes.find_by!(inbox_id: inbox_id).source_id
|
contact_inboxes.find_by!(inbox_id: inbox_id).source_id
|
||||||
end
|
end
|
||||||
|
@ -49,4 +56,8 @@ class Contact < ApplicationRecord
|
||||||
name: name
|
name: name
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def downcase_email
|
||||||
|
email.downcase! if email.present?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,6 +17,10 @@ class MessageTemplates::HookExecutionService
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_send_email_collect?
|
def should_send_email_collect?
|
||||||
conversation.inbox.web_widget? && first_message_from_contact?
|
!contact_has_email? && conversation.inbox.web_widget? && first_message_from_contact?
|
||||||
|
end
|
||||||
|
|
||||||
|
def contact_has_email?
|
||||||
|
contact.email
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -30,11 +30,6 @@ class Twitter::WebhooksBaseService
|
||||||
user['id'], user['name'], additional_contact_attributes(user)
|
user['id'], user['name'], additional_contact_attributes(user)
|
||||||
)
|
)
|
||||||
@contact = @contact_inbox.contact
|
@contact = @contact_inbox.contact
|
||||||
avatar_resource = LocalResource.new(user['profile_image_url'])
|
ContactAvatarJob.perform_later(@contact, user['profile_image_url']) if user['profile_image_url']
|
||||||
@contact.avatar.attach(
|
|
||||||
io: avatar_resource.file,
|
|
||||||
filename: avatar_resource.tmp_filename,
|
|
||||||
content_type: avatar_resource.encoding
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
1
app/views/api/v1/widget/messages/update.json.jbuilder
Normal file
1
app/views/api/v1/widget/messages/update.json.jbuilder
Normal file
|
@ -0,0 +1 @@
|
||||||
|
json.contact @contact
|
|
@ -1,6 +1,11 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
window.chatwootSettings = {
|
||||||
|
hideMessageBubble: false,
|
||||||
|
};
|
||||||
|
|
||||||
(function(d,t) {
|
(function(d,t) {
|
||||||
var BASE_URL = '';
|
var BASE_URL = '';
|
||||||
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||||
|
|
|
@ -103,8 +103,10 @@ Rails.application.routes.draw do
|
||||||
resource :profile, only: [:show, :update]
|
resource :profile, only: [:show, :update]
|
||||||
|
|
||||||
namespace :widget do
|
namespace :widget do
|
||||||
resources :messages, only: [:index, :create, :update]
|
resource :contact, only: [:update]
|
||||||
resources :inbox_members, only: [:index]
|
resources :inbox_members, only: [:index]
|
||||||
|
resources :labels, only: [:create, :destroy]
|
||||||
|
resources :messages, only: [:index, :create, :update]
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :webhooks, only: [] do
|
resources :webhooks, only: [] do
|
||||||
|
|
7
db/migrate/20200331095710_add_identifier_to_contact.rb
Normal file
7
db/migrate/20200331095710_add_identifier_to_contact.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
class AddIdentifierToContact < ActiveRecord::Migration[6.0]
|
||||||
|
def change
|
||||||
|
add_column :contacts, :identifier, :string, index: true, default: nil
|
||||||
|
add_index :contacts, ['identifier', :account_id], unique: true, name: 'uniq_identifier_per_account_contact'
|
||||||
|
add_index :contacts, ['email', :account_id], unique: true, name: 'uniq_email_per_account_contact'
|
||||||
|
end
|
||||||
|
end
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2020_03_25_210612) do
|
ActiveRecord::Schema.define(version: 2020_03_31_095710) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -157,7 +157,10 @@ ActiveRecord::Schema.define(version: 2020_03_25_210612) do
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "pubsub_token"
|
t.string "pubsub_token"
|
||||||
t.jsonb "additional_attributes"
|
t.jsonb "additional_attributes"
|
||||||
|
t.string "identifier"
|
||||||
t.index ["account_id"], name: "index_contacts_on_account_id"
|
t.index ["account_id"], name: "index_contacts_on_account_id"
|
||||||
|
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
|
||||||
|
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
|
||||||
t.index ["pubsub_token"], name: "index_contacts_on_pubsub_token", unique: true
|
t.index ["pubsub_token"], name: "index_contacts_on_pubsub_token", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
45
spec/actions/contact_identify_action_spec.rb
Normal file
45
spec/actions/contact_identify_action_spec.rb
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe ::ContactIdentifyAction do
|
||||||
|
subject(:contact_identify) { described_class.new(contact: contact, params: params).perform }
|
||||||
|
|
||||||
|
let!(:account) { create(:account) }
|
||||||
|
let!(:contact) { create(:contact, account: account) }
|
||||||
|
let(:params) { { name: 'test', identifier: 'test_id' } }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
it 'updates the contact' do
|
||||||
|
expect(ContactAvatarJob).not_to receive(:perform_later).with(contact, params[:avatar_url])
|
||||||
|
contact_identify
|
||||||
|
expect(contact.reload.name).to eq 'test'
|
||||||
|
expect(contact.reload.identifier).to eq 'test_id'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'enques avatar job when avatar url parameter is passed' do
|
||||||
|
params = { name: 'test', avatar_url: 'https://via.placeholder.com/250x250.png' }
|
||||||
|
expect(ContactAvatarJob).to receive(:perform_later).with(contact, params[:avatar_url]).once
|
||||||
|
described_class.new(contact: contact, params: params).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when contact with same identifier exists' do
|
||||||
|
it 'merges the current contact to identified contact' do
|
||||||
|
existing_identified_contact = create(:contact, account: account, identifier: 'test_id')
|
||||||
|
result = contact_identify
|
||||||
|
expect(result.id).to eq existing_identified_contact.id
|
||||||
|
expect(result.name).to eq params[:name]
|
||||||
|
expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when contact with same email exists' do
|
||||||
|
it 'merges the current contact to email contact' do
|
||||||
|
existing_email_contact = create(:contact, account: account, email: 'test@test.com')
|
||||||
|
params = { email: 'test@test.com' }
|
||||||
|
result = described_class.new(contact: contact, params: params).perform
|
||||||
|
expect(result.id).to eq existing_email_contact.id
|
||||||
|
expect(result.name).to eq existing_email_contact.name
|
||||||
|
expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
42
spec/controllers/api/v1/widget/contacts_controller_spec.rb
Normal file
42
spec/controllers/api/v1/widget/contacts_controller_spec.rb
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe '/api/v1/widget/contacts', type: :request do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:web_widget) { create(:channel_widget, account: account) }
|
||||||
|
let(:contact) { create(:contact, account: account) }
|
||||||
|
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
|
||||||
|
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
|
||||||
|
let(:token) { ::Widget::TokenService.new(payload: payload).generate_token }
|
||||||
|
|
||||||
|
describe 'PATCH /api/v1/widget/contact' do
|
||||||
|
let(:params) { { website_token: web_widget.website_token, identifier: 'test' } }
|
||||||
|
|
||||||
|
context 'with invalid website token' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
patch '/api/v1/widget/contact', params: { website_token: '' }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with correct website token' do
|
||||||
|
let(:identify_action) { double }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(ContactIdentifyAction).to receive(:new).and_return(identify_action)
|
||||||
|
allow(identify_action).to receive(:perform)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calls contact identify' do
|
||||||
|
patch '/api/v1/widget/contact',
|
||||||
|
params: params,
|
||||||
|
headers: { 'X-Auth-Token' => token },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expected_params = { contact: contact, params: params }
|
||||||
|
expect(ContactIdentifyAction).to have_received(:new).with(expected_params)
|
||||||
|
expect(identify_action).to have_received(:perform)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
63
spec/controllers/api/v1/widget/labels_controller_spec.rb
Normal file
63
spec/controllers/api/v1/widget/labels_controller_spec.rb
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe '/api/v1/widget/labels', type: :request do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:web_widget) { create(:channel_widget, account: account) }
|
||||||
|
let(:contact) { create(:contact, account: account) }
|
||||||
|
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
|
||||||
|
let!(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) }
|
||||||
|
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
|
||||||
|
let(:token) { ::Widget::TokenService.new(payload: payload).generate_token }
|
||||||
|
|
||||||
|
describe 'POST /api/v1/widget/labels' do
|
||||||
|
let(:params) { { website_token: web_widget.website_token, label: 'customer-support' } }
|
||||||
|
|
||||||
|
context 'with correct website token' do
|
||||||
|
it 'returns the list of labels' do
|
||||||
|
post '/api/v1/widget/labels',
|
||||||
|
params: params,
|
||||||
|
headers: { 'X-Auth-Token' => token },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(conversation.reload.label_list.count).to eq 1
|
||||||
|
expect(conversation.reload.label_list.first).to eq 'customer-support'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid website token' do
|
||||||
|
it 'returns the list of labels' do
|
||||||
|
post '/api/v1/widget/labels', params: { website_token: '' }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'DELETE /api/v1/widget/labels' do
|
||||||
|
before do
|
||||||
|
conversation.label_list.add('customer-support')
|
||||||
|
conversation.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:params) { { website_token: web_widget.website_token, label: 'customer-support' } }
|
||||||
|
|
||||||
|
context 'with correct website token' do
|
||||||
|
it 'returns the list of labels' do
|
||||||
|
delete "/api/v1/widget/labels/#{params[:label]}",
|
||||||
|
params: params,
|
||||||
|
headers: { 'X-Auth-Token' => token },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(conversation.reload.label_list.count).to eq 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid website token' do
|
||||||
|
it 'returns the list of labels' do
|
||||||
|
delete "/api/v1/widget/labels/#{params[:label]}", params: { website_token: '' }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,7 +3,7 @@ require 'rails_helper'
|
||||||
RSpec.describe '/api/v1/widget/messages', type: :request do
|
RSpec.describe '/api/v1/widget/messages', type: :request do
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let(:web_widget) { create(:channel_widget, account: account) }
|
let(:web_widget) { create(:channel_widget, account: account) }
|
||||||
let(:contact) { create(:contact, account: account) }
|
let(:contact) { create(:contact, account: account, email: nil) }
|
||||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
|
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
|
||||||
let(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) }
|
let(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) }
|
||||||
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
|
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
|
||||||
|
|
|
@ -6,7 +6,10 @@ describe ::MessageFinder do
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
let!(:user) { create(:user, account: account) }
|
let!(:user) { create(:user, account: account) }
|
||||||
let!(:inbox) { create(:inbox, account: account) }
|
let!(:inbox) { create(:inbox, account: account) }
|
||||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
let!(:contact) { create(:contact, email: nil) }
|
||||||
|
let!(:conversation) do
|
||||||
|
create(:conversation, account: account, inbox: inbox, assignee: user, contact: contact)
|
||||||
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
create(:message, account: account, inbox: inbox, conversation: conversation)
|
create(:message, account: account, inbox: inbox, conversation: conversation)
|
||||||
|
|
|
@ -3,7 +3,10 @@ require 'rails_helper'
|
||||||
describe ::MessageTemplates::HookExecutionService do
|
describe ::MessageTemplates::HookExecutionService do
|
||||||
context 'when it is a first message from web widget' do
|
context 'when it is a first message from web widget' do
|
||||||
it 'calls ::MessageTemplates::Template::EmailCollect' do
|
it 'calls ::MessageTemplates::Template::EmailCollect' do
|
||||||
message = create(:message)
|
contact = create(:contact, email: nil)
|
||||||
|
conversation = create(:conversation, contact: contact)
|
||||||
|
message = create(:message, conversation: conversation)
|
||||||
|
|
||||||
# this hook will only get executed for conversations with out any template messages
|
# this hook will only get executed for conversations with out any template messages
|
||||||
message.conversation.messages.template.destroy_all
|
message.conversation.messages.template.destroy_all
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue