[Feature] Website live chat (#187)

Co-authored-by: Nithin David Thomas <webofnithin@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Pranav Raj S 2019-10-29 12:50:54 +05:30 committed by GitHub
parent a4114288f3
commit 16fe912fbd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 2040 additions and 106 deletions

View file

@ -1,3 +1,7 @@
linters:
LeadingZero:
enabled: false
exclude:
- 'app/javascript/widget/assets/scss/_reset.scss'
- 'app/javascript/widget/assets/scss/sdk.css'

View file

@ -53,6 +53,8 @@ gem 'telegram-bot-ruby'
gem 'twitter'
# facebook client
gem 'koala'
# Random name generator
gem 'haikunator'
##--- gems for debugging and error reporting ---##
# static analysis

View file

@ -192,6 +192,7 @@ GEM
foreman (0.86.0)
globalid (0.4.2)
activesupport (>= 4.2.0)
haikunator (1.1.0)
hashie (3.6.0)
http (3.3.0)
addressable (~> 2.3)
@ -455,6 +456,7 @@ DEPENDENCIES
faker
figaro
foreman
haikunator
hashie
jbuilder (~> 2.5)
kaminari

View file

@ -0,0 +1,26 @@
class Api::V1::Widget::InboxesController < ApplicationController
def create
ActiveRecord::Base.transaction do
channel = web_widgets.create!(
website_name: permitted_params[:website_name],
website_url: permitted_params[:website_url]
)
inbox = inboxes.create!(name: permitted_params[:website_name], channel: channel)
render json: inbox
end
end
private
def inboxes
current_account.inboxes
end
def web_widgets
current_account.web_widgets
end
def permitted_params
params.fetch(:website).permit(:website_name, :website_url)
end
end

View file

@ -1,28 +1,68 @@
class Api::V1::Widget::MessagesController < ApplicationController
# TODO: move widget apis to different controller.
skip_before_action :set_current_user, only: [:create_incoming]
skip_before_action :check_subscription, only: [:create_incoming]
skip_around_action :handle_with_exception, only: [:create_incoming]
class Api::V1::Widget::MessagesController < ActionController::Base
before_action :set_conversation, only: [:create]
def create_incoming
builder = Integrations::Widget::IncomingMessageBuilder.new(incoming_message_params)
builder.perform
render json: builder.message
def index
@messages = conversation.nil? ? [] : message_finder.perform
end
def create_outgoing
builder = Integrations::Widget::OutgoingMessageBuilder.new(outgoing_message_params)
builder.perform
render json: builder.message
def create
@message = conversation.messages.new(message_params)
@message.save!
end
private
def incoming_message_params
params.require(:message).permit(:contact_id, :inbox_id, :content)
def conversation
@conversation ||= ::Conversation.find_by(
contact_id: cookie_params[:contact_id],
inbox_id: cookie_params[:inbox_id]
)
end
def outgoing_message_params
params.require(:message).permit(:user_id, :inbox_id, :content, :conversation_id)
def set_conversation
@conversation = ::Conversation.create!(conversation_params) if conversation.nil?
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :incoming,
content: permitted_params[:content]
}
end
def conversation_params
{
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: cookie_params[:contact_id]
}
end
def inbox
@inbox ||= ::Inbox.find_by(id: cookie_params[:inbox_id])
end
def cookie_params
JSON.parse(cookies.signed[cookie_name]).symbolize_keys
end
def message_finder_params
{
filter_internal_messages: true
}
end
def message_finder
@message_finder ||= MessageFinder.new(conversation, message_finder_params)
end
def cookie_name
'cw_conversation_' + params[:website_token]
end
def permitted_params
params.fetch(:message).permit(:content)
end
end

View file

@ -0,0 +1,47 @@
class WidgetsController < ActionController::Base
before_action :set_web_widget
before_action :set_contact
before_action :build_contact
private
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
end
def set_contact
return if cookie_params[:source_id].nil?
contact_inbox = ::ContactInbox.find_by(
inbox_id: @web_widget.inbox.id,
source_id: cookie_params[:source_id]
)
@contact = contact_inbox.contact
end
def build_contact
return if @contact.present?
contact_inbox = @web_widget.create_contact_inbox
@contact = contact_inbox.contact
cookies.signed[cookie_name] = JSON.generate(
source_id: contact_inbox.source_id,
contact_id: @contact.id,
inbox_id: @web_widget.inbox.id
).to_s
end
def cookie_params
cookies.signed[cookie_name] ? JSON.parse(cookies.signed[cookie_name]).symbolize_keys : {}
end
def permitted_params
params.permit(:website_token)
end
def cookie_name
'cw_conversation_' + permitted_params[:website_token]
end
end

View file

@ -10,11 +10,17 @@ class MessageFinder
private
def messages
return @conversation.messages if @params[:filter_internal_messages].blank?
@conversation.messages.where.not('private = ? OR message_type = ?', true, 2)
end
def current_messages
if @params[:before].present?
@conversation.messages.reorder('created_at desc').where('id < ?', @params[:before]).limit(20).reverse
messages.reorder('created_at desc').where('id < ?', @params[:before]).limit(20).reverse
else
@conversation.messages.reorder('created_at desc').limit(20).reverse
messages.reorder('created_at desc').limit(20).reverse
end
end
end

View file

@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class WebChannel extends ApiClient {
constructor() {
super('widget/inboxes');
}
}
export default new WebChannel();

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -25,3 +25,14 @@
border-radius: $space-smaller;
font-size: $font-size-mini;
}
code {
border: 0;
font-family: 'Monaco';
font-size: $font-size-mini;
&.hljs {
background: $color-background;
padding: $space-two;
}
}

View file

