[Feature] Email collect message hooks (#331)

- Add email collect hook on creating conversation
- Merge contact if it already exist
This commit is contained in:
Sojan Jose
2020-01-09 13:06:40 +05:30
committed by Pranav Raj S
parent 59d4eaeca7
commit 722f540b03
68 changed files with 1111 additions and 544 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,3 +0,0 @@
<template>
<span class="spinner small"></span>
</template>

View File

@@ -12,7 +12,7 @@
</template>
<script>
import Spinner from '../Spinner';
import Spinner from 'shared/components/Spinner';
export default {
components: {

View File

@@ -15,7 +15,7 @@
/* eslint no-console: 0 */
/* global bus */
import { mapGetters } from 'vuex';
import Spinner from '../Spinner';
import Spinner from 'shared/components/Spinner';
export default {
props: ['conversationId'],

View File

@@ -1,6 +1,7 @@
/* eslint no-plusplus: 0 */
/* eslint-env browser */
import Spinner from 'shared/components/Spinner';
import Bar from './widgets/chart/BarChart';
import Code from './Code';
import LoadingState from './widgets/LoadingState';
@@ -8,7 +9,6 @@ import Modal from './Modal';
import ModalHeader from './ModalHeader';
import ReportStatsCard from './widgets/ReportStatsCard';
import SidemenuIcon from './SidemenuIcon';
import Spinner from './Spinner';
import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';

View File

@@ -74,13 +74,13 @@ export default {
return this.formatMessage(this.data.content);
},
alignBubble() {
return this.data.message_type === 1 ? 'right' : 'left';
return !this.data.message_type ? 'left' : 'right';
},
readableTime() {
return this.messageStamp(this.data.created_at);
},
isBubble() {
return this.data.message_type === 1 || this.data.message_type === 0;
return [0, 1, 3].includes(this.data.message_type);
},
isPrivate() {
return this.data.private;

View File

@@ -120,7 +120,7 @@ const IFrameHelper = {
createFrame: ({ baseUrl, websiteToken }) => {
const iframe = document.createElement('iframe');
const cwCookie = Cookies.get('cw_conversation');
let widgetUrl = `${baseUrl}/widgets?website_token=${websiteToken}`;
let widgetUrl = `${baseUrl}/widget?website_token=${websiteToken}`;
if (cwCookie) {
widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`;
}
@@ -143,6 +143,18 @@ const IFrameHelper = {
);
},
initPostMessageCommunication: () => {
const events = {
loaded: message => {
Cookies.set('cw_conversation', message.config.authToken);
IFrameHelper.sendMessage('config-set', {});
IFrameHelper.onLoad(message.config.channelConfig);
IFrameHelper.setCurrentUrl();
},
set_auth_token: message => {
Cookies.set('cw_conversation', message.authToken);
},
};
window.onmessage = e => {
if (
typeof e.data !== 'string' ||
@@ -151,11 +163,8 @@ const IFrameHelper = {
return;
}
const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
if (message.event === 'loaded') {
Cookies.set('cw_conversation', message.config.authToken);
IFrameHelper.sendMessage('config-set', {});
IFrameHelper.onLoad(message.config.channelConfig);
IFrameHelper.setCurrentUrl();
if (typeof events[message.event] === 'function') {
events[message.event](message);
}
};
},
@@ -195,7 +204,6 @@ const IFrameHelper = {
onClickChatBubble();
},
setCurrentUrl: () => {
console.log(IFrameHelper.getAppFrame(), document);
IFrameHelper.sendMessage('set-current-url', {
refererURL: window.location.href,
});

View File

@@ -1,9 +1,12 @@
import Vue from 'vue';
import Vuelidate from 'vuelidate';
import store from '../widget/store';
import App from '../widget/App.vue';
import router from '../widget/router';
import ActionCableConnector from '../widget/helpers/actionCable';
Vue.use(Vuelidate);
Vue.config.productionTip = false;
window.onload = () => {
window.WOOT_WIDGET = new Vue({

View File

@@ -1,12 +0,0 @@
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,10 @@
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;
};

View File

@@ -14,7 +14,12 @@ const getConversation = ({ before }) => ({
params: { before },
});
const updateContact = id => ({
url: `/api/v1/widget/messages/${id}${window.location.search}`,
});
export default {
sendMessage,
getConversation,
updateContact,
};

View File

@@ -9,6 +9,7 @@ $input-height: $space-two * 2;
appearance: none;
background: $color-white;
border: 1px solid $color-border;
border-radius: $border-radius;
box-sizing: border-box;
color: $color-body;

View File

@@ -57,6 +57,9 @@ $color-background-light: #fafafa;
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;
$color-error: #ff4949;
// Thumbnail
$thumbnail-radius: 4rem;
@@ -89,3 +92,8 @@ $footer-height: 11.2rem;
$header-expanded-height: $space-medium * 10;
$font-family: 'Inter', -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
$ionicons-font-path: '~ionicons/fonts';
$spinkit-spinner-color: $color-white !default;
$spinkit-spinner-margin: 0 0 0 1.6rem !default;
$spinkit-size: 1.6rem !default;

View File

@@ -4,6 +4,8 @@
@import 'mixins';
@import 'forms';
@import 'shared/assets/fonts/inter';
@import '~ionicons/scss/ionicons';
@import '~spinkit/scss/spinners/7-three-bounce';
html,
body {

View File

@@ -9,7 +9,13 @@
/>
</div>
<div class="message-wrap">
<AgentMessageBubble :message="message" />
<AgentMessageBubble
:content-type="contentType"
:message-content-attributes="messageContentAttributes"
:message-id="messageId"
:message-type="messageType"
:message="message"
/>
<p v-if="showAvatar" class="agent-name">
{{ agentName }}
</p>
@@ -32,7 +38,22 @@ export default {
avatarUrl: String,
agentName: String,
showAvatar: Boolean,
createdAt: Number,
contentType: {
type: String,
default: '',
},
messageContentAttributes: {
type: Object,
default: () => {},
},
messageType: {
type: Number,
default: 1,
},
messageId: {
type: Number,
default: 0,
},
},
};
</script>

View File

@@ -1,20 +1,42 @@
<template>
<div class="chat-bubble agent" v-html="formatMessage(message)"></div>
<div class="chat-bubble agent">
<span v-html="formatMessage(message)"></span>
<email-input
v-if="shouldShowInput"
:message-id="messageId"
:message-content-attributes="messageContentAttributes"
/>
</div>
</template>
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import EmailInput from './template/EmailInput';
export default {
name: 'AgentMessageBubble',
components: {
EmailInput,
},
mixins: [messageFormatterMixin],
props: {
message: String,
contentType: String,
messageType: Number,
messageId: Number,
messageContentAttributes: {
type: Object,
default: () => {},
},
},
computed: {
shouldShowInput() {
return this.contentType === 'input_email' && this.messageType === 3;
},
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss">
@import '~widget/assets/scss/variables.scss';

View File

@@ -7,9 +7,13 @@
<AgentMessage
v-else
:agent-name="agentName"
:avatar-url="avatarUrl"
:content-type="message.content_type"
:message-content-attributes="message.content_attributes"
:message-id="message.id"
:message-type="message.message_type"
:message="message.content"
:show-avatar="message.showAvatar"
:avatar-url="avatarUrl"
/>
</template>
@@ -32,9 +36,18 @@ export default {
return this.message.message_type === MESSAGE_TYPE.INCOMING;
},
agentName() {
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
return 'Bot';
}
return this.message.sender ? this.message.sender.name : '';
},
avatarUrl() {
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
// eslint-disable-next-line
return require('dashboard/assets/images/chatwoot_bot.png');
}
return this.message.sender ? this.message.sender.avatar_url : '';
},
},

View File

@@ -6,7 +6,7 @@
@click="onClick"
>
<span v-if="!loading" class="icon-holder">
<img src="~widget/assets/images/message-send.svg" />
<i class="ion-android-send" />
</span>
<spinner v-else size="small" />
</button>
@@ -51,6 +51,7 @@ export default {
align-items: center;
justify-content: center;
fill: $color-white;
font-size: $font-size-big;
font-weight: $font-weight-medium;
}
}

View File

@@ -0,0 +1,115 @@
<template>
<div>
<form
v-if="!hasSubmitted"
class="email-input-group"
@submit.prevent="onSubmit()"
>
<input
v-model.trim="email"
class="form-input small"
placeholder="Please enter your email"
:class="{ error: $v.email.$error }"
@input="$v.email.$touch"
/>
<button
class="button"
:disabled="$v.email.$invalid"
:style="{ background: widgetColor, borderColor: widgetColor }"
>
<i v-if="!uiFlags.isUpdating" class="ion-android-arrow-forward" />
<spinner v-else />
</button>
</form>
<span v-else>
<i>{{ messageContentAttributes.submitted_email }}</i>
</span>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner';
import { required, email } from 'vuelidate/lib/validators';
export default {
components: {
Spinner,
},
props: {
messageId: {
type: Number,
required: true,
},
messageContentAttributes: {
type: Object,
default: () => {},
},
},
data() {
return {
email: '',
};
},
computed: {
...mapGetters({
uiFlags: 'contact/getUIFlags',
widgetColor: 'appConfig/getWidgetColor',
}),
hasSubmitted() {
return (
this.messageContentAttributes &&
this.messageContentAttributes.submitted_email
);
},
},
validations: {
email: {
required,
email,
},
},
methods: {
onSubmit() {
this.$store.dispatch('contact/updateContactAttributes', {
email: this.email,
messageId: this.messageId,
});
},
},
};
</script>
<style lang="scss" scoped>
@import '~widget/assets/scss/variables.scss';
.email-input-group {
display: flex;
margin: $space-small 0;
min-width: 200px;
input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
&.error {
border-color: $color-error;
}
}
.button {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
font-size: $font-size-large;
height: auto;
margin-left: -1px;
.spinner {
display: block;
padding: 0;
height: auto;
width: auto;
}
}
}
</style>

View File

@@ -9,4 +9,6 @@ export const MESSAGE_STATUS = {
export const MESSAGE_TYPE = {
INCOMING: 0,
OUTGOING: 1,
ACTIVITY: 2,
TEMPLATE: 3,
};

View File

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

View File

@@ -0,0 +1,45 @@
import { updateContact } from 'widget/api/contact';
const state = {
uiFlags: {
isUpdating: false,
},
};
const getters = {
getUIFlags: $state => $state.uiFlags,
};
const actions = {
updateContactAttributes: async ({ commit }, { email, messageId }) => {
commit('toggleUpdateStatus', true);
try {
await updateContact({ email, messageId });
commit(
'conversation/updateMessage',
{
id: messageId,
content_attributes: { submitted_email: email },
},
{ root: true }
);
} catch (error) {
// Ignore error
}
commit('toggleUpdateStatus', false);
},
};
const mutations = {
toggleUpdateStatus($state, status) {
$state.uiFlags.isUpdating = status;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -135,6 +135,13 @@ export const mutations = {
payload.map(message => Vue.set($state.conversations, message.id, message));
},
updateMessage($state, { id, content_attributes }) {
$state.conversations[id] = {
...$state.conversations[id],
content_attributes,
};
},
};
export default {