Merge branch 'release/1.15.0'
This commit is contained in:
commit
8720f39ffb
@ -105,6 +105,7 @@ Rails/UniqueValidationWithoutIndex:
|
||||
Exclude:
|
||||
- 'app/models/channel/twitter_profile.rb'
|
||||
- 'app/models/webhook.rb'
|
||||
- 'app/models/contact.rb'
|
||||
Rails/RenderInline:
|
||||
Exclude:
|
||||
- 'app/controllers/swagger_controller.rb'
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/brand.svg" alt="Woot-logo" width="240">
|
||||
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/brand.svg" alt="Woot-logo" width="240" />
|
||||
|
||||
<div align="center">A simple and elegant live chat software</div>
|
||||
<div align="center">An opensource alternative to Intercom, Zendesk, Drift, Crisp etc.</div>
|
||||
<p align="center">Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.</p>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@ -16,11 +15,13 @@ ___
|
||||
<p align="center">
|
||||
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/80f9e1a7c72d186289ad/maintainability" alt="Maintainability"></a>
|
||||
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
|
||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
|
||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
|
||||
<img src="https://img.shields.io/github/license/chatwoot/chatwoot" alt="License">
|
||||
<img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month">
|
||||
<a title="Crowdin" target="_self" href="https://chatwoot.crowdin.com/chatwoot"><img src="https://badges.crowdin.net/e/37ced7eba411064bd792feb3b7a28b16/localized.svg"></a>
|
||||
<a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/discord/647412545203994635" alt="Discord"></a>
|
||||
<a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a>
|
||||
</p>
|
||||
|
||||
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/chatwoot-dashboard-assets.png" width="100%" alt="Chat dashboard"/>
|
||||
|
||||
@ -55,7 +55,7 @@ $color-heading: #1f2d3d;
|
||||
$color-extra-light-blue: #f5f7f9;
|
||||
|
||||
$primary-color: $color-woot;
|
||||
$secondary-color: #35c5ff;
|
||||
$secondary-color: #5d7592;
|
||||
$success-color: #44ce4b;
|
||||
$warning-color: #ffc532;
|
||||
$alert-color: #ff382d;
|
||||
|
||||
41
app/builders/contact_inbox_builder.rb
Normal file
41
app/builders/contact_inbox_builder.rb
Normal file
@ -0,0 +1,41 @@
|
||||
class ContactInboxBuilder
|
||||
pattr_initialize [:contact_id!, :inbox_id!, :source_id]
|
||||
|
||||
def perform
|
||||
@contact = Contact.find(contact_id)
|
||||
@inbox = @contact.account.inboxes.find(inbox_id)
|
||||
return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api'].include? @inbox.channel_type
|
||||
|
||||
source_id = @source_id || generate_source_id
|
||||
create_contact_inbox(source_id) if source_id.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_source_id
|
||||
return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms'
|
||||
return @contact.email if @inbox.channel_type == 'Channel::Email'
|
||||
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api'
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def twilio_source_id
|
||||
return unless @contact.phone_number
|
||||
|
||||
case @inbox.channel.medium
|
||||
when 'sms'
|
||||
@contact.phone_number
|
||||
when 'whatsapp'
|
||||
"whatsapp:#{@contact.phone_number}"
|
||||
end
|
||||
end
|
||||
|
||||
def create_contact_inbox(source_id)
|
||||
::ContactInbox.find_or_create_by!(
|
||||
contact_id: @contact.id,
|
||||
inbox_id: @inbox.id,
|
||||
source_id: source_id
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -8,10 +8,10 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
|
||||
private
|
||||
|
||||
def inbox_ids
|
||||
if current_user.administrator?
|
||||
if Current.user.administrator?
|
||||
Current.account.inboxes.pluck(:id)
|
||||
elsif current_user.agent?
|
||||
current_user.assigned_inboxes.pluck(:id)
|
||||
elsif Current.user.agent?
|
||||
Current.user.assigned_inboxes.pluck(:id)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
@ -4,7 +4,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index, :active, :search]
|
||||
before_action :fetch_contact, only: [:show, :update]
|
||||
before_action :fetch_contact, only: [:show, :update, :contactable_inboxes]
|
||||
|
||||
def index
|
||||
@contacts_count = resolved_contacts.count
|
||||
@ -40,6 +40,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def show; end
|
||||
|
||||
def contactable_inboxes
|
||||
@contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
|
||||
end
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@contact = Current.account.contacts.new(contact_params)
|
||||
|
||||
@ -4,7 +4,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
end
|
||||
|
||||
def create
|
||||
user = current_user || @resource
|
||||
user = Current.user || @resource
|
||||
mb = Messages::MessageBuilder.new(user, @conversation, params)
|
||||
@message = mb.perform
|
||||
rescue StandardError => e
|
||||
|
||||
@ -22,7 +22,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def create
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
ActiveRecord::Base.transaction do
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present?
|
||||
end
|
||||
end
|
||||
|
||||
def show; end
|
||||
@ -78,16 +81,29 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def contact_inbox
|
||||
@contact_inbox = build_contact_inbox
|
||||
|
||||
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
return if params[:contact_id].blank? || params[:inbox_id].blank?
|
||||
|
||||
ContactInboxBuilder.new(
|
||||
contact_id: params[:contact_id],
|
||||
inbox_id: params[:inbox_id],
|
||||
source_id: params[:source_id]
|
||||
).perform
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
additional_attributes = params[:additional_attributes]&.permit! || {}
|
||||
{
|
||||
account_id: Current.account.id,
|
||||
inbox_id: @contact_inbox.inbox_id,
|
||||
contact_id: @contact_inbox.contact_id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: params[:additional_attributes]
|
||||
additional_attributes: additional_attributes
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ module AccessTokenAuthHelper
|
||||
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
|
||||
|
||||
@resource = @access_token.owner
|
||||
Current.user = @resource if current_user.is_a?(User)
|
||||
end
|
||||
|
||||
def super_admin?
|
||||
@ -21,7 +22,7 @@ module AccessTokenAuthHelper
|
||||
end
|
||||
|
||||
def validate_bot_access_token!
|
||||
return if current_user.is_a?(User)
|
||||
return if Current.user.is_a?(User)
|
||||
return if super_admin?
|
||||
return if agent_bot_accessible?
|
||||
|
||||
|
||||
@ -61,7 +61,9 @@ export default {
|
||||
},
|
||||
|
||||
isLoggedIn() {
|
||||
return !!Cookies.getJSON('auth_data');
|
||||
const hasAuthCookie = !!Cookies.getJSON('auth_data');
|
||||
const hasUserCookie = !!Cookies.getJSON('user');
|
||||
return hasAuthCookie && hasUserCookie;
|
||||
},
|
||||
|
||||
isAdmin() {
|
||||
@ -79,7 +81,9 @@ export default {
|
||||
},
|
||||
getPubSubToken() {
|
||||
if (this.isLoggedIn()) {
|
||||
return Cookies.getJSON('user').pubsub_token;
|
||||
const user = Cookies.getJSON('user') || {};
|
||||
const { pubsub_token: pubsubToken } = user;
|
||||
return pubsubToken;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
@ -14,6 +14,10 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${contactId}/conversations`);
|
||||
}
|
||||
|
||||
getContactableInboxes(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
|
||||
}
|
||||
|
||||
search(search = '', page = 1) {
|
||||
return axios.get(`${this.url}/search?q=${search}&page=${page}`);
|
||||
}
|
||||
|
||||
@ -28,8 +28,10 @@ class ConversationApi extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
toggleStatus(conversationId) {
|
||||
return axios.post(`${this.url}/${conversationId}/toggle_status`, {});
|
||||
toggleStatus({ conversationId, status }) {
|
||||
return axios.post(`${this.url}/${conversationId}/toggle_status`, {
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
assignAgent({ conversationId, agentId }) {
|
||||
|
||||
@ -49,7 +49,7 @@ $global-font-size: 10px;
|
||||
$global-width: 100%;
|
||||
$global-lineheight: 1.5;
|
||||
$foundation-palette: (primary: $color-woot,
|
||||
secondary: #35c5ff,
|
||||
secondary: #5d7592,
|
||||
success: #44ce4b,
|
||||
warning: #ffc532,
|
||||
alert: #ff382d);
|
||||
@ -260,11 +260,11 @@ color 0.25s ease-out;
|
||||
// 12. Button Group
|
||||
// ----------------
|
||||
|
||||
$buttongroup-margin: 1rem;
|
||||
$buttongroup-spacing: 1px;
|
||||
$buttongroup-margin: 0;
|
||||
$buttongroup-spacing: 0;
|
||||
$buttongroup-child-selector: '.button';
|
||||
$buttongroup-expand-max: 6;
|
||||
$buttongroup-radius-on-each: true;
|
||||
$buttongroup-radius-on-each: false;
|
||||
|
||||
// 13. Callout
|
||||
// -----------
|
||||
|
||||
@ -55,7 +55,7 @@ $color-heading: #1f2d3d;
|
||||
$color-extra-light-blue: #f5f7f9;
|
||||
|
||||
$primary-color: $color-woot;
|
||||
$secondary-color: #35c5ff;
|
||||
$secondary-color: #5d7592;
|
||||
$success-color: #44ce4b;
|
||||
$warning-color: #ffc532;
|
||||
$alert-color: #ff382d;
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
.dropdown-pane {
|
||||
@include elegant-card;
|
||||
@include border-light;
|
||||
box-sizing: content-box;
|
||||
width: fit-content;
|
||||
z-index: 999;
|
||||
|
||||
&.dropdown-pane--open {
|
||||
|
||||
@ -118,6 +118,8 @@
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@include text-ellipsis;
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
padding: var(--space-slab) var(--space-one);
|
||||
}
|
||||
|
||||
@ -8,20 +8,15 @@ $resolve-button-width: 13.2rem;
|
||||
@include flex-align($x: justify, $y: middle);
|
||||
@include border-normal-bottom;
|
||||
|
||||
// Resolve Button
|
||||
.button {
|
||||
@include margin(0);
|
||||
@include flex;
|
||||
}
|
||||
|
||||
.multiselect-box {
|
||||
@include flex;
|
||||
@include flex-align($x: justify, $y: middle);
|
||||
@include border-light;
|
||||
border-radius: $space-smaller;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--space-smaller);
|
||||
margin-right: var(--space-small);
|
||||
width: 20.2rem;
|
||||
|
||||
&::before {
|
||||
.icon {
|
||||
color: $medium-gray;
|
||||
font-size: $font-size-default;
|
||||
line-height: 3.8rem;
|
||||
@ -31,6 +26,7 @@ $resolve-button-width: 13.2rem;
|
||||
|
||||
.multiselect {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
|
||||
.multiselect__tags {
|
||||
border: 0;
|
||||
@ -66,26 +62,6 @@ $resolve-button-width: 13.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.button.resolve--button {
|
||||
@include flex-align($x: center, $y: middle);
|
||||
font-size: var(--font-size-default);
|
||||
width: $resolve-button-width;
|
||||
|
||||
>.icon {
|
||||
font-size: $font-size-default;
|
||||
padding-right: $space-small;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
margin-right: $space-smaller;
|
||||
padding: 0 $space-one;
|
||||
|
||||
&::before {
|
||||
border-top-color: $color-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.header-actions-wrap {
|
||||
display: flex;
|
||||
|
||||
@ -151,6 +151,7 @@
|
||||
border-top-left-radius: $space-smaller;
|
||||
color: $color-body;
|
||||
margin-right: auto;
|
||||
word-break: break-word;
|
||||
|
||||
&.is-image {
|
||||
border-radius: var(--border-radius-large);
|
||||
@ -198,6 +199,7 @@
|
||||
border-bottom-right-radius: $space-smaller;
|
||||
border-top-right-radius: $space-smaller;
|
||||
margin-left: auto;
|
||||
word-break: break-word;
|
||||
|
||||
&.is-private {
|
||||
background: lighten($warning-color, 32%);
|
||||
@ -228,6 +230,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
@include margin($zero $space-normal);
|
||||
max-width: 69%;
|
||||
@ -247,17 +253,22 @@
|
||||
}
|
||||
|
||||
.activity-wrap {
|
||||
@include flex;
|
||||
@include margin($space-small auto);
|
||||
@include padding($space-small $space-normal);
|
||||
@include flex-align($x: center, $y: null);
|
||||
background: lighten($warning-color, 32%);
|
||||
border: 1px solid lighten($warning-color, 22%);
|
||||
border-radius: $space-smaller;
|
||||
font-size: $font-size-small;
|
||||
background: var(--s-50);
|
||||
border: 1px solid var(--s-100);
|
||||
border-radius: var(--border-radius-medium);
|
||||
display: flex;
|
||||
font-size: var(--font-size-small);
|
||||
justify-content: center;
|
||||
margin: var(--space-small) var(--space-normal);
|
||||
padding: var(--space-small) var(--space-normal);
|
||||
|
||||
.message-text__wrap {
|
||||
.is-text {
|
||||
display: inline-block;
|
||||
text-align: start;
|
||||
|
||||
@include breakpoint(xxxlarge up) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,12 +67,15 @@
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-light);
|
||||
}
|
||||
|
||||
.dropdown-pane {
|
||||
bottom: 6rem;
|
||||
display: block;
|
||||
left: 5rem;
|
||||
visibility: visible;
|
||||
width: 80%;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.active {
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
<slot></slot>
|
||||
<div class="chat-list__top">
|
||||
<h1 class="page-title text-truncate" :title="pageTitle">
|
||||
<woot-sidemenu-icon />
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<chat-filter @statusFilterChange="updateStatusType" />
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/* global bus */
|
||||
export default {
|
||||
methods: {
|
||||
onMenuItemClick() {
|
||||
@ -12,3 +11,8 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.ion-android-menu {
|
||||
font-size: var(--font-size-big);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,29 +1,86 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="button resolve--button"
|
||||
:class="buttonClass"
|
||||
@click="toggleStatus"
|
||||
>
|
||||
<i v-if="!isLoading" class="icon" :class="buttonIconClass" />
|
||||
<spinner v-if="isLoading" />
|
||||
{{ currentStatus }}
|
||||
</button>
|
||||
<div class="resolve-actions">
|
||||
<div class="button-group">
|
||||
<woot-button
|
||||
v-if="isOpen"
|
||||
class-names="resolve"
|
||||
color-scheme="success"
|
||||
icon="ion-checkmark"
|
||||
:is-loading="isLoading"
|
||||
@click="() => toggleStatus(STATUS_TYPE.RESOLVED)"
|
||||
>
|
||||
{{ this.$t('CONVERSATION.HEADER.RESOLVE_ACTION') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-else-if="isResolved"
|
||||
class-names="resolve"
|
||||
color-scheme="warning"
|
||||
icon="ion-refresh"
|
||||
:is-loading="isLoading"
|
||||
@click="() => toggleStatus(STATUS_TYPE.OPEN)"
|
||||
>
|
||||
{{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-else-if="isBot"
|
||||
class-names="resolve"
|
||||
color-scheme="primary"
|
||||
icon="ion-person"
|
||||
:is-loading="isLoading"
|
||||
@click="() => toggleStatus(STATUS_TYPE.OPEN)"
|
||||
>
|
||||
{{ this.$t('CONVERSATION.HEADER.OPEN_ACTION') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-if="showDropDown"
|
||||
class="icon--small"
|
||||
:color-scheme="buttonClass"
|
||||
:disabled="isLoading"
|
||||
icon="ion-arrow-down-b"
|
||||
@click="openDropdown"
|
||||
>
|
||||
</woot-button>
|
||||
</div>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="dropdown-pane dropdown-pane--open"
|
||||
>
|
||||
<woot-dropdown-menu>
|
||||
<woot-dropdown-item v-if="!isBot">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
@click="() => toggleStatus(STATUS_TYPE.BOT)"
|
||||
>
|
||||
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.OPEN_BOT') }}
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
</woot-dropdown-menu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import wootConstants from '../../constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Spinner,
|
||||
WootDropdownItem,
|
||||
WootDropdownMenu,
|
||||
},
|
||||
mixins: [clickaway, alertMixin],
|
||||
props: { conversationId: { type: [String, Number], required: true } },
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
showDropdown: false,
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -33,26 +90,60 @@ export default {
|
||||
isOpen() {
|
||||
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
currentStatus() {
|
||||
return this.isOpen
|
||||
? this.$t('CONVERSATION.HEADER.RESOLVE_ACTION')
|
||||
: this.$t('CONVERSATION.HEADER.REOPEN_ACTION');
|
||||
isBot() {
|
||||
return this.currentChat.status === wootConstants.STATUS_TYPE.BOT;
|
||||
},
|
||||
isResolved() {
|
||||
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
|
||||
},
|
||||
buttonClass() {
|
||||
return this.isOpen ? 'success' : 'warning';
|
||||
if (this.isBot) return 'primary';
|
||||
if (this.isOpen) return 'success';
|
||||
if (this.isResolved) return 'warning';
|
||||
return '';
|
||||
},
|
||||
buttonIconClass() {
|
||||
return this.isOpen ? 'ion-checkmark' : 'ion-refresh';
|
||||
showDropDown() {
|
||||
return !this.isBot;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleStatus() {
|
||||
closeDropdown() {
|
||||
this.showDropdown = false;
|
||||
},
|
||||
openDropdown() {
|
||||
this.showDropdown = true;
|
||||
},
|
||||
toggleStatus(status) {
|
||||
this.closeDropdown();
|
||||
this.isLoading = true;
|
||||
this.$store.dispatch('toggleStatus', this.currentChat.id).then(() => {
|
||||
bus.$emit('newToastMessage', this.$t('CONVERSATION.CHANGE_STATUS'));
|
||||
this.isLoading = false;
|
||||
});
|
||||
this.$store
|
||||
.dispatch('toggleStatus', {
|
||||
conversationId: this.currentChat.id,
|
||||
status,
|
||||
})
|
||||
.then(() => {
|
||||
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.resolve-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.dropdown-pane {
|
||||
left: unset;
|
||||
top: 4.2rem;
|
||||
margin-top: var(--space-micro);
|
||||
right: 0;
|
||||
max-width: 20rem;
|
||||
}
|
||||
.icon--small::v-deep .icon {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
/* eslint-env browser */
|
||||
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
|
||||
import Bar from './widgets/chart/BarChart';
|
||||
import Button from './widgets/Button';
|
||||
import Button from './ui/WootButton';
|
||||
import Code from './Code';
|
||||
import ColorPicker from './widgets/ColorPicker';
|
||||
import DeleteModal from './widgets/modal/DeleteModal.vue';
|
||||
|
||||
@ -170,6 +170,7 @@ export default {
|
||||
.status-change {
|
||||
.dropdown-pane {
|
||||
top: -132px;
|
||||
right: var(--space-normal);
|
||||
}
|
||||
|
||||
.status-items {
|
||||
|
||||
@ -19,12 +19,12 @@
|
||||
:menu-item="teamSection"
|
||||
/>
|
||||
<sidebar-item
|
||||
v-if="shouldShowInboxes"
|
||||
v-if="shouldShowSidebarItem"
|
||||
:key="inboxSection.toState"
|
||||
:menu-item="inboxSection"
|
||||
/>
|
||||
<sidebar-item
|
||||
v-if="shouldShowInboxes"
|
||||
v-if="shouldShowSidebarItem"
|
||||
:key="labelSection.toState"
|
||||
:menu-item="labelSection"
|
||||
/>
|
||||
@ -128,11 +128,11 @@ export default {
|
||||
currentRoute() {
|
||||
return this.$store.state.route.name;
|
||||
},
|
||||
shouldShowInboxes() {
|
||||
shouldShowSidebarItem() {
|
||||
return this.sidemenuItems.common.routes.includes(this.currentRoute);
|
||||
},
|
||||
shouldShowTeams() {
|
||||
return this.shouldShowInboxes && this.teams.length;
|
||||
return this.shouldShowSidebarItem && this.teams.length;
|
||||
},
|
||||
inboxSection() {
|
||||
return {
|
||||
|
||||
@ -80,3 +80,8 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.dropdown-pane {
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<spinner v-if="isLoading" size="small" />
|
||||
<i v-if="icon" :class="icon"></i>
|
||||
<span v-if="$slots.default"><slot></slot></span>
|
||||
<i v-else-if="icon" class="icon" :class="icon"></i>
|
||||
<span v-if="$slots.default" class="button__content"><slot></slot></span>
|
||||
</button>
|
||||
</template>
|
||||
<script>
|
||||
@ -59,3 +59,20 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.link {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.spinner {
|
||||
padding: 0 var(--space-small);
|
||||
}
|
||||
.icon + .button__content {
|
||||
padding-left: var(--space-small);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -11,9 +11,7 @@
|
||||
class="image-thumb"
|
||||
:src="attachment.thumb"
|
||||
/>
|
||||
<span v-else class="attachment-thumb">
|
||||
📄
|
||||
</span>
|
||||
<span v-else class="attachment-thumb"> 📄 </span>
|
||||
</div>
|
||||
<div class="file-name-wrap">
|
||||
<span class="item">
|
||||
@ -37,8 +35,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { formatBytes } from 'dashboard/helper/files';
|
||||
|
||||
import { formatBytes } from 'shared/helpers/FileHelper';
|
||||
export default {
|
||||
props: {
|
||||
attachments: {
|
||||
|
||||
@ -188,6 +188,7 @@ export default {
|
||||
|
||||
> .ProseMirror {
|
||||
padding: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
<contact-panel
|
||||
v-if="showContactPanel"
|
||||
:conversation-id="currentChat.id"
|
||||
:inbox-id="currentChat.inbox_id"
|
||||
:on-toggle="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
@ -59,6 +60,13 @@ export default {
|
||||
return this.isContactPanelOpen && this.currentChat.id;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'currentChat.inbox_id'(inboxId) {
|
||||
if (inboxId) {
|
||||
this.$store.dispatch('inboxMembers/fetch', { inboxId });
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onToggleContactPanel() {
|
||||
this.$emit('contact-panel-toggle');
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
size="40px"
|
||||
/>
|
||||
<div class="conversation--details columns">
|
||||
<span v-if="showInboxName" v-tooltip.bottom="inboxName" class="label">
|
||||
<span v-if="showInboxName" class="label">
|
||||
<i :class="computedInboxClass" />
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
@ -161,7 +161,11 @@ export default {
|
||||
},
|
||||
|
||||
showInboxName() {
|
||||
return !this.hideInboxName && this.isInboxNameVisible;
|
||||
return (
|
||||
!this.hideInboxName &&
|
||||
this.isInboxNameVisible &&
|
||||
this.inboxesList.length > 1
|
||||
);
|
||||
},
|
||||
inboxName() {
|
||||
const stateInbox = this.chatInbox;
|
||||
@ -187,6 +191,10 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.conversation {
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-light);
|
||||
}
|
||||
}
|
||||
|
||||
.has-inbox-name {
|
||||
|
||||
@ -30,9 +30,11 @@
|
||||
class="header-actions-wrap"
|
||||
:class="{ 'has-open-sidebar': isContactPanelOpen }"
|
||||
>
|
||||
<div class="multiselect-box ion-headphone">
|
||||
<div class="multiselect-box multiselect-wrap--small">
|
||||
<i class="icon ion-headphone" />
|
||||
<multiselect
|
||||
v-model="currentChat.meta.assignee"
|
||||
:loading="uiFlags.isFetching"
|
||||
:allow-empty="true"
|
||||
:deselect-label="$t('CONVERSATION.ASSIGNMENT.REMOVE')"
|
||||
:options="agentList"
|
||||
@ -81,7 +83,8 @@ export default {
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
agents: 'agents/getVerifiedAgents',
|
||||
getAgents: 'inboxMembers/getMembersByInbox',
|
||||
uiFlags: 'inboxMembers/getUIFlags',
|
||||
currentChat: 'getSelectedChat',
|
||||
}),
|
||||
|
||||
@ -96,6 +99,8 @@ export default {
|
||||
},
|
||||
|
||||
agentList() {
|
||||
const { inbox_id: inboxId } = this.chat;
|
||||
const agents = this.getAgents(inboxId) || [];
|
||||
return [
|
||||
{
|
||||
confirmed: true,
|
||||
@ -105,7 +110,7 @@ export default {
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
},
|
||||
...this.agents,
|
||||
...agents,
|
||||
];
|
||||
},
|
||||
},
|
||||
|
||||
@ -143,7 +143,11 @@ export default {
|
||||
return `https://twitter.com/${screenName}`;
|
||||
},
|
||||
alignBubble() {
|
||||
return !this.data.message_type ? 'left' : 'right';
|
||||
const { message_type: messageType } = this.data;
|
||||
if (messageType === MESSAGE_TYPE.ACTIVITY) {
|
||||
return 'center';
|
||||
}
|
||||
return !messageType ? 'left' : 'right';
|
||||
},
|
||||
readableTime() {
|
||||
return this.messageStamp(this.data.created_at, 'LLL d, h:mm a');
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
<template>
|
||||
<div class="view-box fill-height">
|
||||
<div v-if="!currentChat.can_reply" class="banner messenger-policy--banner">
|
||||
<div
|
||||
v-if="!currentChat.can_reply && !isATwilioWhatsappChannel"
|
||||
class="banner messenger-policy--banner"
|
||||
>
|
||||
<span>
|
||||
{{ $t('CONVERSATION.CANNOT_REPLY') }}
|
||||
<a
|
||||
href="https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/"
|
||||
:href="facebookReplyPolicy"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
@ -12,6 +15,21 @@
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!currentChat.can_reply && isATwilioWhatsappChannel"
|
||||
class="banner messenger-policy--banner"
|
||||
>
|
||||
<span>
|
||||
{{ $t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY') }}
|
||||
<a
|
||||
:href="twilioWhatsAppReplyPolicy"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isATweet" class="banner">
|
||||
<span v-if="!selectedTweetId">
|
||||
@ -86,20 +104,16 @@ import Message from './Message';
|
||||
import conversationMixin from '../../../mixins/conversations';
|
||||
import { getTypingUsersText } from '../../../helper/commons';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Message,
|
||||
ReplyBox,
|
||||
},
|
||||
|
||||
mixins: [conversationMixin],
|
||||
|
||||
mixins: [conversationMixin, inboxMixin],
|
||||
props: {
|
||||
inboxId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@ -124,6 +138,12 @@ export default {
|
||||
getUnreadCount: 'getUnreadCount',
|
||||
loadingChatList: 'getChatListLoadingStatus',
|
||||
}),
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
},
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
|
||||
typingUsersList() {
|
||||
const userList = this.$store.getters[
|
||||
@ -191,6 +211,12 @@ export default {
|
||||
}
|
||||
return '';
|
||||
},
|
||||
facebookReplyPolicy() {
|
||||
return REPLY_POLICY.FACEBOOK;
|
||||
},
|
||||
twilioWhatsAppReplyPolicy() {
|
||||
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
@ -5,7 +5,10 @@
|
||||
:status="currentChat.status"
|
||||
/>
|
||||
<woot-button
|
||||
class="clear more--button"
|
||||
class="more--button"
|
||||
variant="clear"
|
||||
size="large"
|
||||
color-scheme="secondary"
|
||||
icon="ion-android-more-vertical"
|
||||
@click="toggleConversationActions"
|
||||
/>
|
||||
@ -100,15 +103,6 @@ export default {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-left: var(--space-small);
|
||||
padding: var(--space-small);
|
||||
|
||||
&.clear.more--button {
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--w-800);
|
||||
}
|
||||
}
|
||||
|
||||
.actions--container {
|
||||
@ -116,9 +110,8 @@ export default {
|
||||
}
|
||||
|
||||
.dropdown-pane {
|
||||
right: -12px;
|
||||
right: var(--space-minus-small);
|
||||
top: 48px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@ -72,6 +72,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
import EmojiInput from 'shared/components/emoji/EmojiInput';
|
||||
import CannedResponse from './CannedResponse';
|
||||
@ -81,6 +82,9 @@ import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
|
||||
|
||||
import {
|
||||
isEscape,
|
||||
isEnter,
|
||||
@ -100,7 +104,7 @@ export default {
|
||||
ReplyBottomPanel,
|
||||
WootMessageEditor,
|
||||
},
|
||||
mixins: [clickaway, inboxMixin, uiSettingsMixin],
|
||||
mixins: [clickaway, inboxMixin, uiSettingsMixin, alertMixin],
|
||||
props: {
|
||||
inReplyTo: {
|
||||
type: [String, Number],
|
||||
@ -123,17 +127,25 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
showRichContentEditor() {
|
||||
const {
|
||||
display_rich_content_editor: displayRichContentEditor,
|
||||
} = this.uiSettings;
|
||||
return this.isOnPrivateNote || displayRichContentEditor;
|
||||
if (this.isOnPrivateNote) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isRichEditorEnabled) {
|
||||
const {
|
||||
display_rich_content_editor: displayRichContentEditor,
|
||||
} = this.uiSettings;
|
||||
|
||||
return displayRichContentEditor;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
...mapGetters({ currentChat: 'getSelectedChat' }),
|
||||
enterToSendEnabled() {
|
||||
return !!this.uiSettings.enter_to_send_enabled;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.currentChat.can_reply) {
|
||||
if (this.currentChat.can_reply || this.isATwilioWhatsappChannel) {
|
||||
return this.isOnPrivateNote;
|
||||
}
|
||||
return true;
|
||||
@ -215,9 +227,7 @@ export default {
|
||||
return this.attachedFiles.length;
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return (
|
||||
this.isAWebWidgetInbox || this.isAnEmailChannel || this.isOnPrivateNote
|
||||
);
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel;
|
||||
},
|
||||
isOnPrivateNote() {
|
||||
return this.replyType === REPLY_EDITOR_MODES.NOTE;
|
||||
@ -230,7 +240,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canReply) {
|
||||
if (canReply || this.isATwilioWhatsappChannel) {
|
||||
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
||||
} else {
|
||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||
@ -305,7 +315,7 @@ export default {
|
||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||
const { can_reply: canReply } = this.currentChat;
|
||||
|
||||
if (canReply) this.replyType = mode;
|
||||
if (canReply || this.isATwilioWhatsappChannel) this.replyType = mode;
|
||||
if (this.showRichContentEditor) {
|
||||
return;
|
||||
}
|
||||
@ -351,21 +361,28 @@ export default {
|
||||
}
|
||||
},
|
||||
onFileUpload(file) {
|
||||
this.attachedFiles = [];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.file);
|
||||
|
||||
reader.onloadend = () => {
|
||||
this.attachedFiles.push({
|
||||
currentChatId: this.currentChat.id,
|
||||
resource: file,
|
||||
isPrivate: this.isPrivate,
|
||||
thumb: reader.result,
|
||||
});
|
||||
};
|
||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
this.attachedFiles = [];
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.file);
|
||||
reader.onloadend = () => {
|
||||
this.attachedFiles.push({
|
||||
currentChatId: this.currentChat.id,
|
||||
resource: file,
|
||||
isPrivate: this.isPrivate,
|
||||
thumb: reader.result,
|
||||
});
|
||||
};
|
||||
} else {
|
||||
this.showAlert(
|
||||
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
removeAttachment(itemIndex) {
|
||||
this.attachedFiles = this.attachedFiles.filter(
|
||||
|
||||
@ -95,6 +95,8 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~dashboard/assets/scss/app.scss';
|
||||
|
||||
.right {
|
||||
.message-text--metadata {
|
||||
.time {
|
||||
@ -136,12 +138,16 @@ export default {
|
||||
|
||||
.activity-wrap {
|
||||
.message-text--metadata {
|
||||
display: inline-block;
|
||||
|
||||
.time {
|
||||
color: var(--s-300);
|
||||
display: flex;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-micro);
|
||||
margin-left: var(--space-small);
|
||||
margin-left: 0;
|
||||
|
||||
@include breakpoint(xlarge up) {
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,3 +22,15 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.text-content {
|
||||
overflow: auto;
|
||||
|
||||
&::v-deep {
|
||||
ul,
|
||||
ol {
|
||||
margin-left: var(--space-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
export const formatBytes = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
@ -1,18 +0,0 @@
|
||||
import { formatBytes } from '../files';
|
||||
|
||||
describe('#File Helpers', () => {
|
||||
describe('formatBytes', () => {
|
||||
it('should return zero bytes if 0 is passed', () => {
|
||||
expect(formatBytes(0)).toBe('0 Bytes');
|
||||
});
|
||||
it('should return in bytes if 1000 is passed', () => {
|
||||
expect(formatBytes(1000)).toBe('1000 Bytes');
|
||||
});
|
||||
it('should return in KB if 100000 is passed', () => {
|
||||
expect(formatBytes(10000)).toBe('9.77 KB');
|
||||
});
|
||||
it('should return in MB if 10000000 is passed', () => {
|
||||
expect(formatBytes(10000000)).toBe('9.54 MB');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -88,7 +88,7 @@
|
||||
"SUBMIT_BUTTON": "Δημιουργία Κιβωτίου"
|
||||
},
|
||||
"TWILIO": {
|
||||
"TITLE": "SMS κανάλι από το Twillio",
|
||||
"TITLE": "SMS κανάλι από το Twilio",
|
||||
"DESC": "Ενσωματώστε το Twilio και αρχίστε να υποστηρίζετε τους πελάτες σας μέσω SMS.",
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "SID Λογαριασμού",
|
||||
@ -116,7 +116,7 @@
|
||||
},
|
||||
"API_CALLBACK": {
|
||||
"TITLE": "URL επανάκλησης",
|
||||
"SUBTITLE": "Πρέπει να ρυθμίσετε το callback URL στο Twillio με το URL που αναφέρεται εδώ."
|
||||
"SUBTITLE": "Πρέπει να ρυθμίσετε το callback URL στο Twilio με το URL που αναφέρεται εδώ."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Δημιουργία Καναλιού Twillio",
|
||||
"API": {
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"INITIATED_FROM": "Initiated from",
|
||||
"INITIATED_AT": "Initiated at",
|
||||
"IP_ADDRESS": "IP Address",
|
||||
"NEW_MESSAGE": "New message",
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
|
||||
"TITLE": "Previous Conversations"
|
||||
@ -71,7 +72,9 @@
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"PLACEHOLDER": "Enter the phone number of the contact",
|
||||
"LABEL": "Phone Number"
|
||||
"LABEL": "Phone Number",
|
||||
"HELP": "Phone number should be of E.164 format eg: +1415555555 [+][country code][area code][local phone number]",
|
||||
"ERROR": "Phone number should be either empty or of E.164 format"
|
||||
},
|
||||
"LOCATION": {
|
||||
"PLACEHOLDER": "Enter the location of the contact",
|
||||
@ -104,6 +107,30 @@
|
||||
"CONTACT_ALREADY_EXIST": "This email address is in use for another contact.",
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
},
|
||||
"NEW_CONVERSATION": {
|
||||
"BUTTON_LABEL": "Start conversation",
|
||||
"TITLE": "New conversation",
|
||||
"DESC": "Start a new conversation by sending a new message.",
|
||||
"NO_INBOX": "Couldn't find an inbox to initiate a new conversation with this contact.",
|
||||
"FORM": {
|
||||
"TO": {
|
||||
"LABEL": "To"
|
||||
},
|
||||
"INBOX": {
|
||||
"LABEL": "Inbox",
|
||||
"ERROR": "Select an inbox"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Write your message here",
|
||||
"ERROR": "Message can't be empty"
|
||||
},
|
||||
"SUBMIT": "Send message",
|
||||
"CANCEL": "Cancel",
|
||||
"SUCCESS_MESSAGE": "Message sent!",
|
||||
"ERROR_MESSAGE": "Couldn't send! try again"
|
||||
}
|
||||
},
|
||||
"CONTACTS_PAGE": {
|
||||
"HEADER": "Contacts",
|
||||
"SEARCH_BUTTON": "Search",
|
||||
|
||||
@ -20,6 +20,8 @@
|
||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||
"CANNOT_REPLY": "You cannot reply due to",
|
||||
"24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"LAST_INCOMING_TWEET": "You are replying to the last incoming tweet",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
@ -29,10 +31,14 @@
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
"REOPEN_ACTION": "Reopen",
|
||||
"OPEN_ACTION": "Open",
|
||||
"OPEN": "More",
|
||||
"CLOSE": "Close",
|
||||
"DETAILS": "details"
|
||||
},
|
||||
"RESOLVE_DROPDOWN": {
|
||||
"OPEN_BOT": "Open with bot"
|
||||
},
|
||||
"FOOTER": {
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents"
|
||||
@ -52,6 +58,7 @@
|
||||
"CHANGE_STATUS": "Conversation status changed",
|
||||
"CHANGE_AGENT": "Conversation Assignee changed",
|
||||
"CHANGE_TEAM": "Conversation team changed",
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
|
||||
"SENT_BY": "Sent by:",
|
||||
"ASSIGNMENT": {
|
||||
"SELECT_AGENT": "Select Agent",
|
||||
@ -108,5 +115,4 @@
|
||||
"PLACEHOLDER": "None"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -5,7 +5,8 @@
|
||||
"LIST": {
|
||||
"404": "There are no inboxes attached to this account."
|
||||
},
|
||||
"CREATE_FLOW": [{
|
||||
"CREATE_FLOW": [
|
||||
{
|
||||
"title": "Choose Channel",
|
||||
"route": "settings_inbox_new",
|
||||
"body": "Choose the provider you want to integrate with Chatwoot."
|
||||
@ -193,6 +194,7 @@
|
||||
"TITLE": "Your Inbox is ready!",
|
||||
"MESSAGE": "You can now engage with your customers through your new Channel. Happy supporting ",
|
||||
"BUTTON_TEXT": "Take me there",
|
||||
"MORE_SETTINGS": "More settings",
|
||||
"WEBSITE_SUCCESS": "You have successfully finished creating a website channel. Copy the code shown below and paste it on your website. Next time a customer use the live chat, the conversation will automatically appear on your inbox."
|
||||
},
|
||||
"REAUTH": "Reauthorize",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"LINK": "Profile Settings",
|
||||
"TITLE": "Profile Settings",
|
||||
"BTN_TEXT": "Update Profile",
|
||||
"UPDATE_SUCCESS": "Your profile has been updated successfully",
|
||||
"PASSWORD_UPDATE_SUCCESS": "Your password has been changed successfully",
|
||||
"AFTER_EMAIL_CHANGED": "Your profile has been updated successfully, please login again as your login credentials are changed",
|
||||
"FORM": {
|
||||
"AVATAR": "Profile Image",
|
||||
@ -16,7 +18,8 @@
|
||||
},
|
||||
"PASSWORD_SECTION": {
|
||||
"TITLE": "Password",
|
||||
"NOTE": "Updating your password would reset your logins in multiple devices."
|
||||
"NOTE": "Updating your password would reset your logins in multiple devices.",
|
||||
"BTN_TEXT": "Change password"
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
@ -64,11 +67,7 @@
|
||||
},
|
||||
"AVAILABILITY": {
|
||||
"LABEL": "Availability",
|
||||
"STATUSES_LIST": [
|
||||
"Online",
|
||||
"Busy",
|
||||
"Offline"
|
||||
]
|
||||
"STATUSES_LIST": ["Online", "Busy", "Offline"]
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "Your email address",
|
||||
@ -147,6 +146,5 @@
|
||||
},
|
||||
"SUBMIT": "Submit"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,7 +118,7 @@
|
||||
"TITLE": "URL Callback",
|
||||
"SUBTITLE": "Anda harus mengkonfigurasi pesan URL callback di Twilio dengan URL yang disebutkan di sini."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Buat Channel Twillio",
|
||||
"SUBMIT_BUTTON": "Buat Channel Twilio",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Kami tidak dapat mengautentikasi kredensial Twilio, harap coba lagi"
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<span class="close-button" @click="onClose">
|
||||
<i class="ion-android-close close-icon" />
|
||||
</span>
|
||||
<contact-info :contact="contact" />
|
||||
<contact-info show-new-message :contact="contact" />
|
||||
<contact-custom-attributes
|
||||
v-if="hasContactAttributes"
|
||||
:custom-attributes="contact.custom_attributes"
|
||||
|
||||
@ -127,6 +127,10 @@ export default {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
onToggle: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
@ -135,8 +139,9 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
agents: 'agents/getVerifiedAgents',
|
||||
teams: 'teams/getTeams',
|
||||
getAgents: 'inboxMembers/getMembersByInbox',
|
||||
uiFlags: 'inboxMembers/getUIFlags',
|
||||
}),
|
||||
currentConversationMetaData() {
|
||||
return this.$store.getters[
|
||||
@ -182,7 +187,7 @@ export default {
|
||||
return '';
|
||||
}
|
||||
const countryFlag = countryCode ? flag(countryCode) : '🌎';
|
||||
return `${countryFlag} ${cityAndCountry}`;
|
||||
return `${cityAndCountry} ${countryFlag}`;
|
||||
},
|
||||
platformName() {
|
||||
const {
|
||||
@ -201,7 +206,7 @@ export default {
|
||||
return this.$store.getters['contacts/getContact'](this.contactId);
|
||||
},
|
||||
agentsList() {
|
||||
return [{ id: 0, name: 'None' }, ...this.agents];
|
||||
return [{ id: 0, name: 'None' }, ...this.getAgents(this.inboxId)];
|
||||
},
|
||||
teamsList() {
|
||||
return [{ id: 0, name: 'None' }, ...this.teams];
|
||||
|
||||
@ -35,12 +35,26 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<woot-input
|
||||
v-model.trim="phoneNumber"
|
||||
class="columns"
|
||||
:label="$t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL')"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="medium-12 columns">
|
||||
<label :class="{ error: $v.phoneNumber.$error }">
|
||||
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL') }}
|
||||
<input
|
||||
v-model.trim="phoneNumber"
|
||||
type="text"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')"
|
||||
@input="$v.phoneNumber.$touch"
|
||||
/>
|
||||
<span v-if="$v.phoneNumber.$error" class="message">
|
||||
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="$v.phoneNumber.$error || !phoneNumber"
|
||||
class="callout small warning"
|
||||
>
|
||||
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.HELP') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model.trim="companyName"
|
||||
@ -87,6 +101,8 @@ import {
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import { required } from 'vuelidate/lib/validators';
|
||||
|
||||
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
@ -131,7 +147,9 @@ export default {
|
||||
description: {},
|
||||
email: {},
|
||||
companyName: {},
|
||||
phoneNumber: {},
|
||||
phoneNumber: {
|
||||
isPhoneE164OrEmpty,
|
||||
},
|
||||
bio: {},
|
||||
},
|
||||
|
||||
@ -190,6 +208,7 @@ export default {
|
||||
async handleSubmit() {
|
||||
this.resetDuplicate();
|
||||
this.$v.$touch();
|
||||
|
||||
if (this.$v.$invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -50,18 +50,41 @@
|
||||
</div>
|
||||
</div>
|
||||
<woot-button
|
||||
class="clear edit-contact"
|
||||
variant="primary small"
|
||||
v-if="!showNewMessage"
|
||||
class="edit-contact"
|
||||
variant="clear link"
|
||||
size="small"
|
||||
@click="toggleEditModal"
|
||||
>
|
||||
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
|
||||
</woot-button>
|
||||
<div v-else class="contact-actions">
|
||||
<woot-button
|
||||
class="new-message"
|
||||
size="small expanded"
|
||||
@click="toggleConversationModal"
|
||||
>
|
||||
{{ $t('CONTACT_PANEL.NEW_MESSAGE') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
variant="hollow"
|
||||
size="small expanded"
|
||||
@click="toggleEditModal"
|
||||
>
|
||||
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
<edit-contact
|
||||
v-if="showEditModal"
|
||||
:show="showEditModal"
|
||||
:contact="contact"
|
||||
@cancel="toggleEditModal"
|
||||
/>
|
||||
<new-conversation
|
||||
:show="showConversationModal"
|
||||
:contact="contact"
|
||||
@cancel="toggleConversationModal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -70,6 +93,7 @@ import ContactInfoRow from './ContactInfoRow';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import SocialIcons from './SocialIcons';
|
||||
import EditContact from './EditContact';
|
||||
import NewConversation from './NewConversation';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -77,6 +101,7 @@ export default {
|
||||
EditContact,
|
||||
Thumbnail,
|
||||
SocialIcons,
|
||||
NewConversation,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
@ -87,10 +112,15 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showNewMessage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showEditModal: false,
|
||||
showConversationModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -110,6 +140,9 @@ export default {
|
||||
toggleEditModal() {
|
||||
this.showEditModal = !this.showEditModal;
|
||||
},
|
||||
toggleConversationModal() {
|
||||
this.showConversationModal = !this.showConversationModal;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -119,7 +152,7 @@ export default {
|
||||
@import '~dashboard/assets/scss/mixins';
|
||||
.contact--profile {
|
||||
align-items: flex-start;
|
||||
padding: var(--space-normal) var(--space-normal) var(--space-large);
|
||||
padding: var(--space-normal) var(--space-normal);
|
||||
|
||||
.user-thumbnail-box {
|
||||
margin-right: $space-normal;
|
||||
@ -163,10 +196,21 @@ export default {
|
||||
font-size: $font-weight-normal;
|
||||
}
|
||||
}
|
||||
.contact-actions {
|
||||
margin: var(--space-small) 0;
|
||||
}
|
||||
.button.edit-contact {
|
||||
margin-left: var(--space-two);
|
||||
padding-left: var(--space-micro);
|
||||
}
|
||||
|
||||
.edit-contact {
|
||||
padding: 0 var(--space-slab);
|
||||
margin-left: var(--space-slab);
|
||||
margin-top: var(--space-smaller);
|
||||
.button.new-message {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
.contact-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<form class="conversation--form" @submit.prevent="handleSubmit">
|
||||
<div v-if="showNoInboxAlert" class="callout warning">
|
||||
<p>
|
||||
{{ $t('NEW_CONVERSATION.NO_INBOX') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row">
|
||||
<div class="columns">
|
||||
<label :class="{ error: $v.targetInbox.$error }">
|
||||
{{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }}
|
||||
<select v-model="targetInbox">
|
||||
<option
|
||||
v-for="contactableInbox in inboxes"
|
||||
:key="contactableInbox.inbox.id"
|
||||
:value="contactableInbox"
|
||||
>
|
||||
{{ contactableInbox.inbox.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="$v.targetInbox.$error" class="message">
|
||||
{{ $t('NEW_CONVERSATION.FORM.INBOX.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<label>
|
||||
{{ $t('NEW_CONVERSATION.FORM.TO.LABEL') }}
|
||||
<div class="contact-input">
|
||||
<thumbnail
|
||||
:src="contact.thumbnail"
|
||||
size="24px"
|
||||
:username="contact.name"
|
||||
:status="contact.availability_status"
|
||||
/>
|
||||
<h4 class="text-block-title contact-name">
|
||||
{{ contact.name }}
|
||||
</h4>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="columns">
|
||||
<label :class="{ error: $v.message.$error }">
|
||||
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
|
||||
<textarea
|
||||
v-model="message"
|
||||
class="message-input"
|
||||
type="text"
|
||||
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
|
||||
@input="$v.message.$touch"
|
||||
/>
|
||||
<span v-if="$v.message.$error" class="message">
|
||||
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="button clear" @click.prevent="onCancel">
|
||||
{{ $t('NEW_CONVERSATION.FORM.CANCEL') }}
|
||||
</button>
|
||||
<woot-button type="submit" :is-loading="conversationsUiFlags.isCreating">
|
||||
{{ $t('NEW_CONVERSATION.FORM.SUBMIT') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
|
||||
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
import { required } from 'vuelidate/lib/validators';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
onSubmit: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
message: '',
|
||||
selectedInbox: '',
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
message: {
|
||||
required,
|
||||
},
|
||||
targetInbox: {
|
||||
required,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'contacts/getUIFlags',
|
||||
conversationsUiFlags: 'contactConversations/getUIFlags',
|
||||
}),
|
||||
getNewConversation() {
|
||||
return {
|
||||
inboxId: this.targetInbox.inbox.id,
|
||||
sourceId: this.targetInbox.source_id,
|
||||
contactId: this.contact.id,
|
||||
message: { content: this.message },
|
||||
};
|
||||
},
|
||||
targetInbox: {
|
||||
get() {
|
||||
return this.selectedInbox || '';
|
||||
},
|
||||
set(value) {
|
||||
this.selectedInbox = value;
|
||||
},
|
||||
},
|
||||
showNoInboxAlert() {
|
||||
if (!this.contact.contactableInboxes) {
|
||||
return false;
|
||||
}
|
||||
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
|
||||
},
|
||||
inboxes() {
|
||||
return this.contact.contactableInboxes || [];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
onSuccess() {
|
||||
this.$emit('success');
|
||||
},
|
||||
|
||||
async handleSubmit() {
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = this.getNewConversation;
|
||||
|
||||
await this.onSubmit(payload);
|
||||
this.onSuccess();
|
||||
this.showAlert(this.$t('NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
if (error instanceof ExceptionWithMessage) {
|
||||
this.showAlert(error.data);
|
||||
} else {
|
||||
this.showAlert(this.$t('NEW_CONVERSATION.FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversation--form {
|
||||
padding: var(--space-normal) var(--space-large) var(--space-large);
|
||||
|
||||
.columns {
|
||||
padding: 0 var(--space-smaller);
|
||||
}
|
||||
}
|
||||
|
||||
.input-group-label {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.contact-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 3.9rem;
|
||||
background: var(--color-background-light);
|
||||
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--space-smaller) var(--space-small);
|
||||
border-radius: var(--border-radius-small);
|
||||
|
||||
.contact-name {
|
||||
margin: 0;
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
}
|
||||
|
||||
.message-input {
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<woot-modal :show.sync="show" :on-close="onCancel">
|
||||
<div class="column content-box">
|
||||
<woot-modal-header
|
||||
:header-title="$t('NEW_CONVERSATION.TITLE')"
|
||||
:header-content="$t('NEW_CONVERSATION.DESC')"
|
||||
/>
|
||||
<conversation-form
|
||||
:contact="contact"
|
||||
:on-submit="onSubmit"
|
||||
@success="onSuccess"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ConversationForm from './ConversationForm';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConversationForm,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const { id } = this.contact;
|
||||
this.$store.dispatch('contacts/fetchContactableInbox', id);
|
||||
},
|
||||
methods: {
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
onSuccess() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
async onSubmit(contactItem) {
|
||||
await this.$store.dispatch('contactConversations/create', contactItem);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div v-on-clickaway="closeSearch" class="search-wrap">
|
||||
<div class="search" :class="{ 'is-active': showSearchBox }">
|
||||
<woot-sidemenu-icon />
|
||||
<div class="icon">
|
||||
<i class="ion-ios-search-strong search--icon" />
|
||||
</div>
|
||||
|
||||
@ -29,15 +29,26 @@
|
||||
>
|
||||
</woot-code>
|
||||
</div>
|
||||
<router-link
|
||||
class="button success nice"
|
||||
:to="{
|
||||
name: 'inbox_dashboard',
|
||||
params: { inboxId: this.$route.params.inbox_id },
|
||||
}"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.FINISH.BUTTON_TEXT') }}
|
||||
</router-link>
|
||||
<div class="footer">
|
||||
<router-link
|
||||
class="button hollow primary settings-button"
|
||||
:to="{
|
||||
name: 'settings_inbox_show',
|
||||
params: { inboxId: this.$route.params.inbox_id },
|
||||
}"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.FINISH.MORE_SETTINGS') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="button success"
|
||||
:to="{
|
||||
name: 'inbox_dashboard',
|
||||
params: { inboxId: this.$route.params.inbox_id },
|
||||
}"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.FINISH.BUTTON_TEXT') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</empty-state>
|
||||
</div>
|
||||
@ -90,4 +101,13 @@ export default {
|
||||
margin: $space-normal auto;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="columns profile--settings ">
|
||||
<form @submit.prevent="updateUser">
|
||||
<div class="columns profile--settings">
|
||||
<form @submit.prevent="updateUser('profile')">
|
||||
<div class="small-12 row profile--settings--row">
|
||||
<div class="columns small-3 ">
|
||||
<div class="columns small-3">
|
||||
<h4 class="block-title">
|
||||
{{ $t('PROFILE_SETTINGS.FORM.PROFILE_SECTION.TITLE') }}
|
||||
</h4>
|
||||
@ -49,10 +49,15 @@
|
||||
{{ $t('PROFILE_SETTINGS.FORM.EMAIL.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<woot-button type="submit" :is-loading="isProfileUpdating">
|
||||
{{ $t('PROFILE_SETTINGS.BTN_TEXT') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form @submit.prevent="updateUser('password')">
|
||||
<div class="profile--settings--row row">
|
||||
<div class="columns small-3 ">
|
||||
<div class="columns small-3">
|
||||
<h4 class="block-title">
|
||||
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE') }}
|
||||
</h4>
|
||||
@ -85,27 +90,30 @@
|
||||
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<woot-button
|
||||
:is-loading="isPasswordChanging"
|
||||
type="submit"
|
||||
:disabled="
|
||||
!passwordConfirmation || !$v.passwordConfirmation.isEqPassword
|
||||
"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.BTN_TEXT') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
<notification-settings />
|
||||
<div class="profile--settings--row row">
|
||||
<div class="columns small-3 ">
|
||||
<h4 class="block-title">
|
||||
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE') }}
|
||||
</h4>
|
||||
<p>{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE') }}</p>
|
||||
</div>
|
||||
<div class="columns small-9 medium-5">
|
||||
<woot-code :script="currentUser.access_token"></woot-code>
|
||||
</div>
|
||||
</div>
|
||||
<woot-submit-button
|
||||
class="button nice success button--fixed-right-top"
|
||||
:button-text="$t('PROFILE_SETTINGS.BTN_TEXT')"
|
||||
:loading="isUpdating"
|
||||
>
|
||||
</woot-submit-button>
|
||||
</form>
|
||||
<notification-settings />
|
||||
<div class="profile--settings--row row">
|
||||
<div class="columns small-3">
|
||||
<h4 class="block-title">
|
||||
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE') }}
|
||||
</h4>
|
||||
<p>{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE') }}</p>
|
||||
</div>
|
||||
<div class="columns small-9 medium-5">
|
||||
<woot-code :script="currentUser.access_token"></woot-code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -120,7 +128,7 @@ export default {
|
||||
components: {
|
||||
NotificationSettings,
|
||||
},
|
||||
mixin: [alertMixin],
|
||||
mixins: [alertMixin],
|
||||
data() {
|
||||
return {
|
||||
avatarFile: '',
|
||||
@ -130,7 +138,8 @@ export default {
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
isUpdating: false,
|
||||
isProfileUpdating: false,
|
||||
isPasswordChanging: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@ -181,13 +190,17 @@ export default {
|
||||
this.avatarUrl = this.currentUser.avatar_url;
|
||||
this.displayName = this.currentUser.display_name;
|
||||
},
|
||||
async updateUser() {
|
||||
async updateUser(type) {
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
|
||||
return;
|
||||
}
|
||||
this.isUpdating = true;
|
||||
if (type === 'profile') {
|
||||
this.isProfileUpdating = true;
|
||||
} else if (type === 'password') {
|
||||
this.isPasswordChanging = true;
|
||||
}
|
||||
const hasEmailChanged = this.currentUser.email !== this.email;
|
||||
try {
|
||||
await this.$store.dispatch('updateProfile', {
|
||||
@ -198,13 +211,20 @@ export default {
|
||||
displayName: this.displayName,
|
||||
password_confirmation: this.passwordConfirmation,
|
||||
});
|
||||
this.isUpdating = false;
|
||||
this.isProfileUpdating = false;
|
||||
this.isPasswordChanging = false;
|
||||
if (hasEmailChanged) {
|
||||
clearCookiesOnLogout();
|
||||
this.showAlert(this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED'));
|
||||
}
|
||||
if (type === 'profile') {
|
||||
this.showAlert(this.$t('PROFILE_SETTINGS.UPDATE_SUCCESS'));
|
||||
} else if (type === 'password') {
|
||||
this.showAlert(this.$t('PROFILE_SETTINGS.PASSWORD_UPDATE_SUCCESS'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.isUpdating = false;
|
||||
this.isProfileUpdating = false;
|
||||
this.isPasswordChanging = false;
|
||||
}
|
||||
},
|
||||
handleImageUpload({ file, url }) {
|
||||
|
||||
@ -107,6 +107,7 @@ export default {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
this.$store.dispatch('teams/get');
|
||||
} catch (error) {
|
||||
this.showAlert(error.message);
|
||||
}
|
||||
|
||||
@ -129,6 +129,7 @@ export default {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
this.$store.dispatch('teams/get');
|
||||
} catch (error) {
|
||||
this.showAlert(error.message);
|
||||
}
|
||||
|
||||
@ -83,7 +83,7 @@ export const actions = {
|
||||
setUser(response.data.payload.data, getHeaderExpiry(response));
|
||||
context.commit(types.default.SET_CURRENT_USER);
|
||||
} catch (error) {
|
||||
if (error.response.status === 401) {
|
||||
if (error?.response?.status === 401) {
|
||||
clearCookiesOnLogout();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import Vue from 'vue';
|
||||
import * as types from '../mutation-types';
|
||||
import ContactAPI from '../../api/contacts';
|
||||
import ConversationApi from '../../api/conversations';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
@ -19,6 +20,30 @@ export const getters = {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
create: async ({ commit }, params) => {
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||
isCreating: true,
|
||||
});
|
||||
const { inboxId, message, contactId, sourceId } = params;
|
||||
try {
|
||||
const { data } = await ConversationApi.create({
|
||||
inbox_id: inboxId,
|
||||
contact_id: contactId,
|
||||
source_id: sourceId,
|
||||
message,
|
||||
});
|
||||
commit(types.default.ADD_CONTACT_CONVERSATION, {
|
||||
id: contactId,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
} finally {
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||
isCreating: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
get: async ({ commit }, contactId) => {
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||
isFetching: true,
|
||||
@ -53,6 +78,10 @@ export const mutations = {
|
||||
[types.default.SET_CONTACT_CONVERSATIONS]: ($state, { id, data }) => {
|
||||
Vue.set($state.records, id, data);
|
||||
},
|
||||
[types.default.ADD_CONTACT_CONVERSATION]: ($state, { id, data }) => {
|
||||
const conversations = $state.records[id] || [];
|
||||
Vue.set($state.records, id, [...conversations, data]);
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@ -83,6 +83,26 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
fetchContactableInbox: async ({ commit }, id) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true });
|
||||
try {
|
||||
const response = await ContactAPI.getContactableInboxes(id);
|
||||
const contact = {
|
||||
id,
|
||||
contactableInboxes: response.data.payload,
|
||||
};
|
||||
commit(types.SET_CONTACT_ITEM, contact);
|
||||
} catch (error) {
|
||||
if (error.response?.data?.message) {
|
||||
throw new ExceptionWithMessage(error.response.data.message);
|
||||
} else {
|
||||
throw new Error(error);
|
||||
}
|
||||
} finally {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: false });
|
||||
}
|
||||
},
|
||||
|
||||
updatePresence: ({ commit }, data) => {
|
||||
commit(types.UPDATE_CONTACTS_PRESENCE, data);
|
||||
},
|
||||
|
||||
@ -11,6 +11,7 @@ const state = {
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
isFetchingItem: false,
|
||||
isFetchingInboxes: false,
|
||||
isUpdating: false,
|
||||
},
|
||||
};
|
||||
|
||||
@ -135,9 +135,12 @@ const actions = {
|
||||
commit(types.default.ASSIGN_TEAM, team);
|
||||
},
|
||||
|
||||
toggleStatus: async ({ commit }, data) => {
|
||||
toggleStatus: async ({ commit }, { conversationId, status }) => {
|
||||
try {
|
||||
const response = await ConversationApi.toggleStatus(data);
|
||||
const response = await ConversationApi.toggleStatus({
|
||||
conversationId,
|
||||
status,
|
||||
});
|
||||
commit(
|
||||
types.default.RESOLVE_CONVERSATION,
|
||||
response.data.payload.current_status
|
||||
|
||||
@ -1,10 +1,44 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
import InboxMembersAPI from '../../api/inboxMembers';
|
||||
|
||||
const state = {};
|
||||
const state = {
|
||||
records: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {};
|
||||
export const types = {
|
||||
SET_INBOX_MEMBERS_UI_FLAG: 'SET_INBOX_MEMBERS_UI_FLAG',
|
||||
SET_INBOX_MEMBERS: 'SET_INBOX_MEMBERS',
|
||||
};
|
||||
|
||||
const actions = {
|
||||
export const getters = {
|
||||
getMembersByInbox: $state => inboxId => {
|
||||
const allAgents = $state.records[inboxId] || [];
|
||||
const verifiedAgents = allAgents.filter(record => record.confirmed);
|
||||
return verifiedAgents;
|
||||
},
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
async fetch({ commit }, { inboxId }) {
|
||||
commit(types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await InboxMembersAPI.show(inboxId);
|
||||
commit(types.SET_INBOX_MEMBERS, { inboxId, members: payload });
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
} finally {
|
||||
commit(types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
get(_, { inboxId }) {
|
||||
return InboxMembersAPI.show(inboxId);
|
||||
},
|
||||
@ -13,7 +47,17 @@ const actions = {
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {};
|
||||
export const mutations = {
|
||||
[types.SET_INBOX_MEMBERS_UI_FLAG]($state, data) {
|
||||
$state.uiFlags = {
|
||||
...$state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
[types.SET_INBOX_MEMBERS]: ($state, { inboxId, members }) => {
|
||||
Vue.set($state.records, inboxId, members);
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import * as types from '../mutation-types';
|
||||
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
|
||||
import InboxesAPI from '../../api/inboxes';
|
||||
import WebChannel from '../../api/channel/webChannel';
|
||||
import FBChannel from '../../api/channel/fbChannel';
|
||||
@ -41,6 +42,20 @@ export const getters = {
|
||||
getInboxes($state) {
|
||||
return $state.records;
|
||||
},
|
||||
getNewConversationInboxes($state) {
|
||||
return $state.records.filter(inbox => {
|
||||
const {
|
||||
channel_type: channelType,
|
||||
phone_number: phoneNumber = '',
|
||||
} = inbox;
|
||||
|
||||
const isEmailChannel = channelType === INBOX_TYPES.EMAIL;
|
||||
const isSmsChannel =
|
||||
channelType === INBOX_TYPES.TWILIO &&
|
||||
phoneNumber.startsWith('whatsapp');
|
||||
return isEmailChannel || isSmsChannel;
|
||||
});
|
||||
},
|
||||
getInbox: $state => inboxId => {
|
||||
const [inbox] = $state.records.filter(
|
||||
record => record.id === Number(inboxId)
|
||||
|
||||
@ -38,4 +38,43 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#create', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.post.mockResolvedValue({ data: conversationList[0] });
|
||||
await actions.create(
|
||||
{ commit },
|
||||
{ inboxId: 1, message: { content: 'hi' }, contactId: 4, sourceId: 5 }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
|
||||
|
||||
[
|
||||
types.default.ADD_CONTACT_CONVERSATION,
|
||||
{ id: 4, data: conversationList[0] },
|
||||
],
|
||||
[
|
||||
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
|
||||
{ isCreating: false },
|
||||
],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||
|
||||
await expect(
|
||||
actions.create(
|
||||
{ commit },
|
||||
{ inboxId: 1, message: { content: 'hi' }, contactId: 4, sourceId: 5 }
|
||||
)
|
||||
).rejects.toThrow(Error);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
|
||||
[
|
||||
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
|
||||
{ isCreating: false },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -26,4 +26,17 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ADD_CONTACT_CONVERSATION', () => {
|
||||
it('Adds new contact conversation to records', () => {
|
||||
const state = { records: {} };
|
||||
mutations[types.default.ADD_CONTACT_CONVERSATION](state, {
|
||||
id: 1,
|
||||
data: { id: 1, contact_id: 1, message: 'hello' },
|
||||
});
|
||||
expect(state.records).toEqual({
|
||||
1: [{ id: 1, contact_id: 1, message: 'hello' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import axios from 'axios';
|
||||
import { actions, types } from '../../inboxMembers';
|
||||
import inboxMembers from './fixtures';
|
||||
|
||||
const commit = jest.fn();
|
||||
global.axios = axios;
|
||||
jest.mock('axios');
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#fetch', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.get.mockResolvedValue({ data: { payload: inboxMembers } });
|
||||
await actions.fetch({ commit }, { inboxId: 1 });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: true }],
|
||||
[types.SET_INBOX_MEMBERS, { inboxId: 1, members: inboxMembers }],
|
||||
[types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.get.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await expect(actions.fetch({ commit }, { inboxId: 1 })).rejects.toThrow(
|
||||
Error
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: true }],
|
||||
[types.SET_INBOX_MEMBERS_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,28 @@
|
||||
export default [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'email',
|
||||
uid: 'agent1@chatwoot.com',
|
||||
name: 'Agent1',
|
||||
email: 'agent1@chatwoot.com',
|
||||
account_id: 1,
|
||||
created_at: '2019-11-18T02:21:06.225Z',
|
||||
updated_at: '2019-12-20T07:43:35.794Z',
|
||||
pubsub_token: 'random-1',
|
||||
role: 'agent',
|
||||
confirmed: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
provider: 'email',
|
||||
uid: 'agent2@chatwoot.com',
|
||||
name: 'Agent2',
|
||||
email: 'agent2@chatwoot.com',
|
||||
account_id: 1,
|
||||
created_at: '2019-11-18T02:21:06.225Z',
|
||||
updated_at: '2019-12-20T07:43:35.794Z',
|
||||
pubsub_token: 'random-2',
|
||||
role: 'agent',
|
||||
confirmed: true,
|
||||
},
|
||||
];
|
||||
@ -0,0 +1,24 @@
|
||||
import { getters } from '../../teamMembers';
|
||||
import teamMembers from './fixtures';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('getMembersByInbox', () => {
|
||||
const state = {
|
||||
records: {
|
||||
1: [teamMembers[0]],
|
||||
},
|
||||
};
|
||||
expect(getters.getTeamMembers(state)(1)).toEqual([teamMembers[0]]);
|
||||
});
|
||||
|
||||
it('getUIFlags', () => {
|
||||
const state = {
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
};
|
||||
expect(getters.getUIFlags(state)).toEqual({
|
||||
isFetching: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,16 @@
|
||||
import { mutations, types } from '../../inboxMembers';
|
||||
import inboxMembers from './fixtures';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_INBOX_MEMBERS', () => {
|
||||
it('Adds inbox members to records', () => {
|
||||
const state = { records: {} };
|
||||
mutations[types.SET_INBOX_MEMBERS](state, {
|
||||
members: [...inboxMembers],
|
||||
inboxId: 1,
|
||||
});
|
||||
|
||||
expect(state.records).toEqual({ 1: inboxMembers });
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -114,6 +114,7 @@ export default {
|
||||
// Contact Conversation
|
||||
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
|
||||
SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS',
|
||||
ADD_CONTACT_CONVERSATION: 'ADD_CONTACT_CONVERSATION',
|
||||
|
||||
// Conversation Label
|
||||
SET_CONVERSATION_LABELS_UI_FLAG: 'SET_CONVERSATION_LABELS_UI_FLAG',
|
||||
|
||||
@ -12,7 +12,6 @@ import hljs from 'highlight.js';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import WootSwitch from 'components/ui/Switch';
|
||||
import WootWizard from 'components/ui/Wizard';
|
||||
import WootButton from 'components/ui/WootButton';
|
||||
import { sync } from 'vuex-router-sync';
|
||||
import Vuelidate from 'vuelidate';
|
||||
import VTooltip from 'v-tooltip';
|
||||
@ -22,6 +21,7 @@ import i18n from '../dashboard/i18n';
|
||||
import createAxios from '../dashboard/helper/APIHelper';
|
||||
import commonHelpers from '../dashboard/helper/commons';
|
||||
import { getAlertAudio } from '../shared/helpers/AudioNotificationHelper';
|
||||
import { initFaviconSwitcher } from '../shared/helpers/faviconHelper';
|
||||
import router from '../dashboard/routes';
|
||||
import store from '../dashboard/store';
|
||||
import vueActionCable from '../dashboard/helper/actionCable';
|
||||
@ -49,7 +49,6 @@ Vue.use(hljs.vuePlugin);
|
||||
Vue.component('multiselect', Multiselect);
|
||||
Vue.component('woot-switch', WootSwitch);
|
||||
Vue.component('woot-wizard', WootWizard);
|
||||
Vue.component('woot-button', WootButton);
|
||||
|
||||
const i18nConfig = new VueI18n({
|
||||
locale: 'en',
|
||||
@ -82,4 +81,5 @@ window.addEventListener('load', () => {
|
||||
})
|
||||
);
|
||||
getAlertAudio();
|
||||
initFaviconSwitcher();
|
||||
});
|
||||
|
||||
@ -23,6 +23,11 @@ export const IFrameHelper = {
|
||||
return `${baseUrl}/widget?website_token=${websiteToken}`;
|
||||
},
|
||||
createFrame: ({ baseUrl, websiteToken }) => {
|
||||
if (IFrameHelper.getAppFrame()) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadCSS();
|
||||
const iframe = document.createElement('iframe');
|
||||
const cwCookie = Cookies.get('cw_conversation');
|
||||
let widgetUrl = IFrameHelper.getUrl({ baseUrl, websiteToken });
|
||||
@ -47,6 +52,7 @@ export const IFrameHelper = {
|
||||
IFrameHelper.preventDefaultScroll();
|
||||
},
|
||||
getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'),
|
||||
getBubbleHolder: () => document.getElementsByClassName('woot--bubble-holder'),
|
||||
sendMessage: (key, value) => {
|
||||
const element = IFrameHelper.getAppFrame();
|
||||
element.contentWindow.postMessage(
|
||||
@ -166,7 +172,10 @@ export const IFrameHelper = {
|
||||
iframe.style.visibility = '';
|
||||
iframe.setAttribute('id', `chatwoot_live_chat_widget`);
|
||||
|
||||
loadCSS();
|
||||
if (IFrameHelper.getBubbleHolder().length) {
|
||||
return;
|
||||
}
|
||||
|
||||
createBubbleHolder();
|
||||
|
||||
if (!window.$chatwoot.hideMessageBubble) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export const BUS_EVENTS = {
|
||||
SET_REFERRER_HOST: 'SET_REFERRER_HOST',
|
||||
SET_TWEET_REPLY: 'SET_TWEET_REPLY',
|
||||
ATTACHMENT_SIZE_CHECK_ERROR: 'ATTACHMENT_SIZE_CHECK_ERROR',
|
||||
};
|
||||
|
||||
6
app/javascript/shared/constants/links.js
Normal file
6
app/javascript/shared/constants/links.js
Normal file
@ -0,0 +1,6 @@
|
||||
export const REPLY_POLICY = {
|
||||
FACEBOOK:
|
||||
'https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/',
|
||||
TWILIO_WHATSAPP:
|
||||
'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session',
|
||||
};
|
||||
@ -10,3 +10,5 @@ export const MESSAGE_TYPE = {
|
||||
ACTIVITY: 2,
|
||||
TEMPLATE: 3,
|
||||
};
|
||||
// Size in mega bytes
|
||||
export const MAXIMUM_FILE_UPLOAD_SIZE = 40;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
const notificationAudio = require('shared/assets/audio/ding.mp3');
|
||||
import axios from 'axios';
|
||||
import { showBadgeOnFavicon } from './faviconHelper';
|
||||
|
||||
export const playNotificationAudio = () => {
|
||||
try {
|
||||
@ -74,5 +75,6 @@ export const newMessageNotification = data => {
|
||||
|
||||
if (enableAudioAlerts && playAudio) {
|
||||
window.playAudioAlert();
|
||||
showBadgeOnFavicon();
|
||||
}
|
||||
};
|
||||
|
||||
25
app/javascript/shared/helpers/FileHelper.js
Normal file
25
app/javascript/shared/helpers/FileHelper.js
Normal file
@ -0,0 +1,25 @@
|
||||
export const formatBytes = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const fileSizeInMegaBytes = bytes => {
|
||||
if (bytes === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sizeInMB = (bytes / (1024 * 1024)).toFixed(2);
|
||||
return sizeInMB;
|
||||
};
|
||||
|
||||
export const checkFileSizeLimit = (file, maximumUploadLimit) => {
|
||||
const fileSize = file?.file?.size;
|
||||
const fileSizeInMB = fileSizeInMegaBytes(fileSize);
|
||||
return fileSizeInMB <= maximumUploadLimit;
|
||||
};
|
||||
2
app/javascript/shared/helpers/Validators.js
Normal file
2
app/javascript/shared/helpers/Validators.js
Normal file
@ -0,0 +1,2 @@
|
||||
export const isPhoneE164 = value => !!value.match(/^\+[1-9]\d{1,14}$/);
|
||||
export const isPhoneE164OrEmpty = value => isPhoneE164(value) || value === '';
|
||||
21
app/javascript/shared/helpers/faviconHelper.js
Normal file
21
app/javascript/shared/helpers/faviconHelper.js
Normal file
@ -0,0 +1,21 @@
|
||||
export const showBadgeOnFavicon = () => {
|
||||
const favicons = document.querySelectorAll('.favicon');
|
||||
|
||||
favicons.forEach(favicon => {
|
||||
const newFileName = `/favicon-badge-${favicon.sizes[[0]]}.png`;
|
||||
favicon.href = newFileName;
|
||||
});
|
||||
};
|
||||
|
||||
export const initFaviconSwitcher = () => {
|
||||
const favicons = document.querySelectorAll('.favicon');
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
favicons.forEach(favicon => {
|
||||
const oldFileName = `/favicon-${favicon.sizes[[0]]}.png`;
|
||||
favicon.href = oldFileName;
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
38
app/javascript/shared/helpers/specs/FileHelper.spec.js
Normal file
38
app/javascript/shared/helpers/specs/FileHelper.spec.js
Normal file
@ -0,0 +1,38 @@
|
||||
import {
|
||||
formatBytes,
|
||||
fileSizeInMegaBytes,
|
||||
checkFileSizeLimit,
|
||||
} from '../FileHelper';
|
||||
|
||||
describe('#File Helpers', () => {
|
||||
describe('formatBytes', () => {
|
||||
it('should return zero bytes if 0 is passed', () => {
|
||||
expect(formatBytes(0)).toBe('0 Bytes');
|
||||
});
|
||||
it('should return in bytes if 1000 is passed', () => {
|
||||
expect(formatBytes(1000)).toBe('1000 Bytes');
|
||||
});
|
||||
it('should return in KB if 100000 is passed', () => {
|
||||
expect(formatBytes(10000)).toBe('9.77 KB');
|
||||
});
|
||||
it('should return in MB if 10000000 is passed', () => {
|
||||
expect(formatBytes(10000000)).toBe('9.54 MB');
|
||||
});
|
||||
});
|
||||
describe('fileSizeInMegaBytes', () => {
|
||||
it('should return zero if 0 is passed', () => {
|
||||
expect(fileSizeInMegaBytes(0)).toBe(0);
|
||||
});
|
||||
it('should return 19.07 if 20000000 is passed', () => {
|
||||
expect(fileSizeInMegaBytes(20000000)).toBe('19.07');
|
||||
});
|
||||
});
|
||||
describe('checkFileSizeLimit', () => {
|
||||
it('should return false if file with size 62208194 and file size limit 40 are passed', () => {
|
||||
expect(checkFileSizeLimit({ file: { size: 62208194 } }, 40)).toBe(false);
|
||||
});
|
||||
it('should return true if file with size 62208194 and file size limit 40 are passed', () => {
|
||||
expect(checkFileSizeLimit({ file: { size: 199154 } }, 40)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -4,7 +4,7 @@
|
||||
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
|
||||
@input-file="onFileUpload"
|
||||
>
|
||||
<span class="attachment-button ">
|
||||
<span class="attachment-button">
|
||||
<i v-if="!isUploading.image" class="ion-android-attach" />
|
||||
<spinner v-if="isUploading" size="small" />
|
||||
</span>
|
||||
@ -14,6 +14,9 @@
|
||||
<script>
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
export default {
|
||||
components: { FileUpload, Spinner },
|
||||
@ -31,14 +34,21 @@ export default {
|
||||
return fileType.includes('image') ? 'image' : 'file';
|
||||
},
|
||||
async onFileUpload(file) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
this.isUploading = true;
|
||||
try {
|
||||
const thumbUrl = window.URL.createObjectURL(file.file);
|
||||
await this.onAttach({
|
||||
fileType: this.getFileType(file.type),
|
||||
file: file.file,
|
||||
thumbUrl,
|
||||
});
|
||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
const thumbUrl = window.URL.createObjectURL(file.file);
|
||||
await this.onAttach({
|
||||
fileType: this.getFileType(file.type),
|
||||
file: file.file,
|
||||
thumbUrl,
|
||||
});
|
||||
} else {
|
||||
window.bus.$emit(BUS_EVENTS.ATTACHMENT_SIZE_CHECK_ERROR);
|
||||
}
|
||||
} catch (error) {
|
||||
// Error
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
:placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')"
|
||||
:error="$v.message.$error ? $t('PRE_CHAT_FORM.FIELDS.MESSAGE.ERROR') : ''"
|
||||
/>
|
||||
<woot-button
|
||||
<custom-button
|
||||
class="font-medium"
|
||||
block
|
||||
:bg-color="widgetColor"
|
||||
@ -46,12 +46,12 @@
|
||||
>
|
||||
<spinner v-if="isCreating" class="p-0" />
|
||||
{{ $t('START_CONVERSATION') }}
|
||||
</woot-button>
|
||||
</custom-button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WootButton from 'shared/components/Button';
|
||||
import CustomButton from 'shared/components/Button';
|
||||
import FormInput from '../Form/Input';
|
||||
import FormTextArea from '../Form/TextArea';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
@ -62,7 +62,7 @@ export default {
|
||||
components: {
|
||||
FormInput,
|
||||
FormTextArea,
|
||||
WootButton,
|
||||
CustomButton,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
</div>
|
||||
<available-agents v-if="isOnline" :agents="availableAgents" />
|
||||
</div>
|
||||
<woot-button
|
||||
<custom-button
|
||||
class="font-medium"
|
||||
block
|
||||
:bg-color="widgetColor"
|
||||
@ -23,7 +23,7 @@
|
||||
@click="startConversation"
|
||||
>
|
||||
{{ $t('START_CONVERSATION') }}
|
||||
</woot-button>
|
||||
</custom-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import AvailableAgents from 'widget/components/AvailableAgents.vue';
|
||||
import { getContrastingTextColor } from 'shared/helpers/ColorHelper';
|
||||
import WootButton from 'shared/components/Button';
|
||||
import CustomButton from 'shared/components/Button';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import availabilityMixin from 'widget/mixins/availability';
|
||||
|
||||
@ -39,7 +39,7 @@ export default {
|
||||
name: 'TeamAvailability',
|
||||
components: {
|
||||
AvailableAgents,
|
||||
WootButton,
|
||||
CustomButton,
|
||||
},
|
||||
mixins: [configMixin, availabilityMixin],
|
||||
props: {
|
||||
|
||||
@ -48,5 +48,6 @@
|
||||
"ERROR": "Message too short"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit"
|
||||
}
|
||||
|
||||
@ -34,6 +34,15 @@
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<div v-if="showAttachmentError" class="banner">
|
||||
<span>
|
||||
{{
|
||||
$t('FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_FILE_UPLOAD_SIZE: fileUploadSizeLimit,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-1 overflow-auto">
|
||||
<conversation-wrap
|
||||
v-if="currentView === 'messageView'"
|
||||
@ -80,6 +89,8 @@ import configMixin from '../mixins/configMixin';
|
||||
import TeamAvailability from 'widget/components/TeamAvailability';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import PreChatForm from '../components/PreChat/Form';
|
||||
export default {
|
||||
name: 'Home',
|
||||
@ -105,7 +116,7 @@ export default {
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { isOnCollapsedView: false };
|
||||
return { isOnCollapsedView: false, showAttachmentError: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@ -132,6 +143,9 @@ export default {
|
||||
isOpen() {
|
||||
return this.conversationAttributes.status === 'open';
|
||||
},
|
||||
fileUploadSizeLimit() {
|
||||
return MAXIMUM_FILE_UPLOAD_SIZE;
|
||||
},
|
||||
showInputTextArea() {
|
||||
if (this.hideInputForBotConversations) {
|
||||
if (this.isOpen) {
|
||||
@ -154,6 +168,14 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(BUS_EVENTS.ATTACHMENT_SIZE_CHECK_ERROR, () => {
|
||||
this.showAttachmentError = true;
|
||||
setTimeout(() => {
|
||||
this.showAttachmentError = false;
|
||||
}, 3000);
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
startConversation() {
|
||||
this.isOnCollapsedView = !this.isOnCollapsedView;
|
||||
@ -221,5 +243,13 @@ export default {
|
||||
.input-wrap {
|
||||
padding: 0 $space-normal;
|
||||
}
|
||||
.banner {
|
||||
background: $color-error;
|
||||
color: $color-white;
|
||||
font-size: $font-size-default;
|
||||
font-weight: $font-weight-bold;
|
||||
padding: $space-slab;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -29,13 +29,13 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
|
||||
send_mail_with_liquid(to: @agent.email, subject: subject) and return
|
||||
end
|
||||
|
||||
def assigned_conversation_new_message(conversation, agent)
|
||||
def assigned_conversation_new_message(message, agent)
|
||||
return unless smtp_config_set_or_development?
|
||||
# Don't spam with email notifications if agent is online
|
||||
return if ::OnlineStatusTracker.get_presence(conversation.account.id, 'User', agent.id)
|
||||
return if ::OnlineStatusTracker.get_presence(message.account_id, 'User', agent.id)
|
||||
|
||||
@agent = agent
|
||||
@conversation = conversation
|
||||
@conversation = message.conversation
|
||||
subject = "#{@agent.available_name}, New message in your assigned conversation [ID - #{@conversation.display_id}]."
|
||||
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
|
||||
send_mail_with_liquid(to: @agent.email, subject: subject) and return
|
||||
|
||||
@ -16,12 +16,19 @@ class ApplicationMailer < ActionMailer::Base
|
||||
end
|
||||
end
|
||||
|
||||
rescue_from(*ExceptionList::SMTP_EXCEPTIONS, with: :handle_smtp_exceptions)
|
||||
|
||||
def smtp_config_set_or_development?
|
||||
ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_smtp_exceptions(message)
|
||||
Rails.logger.info 'Failed to send Email'
|
||||
Rails.logger.info "Exception: #{message}"
|
||||
end
|
||||
|
||||
def send_mail_with_liquid(*args)
|
||||
mail(*args) do |format|
|
||||
# explored sending a multipart email containing both text type and html
|
||||
|
||||
@ -31,6 +31,9 @@ class Contact < ApplicationRecord
|
||||
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] }
|
||||
validates :phone_number,
|
||||
allow_blank: true, uniqueness: { scope: [:account_id] },
|
||||
format: { with: /\+[1-9]\d{1,14}\z/, message: 'should be in e164 format' }
|
||||
|
||||
belongs_to :account
|
||||
has_many :conversations, dependent: :destroy
|
||||
|
||||
@ -53,10 +53,10 @@ class ContactInbox < ApplicationRecord
|
||||
|
||||
def validate_twilio_source_id
|
||||
# https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164
|
||||
if inbox.channel.medium == 'sms' && !/^\+[1-9]\d{1,14}$/.match?(source_id)
|
||||
errors.add(:source_id, 'invalid source id for twilio sms inbox. valid Regex /^\+[1-9]\d{1,14}$/')
|
||||
elsif inbox.channel.medium == 'whatsapp' && !/^whatsapp:\+[1-9]\d{1,14}$/.match?(source_id)
|
||||
errors.add(:source_id, 'invalid source id for twilio whatsapp inbox. valid Regex /^whatsapp:\+[1-9]\d{1,14}$/')
|
||||
if inbox.channel.medium == 'sms' && !/\+[1-9]\d{1,14}\z/.match?(source_id)
|
||||
errors.add(:source_id, 'invalid source id for twilio sms inbox. valid Regex /\+[1-9]\d{1,14}\z/')
|
||||
elsif inbox.channel.medium == 'whatsapp' && !/whatsapp:\+[1-9]\d{1,14}\z/.match?(source_id)
|
||||
errors.add(:source_id, 'invalid source id for twilio whatsapp inbox. valid Regex /whatsapp:\+[1-9]\d{1,14}\z/')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@ -148,7 +148,9 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
def send_reply
|
||||
::SendReplyJob.perform_later(id)
|
||||
# FIXME: Giving it few seconds for the attachment to be uploaded to the service
|
||||
# active storage attaches the file only after commit
|
||||
attachments.blank? ? ::SendReplyJob.perform_later(id) : ::SendReplyJob.set(wait: 2.seconds).perform_later(id)
|
||||
end
|
||||
|
||||
def reopen_conversation
|
||||
|
||||
@ -20,8 +20,6 @@
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
class Team < ApplicationRecord
|
||||
include RegexHelper
|
||||
|
||||
belongs_to :account
|
||||
has_many :team_members, dependent: :destroy
|
||||
has_many :members, through: :team_members, source: :user
|
||||
@ -29,7 +27,6 @@ class Team < ApplicationRecord
|
||||
|
||||
validates :name,
|
||||
presence: { message: 'must not be blank' },
|
||||
format: { with: UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE },
|
||||
uniqueness: { scope: :account_id }
|
||||
|
||||
before_validation do
|
||||
|
||||
@ -19,6 +19,10 @@ class ContactPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def contactable_inboxes?
|
||||
true
|
||||
end
|
||||
|
||||
def show?
|
||||
true
|
||||
end
|
||||
|
||||
52
app/services/contacts/contactable_inboxes_service.rb
Normal file
52
app/services/contacts/contactable_inboxes_service.rb
Normal file
@ -0,0 +1,52 @@
|
||||
class Contacts::ContactableInboxesService
|
||||
pattr_initialize [:contact!]
|
||||
|
||||
def get
|
||||
account = contact.account
|
||||
account.inboxes.map { |inbox| get_contactable_inbox(inbox) }.compact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_contactable_inbox(inbox)
|
||||
return twilio_contactable_inbox(inbox) if inbox.channel_type == 'Channel::TwilioSms'
|
||||
return email_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Email'
|
||||
return api_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Api'
|
||||
return website_contactable_inbox(inbox) if inbox.channel_type == 'Channel::WebWidget'
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def website_contactable_inbox(inbox)
|
||||
latest_contact_inbox = inbox.contact_inboxes.where(contact: @contact).last
|
||||
return unless latest_contact_inbox
|
||||
# FIXME : change this when multiple conversations comes in
|
||||
return if latest_contact_inbox.conversations.present?
|
||||
|
||||
{ source_id: latest_contact_inbox.source_id, inbox: inbox }
|
||||
end
|
||||
|
||||
def api_contactable_inbox(inbox)
|
||||
latest_contact_inbox = inbox.contact_inboxes.where(contact: @contact).last
|
||||
source_id = latest_contact_inbox&.source_id || SecureRandom.uuid
|
||||
|
||||
{ source_id: source_id, inbox: inbox }
|
||||
end
|
||||
|
||||
def email_contactable_inbox(inbox)
|
||||
return unless @contact.email
|
||||
|
||||
{ source_id: @contact.email, inbox: inbox }
|
||||
end
|
||||
|
||||
def twilio_contactable_inbox(inbox)
|
||||
return if @contact.phone_number.blank?
|
||||
|
||||
case inbox.channel.medium
|
||||
when 'sms'
|
||||
{ source_id: @contact.phone_number, inbox: inbox }
|
||||
when 'whatsapp'
|
||||
{ source_id: "whatsapp:#{@contact.phone_number}", inbox: inbox }
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,8 @@
|
||||
json.payload do
|
||||
json.array! @contactable_inboxes do |contactable_inbox|
|
||||
json.inbox do
|
||||
json.partial! 'api/v1/models/inbox.json.jbuilder', resource: contactable_inbox[:inbox]
|
||||
end
|
||||
json.source_id contactable_inbox[:source_id]
|
||||
end
|
||||
end
|
||||
@ -1,2 +1,4 @@
|
||||
json.source_id resource.source_id
|
||||
json.inbox resource.inbox
|
||||
json.inbox do
|
||||
json.partial! 'api/v1/models/inbox.json.jbuilder', resource: resource.inbox
|
||||
end
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
<% headers = ['Agent name', 'Conversations count', 'Avg first response time (Minutes)', 'Avg resolution time (Minutes)'] %>
|
||||
<% headers = [
|
||||
I18n.t('reports.agent_csv.agent_name'),
|
||||
I18n.t('reports.agent_csv.conversations_count'),
|
||||
I18n.t('reports.agent_csv.avg_first_response_time'),
|
||||
I18n.t('reports.agent_csv.avg_resolution_time')
|
||||
]
|
||||
%>
|
||||
<%= CSV.generate_line headers %>
|
||||
<% Current.account.users.each do |agent| %>
|
||||
<% agent_report = V2::ReportBuilder.new(Current.account, {
|
||||
|
||||
@ -23,9 +23,9 @@
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link class="favicon" rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link class="favicon" rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
||||
<link class="favicon" rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<% end %>
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="<%= @global_config['LOGO_THUMBNAIL'] %>">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '1.14.3'
|
||||
version: '1.15.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@ -4,7 +4,3 @@ redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
|
||||
# Add here as you use it for more features
|
||||
# Used for Round Robin, Conversation Emails & Online Presence
|
||||
$alfred = Redis::Namespace.new('alfred', redis: redis, warning: true)
|
||||
|
||||
# https://github.com/mperham/sidekiq/issues/4591
|
||||
# TODO once sidekiq remove we can remove this
|
||||
Redis.exists_returns_integer = false
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user