@ -121,7 +121,7 @@ export default {
fetchData() {
if (this.chatLists.length === 0) {
this.$store.dispatch('fetchAllConversations', {
inbox: this.conversationInbox,
inboxId: this.conversationInbox ? this.conversationInbox : undefined,
assigneeStatus: this.allMessageType,
convStatus: this.activeStatusTab,
});

View file

@ -22,7 +22,7 @@
<ul v-if="menuItem.hasSubMenu" class="nested vertical menu">
<router-link
v-for="child in menuItem.children"
:key="child.label"
:key="child.id"
active-class="active flex-container"
:class="computedInboxClass(child)"
tag="li"

View file

@ -1,22 +1,50 @@
<template>
<div class="small-3 columns channel" :class="{ inactive: channel !== 'facebook' }" @click.capture="itemClick">
<img src="~dashboard/assets/images/channels/facebook.png" v-if="channel === 'facebook'">
<img src="~dashboard/assets/images/channels/twitter.png" v-if="channel === 'twitter'">
<img src="~dashboard/assets/images/channels/telegram.png" v-if="channel === 'telegram'">
<img src="~dashboard/assets/images/channels/line.png" v-if="channel === 'line'">
<h3 class="channel__title">{{channel}}</h3>
<!-- <p>This is the most sexiest integration to begin </p> -->
<div
class="small-3 columns channel"
:class="{ inactive: !isActive(channel) }"
@click="onItemClick"
>
<img
v-if="channel === 'facebook'"
src="~dashboard/assets/images/channels/facebook.png"
/>
<img
v-if="channel === 'twitter'"
src="~dashboard/assets/images/channels/twitter.png"
/>
<img
v-if="channel === 'telegram'"
src="~dashboard/assets/images/channels/telegram.png"
/>
<img
v-if="channel === 'line'"
src="~dashboard/assets/images/channels/line.png"
/>
<img
v-if="channel === 'website'"
src="~dashboard/assets/images/channels/website.png"
/>
<h3 class="channel__title">
{{ channel }}
</h3>
</div>
</template>
<script>
/* global bus */
export default {
props: ['channel'],
created() {
props: {
channel: {
type: String,
required: true,
},
},
methods: {
itemClick() {
bus.$emit('channelItemClick', this.channel);
isActive(channel) {
return ['facebook', 'website'].includes(channel);
},
onItemClick() {
if (this.isActive(this.channel)) {
this.$emit('channel-item-click', this.channel);
}
},
},
};

View file

@ -4,9 +4,6 @@ export default {
return `${this.APP_BASE_URL}/`;
},
GRAVATAR_URL: 'https://www.gravatar.com/avatar/',
CHANNELS: {
FACEBOOK: 'facebook',
},
ASSIGNEE_TYPE_SLUG: {
MINE: 0,
UNASSIGNED: 1,

View file

@ -1,20 +1,9 @@
import { createConsumer } from '@rails/actioncable';
import AuthAPI from '../api/auth';
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
class ActionCableConnector {
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
const consumer = createConsumer();
consumer.subscriptions.create(
{
channel: 'RoomChannel',
pubsub_token: pubsubToken,
},
{
received: this.onReceived,
}
);
this.app = app;
super(app, pubsubToken);
this.events = {
'message.created': this.onMessageCreated,
'conversation.created': this.onConversationCreated,
@ -43,12 +32,6 @@ class ActionCableConnector {
this.app.$store.dispatch('addMessage', data);
};
onReceived = ({ event, data } = {}) => {
if (this.events[event]) {
this.events[event](data);
}
};
onReload = () => window.location.reload();
onStatusChange = data => {

View file

@ -15,6 +15,19 @@
"FB": {
"HELP": "PS: By signing in, we only get access to your Page's messages. Your private messages can never be accessed by Chatwoot."
},
"WEBSITE_CHANNEL": {
"TITLE": "Website channel",
"DESC": "Create a channel for your website and start supporting your customers via our website widget.",
"CHANNEL_NAME": {
"LABEL": "Website Name",
"PLACEHOLDER": "Enter your website name (eg: Acme Inc)"
},
"CHANNEL_DOMAIN": {
"LABEL": "Website Domain",
"PLACEHOLDER": "Enter your website domain (eg: acme.com)"
},
"SUBMIT_BUTTON":"Create inbox"
},
"AUTH": {
"TITLE": "Channels",
"DESC": "Currently we support only Facebook Pages as a platform. We have more platforms like Twitter, Telegram and Line in the works, which will be out soon."

View file

@ -9,14 +9,14 @@
v-for="channel in channelList"
:key="channel"
:channel="channel"
@channel-item-click="initChannelAuth"
/>
</div>
</div>
</template>
<script>
/* global bus */
import ChannelItem from '../../../../components/widgets/ChannelItem';
import ChannelItem from 'dashboard/components/widgets/ChannelItem';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader';
@ -27,22 +27,16 @@ export default {
},
data() {
return {
channelList: ['facebook', 'twitter', 'telegram', 'line'],
channelList: ['website', 'facebook', 'twitter', 'telegram', 'line'],
};
},
created() {
bus.$on('channelItemClick', channel => {
this.initChannelAuth(channel);
});
},
methods: {
initChannelAuth(channel) {
if (channel === 'facebook') {
router.push({
name: 'settings_inboxes_page_channel',
params: { page: 'new', sub_page: 'facebook' },
});
}
const params = {
page: 'new',
sub_page: channel,
};
router.push({ name: 'settings_inboxes_page_channel', params });
},
},
};

View file

@ -15,7 +15,7 @@
<table v-if="inboxesList.length" class="woot-table">
<tbody>
<tr v-for="item in inboxesList" :key="item.label">
<tr v-for="item in inboxesList" :key="item.id">
<td>
<img
class="woot-thumbnail"
@ -26,7 +26,12 @@
<!-- Short Code -->
<td>
<span class="agent-name">{{ item.label }}</span>
<span>Facebook</span>
<span v-if="item.channelType === 'Channel::FacebookPage'">
Facebook
</span>
<span v-if="item.channelType === 'Channel::WebWidget'">
Website
</span>
</td>
<!-- Action Buttons -->

View file

@ -5,7 +5,10 @@
:header-image="inbox.avatarUrl"
:header-title="inbox.label"
/>
<div class="code-wrapper">
<div
v-if="inbox.channelType === 'Channel::FacebookPage'"
class="code-wrapper"
>
<p class="title">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_HEADING') }}
</p>
@ -18,6 +21,20 @@
</code>
</p>
</div>
<div
v-else-if="inbox.channelType === 'Channel::WebWidget'"
class="code-wrapper"
>
<p class="title">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_HEADING') }}
</p>
<p class="sub-head">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_SUB_HEAD') }}
</p>
<highlight-code lang="javascript">
{{ webWidgetScript }}
</highlight-code>
</div>
<div class="agent-wrapper">
<p class="title">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS') }}
@ -53,6 +70,7 @@
/* eslint-disable no-useless-escape */
/* global bus */
import { mapGetters } from 'vuex';
import 'highlight.js/styles/default.css';
export default {
props: ['onClose', 'inbox', 'show'],
@ -83,6 +101,20 @@ export default {
color="blue"
size="standard" >
</div>`,
webWidgetScript: `
(function(d,t) {
var BASE_URL = '${window.location.origin}';
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src= BASE_URL + "/packs/js/sdk.js";
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken: '${this.inbox.websiteToken}',
baseUrl: BASE_URL
})
}
})(document,"script");
`,
};
},
computed: {

View file

@ -1,19 +1,23 @@
import CONSTANTS from '../../../../constants';
import FacebookView from './Facebook';
import Facebook from './channels/Facebook';
import Website from './channels/Website';
const channelViewList = {
facebook: Facebook,
website: Website,
};
export default {
create() {
return {
name: 'new-channel-view',
render(h) {
if (this.channel_name === CONSTANTS.CHANNELS.FACEBOOK) {
return h(FacebookView);
}
return null;
},
props: {
channel_name: String,
channel_name: {
type: String,
required: true,
},
},
name: 'new-channel-view',
render(h) {
return h(channelViewList[this.channel_name] || null);
},
};
},

View file

@ -64,14 +64,13 @@
</div>
</template>
<script>
/* eslint no-console: 0 */
/* eslint-env browser */
/* global FB */
import { required } from 'vuelidate/lib/validators';
import ChannelApi from '../../../../api/channels';
import LoadingState from '../../../../components/widgets/LoadingState';
import PageHeader from '../SettingsSubPageHeader';
import router from '../../../index';
import LoadingState from 'dashboard/components/widgets/LoadingState';
import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader';
import router from '../../../../index';
export default {
components: {

View file

@ -0,0 +1,83 @@
<template>
<div class="wizard-body small-9 columns">
<page-header
:header-title="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.DESC')"
/>
<loading-state
v-if="isCreating"
message="Creating Website Support Channel"
></loading-state>
<form v-if="!isCreating" class="row" @submit.prevent="createChannel()">
<div class="medium-12 columns">
<label>
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.LABEL') }}
<input
v-model.trim="websiteName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.PLACEHOLDER')
"
/>
</label>
</div>
<div class="medium-12 columns">
<label>
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL') }}
<input
v-model.trim="websiteUrl"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.PLACEHOLDER')
"
/>
</label>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-submit-button
:button-text="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.SUBMIT_BUTTON')"
/>
</div>
</div>
</form>
</div>
</template>
<script>
/* global bus */
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader';
export default {
components: {
PageHeader,
},
data() {
return {
websiteName: '',
websiteUrl: '',
isCreating: false,
};
},
mounted() {
bus.$on('new_website_channel', ({ inboxId }) => {
router.replace({
name: 'settings_inboxes_add_agents',
params: { page: 'new', inbox_id: inboxId },
});
});
},
methods: {
createChannel() {
this.isCreating = true;
this.$store.dispatch('addWebsiteChannel', {
website: {
website_name: this.websiteName,
website_url: this.websiteUrl,
},
});
},
},
};
</script>

View file

@ -1,13 +1,14 @@
/* eslint no-console: 0 */
/* eslint-env browser */
/* eslint no-param-reassign: 0 */
/* global bus */
// import * as types from '../mutation-types';
import defaultState from '../../i18n/default-sidebar';
import * as types from '../mutation-types';
import Account from '../../api/account';
import ChannelApi from '../../api/channels';
import { frontendURL } from '../../helper/URLHelper';
import WebChannel from '../../api/channel/webChannel';
const state = defaultState;
// inboxes fetch flag
@ -66,6 +67,15 @@ const actions = {
});
});
},
addWebsiteChannel: async ({ commit }, params) => {
try {
const response = await WebChannel.create(params);
commit(types.default.SET_INBOX_ITEM, response);
bus.$emit('new_website_channel', { inboxId: response.data.id });
} catch (error) {
// Handle error
}
},
addInboxItem({ commit }, { channel, params }) {
const donePromise = new Promise(resolve => {
ChannelApi.createChannel(channel, params)
@ -137,9 +147,10 @@ const mutations = {
channel_id: item.id,
label: item.name,
toState: frontendURL(`inbox/${item.id}`),
channelType: item.channelType,
channelType: item.channel_type,
avatarUrl: item.avatar_url,
pageId: item.page_id,
websiteToken: item.website_token,
}));
// Identify menuItem to update
// May have more than one object to update
@ -156,7 +167,7 @@ const mutations = {
channel_id: data.id,
label: data.name,
toState: frontendURL(`inbox/${data.id}`),
channelType: data.channelType,
channelType: data.channel_type,
avatarUrl: data.avatar_url === undefined ? null : data.avatar_url,
pageId: data.page_id,
});

View file

@ -16,6 +16,8 @@ import WootWizard from 'components/ui/Wizard';
import { sync } from 'vuex-router-sync';
import Vuelidate from 'vuelidate';
import VTooltip from 'v-tooltip';
import VueHighlightJS from 'vue-highlight.js';
import javascript from 'highlight.js/lib/languages/javascript';
import WootUiKit from '../dashboard/components';
import App from '../dashboard/App';
@ -34,6 +36,11 @@ Vue.use(VueI18n);
Vue.use(WootUiKit);
Vue.use(Vuelidate);
Vue.use(VTooltip);
Vue.use(VueHighlightJS, {
languages: {
javascript,
},
});
Vue.component('multiselect', Multiselect);
Vue.component('woot-switch', WootSwitch);

160
app/javascript/packs/sdk.js Executable file
View file

@ -0,0 +1,160 @@
import sdkStyles from '../widget/assets/scss/sdk.css';
/* eslint-disable no-param-reassign */
const bubbleImg =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg==';
const closeImg =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAP1BMVEUAAAD///////////////////////////////////////////////////////////////////////////////9Du/pqAAAAFHRSTlMACBstLi8wMVB+mcbT2err7O3w8n+sjtQAAAEuSURBVHgBtNLdcoMgGITh1SCGH9DId//X2mnTg7hYxj0oh8w8r+MqgDnmlsIE6UwhtRxnAHge9n2KV7wvP+h4AvPbm73W+359/aJjRjQTCuTNIrJJBfKW0UwqkLeGZJ8Ff2O/T28JwZQCewuYilJgX6buavdDv188br1RIE+jc2H5yy+9VwrXXij0nsflwth7YFRw7N3Y88BcYL+z7wubO/lt6AcFwQMLF9irP8r2eF8/ei8VLrxUkDzguMDejX03WK3dsGJB9lxgrxd0T8PTRxUL5OUCealQz76KXg/or/CvI36VXgcEAAAgCMP6t16IZVDg3zPuI+0rb5g2zlsoW2lbqlvrOyw7bTuuO+8LGIs4C1mLeQuai7oL2437LRytPC1drX0tnq2+Ld+r/wDPIIIJkfdlbQAAAABJRU5ErkJggg==';
const body = document.getElementsByTagName('body')[0];
const iframe = document.createElement('iframe');
const holder = document.createElement('div');
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 addClass(elm, classes) {
if (classes) {
elm.className += ` ${classes}`;
}
}
function loadCSS() {
const css = document.createElement('style');
css.type = 'text/css';
css.innerHTML = sdkStyles;
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 classarray;
let search;
let replace;
let i;
let has = false;
if (classes) {
// Trim any whitespace
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;
}
// 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(chatBubble, 'click', bubbleClickCallback);
wootOn(closeBubble, 'click', bubbleClickCallback);
}
function disableScroll() {
document.body.style.overflow = 'hidden';
}
function enableScroll() {
document.body.style.overflow = bodyOverFlowStyle;
}
function loadCallback() {
iframe.style.visibility = '';
iframe.setAttribute('id', `chatwoot_live_chat_widget`);
iframe.onmouseenter = disableScroll;
iframe.onmouseleave = enableScroll;
loadCSS();
createBubbleHolder();
bubbleHolder.appendChild(
createBubbleIcon({
className: 'woot-widget-bubble',
src: bubbleImg,
target: chatBubble,
})
);
bubbleHolder.appendChild(
createBubbleIcon({
className: 'woot-widget-bubble woot--close woot--hide',
src: closeImg,
target: closeBubble,
})
);
bubbleHolder.appendChild(createNotificationBubble());
onClickChatBubble();
}
function loadIframe({ websiteToken, baseUrl }) {
iframe.style.visibility = 'hidden';
iframe.src = `${baseUrl}/widgets?website_token=${websiteToken}`;
iframe.onload = loadCallback;
holder.className = 'woot-widget-holder woot--hide';
holder.appendChild(iframe);
body.appendChild(holder);
}
window.chatwootSDK = {
run: loadIframe,
};

View file

@ -0,0 +1,19 @@
import Vue from 'vue';
import store from '../widget/store';
import App from '../widget/App.vue';
import router from '../widget/router';
import ActionCableConnector from '../widget/helpers/actionCable';
Vue.config.productionTip = false;
window.onload = () => {
window.WOOT_WIDGET = new Vue({
router,
store,
render: h => h(App),
}).$mount('#app');
window.actionCable = new ActionCableConnector(
window.WOOT_WIDGET,
window.chatwootPubsubToken
);
};

View file

@ -0,0 +1,26 @@
import { createConsumer } from '@rails/actioncable';
class BaseActionCableConnector {
constructor(app, pubsubToken) {
const consumer = createConsumer();
consumer.subscriptions.create(
{
channel: 'RoomChannel',
pubsub_token: pubsubToken,
},
{
received: this.onReceived,
}
);
this.app = app;
this.events = {};
}
onReceived = ({ event, data } = {}) => {
if (this.events[event] && typeof this.events[event] === 'function') {
this.events[event](data);
}
};
}
export default BaseActionCableConnector;

25
app/javascript/widget/App.vue Executable file
View file

@ -0,0 +1,25 @@
<template>
<div id="app" class="woot-widget-wrap">
<router-view />
</div>
</template>
<script>
import { mapActions } from 'vuex';
export default {
name: 'App',
methods: {
...mapActions('conversation', ['fetchOldConversations']),
},
mounted() {
this.fetchOldConversations();
},
};
</script>
<style lang="scss">
@import '~widget/assets/scss/woot.scss';
</style>

View file

@ -0,0 +1,12 @@
import authEndPoint from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios';
const createContact = async (inboxId, accountId) => {
const urlData = authEndPoint.createContact(inboxId, accountId);
const result = await API.post(urlData.url, urlData.params);
return result;
};
export default {
createContact,
};

View file

@ -0,0 +1,16 @@
import endPoints from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios';
const sendMessageAPI = async content => {
const urlData = endPoints.sendMessage(content);
const result = await API.post(urlData.url, urlData.params);
return result;
};
const getConversationAPI = async conversationId => {
const urlData = endPoints.getConversation(conversationId);
const result = await API.get(urlData.url);
return result;
};
export { sendMessageAPI, getConversationAPI };

View file

@ -0,0 +1,17 @@
const sendMessage = content => ({
url: `/api/v1/widget/messages${window.location.search}`,
params: {
message: {
content,
},
},
});
const getConversation = () => ({
url: `/api/v1/widget/messages${window.location.search}`,
});
export default {
sendMessage,
getConversation,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="21px" height="21px" viewBox="0 0 21 21" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53 (72520) - https://sketchapp.com -->
<title>Untitled</title>
<desc>Created with Sketch.</desc>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="message-send" fill="#FFFFFF" fill-rule="nonzero">
<path d="M18.34,7.32 L4.34,0.32 C3.20803579,-0.243393454 1.84434515,-0.0365739638 0.930331262,0.837115781 C0.0163173744,1.71080553 -0.251780361,3.06378375 0.26,4.22 L2.66,9.59 L2.66,9.59 C2.77000426,9.8522654 2.77000426,10.1477346 2.66,10.41 L0.26,15.78 C-0.153051509,16.7079201 -0.0685371519,17.7818234 0.48458191,18.6337075 C1.03770097,19.4855916 1.98429967,19.9997529 3,20 C3.46823099,19.9953274 3.9294892,19.8859921 4.35,19.68 L18.35,12.68 C19.3627539,12.1705304 20.001816,11.1336797 20.001816,10 C20.001816,8.86632027 19.3627539,7.82946961 18.35,7.32 L18.34,7.32 Z M17.45,10.89 L3.45,17.89 C3.07351737,18.0707705 2.62434212,17.9985396 2.32351279,17.7088521 C2.02268345,17.4191646 1.93356002,16.9730338 2.1,16.59 L4.49,11.22 C4.5209392,11.1482915 4.54765161,11.0748324 4.57,11 L11.46,11 C12.0122847,11 12.46,10.5522847 12.46,10 C12.46,9.44771525 12.0122847,9 11.46,9 L4.57,9 C4.54765161,8.9251676 4.5209392,8.85170847 4.49,8.78 L2.1,3.41 C1.93356002,3.02696622 2.02268345,2.5808354 2.32351279,2.2911479 C2.62434212,2.00146039 3.07351737,1.92922952 3.45,2.11 L17.45,9.11 C17.7839662,9.28109597 17.9940395,9.62475706 17.9940395,10 C17.9940395,10.3752429 17.7839662,10.718904 17.45,10.89 Z" id="Shape"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,57 @@
$button-border-width: 1px;
// Buttons
.button {
appearance: none;
background: $color-primary;
border: $button-border-width solid $color-primary;
border-radius: $border-radius;
color: $color-white;
cursor: pointer;
display: inline-block;
font-size: $font-size-default;
height: $space-two * 2;
line-height: $line-height;
outline: none;
padding: $space-smaller $space-normal;
text-align: center;
text-decoration: none;
transition: background .2s, border .2s, box-shadow .2s, color .2s;
user-select: none;
vertical-align: middle;
white-space: nowrap;
&:focus,
&:hover {
background: lighten($color-primary, 7%);
border-color: $color-primary;
text-decoration: none;
}
&:active,
&.active {
background: $color-primary;
border-color: darken($color-primary, 5%);
color: lighten($color-primary, 20%);
text-decoration: none;
}
&[disabled],
&:disabled,
&.disabled {
cursor: default;
opacity: .5;
pointer-events: none;
}
&.small {
font-size: $font-size-small;
height: $space-medium;
padding: $space-smaller $space-slab;
}
&.large {
font-size: $font-size-medium;
height: $space-larger;
padding: $space-small $space-medium;
}
}

View file

@ -0,0 +1,71 @@
// scss-lint:disable PropertySortOrder DeclarationOrder QualifyingElement
$form-border-width: 1px;
$input-height: $space-two * 2;
.form-input {
@include placeholder {
color: $color-gray;
}
appearance: none;
background: $color-white;
border: $form-border-width solid $color-border;
border-radius: $border-radius;
box-sizing: border-box;
color: $color-body;
display: block;
font-size: $font-size-default;
height: $input-height;
line-height: 1.3;
max-width: 100%;
outline: none;
padding: $space-small $space-slab;
position: relative;
transition: background .2s, border .2s, box-shadow .2s, color .2s;
width: 100%;
&:focus {
border-color: $color-primary;
}
&::placeholder {
color: $color-gray;
}
// Input sizes
&.small {
font-size: $font-size-small;
height: $space-large;
padding: $space-small $space-slab;
}
&.large {
font-size: $font-size-medium;
height: $space-larger;
padding: $space-slab $space-two;
}
&.input-inline {
display: inline-block;
vertical-align: middle;
width: auto;
}
// Input types
&[type="file"] {
height: auto;
}
}
// Form element: Textarea
textarea.form-input {
@include placeholder {
color: $color-light-gray;
}
&,
&.large,
&.small {
height: auto;
}
}

View file

@ -0,0 +1,20 @@
// scss-lint:disable PseudoElement SpaceBeforeBrace VendorPrefix
$shadow-color-1: rgba(50, 50, 93, 0.2);
$shadow-color-2: rgba(0, 0, 0, 0.07);
$shadow-color-3: rgba(50, 50, 93, .08);
$shadow-color-4: rgba(0, 0, 0, .05);
@mixin normal-shadow {
box-shadow: 0 $space-small $space-normal $shadow-color-1, 0 $space-smaller $space-slab $shadow-color-2;
}
@mixin light-shadow {
box-shadow: 0 $space-smaller 6px $shadow-color-3, 0 1px 3px $shadow-color-4;
}
@mixin placeholder {
&::-webkit-input-placeholder {@content}
&:-moz-placeholder {@content}
&::-moz-placeholder {@content}
&:-ms-input-placeholder {@content}
}

View file

@ -0,0 +1,54 @@
// scss-lint:disable
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

View file

@ -0,0 +1,90 @@
// Font sizes
$font-size-nano: 0.8rem;
$font-size-micro: 0.8rem;
$font-size-mini: 1rem;
$font-size-small: 1.2rem;
$font-size-default: 1.4rem;
$font-size-medium: 1.6rem;
$font-size-large: 2rem;
$font-size-big: 2.4rem;
$font-size-bigger: 3.2rem;
$font-size-mega: 4rem;
$font-size-giga: 5.6rem;
// spaces
$zero: 0;
$space-micro: 0.2rem;
$space-smaller: 0.4rem;
$space-small: 0.8rem;
$space-one: 1rem;
$space-slab: 1.2rem;
$space-normal: 1.6rem;
$space-two: 2rem;
$space-medium: 2.4rem;
$space-large: 3.2rem;
$space-larger: 4.8rem;
$space-big: 6.4rem;
$space-jumbo: 8rem;
$space-mega: 10rem;
// font-weight
$font-weight-feather: 100;
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-bold: 600;
$font-weight-black: 700;
//Navbar
$nav-bar-width: 23rem;
$header-height: 5.6rem;
// Woot Logo
$woot-logo-width: 20rem;
$woot-logo-height: 8rem;
$woot-logo-padding: $space-large $space-large $space-large $space-large;
// Colors
$color-woot: #1f93ff;
$color-primary: $color-woot;
$color-gray: #6e6f73;
$color-light-gray: #999a9b;
$color-border: #e0e6ed;
$color-border-transparent: rgba(224, 230, 237, 0.5);
$color-border-light: #f0f4f5;
$color-background: #ecf3f9;
$color-background-light: #fafafa;
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;
$color-modal-header: #f1f1f1;
// Thumbnail
$thumbnail-radius: 4rem;
// chat-header
$conv-header-height: 4rem;
// login
// Inbox List
$inbox-thumb-size: 4.8rem;
// Spinner
$spinkit-spinner-color: $color-white !default;
$spinkit-spinner-margin: 0 0 0 1.6rem !default;
$spinkit-size: 1.6rem !default;
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
$swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
$border-radius: 3px;
$line-height: 1;
$footer-height: 11.2rem;
$header-expanded-height: $space-medium * 10;

View file

@ -0,0 +1,65 @@
.woot-widget-holder {
z-index: 2147483000!important;
position: fixed!important;
bottom: 104px;
right: 20px;
height: calc(85% - 64px - 20px);
width: 370px!important;
min-height: 250px!important;
max-height: 590px!important;
-moz-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
-o-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
-webkit-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
-o-border-radius: 8px!important;
-moz-border-radius: 8px!important;
-webkit-border-radius: 8px!important;
border-radius: 8px!important;
overflow: hidden!important;
opacity: 1!important;
}
.woot-widget-holder iframe { width: 100% !important; height: 100% !important; border: 0; }
.woot-widget-bubble {
z-index: 2147483000!important;
-moz-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
-o-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
-webkit-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
-o-border-radius: 100px!important;
-moz-border-radius: 100px!important;
-webkit-border-radius: 100px!important;
border-radius: 100px!important;
background: #1f93ff;
position: fixed;
cursor: pointer;
right: 20px;
bottom: 20px;
width: 64px!important;
height: 64px!important;
}
.woot-widget-bubble:hover {
background: #1f93ff;
-moz-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
-o-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
-webkit-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
}
.woot-widget-bubble img {
width: 24px;
height: 24px;
margin: 20px;
}
.woot-widget-bubble.woot--close img {
width: 16px;
height: 16px;
margin: 24px;
}
.woot--hide {
display: none !important;
}

View file

@ -0,0 +1,16 @@
@import 'variables';
@import 'buttons';
@import 'mixins';
@import 'forms';
@import 'reset';
html,
body {
font-family: -apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
font-size: 10px;
height: 100%;
}
.woot-widget-wrap {
height: 100%;
}

View file

@ -0,0 +1,83 @@
<template>
<div class="agent-message">
<div class="avatar-wrap">
<UserAvatar size="small" :src="avatarUrl" />
</div>
<div class="message-wrap">
<h5 class="agent-name">
{{ agentName }}
</h5>
<AgentMessageBubble :message="message" />
</div>
</div>
</template>
<script>
import UserAvatar from 'widget/components/UserAvatar.vue';
import AgentMessageBubble from 'widget/components/AgentMessageBubble.vue';
export default {
name: 'AgentMessage',
components: {
UserAvatar,
AgentMessageBubble,
},
props: {
message: String,
avatarUrl: String,
agentName: String,
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.agent-message {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-end;
margin: 0 $space-smaller $space-micro auto;
& + .agent-message {
margin-bottom: $space-micro;
.chat-bubble {
border-top-left-radius: $space-smaller;
}
.user-avatar {
visibility: hidden;
}
.agent-name {
display: none;
}
}
& + .user-message {
margin-bottom: $space-normal;
}
.avatar-wrap {
flex-shrink: 1;
flex-grow: 0;
}
.message-wrap {
max-width: 90%;
flex-shrink: 0;
flex-grow: 1;
margin-left: $space-small;
.agent-name {
font-weight: $font-weight-medium;
margin-bottom: $space-smaller;
margin-left: $space-two;
color: $color-body;
}
}
}
</style>

View file

@ -0,0 +1,27 @@
<template>
<div class="chat-bubble agent">
{{ message }}
</div>
</template>
<script>
export default {
name: 'AgentMessageBubble',
props: {
message: String,
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss">
@import '~widget/assets/scss/variables.scss';
.chat-bubble {
&.agent {
background: $color-white;
border-bottom-left-radius: $space-smaller;
color: $color-body;
}
}
</style>

View file

@ -0,0 +1,33 @@
<template>
<footer class="footer">
<ChatInputWrap :on-send-message="onSendMessage" />
</footer>
</template>
<script>
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
export default {
components: {
ChatInputWrap,
},
props: {
msg: String,
onSendMessage: Function,
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.footer {
background: $color-white;
box-shadow: 0 -$space-micro 3px rgba(50, 50, 93, 0.04),
0 -1px 2px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
padding: $space-small;
width: 100%;
}
</style>

View file

@ -0,0 +1,51 @@
<template>
<header class="header-expanded">
<div>
<h2 class="title">
{{ introHeading }}
</h2>
<p class="body">
{{ introBody }}
</p>
</div>
</header>
</template>
<script>
export default {
name: 'ChatHeaderExpanded',
props: {
introHeading: {
type: String,
default: 'Hi there ! 🙌🏼',
},
introBody: {
type: String,
default:
'We make it simple to connect with us. Ask us anything, or share your feedback.',
},
},
};
</script>
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.header-expanded {
background: $color-woot;
padding: $space-large;
width: 100%;
box-sizing: border-box;
color: $color-white;
.title {
font-size: $font-size-mega;
margin-bottom: $space-two;
}
.body {
font-size: $font-size-medium;
line-height: 1.5;
}
}
</style>

View file

@ -0,0 +1,29 @@
<template>
<textarea
class="form-input user-message-input"
:placeholder="placeholder"
:value="value"
@input="$emit('input', $event.target.value)"
/>
</template>
<script>
export default {
props: {
placeholder: String,
value: String,
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.user-message-input {
border-color: $color-white;
border-bottom-color: $color-border-light;
height: $space-big;
resize: none;
}
</style>

View file

@ -0,0 +1,65 @@
<template>
<div class="input-wrap">
<div>
<ChatInputArea v-model="userInput" :placeholder="placeholder" />
</div>
<div class="message-button-wrap">
<ChatSendButton
:on-click="handleButtonClick"
:disabled="!userInput.length"
/>
</div>
</div>
</template>
<script>
import ChatSendButton from 'widget/components/ChatSendButton.vue';
import ChatInputArea from 'widget/components/ChatInputArea.vue';
export default {
name: 'ChatInputWrap',
components: {
ChatSendButton,
ChatInputArea,
},
props: {
placeholder: {
type: String,
default: 'Type your message',
},
onSendMessage: {
type: Function,
default: () => {},
},
},
data() {
return {
userInput: '',
};
},
methods: {
handleButtonClick() {
if (this.userInput) {
this.onSendMessage(this.userInput);
}
this.userInput = '';
},
},
};
</script>
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.input-wrap {
.message-button-wrap {
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: $space-small;
}
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<UserMessage v-if="isUserMessage" :message="message.content" />
<AgentMessage
v-else
:agent-name="message.sender_name"
:message="message.content"
/>
</template>
<script>
import AgentMessage from 'widget/components/AgentMessage.vue';
import UserMessage from 'widget/components/UserMessage.vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
export default {
components: {
AgentMessage,
UserMessage,
},
props: {
message: Object,
},
computed: {
isUserMessage() {
return this.message.message_type === MESSAGE_TYPE.INCOMING;
},
},
};
</script>
<style scoped lang="scss">
.message-wrap {
display: flex;
flex-direction: row;
align-items: flex-end;
max-width: 90%;
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<button
type="submit"
:disabled="disabled"
class="button send-button"
@click="onClick"
>
<span v-if="!loading" class="icon-holder">
<img src="~widget/assets/images/message-send.svg" />
<span>Send</span>
</span>
<spinner v-else size="small" />
</button>
</template>
<script>
import Spinner from 'widget/components/Spinner.vue';
export default {
components: {
Spinner,
},
props: {
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
onClick: {
type: Function,
default: () => {},
},
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.send-button {
align-items: center;
display: flex;
justify-content: space-around;
min-width: $space-big;
position: relative;
.icon-holder {
display: flex;
align-items: center;
justify-content: center;
fill: $color-white;
font-weight: $font-weight-medium;
img {
margin-right: $space-small;
}
}
}
</style>

View file

@ -0,0 +1,33 @@
<template>
<section class="conversation">
<ChatMessage
v-for="message in messages"
:key="message.id"
:message="message"
/>
</section>
</template>
<script>
import ChatMessage from 'widget/components/ChatMessage.vue';
export default {
name: 'ConversationWrap',
components: {
ChatMessage,
},
props: {
messages: Object,
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.conversation {
height: 100%;
padding: $space-large $space-small $space-large $space-normal;
}
</style>

View file

@ -0,0 +1,31 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
</div>
</template>
<script>
export default {
props: {
msg: String,
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

View file

@ -0,0 +1,52 @@
<template>
<span class="spinner" :class="size"></span>
</template>
<script>
const SIZES = ['small', 'medium', 'large'];
export default {
props: {
size: {
validator: value => SIZES.includes(value),
},
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.spinner {
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
&:before {
animation: spinner 0.7s linear infinite;
border-radius: 50%;
border-top-color: lighten($color-woot, 10%);
border: 2px solid rgba(255, 255, 255, 0.7);
box-sizing: border-box;
content: '';
height: $space-medium;
left: 50%;
margin-left: -$space-slab;
margin-top: -$space-slab;
position: absolute;
top: 50%;
width: $space-medium;
}
&.small:before {
border-width: 1px;
height: $space-slab;
margin-left: -$space-slab/2;
margin-top: -$space-slab/2;
width: $space-slab;
}
}
</style>

View file

@ -0,0 +1,46 @@
<template>
<div class="user-avatar" :class="size" :style="getBgImage"></div>
</template>
<script>
/**
* Thumbnail Component
* Src - source for round image
*/
export default {
name: 'UserAvatar',
props: {
src: {
type: String,
},
size: {
type: String,
},
},
computed: {
getBgImage() {
if (this.src) return { 'background-image': `url(${this.src})` };
return {};
},
},
};
</script>
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
@import '~widget/assets/scss/mixins.scss';
.user-avatar {
@include light-shadow;
background: url('~widget/assets/images/defaultUser.png') center center
no-repeat;
background-size: cover;
border-radius: 50%;
height: 40px;
width: 40px;
&.small {
width: $space-medium;
height: $space-medium;
}
}
</style>

View file

@ -0,0 +1,55 @@
<template>
<div class="user-message">
<div class="message-wrap">
<UserMessageBubble :message="message" />
</div>
</div>
</template>
<script>
import UserMessageBubble from 'widget/components/UserMessageBubble.vue';
export default {
name: 'UserMessage',
components: {
UserMessageBubble,
},
props: {
message: String,
avatarUrl: String,
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.user-message {
align-items: flex-end;
display: flex;
flex-direction: row;
justify-content: flex-end;
margin: 0 $space-smaller $space-micro auto;
text-align: right;
& + .user-message {
margin-bottom: $space-micro;
.chat-bubble {
border-top-right-radius: $space-smaller;
}
.user-avatar {
visibility: hidden;
}
.agent-name {
display: none;
}
}
& + .agent-message {
margin-bottom: $space-normal;
}
.message-wrap {
margin-right: $space-small;
}
}
</style>

View file

@ -0,0 +1,36 @@
<template>
<div class="chat-bubble user">
{{ message }}
</div>
</template>
<script>
export default {
name: 'UserMessageBubble',
props: {
message: String,
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss">
@import '~widget/assets/scss/variables.scss';
@import '~widget/assets/scss/mixins.scss';
.chat-bubble {
@include light-shadow;
background: $color-woot;
border-radius: $space-two;
color: $color-white;
display: inline-block;
font-size: $font-size-default;
line-height: 1.5;
max-width: 80%;
padding: $space-small $space-two;
&.user {
border-bottom-right-radius: $space-smaller;
}
}
</style>

View file

@ -0,0 +1,16 @@
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
super(app, pubsubToken);
this.events = {
'message.created': this.onMessageCreated,
};
}
onMessageCreated = data => {
this.app.$store.dispatch('conversation/addMessage', data);
};
}
export default ActionCableConnector;

View file

@ -0,0 +1,15 @@
import axios from 'axios';
import { APP_BASE_URL } from 'widget/helpers/constants';
export const API = axios.create({
baseURL: APP_BASE_URL,
withCredentials: false,
});
export const setHeader = (key, value) => {
API.defaults.headers.common[key] = value;
};
export const removeHeader = key => {
delete API.defaults.headers.common[key];
};

View file

@ -0,0 +1,12 @@
export const APP_BASE_URL = '';
export const MESSAGE_STATUS = {
FAILED: 'failed',
SUCCESS: 'success',
PROGRESS: 'progress',
};
export const MESSAGE_TYPE = {
INCOMING: 0,
OUTGOING: 1,
};

View file

@ -0,0 +1,10 @@
/* eslint-disable import/prefer-default-export */
export const isEmptyObject = obj =>
Object.keys(obj).length === 0 && obj.constructor === Object;
export const arrayToHashById = array =>
array.reduce((map, obj) => {
const newMap = map;
newMap[obj.id] = obj;
return newMap;
}, {});

24
app/javascript/widget/router.js Executable file
View file

@ -0,0 +1,24 @@
import Vue from 'vue';
import Router from 'vue-router';
import Home from './views/Home.vue';
Vue.use(Router);
export default new Router({
routes: [
{
path: '/',
name: 'home',
component: Home,
},
// {
// path: '/about',
// name: 'about',
// // route level code-splitting
// // this generates a separate chunk (about.[hash].js) for this route
// // which is lazy-loaded when the route is visited.
// component: () =>
// import(/* webpackChunkName: "about" */ './views/About.vue'),
// },
],
});

View file

@ -0,0 +1,11 @@
import Vue from 'vue';
import Vuex from 'vuex';
import conversation from 'widget/store/modules/conversation';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
conversation,
},
});

View file

@ -0,0 +1,60 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
import { sendMessageAPI, getConversationAPI } from 'widget/api/conversation';
export const DEFAULT_CONVERSATION = 'default';
const state = {
conversations: {},
};
const getters = {
getConversation: _state => _state.conversations,
};
const actions = {
sendMessage: async (_, params) => {
const { content } = params;
await sendMessageAPI(content);
},
fetchOldConversations: async ({ commit }) => {
try {
const { data } = await getConversationAPI();
commit('initMessagesInConversation', data);
} catch (error) {
// Handle error
}
},
addMessage({ commit }, data) {
commit('pushMessageToConversations', data);
},
};
const mutations = {
initInboxInConversations($state, lastConversation) {
Vue.set($state.conversations, lastConversation, {});
},
pushMessageToConversations($state, message) {
const { id } = message;
const messagesInbox = $state.conversations;
Vue.set(messagesInbox, id, message);
},
initMessagesInConversation(_state, payload) {
if (!payload.length) {
return;
}
payload.map(message => Vue.set(_state.conversations, message.id, message));
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -0,0 +1,5 @@
<template>
<div class="about">
<h1>Chatwoot</h1>
</div>
</template>

View file

@ -0,0 +1,78 @@
<template>
<div class="home">
<div class="header-wrap">
<ChatHeaderExpanded />
</div>
<div class="conversation-wrap">
<ConversationWrap :messages="getConversation" />
</div>
<div class="footer-wrap">
<ChatFooter :on-send-message="handleSendMessage" />
</div>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
// import { DEFAULT_CONVERSATION } from 'widget/store/modules/conversation';
import ChatFooter from 'widget/components/ChatFooter.vue';
import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue';
import ConversationWrap from 'widget/components/ConversationWrap.vue';
export default {
name: 'Home',
components: {
ChatFooter,
ChatHeaderExpanded,
ConversationWrap,
},
methods: {
...mapActions('conversation', ['sendMessage']),
handleSendMessage(content) {
this.sendMessage({
content,
});
},
scrollToBottom() {
const container = this.$el.querySelector('.conversation-wrap');
container.scrollTop = container.scrollHeight;
},
},
computed: {
...mapGetters('conversation', ['getConversation']),
},
mounted() {
this.scrollToBottom();
},
updated() {
this.scrollToBottom();
},
};
</script>
<style scoped lang="scss">
@import '~widget/assets/scss/woot.scss';
.home {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
background: $color-background;
.header-wrap {
flex-shrink: 0;
}
.conversation-wrap {
flex: 1;
overflow-y: auto;
}
.footer-wrap {
flex-shrink: 0;
}
}
</style>

View file

@ -16,8 +16,10 @@ class ActionCableListener < BaseListener
def message_created(event)
message, account, timestamp = extract_message_and_account(event)
conversation = message.conversation
contact = conversation.contact
members = conversation.inbox.members.pluck(:pubsub_token)
send_to_members(members, MESSAGE_CREATED, message.push_event_data)
send_to_contact(contact, MESSAGE_CREATED, message.push_event_data)
end
def conversation_reopened(event)
@ -48,6 +50,12 @@ class ActionCableListener < BaseListener
end
end
def send_to_contact(contact, event_name, data)
return if contact.nil?
ActionCable.server.broadcast(contact.pubsub_token, event: event_name, data: data)
end
def push(pubsub_token, data)
# Enqueue sidekiq job to push event to corresponding channel
end

View file

@ -6,6 +6,7 @@ class Account < ApplicationRecord
has_many :conversations, dependent: :destroy
has_many :contacts, dependent: :destroy
has_many :facebook_pages, dependent: :destroy, class_name: '::Channel::FacebookPage'
has_many :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget'
has_many :telegram_bots, dependent: :destroy
has_many :canned_responses, dependent: :destroy
has_one :subscription, dependent: :destroy

View file

@ -2,7 +2,24 @@ module Channel
class WebWidget < ApplicationRecord
self.table_name = 'channel_web_widgets'
validates :website_name, presence: true
validates :website_url, presence: true
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy
has_secure_token :website_token
def create_contact_inbox
ActiveRecord::Base.transaction do
contact = inbox.account.contacts.create!(name: ::Haikunator.haikunate(1000))
::ContactInbox.create!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: SecureRandom.uuid
)
rescue StandardError => e
Rails.logger e
end
end
end
end

View file

@ -10,6 +10,7 @@ json.data do
json.channel_type inbox.channel_type
json.avatar_url inbox.channel.try(:avatar).try(:url)
json.page_id inbox.channel.try(:page_id)
json.website_token inbox.channel.try(:website_token)
end
end
end

View file

@ -0,0 +1,9 @@
json.array! @messages do |message|
json.id message.id
json.content message.content
json.message_type message.message_type_before_type_cast
json.created_at message.created_at.to_i
json.conversation_id message. conversation_id
json.attachment message.attachment.push_event_data if message.attachment
json.sender_name message.user.name if message.user
end

View file

@ -31,7 +31,7 @@
<%= yield %>
<script>
window.chatwootConfig = {
fbAppId: <%= ENV['fb_app_id'] %>
fbAppId: '<%= ENV['fb_app_id'] %>'
}
</script>
</body>

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<title>Chatwoot</title>
<%= csrf_meta_tags %>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<%= javascript_pack_tag 'widget' %>
<%= stylesheet_pack_tag 'widget' %>
</head>
<body>
<div id="app"></div>
<%= yield %>
<script>
window.chatwootWebChannel = '<%= @web_widget.website_name %>'
window.chatwootPubsubToken = '<%= @contact.pubsub_token %>'
</script>
</body>
</html>

View file

@ -22,5 +22,9 @@ module Chatwoot
# the framework and any gems in your application.
config.generators.javascripts = false
config.generators.stylesheets = false
config.action_dispatch.default_headers = {
'X-Frame-Options' => 'ALLOWALL'
}
end
end

View file

@ -11,6 +11,8 @@ Rails.application.routes.draw do
match '/status', to: 'home#status', via: [:get]
resources :widgets, only: [:index]
namespace :api, :defaults => { :format => 'json' } do
namespace :v1 do
resources :callbacks, only: [] do
@ -23,12 +25,8 @@ Rails.application.routes.draw do
end
namespace :widget do
resources :messages, only: [] do
collection do
post :create_incoming
post :create_outgoing
end
end
resources :messages, only: [:index, :create]
resources :inboxes, only: [:create]
end
resources :accounts, only: [:create]

View file

@ -1,9 +1,5 @@
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
const dotenv = require('dotenv');
dotenv.config({ path: '.env', silent: true });
const environment = require('./environment');
module.exports = environment.toWebpackConfig();

View file

@ -15,5 +15,10 @@ environment.loaders.append('audio', {
});
environment.config.merge({ resolve });
environment.config.set('output.filename', chunkData => {
return chunkData.chunk.name === 'sdk'
? 'js/[name].js'
: 'js/[name]-[hash].js';
});
module.exports = environment;

View file

@ -5,6 +5,7 @@ const resolve = {
alias: {
vue$: 'vue/dist/vue.common.js',
dashboard: path.resolve('./app/javascript/dashboard'),
widget: path.resolve('./app/javascript/widget'),
assets: path.resolve('./app/javascript/dashboard/assets'),
components: path.resolve('./app/javascript/dashboard/components'),
},

View file

@ -0,0 +1,6 @@
class AddWebsiteTokenToWebWidget < ActiveRecord::Migration[6.1]
def change
add_column :channel_web_widgets, :website_token, :string
add_index :channel_web_widgets, :website_token, unique: true
end
end

View file

@ -11,7 +11,6 @@
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_10_27_054756) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -61,6 +60,8 @@ ActiveRecord::Schema.define(version: 2019_10_27_054756) do
t.integer "account_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "website_token"
t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true
end
create_table "contact_inboxes", force: :cascade do |t|

View file

@ -12,8 +12,8 @@
},
"dependencies": {
"@babel/polyfill": "^7.6.0",
"@rails/actioncable": "^6.0.0",
"@babel/preset-env": "~7.3.4",
"@rails/actioncable": "^6.0.0",
"@rails/webpacker": "^4.0.7",
"axios": "^0.19.0",
"babel-helper-vue-jsx-merge-props": "^2.0.3",
@ -24,6 +24,7 @@
"dotenv": "^8.0.0",
"emojione": "~2.2.7",
"foundation-sites": "6.3.0",
"highlight.js": "^9.15.10",
"ionicons": "~2.0.1",
"js-cookie": "~2.1.3",
"md5": "~2.2.1",
@ -39,6 +40,7 @@
"vue-axios": "~1.2.2",
"vue-chartjs": "^3.4.2",
"vue-clickaway": "~2.1.0",
"vue-highlight.js": "^3.1.0",
"vue-i18n": "~5.0.3",
"vue-loader": "^15.7.0",
"vue-multiselect": "~2.1.6",

View file

@ -3276,6 +3276,11 @@ detect-file@^1.0.0:
resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
detect-indent@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d"
integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50=
detect-libc@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
@ -4665,6 +4670,11 @@ hex-color-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
highlight.js@^9.15.10:
version "9.15.10"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.15.10.tgz#7b18ed75c90348c045eef9ed08ca1319a2219ad2"
integrity sha512-RoV7OkQm0T3os3Dd2VHLNMoaoDVx77Wygln3n9l5YV172XonWG6rgQD3XnF/BuFFZw9A0TJgmMSO8FEWQgvcXw==
hmac-drbg@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@ -8558,6 +8568,14 @@ redent@^1.0.0:
indent-string "^2.1.0"
strip-indent "^1.0.1"
redent@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-2.0.0.tgz#c1b2007b42d57eb1389079b3c8333639d5e1ccaa"
integrity sha1-wbIAe0LVfrE4kHmzyDM2OdXhzKo=
dependencies:
indent-string "^3.0.0"
strip-indent "^2.0.0"
regenerate-unicode-properties@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e"
@ -9531,6 +9549,11 @@ strip-indent@^1.0.1:
dependencies:
get-stdin "^4.0.1"
strip-indent@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
strip-json-comments@^2.0.0, strip-json-comments@^2.0.1, strip-json-comments@~2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
@ -9836,7 +9859,7 @@ tsconfig@^7.0.0:
strip-bom "^3.0.0"
strip-json-comments "^2.0.0"
tslib@^1.9.0:
tslib@^1.9.0, tslib@^1.9.3:
version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
@ -10144,6 +10167,15 @@ vue-eslint-parser@^5.0.0:
esquery "^1.0.1"
lodash "^4.17.11"
vue-highlight.js@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/vue-highlight.js/-/vue-highlight.js-3.1.0.tgz#87b60b4931fd310b318f2b2c9116fe71b69dd053"
integrity sha512-i55SERtdV0CYQppGo29iT6NOq+oOenOKVwkLWZRt7bSynbsQoj/e8GJy/5xL1s5OOYObC/CxA39bRadVyPQt1A==
dependencies:
detect-indent "^5.0.0"
redent "^2.0.0"
tslib "^1.9.3"
vue-hot-reload-api@^2.3.0:
version "2.3.4"
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2"