Merge branch 'release/1.15.0'
This commit is contained in:
commit
8720f39ffb
@ -105,6 +105,7 @@ Rails/UniqueValidationWithoutIndex:
|
|||||||
Exclude:
|
Exclude:
|
||||||
- 'app/models/channel/twitter_profile.rb'
|
- 'app/models/channel/twitter_profile.rb'
|
||||||
- 'app/models/webhook.rb'
|
- 'app/models/webhook.rb'
|
||||||
|
- 'app/models/contact.rb'
|
||||||
Rails/RenderInline:
|
Rails/RenderInline:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/swagger_controller.rb'
|
- 'app/controllers/swagger_controller.rb'
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
<p align="center">
|
<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>
|
<p align="center">Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.</p>
|
||||||
<div align="center">An opensource alternative to Intercom, Zendesk, Drift, Crisp etc.</div>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@ -16,11 +15,13 @@ ___
|
|||||||
<p align="center">
|
<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>
|
<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">
|
<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>
|
<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/license/chatwoot/chatwoot" alt="License">
|
||||||
<img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month">
|
<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 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://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>
|
</p>
|
||||||
|
|
||||||
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/chatwoot-dashboard-assets.png" width="100%" alt="Chat dashboard"/>
|
<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;
|
$color-extra-light-blue: #f5f7f9;
|
||||||
|
|
||||||
$primary-color: $color-woot;
|
$primary-color: $color-woot;
|
||||||
$secondary-color: #35c5ff;
|
$secondary-color: #5d7592;
|
||||||
$success-color: #44ce4b;
|
$success-color: #44ce4b;
|
||||||
$warning-color: #ffc532;
|
$warning-color: #ffc532;
|
||||||
$alert-color: #ff382d;
|
$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
|
private
|
||||||
|
|
||||||
def inbox_ids
|
def inbox_ids
|
||||||
if current_user.administrator?
|
if Current.user.administrator?
|
||||||
Current.account.inboxes.pluck(:id)
|
Current.account.inboxes.pluck(:id)
|
||||||
elsif current_user.agent?
|
elsif Current.user.agent?
|
||||||
current_user.assigned_inboxes.pluck(:id)
|
Current.user.assigned_inboxes.pluck(:id)
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|||||||
@ -4,7 +4,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :set_current_page, only: [:index, :active, :search]
|
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
|
def index
|
||||||
@contacts_count = resolved_contacts.count
|
@contacts_count = resolved_contacts.count
|
||||||
@ -40,6 +40,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
|
def contactable_inboxes
|
||||||
|
@contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@contact = Current.account.contacts.new(contact_params)
|
@contact = Current.account.contacts.new(contact_params)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
user = current_user || @resource
|
user = Current.user || @resource
|
||||||
mb = Messages::MessageBuilder.new(user, @conversation, params)
|
mb = Messages::MessageBuilder.new(user, @conversation, params)
|
||||||
@message = mb.perform
|
@message = mb.perform
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
|
|||||||
@ -22,7 +22,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
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
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
@ -78,16 +81,29 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def contact_inbox
|
def contact_inbox
|
||||||
|
@contact_inbox = build_contact_inbox
|
||||||
|
|
||||||
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
|
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
|
||||||
end
|
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
|
def conversation_params
|
||||||
|
additional_attributes = params[:additional_attributes]&.permit! || {}
|
||||||
{
|
{
|
||||||
account_id: Current.account.id,
|
account_id: Current.account.id,
|
||||||
inbox_id: @contact_inbox.inbox_id,
|
inbox_id: @contact_inbox.inbox_id,
|
||||||
contact_id: @contact_inbox.contact_id,
|
contact_id: @contact_inbox.contact_id,
|
||||||
contact_inbox_id: @contact_inbox.id,
|
contact_inbox_id: @contact_inbox.id,
|
||||||
additional_attributes: params[:additional_attributes]
|
additional_attributes: additional_attributes
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ module AccessTokenAuthHelper
|
|||||||
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
|
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
|
||||||
|
|
||||||
@resource = @access_token.owner
|
@resource = @access_token.owner
|
||||||
|
Current.user = @resource if current_user.is_a?(User)
|
||||||
end
|
end
|
||||||
|
|
||||||
def super_admin?
|
def super_admin?
|
||||||
@ -21,7 +22,7 @@ module AccessTokenAuthHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def validate_bot_access_token!
|
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 super_admin?
|
||||||
return if agent_bot_accessible?
|
return if agent_bot_accessible?
|
||||||
|
|
||||||
|
|||||||
@ -61,7 +61,9 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
isLoggedIn() {
|
isLoggedIn() {
|
||||||
return !!Cookies.getJSON('auth_data');
|
const hasAuthCookie = !!Cookies.getJSON('auth_data');
|
||||||
|
const hasUserCookie = !!Cookies.getJSON('user');
|
||||||
|
return hasAuthCookie && hasUserCookie;
|
||||||
},
|
},
|
||||||
|
|
||||||
isAdmin() {
|
isAdmin() {
|
||||||
@ -79,7 +81,9 @@ export default {
|
|||||||
},
|
},
|
||||||
getPubSubToken() {
|
getPubSubToken() {
|
||||||
if (this.isLoggedIn()) {
|
if (this.isLoggedIn()) {
|
||||||
return Cookies.getJSON('user').pubsub_token;
|
const user = Cookies.getJSON('user') || {};
|
||||||
|
const { pubsub_token: pubsubToken } = user;
|
||||||
|
return pubsubToken;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -14,6 +14,10 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${contactId}/conversations`);
|
return axios.get(`${this.url}/${contactId}/conversations`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getContactableInboxes(contactId) {
|
||||||
|
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
|
||||||
|
}
|
||||||
|
|
||||||
search(search = '', page = 1) {
|
search(search = '', page = 1) {
|
||||||
return axios.get(`${this.url}/search?q=${search}&page=${page}`);
|
return axios.get(`${this.url}/search?q=${search}&page=${page}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,8 +28,10 @@ class ConversationApi extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleStatus(conversationId) {
|
toggleStatus({ conversationId, status }) {
|
||||||
return axios.post(`${this.url}/${conversationId}/toggle_status`, {});
|
return axios.post(`${this.url}/${conversationId}/toggle_status`, {
|
||||||
|
status,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
assignAgent({ conversationId, agentId }) {
|
assignAgent({ conversationId, agentId }) {
|
||||||
|
|||||||
@ -49,7 +49,7 @@ $global-font-size: 10px;
|
|||||||
$global-width: 100%;
|
$global-width: 100%;
|
||||||
$global-lineheight: 1.5;
|
$global-lineheight: 1.5;
|
||||||
$foundation-palette: (primary: $color-woot,
|
$foundation-palette: (primary: $color-woot,
|
||||||
secondary: #35c5ff,
|
secondary: #5d7592,
|
||||||
success: #44ce4b,
|
success: #44ce4b,
|
||||||
warning: #ffc532,
|
warning: #ffc532,
|
||||||
alert: #ff382d);
|
alert: #ff382d);
|
||||||
@ -260,11 +260,11 @@ color 0.25s ease-out;
|
|||||||
// 12. Button Group
|
// 12. Button Group
|
||||||
// ----------------
|
// ----------------
|
||||||
|
|
||||||
$buttongroup-margin: 1rem;
|
$buttongroup-margin: 0;
|
||||||
$buttongroup-spacing: 1px;
|
$buttongroup-spacing: 0;
|
||||||
$buttongroup-child-selector: '.button';
|
$buttongroup-child-selector: '.button';
|
||||||
$buttongroup-expand-max: 6;
|
$buttongroup-expand-max: 6;
|
||||||
$buttongroup-radius-on-each: true;
|
$buttongroup-radius-on-each: false;
|
||||||
|
|
||||||
// 13. Callout
|
// 13. Callout
|
||||||
// -----------
|
// -----------
|
||||||
|
|||||||
@ -55,7 +55,7 @@ $color-heading: #1f2d3d;
|
|||||||
$color-extra-light-blue: #f5f7f9;
|
$color-extra-light-blue: #f5f7f9;
|
||||||
|
|
||||||
$primary-color: $color-woot;
|
$primary-color: $color-woot;
|
||||||
$secondary-color: #35c5ff;
|
$secondary-color: #5d7592;
|
||||||
$success-color: #44ce4b;
|
$success-color: #44ce4b;
|
||||||
$warning-color: #ffc532;
|
$warning-color: #ffc532;
|
||||||
$alert-color: #ff382d;
|
$alert-color: #ff382d;
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
.dropdown-pane {
|
.dropdown-pane {
|
||||||
@include elegant-card;
|
@include elegant-card;
|
||||||
@include border-light;
|
@include border-light;
|
||||||
|
box-sizing: content-box;
|
||||||
|
width: fit-content;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
&.dropdown-pane--open {
|
&.dropdown-pane--open {
|
||||||
|
|||||||
@ -118,6 +118,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.multiselect__single {
|
.multiselect__single {
|
||||||
|
@include text-ellipsis;
|
||||||
|
display: inline-block;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding: var(--space-slab) var(--space-one);
|
padding: var(--space-slab) var(--space-one);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,20 +8,15 @@ $resolve-button-width: 13.2rem;
|
|||||||
@include flex-align($x: justify, $y: middle);
|
@include flex-align($x: justify, $y: middle);
|
||||||
@include border-normal-bottom;
|
@include border-normal-bottom;
|
||||||
|
|
||||||
// Resolve Button
|
|
||||||
.button {
|
|
||||||
@include margin(0);
|
|
||||||
@include flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.multiselect-box {
|
.multiselect-box {
|
||||||
@include flex;
|
@include flex;
|
||||||
@include flex-align($x: justify, $y: middle);
|
@include flex-align($x: justify, $y: middle);
|
||||||
@include border-light;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: $space-smaller;
|
border-radius: var(--space-smaller);
|
||||||
margin-right: var(--space-small);
|
margin-right: var(--space-small);
|
||||||
|
width: 20.2rem;
|
||||||
|
|
||||||
&::before {
|
.icon {
|
||||||
color: $medium-gray;
|
color: $medium-gray;
|
||||||
font-size: $font-size-default;
|
font-size: $font-size-default;
|
||||||
line-height: 3.8rem;
|
line-height: 3.8rem;
|
||||||
@ -31,6 +26,7 @@ $resolve-button-width: 13.2rem;
|
|||||||
|
|
||||||
.multiselect {
|
.multiselect {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
.multiselect__tags {
|
.multiselect__tags {
|
||||||
border: 0;
|
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 {
|
.header-actions-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -151,6 +151,7 @@
|
|||||||
border-top-left-radius: $space-smaller;
|
border-top-left-radius: $space-smaller;
|
||||||
color: $color-body;
|
color: $color-body;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
&.is-image {
|
&.is-image {
|
||||||
border-radius: var(--border-radius-large);
|
border-radius: var(--border-radius-large);
|
||||||
@ -198,6 +199,7 @@
|
|||||||
border-bottom-right-radius: $space-smaller;
|
border-bottom-right-radius: $space-smaller;
|
||||||
border-top-right-radius: $space-smaller;
|
border-top-right-radius: $space-smaller;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
&.is-private {
|
&.is-private {
|
||||||
background: lighten($warning-color, 32%);
|
background: lighten($warning-color, 32%);
|
||||||
@ -228,6 +230,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
@include margin($zero $space-normal);
|
@include margin($zero $space-normal);
|
||||||
max-width: 69%;
|
max-width: 69%;
|
||||||
@ -247,17 +253,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.activity-wrap {
|
.activity-wrap {
|
||||||
@include flex;
|
background: var(--s-50);
|
||||||
@include margin($space-small auto);
|
border: 1px solid var(--s-100);
|
||||||
@include padding($space-small $space-normal);
|
border-radius: var(--border-radius-medium);
|
||||||
@include flex-align($x: center, $y: null);
|
display: flex;
|
||||||
background: lighten($warning-color, 32%);
|
font-size: var(--font-size-small);
|
||||||
border: 1px solid lighten($warning-color, 22%);
|
justify-content: center;
|
||||||
border-radius: $space-smaller;
|
margin: var(--space-small) var(--space-normal);
|
||||||
font-size: $font-size-small;
|
padding: var(--space-small) var(--space-normal);
|
||||||
|
|
||||||
.message-text__wrap {
|
.is-text {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
text-align: start;
|
||||||
|
|
||||||
|
@include breakpoint(xxxlarge up) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,12 +67,15 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-background-light);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-pane {
|
.dropdown-pane {
|
||||||
bottom: 6rem;
|
bottom: 6rem;
|
||||||
display: block;
|
display: block;
|
||||||
left: 5rem;
|
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
width: 80%;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
<slot></slot>
|
<slot></slot>
|
||||||
<div class="chat-list__top">
|
<div class="chat-list__top">
|
||||||
<h1 class="page-title text-truncate" :title="pageTitle">
|
<h1 class="page-title text-truncate" :title="pageTitle">
|
||||||
<woot-sidemenu-icon />
|
|
||||||
{{ pageTitle }}
|
{{ pageTitle }}
|
||||||
</h1>
|
</h1>
|
||||||
<chat-filter @statusFilterChange="updateStatusType" />
|
<chat-filter @statusFilterChange="updateStatusType" />
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* global bus */
|
|
||||||
export default {
|
export default {
|
||||||
methods: {
|
methods: {
|
||||||
onMenuItemClick() {
|
onMenuItemClick() {
|
||||||
@ -12,3 +11,8 @@ export default {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.ion-android-menu {
|
||||||
|
font-size: var(--font-size-big);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,29 +1,86 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<div class="resolve-actions">
|
||||||
type="button"
|
<div class="button-group">
|
||||||
class="button resolve--button"
|
<woot-button
|
||||||
:class="buttonClass"
|
v-if="isOpen"
|
||||||
@click="toggleStatus"
|
class-names="resolve"
|
||||||
>
|
color-scheme="success"
|
||||||
<i v-if="!isLoading" class="icon" :class="buttonIconClass" />
|
icon="ion-checkmark"
|
||||||
<spinner v-if="isLoading" />
|
:is-loading="isLoading"
|
||||||
{{ currentStatus }}
|
@click="() => toggleStatus(STATUS_TYPE.RESOLVED)"
|
||||||
</button>
|
>
|
||||||
|
{{ 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>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
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';
|
import wootConstants from '../../constants';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Spinner,
|
WootDropdownItem,
|
||||||
|
WootDropdownMenu,
|
||||||
},
|
},
|
||||||
|
mixins: [clickaway, alertMixin],
|
||||||
props: { conversationId: { type: [String, Number], required: true } },
|
props: { conversationId: { type: [String, Number], required: true } },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
showDropdown: false,
|
||||||
|
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -33,26 +90,60 @@ export default {
|
|||||||
isOpen() {
|
isOpen() {
|
||||||
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
|
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
|
||||||
},
|
},
|
||||||
currentStatus() {
|
isBot() {
|
||||||
return this.isOpen
|
return this.currentChat.status === wootConstants.STATUS_TYPE.BOT;
|
||||||
? this.$t('CONVERSATION.HEADER.RESOLVE_ACTION')
|
},
|
||||||
: this.$t('CONVERSATION.HEADER.REOPEN_ACTION');
|
isResolved() {
|
||||||
|
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
|
||||||
},
|
},
|
||||||
buttonClass() {
|
buttonClass() {
|
||||||
return this.isOpen ? 'success' : 'warning';
|
if (this.isBot) return 'primary';
|
||||||
|
if (this.isOpen) return 'success';
|
||||||
|
if (this.isResolved) return 'warning';
|
||||||
|
return '';
|
||||||
},
|
},
|
||||||
buttonIconClass() {
|
showDropDown() {
|
||||||
return this.isOpen ? 'ion-checkmark' : 'ion-refresh';
|
return !this.isBot;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleStatus() {
|
closeDropdown() {
|
||||||
|
this.showDropdown = false;
|
||||||
|
},
|
||||||
|
openDropdown() {
|
||||||
|
this.showDropdown = true;
|
||||||
|
},
|
||||||
|
toggleStatus(status) {
|
||||||
|
this.closeDropdown();
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.$store.dispatch('toggleStatus', this.currentChat.id).then(() => {
|
this.$store
|
||||||
bus.$emit('newToastMessage', this.$t('CONVERSATION.CHANGE_STATUS'));
|
.dispatch('toggleStatus', {
|
||||||
this.isLoading = false;
|
conversationId: this.currentChat.id,
|
||||||
});
|
status,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
|
||||||
|
this.isLoading = false;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</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 */
|
/* eslint-env browser */
|
||||||
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
|
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
|
||||||
import Bar from './widgets/chart/BarChart';
|
import Bar from './widgets/chart/BarChart';
|
||||||
import Button from './widgets/Button';
|
import Button from './ui/WootButton';
|
||||||
import Code from './Code';
|
import Code from './Code';
|
||||||
import ColorPicker from './widgets/ColorPicker';
|
import ColorPicker from './widgets/ColorPicker';
|
||||||
import DeleteModal from './widgets/modal/DeleteModal.vue';
|
import DeleteModal from './widgets/modal/DeleteModal.vue';
|
||||||
|
|||||||
@ -170,6 +170,7 @@ export default {
|
|||||||
.status-change {
|
.status-change {
|
||||||
.dropdown-pane {
|
.dropdown-pane {
|
||||||
top: -132px;
|
top: -132px;
|
||||||
|
right: var(--space-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-items {
|
.status-items {
|
||||||
|
|||||||
@ -19,12 +19,12 @@
|
|||||||
:menu-item="teamSection"
|
:menu-item="teamSection"
|
||||||
/>
|
/>
|
||||||
<sidebar-item
|
<sidebar-item
|
||||||
v-if="shouldShowInboxes"
|
v-if="shouldShowSidebarItem"
|
||||||
:key="inboxSection.toState"
|
:key="inboxSection.toState"
|
||||||
:menu-item="inboxSection"
|
:menu-item="inboxSection"
|
||||||
/>
|
/>
|
||||||
<sidebar-item
|
<sidebar-item
|
||||||
v-if="shouldShowInboxes"
|
v-if="shouldShowSidebarItem"
|
||||||
:key="labelSection.toState"
|
:key="labelSection.toState"
|
||||||
:menu-item="labelSection"
|
:menu-item="labelSection"
|
||||||
/>
|
/>
|
||||||
@ -128,11 +128,11 @@ export default {
|
|||||||
currentRoute() {
|
currentRoute() {
|
||||||
return this.$store.state.route.name;
|
return this.$store.state.route.name;
|
||||||
},
|
},
|
||||||
shouldShowInboxes() {
|
shouldShowSidebarItem() {
|
||||||
return this.sidemenuItems.common.routes.includes(this.currentRoute);
|
return this.sidemenuItems.common.routes.includes(this.currentRoute);
|
||||||
},
|
},
|
||||||
shouldShowTeams() {
|
shouldShowTeams() {
|
||||||
return this.shouldShowInboxes && this.teams.length;
|
return this.shouldShowSidebarItem && this.teams.length;
|
||||||
},
|
},
|
||||||
inboxSection() {
|
inboxSection() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -80,3 +80,8 @@ export default {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.dropdown-pane {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -12,8 +12,8 @@
|
|||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<spinner v-if="isLoading" size="small" />
|
<spinner v-if="isLoading" size="small" />
|
||||||
<i v-if="icon" :class="icon"></i>
|
<i v-else-if="icon" class="icon" :class="icon"></i>
|
||||||
<span v-if="$slots.default"><slot></slot></span>
|
<span v-if="$slots.default" class="button__content"><slot></slot></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
@ -59,3 +59,20 @@ export default {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</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"
|
class="image-thumb"
|
||||||
:src="attachment.thumb"
|
:src="attachment.thumb"
|
||||||
/>
|
/>
|
||||||
<span v-else class="attachment-thumb">
|
<span v-else class="attachment-thumb"> 📄 </span>
|
||||||
📄
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="file-name-wrap">
|
<div class="file-name-wrap">
|
||||||
<span class="item">
|
<span class="item">
|
||||||
@ -37,8 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { formatBytes } from 'dashboard/helper/files';
|
import { formatBytes } from 'shared/helpers/FileHelper';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
attachments: {
|
attachments: {
|
||||||
|
|||||||
@ -188,6 +188,7 @@ export default {
|
|||||||
|
|
||||||
> .ProseMirror {
|
> .ProseMirror {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
<contact-panel
|
<contact-panel
|
||||||
v-if="showContactPanel"
|
v-if="showContactPanel"
|
||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
|
:inbox-id="currentChat.inbox_id"
|
||||||
:on-toggle="onToggleContactPanel"
|
:on-toggle="onToggleContactPanel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -59,6 +60,13 @@ export default {
|
|||||||
return this.isContactPanelOpen && this.currentChat.id;
|
return this.isContactPanelOpen && this.currentChat.id;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
'currentChat.inbox_id'(inboxId) {
|
||||||
|
if (inboxId) {
|
||||||
|
this.$store.dispatch('inboxMembers/fetch', { inboxId });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onToggleContactPanel() {
|
onToggleContactPanel() {
|
||||||
this.$emit('contact-panel-toggle');
|
this.$emit('contact-panel-toggle');
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
size="40px"
|
size="40px"
|
||||||
/>
|
/>
|
||||||
<div class="conversation--details columns">
|
<div class="conversation--details columns">
|
||||||
<span v-if="showInboxName" v-tooltip.bottom="inboxName" class="label">
|
<span v-if="showInboxName" class="label">
|
||||||
<i :class="computedInboxClass" />
|
<i :class="computedInboxClass" />
|
||||||
{{ inboxName }}
|
{{ inboxName }}
|
||||||
</span>
|
</span>
|
||||||
@ -161,7 +161,11 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showInboxName() {
|
showInboxName() {
|
||||||
return !this.hideInboxName && this.isInboxNameVisible;
|
return (
|
||||||
|
!this.hideInboxName &&
|
||||||
|
this.isInboxNameVisible &&
|
||||||
|
this.inboxesList.length > 1
|
||||||
|
);
|
||||||
},
|
},
|
||||||
inboxName() {
|
inboxName() {
|
||||||
const stateInbox = this.chatInbox;
|
const stateInbox = this.chatInbox;
|
||||||
@ -187,6 +191,10 @@ export default {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.conversation {
|
.conversation {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-background-light);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.has-inbox-name {
|
.has-inbox-name {
|
||||||
|
|||||||
@ -30,9 +30,11 @@
|
|||||||
class="header-actions-wrap"
|
class="header-actions-wrap"
|
||||||
:class="{ 'has-open-sidebar': isContactPanelOpen }"
|
:class="{ 'has-open-sidebar': isContactPanelOpen }"
|
||||||
>
|
>
|
||||||
<div class="multiselect-box ion-headphone">
|
<div class="multiselect-box multiselect-wrap--small">
|
||||||
|
<i class="icon ion-headphone" />
|
||||||
<multiselect
|
<multiselect
|
||||||
v-model="currentChat.meta.assignee"
|
v-model="currentChat.meta.assignee"
|
||||||
|
:loading="uiFlags.isFetching"
|
||||||
:allow-empty="true"
|
:allow-empty="true"
|
||||||
:deselect-label="$t('CONVERSATION.ASSIGNMENT.REMOVE')"
|
:deselect-label="$t('CONVERSATION.ASSIGNMENT.REMOVE')"
|
||||||
:options="agentList"
|
:options="agentList"
|
||||||
@ -81,7 +83,8 @@ export default {
|
|||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
agents: 'agents/getVerifiedAgents',
|
getAgents: 'inboxMembers/getMembersByInbox',
|
||||||
|
uiFlags: 'inboxMembers/getUIFlags',
|
||||||
currentChat: 'getSelectedChat',
|
currentChat: 'getSelectedChat',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -96,6 +99,8 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
agentList() {
|
agentList() {
|
||||||
|
const { inbox_id: inboxId } = this.chat;
|
||||||
|
const agents = this.getAgents(inboxId) || [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
@ -105,7 +110,7 @@ export default {
|
|||||||
account_id: 0,
|
account_id: 0,
|
||||||
email: 'None',
|
email: 'None',
|
||||||
},
|
},
|
||||||
...this.agents,
|
...agents,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -143,7 +143,11 @@ export default {
|
|||||||
return `https://twitter.com/${screenName}`;
|
return `https://twitter.com/${screenName}`;
|
||||||
},
|
},
|
||||||
alignBubble() {
|
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() {
|
readableTime() {
|
||||||
return this.messageStamp(this.data.created_at, 'LLL d, h:mm a');
|
return this.messageStamp(this.data.created_at, 'LLL d, h:mm a');
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="view-box fill-height">
|
<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>
|
<span>
|
||||||
{{ $t('CONVERSATION.CANNOT_REPLY') }}
|
{{ $t('CONVERSATION.CANNOT_REPLY') }}
|
||||||
<a
|
<a
|
||||||
href="https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/"
|
:href="facebookReplyPolicy"
|
||||||
rel="noopener noreferrer nofollow"
|
rel="noopener noreferrer nofollow"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
@ -12,6 +15,21 @@
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div v-if="isATweet" class="banner">
|
||||||
<span v-if="!selectedTweetId">
|
<span v-if="!selectedTweetId">
|
||||||
@ -86,20 +104,16 @@ import Message from './Message';
|
|||||||
import conversationMixin from '../../../mixins/conversations';
|
import conversationMixin from '../../../mixins/conversations';
|
||||||
import { getTypingUsersText } from '../../../helper/commons';
|
import { getTypingUsersText } from '../../../helper/commons';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
|
import { REPLY_POLICY } from 'shared/constants/links';
|
||||||
|
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Message,
|
Message,
|
||||||
ReplyBox,
|
ReplyBox,
|
||||||
},
|
},
|
||||||
|
mixins: [conversationMixin, inboxMixin],
|
||||||
mixins: [conversationMixin],
|
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
inboxId: {
|
|
||||||
type: [Number, String],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isContactPanelOpen: {
|
isContactPanelOpen: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@ -124,6 +138,12 @@ export default {
|
|||||||
getUnreadCount: 'getUnreadCount',
|
getUnreadCount: 'getUnreadCount',
|
||||||
loadingChatList: 'getChatListLoadingStatus',
|
loadingChatList: 'getChatListLoadingStatus',
|
||||||
}),
|
}),
|
||||||
|
inboxId() {
|
||||||
|
return this.currentChat.inbox_id;
|
||||||
|
},
|
||||||
|
inbox() {
|
||||||
|
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||||
|
},
|
||||||
|
|
||||||
typingUsersList() {
|
typingUsersList() {
|
||||||
const userList = this.$store.getters[
|
const userList = this.$store.getters[
|
||||||
@ -191,6 +211,12 @@ export default {
|
|||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
|
facebookReplyPolicy() {
|
||||||
|
return REPLY_POLICY.FACEBOOK;
|
||||||
|
},
|
||||||
|
twilioWhatsAppReplyPolicy() {
|
||||||
|
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
|
|||||||
@ -5,7 +5,10 @@
|
|||||||
:status="currentChat.status"
|
:status="currentChat.status"
|
||||||
/>
|
/>
|
||||||
<woot-button
|
<woot-button
|
||||||
class="clear more--button"
|
class="more--button"
|
||||||
|
variant="clear"
|
||||||
|
size="large"
|
||||||
|
color-scheme="secondary"
|
||||||
icon="ion-android-more-vertical"
|
icon="ion-android-more-vertical"
|
||||||
@click="toggleConversationActions"
|
@click="toggleConversationActions"
|
||||||
/>
|
/>
|
||||||
@ -100,15 +103,6 @@ export default {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-left: var(--space-small);
|
margin-left: var(--space-small);
|
||||||
padding: var(--space-small);
|
|
||||||
|
|
||||||
&.clear.more--button {
|
|
||||||
color: var(--color-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--w-800);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions--container {
|
.actions--container {
|
||||||
@ -116,9 +110,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-pane {
|
.dropdown-pane {
|
||||||
right: -12px;
|
right: var(--space-minus-small);
|
||||||
top: 48px;
|
top: 48px;
|
||||||
width: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
|
|||||||
@ -72,6 +72,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { mixin as clickaway } from 'vue-clickaway';
|
import { mixin as clickaway } from 'vue-clickaway';
|
||||||
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
|
|
||||||
import EmojiInput from 'shared/components/emoji/EmojiInput';
|
import EmojiInput from 'shared/components/emoji/EmojiInput';
|
||||||
import CannedResponse from './CannedResponse';
|
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 ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel';
|
||||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||||
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
|
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isEscape,
|
isEscape,
|
||||||
isEnter,
|
isEnter,
|
||||||
@ -100,7 +104,7 @@ export default {
|
|||||||
ReplyBottomPanel,
|
ReplyBottomPanel,
|
||||||
WootMessageEditor,
|
WootMessageEditor,
|
||||||
},
|
},
|
||||||
mixins: [clickaway, inboxMixin, uiSettingsMixin],
|
mixins: [clickaway, inboxMixin, uiSettingsMixin, alertMixin],
|
||||||
props: {
|
props: {
|
||||||
inReplyTo: {
|
inReplyTo: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
@ -123,17 +127,25 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showRichContentEditor() {
|
showRichContentEditor() {
|
||||||
const {
|
if (this.isOnPrivateNote) {
|
||||||
display_rich_content_editor: displayRichContentEditor,
|
return true;
|
||||||
} = this.uiSettings;
|
}
|
||||||
return this.isOnPrivateNote || displayRichContentEditor;
|
|
||||||
|
if (this.isRichEditorEnabled) {
|
||||||
|
const {
|
||||||
|
display_rich_content_editor: displayRichContentEditor,
|
||||||
|
} = this.uiSettings;
|
||||||
|
|
||||||
|
return displayRichContentEditor;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
...mapGetters({ currentChat: 'getSelectedChat' }),
|
...mapGetters({ currentChat: 'getSelectedChat' }),
|
||||||
enterToSendEnabled() {
|
enterToSendEnabled() {
|
||||||
return !!this.uiSettings.enter_to_send_enabled;
|
return !!this.uiSettings.enter_to_send_enabled;
|
||||||
},
|
},
|
||||||
isPrivate() {
|
isPrivate() {
|
||||||
if (this.currentChat.can_reply) {
|
if (this.currentChat.can_reply || this.isATwilioWhatsappChannel) {
|
||||||
return this.isOnPrivateNote;
|
return this.isOnPrivateNote;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -215,9 +227,7 @@ export default {
|
|||||||
return this.attachedFiles.length;
|
return this.attachedFiles.length;
|
||||||
},
|
},
|
||||||
isRichEditorEnabled() {
|
isRichEditorEnabled() {
|
||||||
return (
|
return this.isAWebWidgetInbox || this.isAnEmailChannel;
|
||||||
this.isAWebWidgetInbox || this.isAnEmailChannel || this.isOnPrivateNote
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
isOnPrivateNote() {
|
isOnPrivateNote() {
|
||||||
return this.replyType === REPLY_EDITOR_MODES.NOTE;
|
return this.replyType === REPLY_EDITOR_MODES.NOTE;
|
||||||
@ -230,7 +240,7 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canReply) {
|
if (canReply || this.isATwilioWhatsappChannel) {
|
||||||
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
||||||
} else {
|
} else {
|
||||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||||
@ -305,7 +315,7 @@ export default {
|
|||||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||||
const { can_reply: canReply } = this.currentChat;
|
const { can_reply: canReply } = this.currentChat;
|
||||||
|
|
||||||
if (canReply) this.replyType = mode;
|
if (canReply || this.isATwilioWhatsappChannel) this.replyType = mode;
|
||||||
if (this.showRichContentEditor) {
|
if (this.showRichContentEditor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -351,21 +361,28 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFileUpload(file) {
|
onFileUpload(file) {
|
||||||
this.attachedFiles = [];
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reader = new FileReader();
|
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||||
reader.readAsDataURL(file.file);
|
this.attachedFiles = [];
|
||||||
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => {
|
reader.readAsDataURL(file.file);
|
||||||
this.attachedFiles.push({
|
reader.onloadend = () => {
|
||||||
currentChatId: this.currentChat.id,
|
this.attachedFiles.push({
|
||||||
resource: file,
|
currentChatId: this.currentChat.id,
|
||||||
isPrivate: this.isPrivate,
|
resource: file,
|
||||||
thumb: reader.result,
|
isPrivate: this.isPrivate,
|
||||||
});
|
thumb: reader.result,
|
||||||
};
|
});
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.showAlert(
|
||||||
|
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||||
|
MAXIMUM_FILE_UPLOAD_SIZE,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
removeAttachment(itemIndex) {
|
removeAttachment(itemIndex) {
|
||||||
this.attachedFiles = this.attachedFiles.filter(
|
this.attachedFiles = this.attachedFiles.filter(
|
||||||
|
|||||||
@ -95,6 +95,8 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '~dashboard/assets/scss/app.scss';
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
.message-text--metadata {
|
.message-text--metadata {
|
||||||
.time {
|
.time {
|
||||||
@ -136,12 +138,16 @@ export default {
|
|||||||
|
|
||||||
.activity-wrap {
|
.activity-wrap {
|
||||||
.message-text--metadata {
|
.message-text--metadata {
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
color: var(--s-300);
|
color: var(--s-300);
|
||||||
|
display: flex;
|
||||||
|
text-align: center;
|
||||||
font-size: var(--font-size-micro);
|
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>
|
</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": "Δημιουργία Κιβωτίου"
|
"SUBMIT_BUTTON": "Δημιουργία Κιβωτίου"
|
||||||
},
|
},
|
||||||
"TWILIO": {
|
"TWILIO": {
|
||||||
"TITLE": "SMS κανάλι από το Twillio",
|
"TITLE": "SMS κανάλι από το Twilio",
|
||||||
"DESC": "Ενσωματώστε το Twilio και αρχίστε να υποστηρίζετε τους πελάτες σας μέσω SMS.",
|
"DESC": "Ενσωματώστε το Twilio και αρχίστε να υποστηρίζετε τους πελάτες σας μέσω SMS.",
|
||||||
"ACCOUNT_SID": {
|
"ACCOUNT_SID": {
|
||||||
"LABEL": "SID Λογαριασμού",
|
"LABEL": "SID Λογαριασμού",
|
||||||
@ -116,7 +116,7 @@
|
|||||||
},
|
},
|
||||||
"API_CALLBACK": {
|
"API_CALLBACK": {
|
||||||
"TITLE": "URL επανάκλησης",
|
"TITLE": "URL επανάκλησης",
|
||||||
"SUBTITLE": "Πρέπει να ρυθμίσετε το callback URL στο Twillio με το URL που αναφέρεται εδώ."
|
"SUBTITLE": "Πρέπει να ρυθμίσετε το callback URL στο Twilio με το URL που αναφέρεται εδώ."
|
||||||
},
|
},
|
||||||
"SUBMIT_BUTTON": "Δημιουργία Καναλιού Twillio",
|
"SUBMIT_BUTTON": "Δημιουργία Καναλιού Twillio",
|
||||||
"API": {
|
"API": {
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"INITIATED_FROM": "Initiated from",
|
"INITIATED_FROM": "Initiated from",
|
||||||
"INITIATED_AT": "Initiated at",
|
"INITIATED_AT": "Initiated at",
|
||||||
"IP_ADDRESS": "IP Address",
|
"IP_ADDRESS": "IP Address",
|
||||||
|
"NEW_MESSAGE": "New message",
|
||||||
"CONVERSATIONS": {
|
"CONVERSATIONS": {
|
||||||
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
|
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
|
||||||
"TITLE": "Previous Conversations"
|
"TITLE": "Previous Conversations"
|
||||||
@ -71,7 +72,9 @@
|
|||||||
},
|
},
|
||||||
"PHONE_NUMBER": {
|
"PHONE_NUMBER": {
|
||||||
"PLACEHOLDER": "Enter the phone number of the contact",
|
"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": {
|
"LOCATION": {
|
||||||
"PLACEHOLDER": "Enter the location of the contact",
|
"PLACEHOLDER": "Enter the location of the contact",
|
||||||
@ -104,6 +107,30 @@
|
|||||||
"CONTACT_ALREADY_EXIST": "This email address is in use for another contact.",
|
"CONTACT_ALREADY_EXIST": "This email address is in use for another contact.",
|
||||||
"ERROR_MESSAGE": "There was an error, please try again"
|
"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": {
|
"CONTACTS_PAGE": {
|
||||||
"HEADER": "Contacts",
|
"HEADER": "Contacts",
|
||||||
"SEARCH_BUTTON": "Search",
|
"SEARCH_BUTTON": "Search",
|
||||||
|
|||||||
@ -20,6 +20,8 @@
|
|||||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||||
"CANNOT_REPLY": "You cannot reply due to",
|
"CANNOT_REPLY": "You cannot reply due to",
|
||||||
"24_HOURS_WINDOW": "24 hour message window restriction",
|
"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",
|
"LAST_INCOMING_TWEET": "You are replying to the last incoming tweet",
|
||||||
"REPLYING_TO": "You are replying to:",
|
"REPLYING_TO": "You are replying to:",
|
||||||
"REMOVE_SELECTION": "Remove Selection",
|
"REMOVE_SELECTION": "Remove Selection",
|
||||||
@ -29,10 +31,14 @@
|
|||||||
"HEADER": {
|
"HEADER": {
|
||||||
"RESOLVE_ACTION": "Resolve",
|
"RESOLVE_ACTION": "Resolve",
|
||||||
"REOPEN_ACTION": "Reopen",
|
"REOPEN_ACTION": "Reopen",
|
||||||
|
"OPEN_ACTION": "Open",
|
||||||
"OPEN": "More",
|
"OPEN": "More",
|
||||||
"CLOSE": "Close",
|
"CLOSE": "Close",
|
||||||
"DETAILS": "details"
|
"DETAILS": "details"
|
||||||
},
|
},
|
||||||
|
"RESOLVE_DROPDOWN": {
|
||||||
|
"OPEN_BOT": "Open with bot"
|
||||||
|
},
|
||||||
"FOOTER": {
|
"FOOTER": {
|
||||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
"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"
|
"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_STATUS": "Conversation status changed",
|
||||||
"CHANGE_AGENT": "Conversation Assignee changed",
|
"CHANGE_AGENT": "Conversation Assignee changed",
|
||||||
"CHANGE_TEAM": "Conversation team changed",
|
"CHANGE_TEAM": "Conversation team changed",
|
||||||
|
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
|
||||||
"SENT_BY": "Sent by:",
|
"SENT_BY": "Sent by:",
|
||||||
"ASSIGNMENT": {
|
"ASSIGNMENT": {
|
||||||
"SELECT_AGENT": "Select Agent",
|
"SELECT_AGENT": "Select Agent",
|
||||||
@ -108,5 +115,4 @@
|
|||||||
"PLACEHOLDER": "None"
|
"PLACEHOLDER": "None"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
"LIST": {
|
"LIST": {
|
||||||
"404": "There are no inboxes attached to this account."
|
"404": "There are no inboxes attached to this account."
|
||||||
},
|
},
|
||||||
"CREATE_FLOW": [{
|
"CREATE_FLOW": [
|
||||||
|
{
|
||||||
"title": "Choose Channel",
|
"title": "Choose Channel",
|
||||||
"route": "settings_inbox_new",
|
"route": "settings_inbox_new",
|
||||||
"body": "Choose the provider you want to integrate with Chatwoot."
|
"body": "Choose the provider you want to integrate with Chatwoot."
|
||||||
@ -193,6 +194,7 @@
|
|||||||
"TITLE": "Your Inbox is ready!",
|
"TITLE": "Your Inbox is ready!",
|
||||||
"MESSAGE": "You can now engage with your customers through your new Channel. Happy supporting ",
|
"MESSAGE": "You can now engage with your customers through your new Channel. Happy supporting ",
|
||||||
"BUTTON_TEXT": "Take me there",
|
"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."
|
"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",
|
"REAUTH": "Reauthorize",
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
"LINK": "Profile Settings",
|
"LINK": "Profile Settings",
|
||||||
"TITLE": "Profile Settings",
|
"TITLE": "Profile Settings",
|
||||||
"BTN_TEXT": "Update Profile",
|
"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",
|
"AFTER_EMAIL_CHANGED": "Your profile has been updated successfully, please login again as your login credentials are changed",
|
||||||
"FORM": {
|
"FORM": {
|
||||||
"AVATAR": "Profile Image",
|
"AVATAR": "Profile Image",
|
||||||
@ -16,7 +18,8 @@
|
|||||||
},
|
},
|
||||||
"PASSWORD_SECTION": {
|
"PASSWORD_SECTION": {
|
||||||
"TITLE": "Password",
|
"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": {
|
"ACCESS_TOKEN": {
|
||||||
"TITLE": "Access Token",
|
"TITLE": "Access Token",
|
||||||
@ -64,11 +67,7 @@
|
|||||||
},
|
},
|
||||||
"AVAILABILITY": {
|
"AVAILABILITY": {
|
||||||
"LABEL": "Availability",
|
"LABEL": "Availability",
|
||||||
"STATUSES_LIST": [
|
"STATUSES_LIST": ["Online", "Busy", "Offline"]
|
||||||
"Online",
|
|
||||||
"Busy",
|
|
||||||
"Offline"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"EMAIL": {
|
"EMAIL": {
|
||||||
"LABEL": "Your email address",
|
"LABEL": "Your email address",
|
||||||
@ -147,6 +146,5 @@
|
|||||||
},
|
},
|
||||||
"SUBMIT": "Submit"
|
"SUBMIT": "Submit"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,7 +118,7 @@
|
|||||||
"TITLE": "URL Callback",
|
"TITLE": "URL Callback",
|
||||||
"SUBTITLE": "Anda harus mengkonfigurasi pesan URL callback di Twilio dengan URL yang disebutkan di sini."
|
"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": {
|
"API": {
|
||||||
"ERROR_MESSAGE": "Kami tidak dapat mengautentikasi kredensial Twilio, harap coba lagi"
|
"ERROR_MESSAGE": "Kami tidak dapat mengautentikasi kredensial Twilio, harap coba lagi"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<span class="close-button" @click="onClose">
|
<span class="close-button" @click="onClose">
|
||||||
<i class="ion-android-close close-icon" />
|
<i class="ion-android-close close-icon" />
|
||||||
</span>
|
</span>
|
||||||
<contact-info :contact="contact" />
|
<contact-info show-new-message :contact="contact" />
|
||||||
<contact-custom-attributes
|
<contact-custom-attributes
|
||||||
v-if="hasContactAttributes"
|
v-if="hasContactAttributes"
|
||||||
:custom-attributes="contact.custom_attributes"
|
:custom-attributes="contact.custom_attributes"
|
||||||
|
|||||||
@ -127,6 +127,10 @@ export default {
|
|||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
inboxId: {
|
||||||
|
type: Number,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
onToggle: {
|
onToggle: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
@ -135,8 +139,9 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
currentChat: 'getSelectedChat',
|
currentChat: 'getSelectedChat',
|
||||||
agents: 'agents/getVerifiedAgents',
|
|
||||||
teams: 'teams/getTeams',
|
teams: 'teams/getTeams',
|
||||||
|
getAgents: 'inboxMembers/getMembersByInbox',
|
||||||
|
uiFlags: 'inboxMembers/getUIFlags',
|
||||||
}),
|
}),
|
||||||
currentConversationMetaData() {
|
currentConversationMetaData() {
|
||||||
return this.$store.getters[
|
return this.$store.getters[
|
||||||
@ -182,7 +187,7 @@ export default {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const countryFlag = countryCode ? flag(countryCode) : '🌎';
|
const countryFlag = countryCode ? flag(countryCode) : '🌎';
|
||||||
return `${countryFlag} ${cityAndCountry}`;
|
return `${cityAndCountry} ${countryFlag}`;
|
||||||
},
|
},
|
||||||
platformName() {
|
platformName() {
|
||||||
const {
|
const {
|
||||||
@ -201,7 +206,7 @@ export default {
|
|||||||
return this.$store.getters['contacts/getContact'](this.contactId);
|
return this.$store.getters['contacts/getContact'](this.contactId);
|
||||||
},
|
},
|
||||||
agentsList() {
|
agentsList() {
|
||||||
return [{ id: 0, name: 'None' }, ...this.agents];
|
return [{ id: 0, name: 'None' }, ...this.getAgents(this.inboxId)];
|
||||||
},
|
},
|
||||||
teamsList() {
|
teamsList() {
|
||||||
return [{ id: 0, name: 'None' }, ...this.teams];
|
return [{ id: 0, name: 'None' }, ...this.teams];
|
||||||
|
|||||||
@ -35,12 +35,26 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<woot-input
|
<div class="medium-12 columns">
|
||||||
v-model.trim="phoneNumber"
|
<label :class="{ error: $v.phoneNumber.$error }">
|
||||||
class="columns"
|
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL') }}
|
||||||
:label="$t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL')"
|
<input
|
||||||
:placeholder="$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')"
|
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>
|
</div>
|
||||||
<woot-input
|
<woot-input
|
||||||
v-model.trim="companyName"
|
v-model.trim="companyName"
|
||||||
@ -87,6 +101,8 @@ import {
|
|||||||
} from 'shared/helpers/CustomErrors';
|
} from 'shared/helpers/CustomErrors';
|
||||||
import { required } from 'vuelidate/lib/validators';
|
import { required } from 'vuelidate/lib/validators';
|
||||||
|
|
||||||
|
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [alertMixin],
|
mixins: [alertMixin],
|
||||||
props: {
|
props: {
|
||||||
@ -131,7 +147,9 @@ export default {
|
|||||||
description: {},
|
description: {},
|
||||||
email: {},
|
email: {},
|
||||||
companyName: {},
|
companyName: {},
|
||||||
phoneNumber: {},
|
phoneNumber: {
|
||||||
|
isPhoneE164OrEmpty,
|
||||||
|
},
|
||||||
bio: {},
|
bio: {},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -190,6 +208,7 @@ export default {
|
|||||||
async handleSubmit() {
|
async handleSubmit() {
|
||||||
this.resetDuplicate();
|
this.resetDuplicate();
|
||||||
this.$v.$touch();
|
this.$v.$touch();
|
||||||
|
|
||||||
if (this.$v.$invalid) {
|
if (this.$v.$invalid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,18 +50,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<woot-button
|
<woot-button
|
||||||
class="clear edit-contact"
|
v-if="!showNewMessage"
|
||||||
variant="primary small"
|
class="edit-contact"
|
||||||
|
variant="clear link"
|
||||||
|
size="small"
|
||||||
@click="toggleEditModal"
|
@click="toggleEditModal"
|
||||||
>
|
>
|
||||||
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
|
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
|
||||||
</woot-button>
|
</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
|
<edit-contact
|
||||||
v-if="showEditModal"
|
v-if="showEditModal"
|
||||||
:show="showEditModal"
|
:show="showEditModal"
|
||||||
:contact="contact"
|
:contact="contact"
|
||||||
@cancel="toggleEditModal"
|
@cancel="toggleEditModal"
|
||||||
/>
|
/>
|
||||||
|
<new-conversation
|
||||||
|
:show="showConversationModal"
|
||||||
|
:contact="contact"
|
||||||
|
@cancel="toggleConversationModal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -70,6 +93,7 @@ import ContactInfoRow from './ContactInfoRow';
|
|||||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||||
import SocialIcons from './SocialIcons';
|
import SocialIcons from './SocialIcons';
|
||||||
import EditContact from './EditContact';
|
import EditContact from './EditContact';
|
||||||
|
import NewConversation from './NewConversation';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -77,6 +101,7 @@ export default {
|
|||||||
EditContact,
|
EditContact,
|
||||||
Thumbnail,
|
Thumbnail,
|
||||||
SocialIcons,
|
SocialIcons,
|
||||||
|
NewConversation,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
contact: {
|
contact: {
|
||||||
@ -87,10 +112,15 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
showNewMessage: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showEditModal: false,
|
showEditModal: false,
|
||||||
|
showConversationModal: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -110,6 +140,9 @@ export default {
|
|||||||
toggleEditModal() {
|
toggleEditModal() {
|
||||||
this.showEditModal = !this.showEditModal;
|
this.showEditModal = !this.showEditModal;
|
||||||
},
|
},
|
||||||
|
toggleConversationModal() {
|
||||||
|
this.showConversationModal = !this.showConversationModal;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@ -119,7 +152,7 @@ export default {
|
|||||||
@import '~dashboard/assets/scss/mixins';
|
@import '~dashboard/assets/scss/mixins';
|
||||||
.contact--profile {
|
.contact--profile {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: var(--space-normal) var(--space-normal) var(--space-large);
|
padding: var(--space-normal) var(--space-normal);
|
||||||
|
|
||||||
.user-thumbnail-box {
|
.user-thumbnail-box {
|
||||||
margin-right: $space-normal;
|
margin-right: $space-normal;
|
||||||
@ -163,10 +196,21 @@ export default {
|
|||||||
font-size: $font-weight-normal;
|
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 {
|
.button.new-message {
|
||||||
padding: 0 var(--space-slab);
|
margin-right: var(--space-small);
|
||||||
margin-left: var(--space-slab);
|
}
|
||||||
margin-top: var(--space-smaller);
|
|
||||||
|
.contact-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
<template>
|
||||||
<div v-on-clickaway="closeSearch" class="search-wrap">
|
<div v-on-clickaway="closeSearch" class="search-wrap">
|
||||||
<div class="search" :class="{ 'is-active': showSearchBox }">
|
<div class="search" :class="{ 'is-active': showSearchBox }">
|
||||||
|
<woot-sidemenu-icon />
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<i class="ion-ios-search-strong search--icon" />
|
<i class="ion-ios-search-strong search--icon" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -29,15 +29,26 @@
|
|||||||
>
|
>
|
||||||
</woot-code>
|
</woot-code>
|
||||||
</div>
|
</div>
|
||||||
<router-link
|
<div class="footer">
|
||||||
class="button success nice"
|
<router-link
|
||||||
:to="{
|
class="button hollow primary settings-button"
|
||||||
name: 'inbox_dashboard',
|
:to="{
|
||||||
params: { inboxId: this.$route.params.inbox_id },
|
name: 'settings_inbox_show',
|
||||||
}"
|
params: { inboxId: this.$route.params.inbox_id },
|
||||||
>
|
}"
|
||||||
{{ $t('INBOX_MGMT.FINISH.BUTTON_TEXT') }}
|
>
|
||||||
</router-link>
|
{{ $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>
|
</div>
|
||||||
</empty-state>
|
</empty-state>
|
||||||
</div>
|
</div>
|
||||||
@ -90,4 +101,13 @@ export default {
|
|||||||
margin: $space-normal auto;
|
margin: $space-normal auto;
|
||||||
max-width: 70%;
|
max-width: 70%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button {
|
||||||
|
margin-right: var(--space-small);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="columns profile--settings ">
|
<div class="columns profile--settings">
|
||||||
<form @submit.prevent="updateUser">
|
<form @submit.prevent="updateUser('profile')">
|
||||||
<div class="small-12 row profile--settings--row">
|
<div class="small-12 row profile--settings--row">
|
||||||
<div class="columns small-3 ">
|
<div class="columns small-3">
|
||||||
<h4 class="block-title">
|
<h4 class="block-title">
|
||||||
{{ $t('PROFILE_SETTINGS.FORM.PROFILE_SECTION.TITLE') }}
|
{{ $t('PROFILE_SETTINGS.FORM.PROFILE_SECTION.TITLE') }}
|
||||||
</h4>
|
</h4>
|
||||||
@ -49,10 +49,15 @@
|
|||||||
{{ $t('PROFILE_SETTINGS.FORM.EMAIL.ERROR') }}
|
{{ $t('PROFILE_SETTINGS.FORM.EMAIL.ERROR') }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<woot-button type="submit" :is-loading="isProfileUpdating">
|
||||||
|
{{ $t('PROFILE_SETTINGS.BTN_TEXT') }}
|
||||||
|
</woot-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
<form @submit.prevent="updateUser('password')">
|
||||||
<div class="profile--settings--row row">
|
<div class="profile--settings--row row">
|
||||||
<div class="columns small-3 ">
|
<div class="columns small-3">
|
||||||
<h4 class="block-title">
|
<h4 class="block-title">
|
||||||
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE') }}
|
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE') }}
|
||||||
</h4>
|
</h4>
|
||||||
@ -85,27 +90,30 @@
|
|||||||
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.ERROR') }}
|
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.ERROR') }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -120,7 +128,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
},
|
},
|
||||||
mixin: [alertMixin],
|
mixins: [alertMixin],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
avatarFile: '',
|
avatarFile: '',
|
||||||
@ -130,7 +138,8 @@ export default {
|
|||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
passwordConfirmation: '',
|
passwordConfirmation: '',
|
||||||
isUpdating: false,
|
isProfileUpdating: false,
|
||||||
|
isPasswordChanging: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
validations: {
|
validations: {
|
||||||
@ -181,13 +190,17 @@ export default {
|
|||||||
this.avatarUrl = this.currentUser.avatar_url;
|
this.avatarUrl = this.currentUser.avatar_url;
|
||||||
this.displayName = this.currentUser.display_name;
|
this.displayName = this.currentUser.display_name;
|
||||||
},
|
},
|
||||||
async updateUser() {
|
async updateUser(type) {
|
||||||
this.$v.$touch();
|
this.$v.$touch();
|
||||||
if (this.$v.$invalid) {
|
if (this.$v.$invalid) {
|
||||||
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
|
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.isUpdating = true;
|
if (type === 'profile') {
|
||||||
|
this.isProfileUpdating = true;
|
||||||
|
} else if (type === 'password') {
|
||||||
|
this.isPasswordChanging = true;
|
||||||
|
}
|
||||||
const hasEmailChanged = this.currentUser.email !== this.email;
|
const hasEmailChanged = this.currentUser.email !== this.email;
|
||||||
try {
|
try {
|
||||||
await this.$store.dispatch('updateProfile', {
|
await this.$store.dispatch('updateProfile', {
|
||||||
@ -198,13 +211,20 @@ export default {
|
|||||||
displayName: this.displayName,
|
displayName: this.displayName,
|
||||||
password_confirmation: this.passwordConfirmation,
|
password_confirmation: this.passwordConfirmation,
|
||||||
});
|
});
|
||||||
this.isUpdating = false;
|
this.isProfileUpdating = false;
|
||||||
|
this.isPasswordChanging = false;
|
||||||
if (hasEmailChanged) {
|
if (hasEmailChanged) {
|
||||||
clearCookiesOnLogout();
|
clearCookiesOnLogout();
|
||||||
this.showAlert(this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED'));
|
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) {
|
} catch (error) {
|
||||||
this.isUpdating = false;
|
this.isProfileUpdating = false;
|
||||||
|
this.isPasswordChanging = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleImageUpload({ file, url }) {
|
handleImageUpload({ file, url }) {
|
||||||
|
|||||||
@ -107,6 +107,7 @@ export default {
|
|||||||
teamId,
|
teamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.$store.dispatch('teams/get');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showAlert(error.message);
|
this.showAlert(error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -129,6 +129,7 @@ export default {
|
|||||||
teamId,
|
teamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.$store.dispatch('teams/get');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showAlert(error.message);
|
this.showAlert(error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export const actions = {
|
|||||||
setUser(response.data.payload.data, getHeaderExpiry(response));
|
setUser(response.data.payload.data, getHeaderExpiry(response));
|
||||||
context.commit(types.default.SET_CURRENT_USER);
|
context.commit(types.default.SET_CURRENT_USER);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response.status === 401) {
|
if (error?.response?.status === 401) {
|
||||||
clearCookiesOnLogout();
|
clearCookiesOnLogout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import * as types from '../mutation-types';
|
import * as types from '../mutation-types';
|
||||||
import ContactAPI from '../../api/contacts';
|
import ContactAPI from '../../api/contacts';
|
||||||
|
import ConversationApi from '../../api/conversations';
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
records: {},
|
records: {},
|
||||||
@ -19,6 +20,30 @@ export const getters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
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) => {
|
get: async ({ commit }, contactId) => {
|
||||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||||
isFetching: true,
|
isFetching: true,
|
||||||
@ -53,6 +78,10 @@ export const mutations = {
|
|||||||
[types.default.SET_CONTACT_CONVERSATIONS]: ($state, { id, data }) => {
|
[types.default.SET_CONTACT_CONVERSATIONS]: ($state, { id, data }) => {
|
||||||
Vue.set($state.records, 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 {
|
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) => {
|
updatePresence: ({ commit }, data) => {
|
||||||
commit(types.UPDATE_CONTACTS_PRESENCE, data);
|
commit(types.UPDATE_CONTACTS_PRESENCE, data);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -11,6 +11,7 @@ const state = {
|
|||||||
uiFlags: {
|
uiFlags: {
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
isFetchingItem: false,
|
isFetchingItem: false,
|
||||||
|
isFetchingInboxes: false,
|
||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -135,9 +135,12 @@ const actions = {
|
|||||||
commit(types.default.ASSIGN_TEAM, team);
|
commit(types.default.ASSIGN_TEAM, team);
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleStatus: async ({ commit }, data) => {
|
toggleStatus: async ({ commit }, { conversationId, status }) => {
|
||||||
try {
|
try {
|
||||||
const response = await ConversationApi.toggleStatus(data);
|
const response = await ConversationApi.toggleStatus({
|
||||||
|
conversationId,
|
||||||
|
status,
|
||||||
|
});
|
||||||
commit(
|
commit(
|
||||||
types.default.RESOLVE_CONVERSATION,
|
types.default.RESOLVE_CONVERSATION,
|
||||||
response.data.payload.current_status
|
response.data.payload.current_status
|
||||||
|
|||||||
@ -1,10 +1,44 @@
|
|||||||
|
import Vue from 'vue';
|
||||||
|
|
||||||
import InboxMembersAPI from '../../api/inboxMembers';
|
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 }) {
|
get(_, { inboxId }) {
|
||||||
return InboxMembersAPI.show(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 {
|
export default {
|
||||||
namespaced: true,
|
namespaced: true,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||||
import * as types from '../mutation-types';
|
import * as types from '../mutation-types';
|
||||||
|
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
|
||||||
import InboxesAPI from '../../api/inboxes';
|
import InboxesAPI from '../../api/inboxes';
|
||||||
import WebChannel from '../../api/channel/webChannel';
|
import WebChannel from '../../api/channel/webChannel';
|
||||||
import FBChannel from '../../api/channel/fbChannel';
|
import FBChannel from '../../api/channel/fbChannel';
|
||||||
@ -41,6 +42,20 @@ export const getters = {
|
|||||||
getInboxes($state) {
|
getInboxes($state) {
|
||||||
return $state.records;
|
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 => {
|
getInbox: $state => inboxId => {
|
||||||
const [inbox] = $state.records.filter(
|
const [inbox] = $state.records.filter(
|
||||||
record => record.id === Number(inboxId)
|
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
|
// Contact Conversation
|
||||||
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
|
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
|
||||||
SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS',
|
SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS',
|
||||||
|
ADD_CONTACT_CONVERSATION: 'ADD_CONTACT_CONVERSATION',
|
||||||
|
|
||||||
// Conversation Label
|
// Conversation Label
|
||||||
SET_CONVERSATION_LABELS_UI_FLAG: 'SET_CONVERSATION_LABELS_UI_FLAG',
|
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 Multiselect from 'vue-multiselect';
|
||||||
import WootSwitch from 'components/ui/Switch';
|
import WootSwitch from 'components/ui/Switch';
|
||||||
import WootWizard from 'components/ui/Wizard';
|
import WootWizard from 'components/ui/Wizard';
|
||||||
import WootButton from 'components/ui/WootButton';
|
|
||||||
import { sync } from 'vuex-router-sync';
|
import { sync } from 'vuex-router-sync';
|
||||||
import Vuelidate from 'vuelidate';
|
import Vuelidate from 'vuelidate';
|
||||||
import VTooltip from 'v-tooltip';
|
import VTooltip from 'v-tooltip';
|
||||||
@ -22,6 +21,7 @@ import i18n from '../dashboard/i18n';
|
|||||||
import createAxios from '../dashboard/helper/APIHelper';
|
import createAxios from '../dashboard/helper/APIHelper';
|
||||||
import commonHelpers from '../dashboard/helper/commons';
|
import commonHelpers from '../dashboard/helper/commons';
|
||||||
import { getAlertAudio } from '../shared/helpers/AudioNotificationHelper';
|
import { getAlertAudio } from '../shared/helpers/AudioNotificationHelper';
|
||||||
|
import { initFaviconSwitcher } from '../shared/helpers/faviconHelper';
|
||||||
import router from '../dashboard/routes';
|
import router from '../dashboard/routes';
|
||||||
import store from '../dashboard/store';
|
import store from '../dashboard/store';
|
||||||
import vueActionCable from '../dashboard/helper/actionCable';
|
import vueActionCable from '../dashboard/helper/actionCable';
|
||||||
@ -49,7 +49,6 @@ Vue.use(hljs.vuePlugin);
|
|||||||
Vue.component('multiselect', Multiselect);
|
Vue.component('multiselect', Multiselect);
|
||||||
Vue.component('woot-switch', WootSwitch);
|
Vue.component('woot-switch', WootSwitch);
|
||||||
Vue.component('woot-wizard', WootWizard);
|
Vue.component('woot-wizard', WootWizard);
|
||||||
Vue.component('woot-button', WootButton);
|
|
||||||
|
|
||||||
const i18nConfig = new VueI18n({
|
const i18nConfig = new VueI18n({
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
@ -82,4 +81,5 @@ window.addEventListener('load', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
getAlertAudio();
|
getAlertAudio();
|
||||||
|
initFaviconSwitcher();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -23,6 +23,11 @@ export const IFrameHelper = {
|
|||||||
return `${baseUrl}/widget?website_token=${websiteToken}`;
|
return `${baseUrl}/widget?website_token=${websiteToken}`;
|
||||||
},
|
},
|
||||||
createFrame: ({ baseUrl, websiteToken }) => {
|
createFrame: ({ baseUrl, websiteToken }) => {
|
||||||
|
if (IFrameHelper.getAppFrame()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCSS();
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
const cwCookie = Cookies.get('cw_conversation');
|
const cwCookie = Cookies.get('cw_conversation');
|
||||||
let widgetUrl = IFrameHelper.getUrl({ baseUrl, websiteToken });
|
let widgetUrl = IFrameHelper.getUrl({ baseUrl, websiteToken });
|
||||||
@ -47,6 +52,7 @@ export const IFrameHelper = {
|
|||||||
IFrameHelper.preventDefaultScroll();
|
IFrameHelper.preventDefaultScroll();
|
||||||
},
|
},
|
||||||
getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'),
|
getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'),
|
||||||
|
getBubbleHolder: () => document.getElementsByClassName('woot--bubble-holder'),
|
||||||
sendMessage: (key, value) => {
|
sendMessage: (key, value) => {
|
||||||
const element = IFrameHelper.getAppFrame();
|
const element = IFrameHelper.getAppFrame();
|
||||||
element.contentWindow.postMessage(
|
element.contentWindow.postMessage(
|
||||||
@ -166,7 +172,10 @@ export const IFrameHelper = {
|
|||||||
iframe.style.visibility = '';
|
iframe.style.visibility = '';
|
||||||
iframe.setAttribute('id', `chatwoot_live_chat_widget`);
|
iframe.setAttribute('id', `chatwoot_live_chat_widget`);
|
||||||
|
|
||||||
loadCSS();
|
if (IFrameHelper.getBubbleHolder().length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
createBubbleHolder();
|
createBubbleHolder();
|
||||||
|
|
||||||
if (!window.$chatwoot.hideMessageBubble) {
|
if (!window.$chatwoot.hideMessageBubble) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export const BUS_EVENTS = {
|
export const BUS_EVENTS = {
|
||||||
SET_REFERRER_HOST: 'SET_REFERRER_HOST',
|
SET_REFERRER_HOST: 'SET_REFERRER_HOST',
|
||||||
SET_TWEET_REPLY: 'SET_TWEET_REPLY',
|
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,
|
ACTIVITY: 2,
|
||||||
TEMPLATE: 3,
|
TEMPLATE: 3,
|
||||||
};
|
};
|
||||||
|
// Size in mega bytes
|
||||||
|
export const MAXIMUM_FILE_UPLOAD_SIZE = 40;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||||
const notificationAudio = require('shared/assets/audio/ding.mp3');
|
const notificationAudio = require('shared/assets/audio/ding.mp3');
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { showBadgeOnFavicon } from './faviconHelper';
|
||||||
|
|
||||||
export const playNotificationAudio = () => {
|
export const playNotificationAudio = () => {
|
||||||
try {
|
try {
|
||||||
@ -74,5 +75,6 @@ export const newMessageNotification = data => {
|
|||||||
|
|
||||||
if (enableAudioAlerts && playAudio) {
|
if (enableAudioAlerts && playAudio) {
|
||||||
window.playAudioAlert();
|
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"
|
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
|
||||||
@input-file="onFileUpload"
|
@input-file="onFileUpload"
|
||||||
>
|
>
|
||||||
<span class="attachment-button ">
|
<span class="attachment-button">
|
||||||
<i v-if="!isUploading.image" class="ion-android-attach" />
|
<i v-if="!isUploading.image" class="ion-android-attach" />
|
||||||
<spinner v-if="isUploading" size="small" />
|
<spinner v-if="isUploading" size="small" />
|
||||||
</span>
|
</span>
|
||||||
@ -14,6 +14,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import FileUpload from 'vue-upload-component';
|
import FileUpload from 'vue-upload-component';
|
||||||
import Spinner from 'shared/components/Spinner.vue';
|
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 {
|
export default {
|
||||||
components: { FileUpload, Spinner },
|
components: { FileUpload, Spinner },
|
||||||
@ -31,14 +34,21 @@ export default {
|
|||||||
return fileType.includes('image') ? 'image' : 'file';
|
return fileType.includes('image') ? 'image' : 'file';
|
||||||
},
|
},
|
||||||
async onFileUpload(file) {
|
async onFileUpload(file) {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.isUploading = true;
|
this.isUploading = true;
|
||||||
try {
|
try {
|
||||||
const thumbUrl = window.URL.createObjectURL(file.file);
|
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||||
await this.onAttach({
|
const thumbUrl = window.URL.createObjectURL(file.file);
|
||||||
fileType: this.getFileType(file.type),
|
await this.onAttach({
|
||||||
file: file.file,
|
fileType: this.getFileType(file.type),
|
||||||
thumbUrl,
|
file: file.file,
|
||||||
});
|
thumbUrl,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.bus.$emit(BUS_EVENTS.ATTACHMENT_SIZE_CHECK_ERROR);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error
|
// Error
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
:placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')"
|
:placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')"
|
||||||
:error="$v.message.$error ? $t('PRE_CHAT_FORM.FIELDS.MESSAGE.ERROR') : ''"
|
:error="$v.message.$error ? $t('PRE_CHAT_FORM.FIELDS.MESSAGE.ERROR') : ''"
|
||||||
/>
|
/>
|
||||||
<woot-button
|
<custom-button
|
||||||
class="font-medium"
|
class="font-medium"
|
||||||
block
|
block
|
||||||
:bg-color="widgetColor"
|
:bg-color="widgetColor"
|
||||||
@ -46,12 +46,12 @@
|
|||||||
>
|
>
|
||||||
<spinner v-if="isCreating" class="p-0" />
|
<spinner v-if="isCreating" class="p-0" />
|
||||||
{{ $t('START_CONVERSATION') }}
|
{{ $t('START_CONVERSATION') }}
|
||||||
</woot-button>
|
</custom-button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import WootButton from 'shared/components/Button';
|
import CustomButton from 'shared/components/Button';
|
||||||
import FormInput from '../Form/Input';
|
import FormInput from '../Form/Input';
|
||||||
import FormTextArea from '../Form/TextArea';
|
import FormTextArea from '../Form/TextArea';
|
||||||
import Spinner from 'shared/components/Spinner';
|
import Spinner from 'shared/components/Spinner';
|
||||||
@ -62,7 +62,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
FormInput,
|
FormInput,
|
||||||
FormTextArea,
|
FormTextArea,
|
||||||
WootButton,
|
CustomButton,
|
||||||
Spinner,
|
Spinner,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<available-agents v-if="isOnline" :agents="availableAgents" />
|
<available-agents v-if="isOnline" :agents="availableAgents" />
|
||||||
</div>
|
</div>
|
||||||
<woot-button
|
<custom-button
|
||||||
class="font-medium"
|
class="font-medium"
|
||||||
block
|
block
|
||||||
:bg-color="widgetColor"
|
:bg-color="widgetColor"
|
||||||
@ -23,7 +23,7 @@
|
|||||||
@click="startConversation"
|
@click="startConversation"
|
||||||
>
|
>
|
||||||
{{ $t('START_CONVERSATION') }}
|
{{ $t('START_CONVERSATION') }}
|
||||||
</woot-button>
|
</custom-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -31,7 +31,7 @@
|
|||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import AvailableAgents from 'widget/components/AvailableAgents.vue';
|
import AvailableAgents from 'widget/components/AvailableAgents.vue';
|
||||||
import { getContrastingTextColor } from 'shared/helpers/ColorHelper';
|
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 configMixin from 'widget/mixins/configMixin';
|
||||||
import availabilityMixin from 'widget/mixins/availability';
|
import availabilityMixin from 'widget/mixins/availability';
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ export default {
|
|||||||
name: 'TeamAvailability',
|
name: 'TeamAvailability',
|
||||||
components: {
|
components: {
|
||||||
AvailableAgents,
|
AvailableAgents,
|
||||||
WootButton,
|
CustomButton,
|
||||||
},
|
},
|
||||||
mixins: [configMixin, availabilityMixin],
|
mixins: [configMixin, availabilityMixin],
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@ -48,5 +48,6 @@
|
|||||||
"ERROR": "Message too short"
|
"ERROR": "Message too short"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,15 @@
|
|||||||
/>
|
/>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</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">
|
<div class="flex flex-1 overflow-auto">
|
||||||
<conversation-wrap
|
<conversation-wrap
|
||||||
v-if="currentView === 'messageView'"
|
v-if="currentView === 'messageView'"
|
||||||
@ -80,6 +89,8 @@ import configMixin from '../mixins/configMixin';
|
|||||||
import TeamAvailability from 'widget/components/TeamAvailability';
|
import TeamAvailability from 'widget/components/TeamAvailability';
|
||||||
import Spinner from 'shared/components/Spinner.vue';
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
import { mapGetters } from 'vuex';
|
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';
|
import PreChatForm from '../components/PreChat/Form';
|
||||||
export default {
|
export default {
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
@ -105,7 +116,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return { isOnCollapsedView: false };
|
return { isOnCollapsedView: false, showAttachmentError: false };
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
@ -132,6 +143,9 @@ export default {
|
|||||||
isOpen() {
|
isOpen() {
|
||||||
return this.conversationAttributes.status === 'open';
|
return this.conversationAttributes.status === 'open';
|
||||||
},
|
},
|
||||||
|
fileUploadSizeLimit() {
|
||||||
|
return MAXIMUM_FILE_UPLOAD_SIZE;
|
||||||
|
},
|
||||||
showInputTextArea() {
|
showInputTextArea() {
|
||||||
if (this.hideInputForBotConversations) {
|
if (this.hideInputForBotConversations) {
|
||||||
if (this.isOpen) {
|
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: {
|
methods: {
|
||||||
startConversation() {
|
startConversation() {
|
||||||
this.isOnCollapsedView = !this.isOnCollapsedView;
|
this.isOnCollapsedView = !this.isOnCollapsedView;
|
||||||
@ -221,5 +243,13 @@ export default {
|
|||||||
.input-wrap {
|
.input-wrap {
|
||||||
padding: 0 $space-normal;
|
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>
|
</style>
|
||||||
|
|||||||
@ -29,13 +29,13 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
|
|||||||
send_mail_with_liquid(to: @agent.email, subject: subject) and return
|
send_mail_with_liquid(to: @agent.email, subject: subject) and return
|
||||||
end
|
end
|
||||||
|
|
||||||
def assigned_conversation_new_message(conversation, agent)
|
def assigned_conversation_new_message(message, agent)
|
||||||
return unless smtp_config_set_or_development?
|
return unless smtp_config_set_or_development?
|
||||||
# Don't spam with email notifications if agent is online
|
# 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
|
@agent = agent
|
||||||
@conversation = conversation
|
@conversation = message.conversation
|
||||||
subject = "#{@agent.available_name}, New message in your assigned conversation [ID - #{@conversation.display_id}]."
|
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)
|
@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
|
send_mail_with_liquid(to: @agent.email, subject: subject) and return
|
||||||
|
|||||||
@ -16,12 +16,19 @@ class ApplicationMailer < ActionMailer::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
rescue_from(*ExceptionList::SMTP_EXCEPTIONS, with: :handle_smtp_exceptions)
|
||||||
|
|
||||||
def smtp_config_set_or_development?
|
def smtp_config_set_or_development?
|
||||||
ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development?
|
ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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)
|
def send_mail_with_liquid(*args)
|
||||||
mail(*args) do |format|
|
mail(*args) do |format|
|
||||||
# explored sending a multipart email containing both text type and html
|
# explored sending a multipart email containing both text type and html
|
||||||
|
|||||||
@ -31,6 +31,9 @@ class Contact < ApplicationRecord
|
|||||||
validates :account_id, presence: true
|
validates :account_id, presence: true
|
||||||
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false }
|
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false }
|
||||||
validates :identifier, allow_blank: true, uniqueness: { scope: [:account_id] }
|
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
|
belongs_to :account
|
||||||
has_many :conversations, dependent: :destroy
|
has_many :conversations, dependent: :destroy
|
||||||
|
|||||||
@ -53,10 +53,10 @@ class ContactInbox < ApplicationRecord
|
|||||||
|
|
||||||
def validate_twilio_source_id
|
def validate_twilio_source_id
|
||||||
# https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164
|
# 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)
|
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}$/')
|
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}$/.match?(source_id)
|
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}$/')
|
errors.add(:source_id, 'invalid source id for twilio whatsapp inbox. valid Regex /whatsapp:\+[1-9]\d{1,14}\z/')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -148,7 +148,9 @@ class Message < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def send_reply
|
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
|
end
|
||||||
|
|
||||||
def reopen_conversation
|
def reopen_conversation
|
||||||
|
|||||||
@ -20,8 +20,6 @@
|
|||||||
# fk_rails_... (account_id => accounts.id)
|
# fk_rails_... (account_id => accounts.id)
|
||||||
#
|
#
|
||||||
class Team < ApplicationRecord
|
class Team < ApplicationRecord
|
||||||
include RegexHelper
|
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
has_many :team_members, dependent: :destroy
|
has_many :team_members, dependent: :destroy
|
||||||
has_many :members, through: :team_members, source: :user
|
has_many :members, through: :team_members, source: :user
|
||||||
@ -29,7 +27,6 @@ class Team < ApplicationRecord
|
|||||||
|
|
||||||
validates :name,
|
validates :name,
|
||||||
presence: { message: 'must not be blank' },
|
presence: { message: 'must not be blank' },
|
||||||
format: { with: UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE },
|
|
||||||
uniqueness: { scope: :account_id }
|
uniqueness: { scope: :account_id }
|
||||||
|
|
||||||
before_validation do
|
before_validation do
|
||||||
|
|||||||
@ -19,6 +19,10 @@ class ContactPolicy < ApplicationPolicy
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def contactable_inboxes?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
def show?
|
def show?
|
||||||
true
|
true
|
||||||
end
|
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.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 %>
|
<%= CSV.generate_line headers %>
|
||||||
<% Current.account.users.each do |agent| %>
|
<% Current.account.users.each do |agent| %>
|
||||||
<% agent_report = V2::ReportBuilder.new(Current.account, {
|
<% 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="152x152" href="/apple-icon-152x152.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.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="192x192" href="/android-icon-192x192.png">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
<link class="favicon" rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
<link class="favicon" 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="16x16" href="/favicon-16x16.png">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<% end %>
|
<% end %>
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="<%= @global_config['LOGO_THUMBNAIL'] %>">
|
<link rel="icon" type="image/png" sizes="512x512" href="<%= @global_config['LOGO_THUMBNAIL'] %>">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
shared: &shared
|
shared: &shared
|
||||||
version: '1.14.3'
|
version: '1.15.0'
|
||||||
|
|
||||||
development:
|
development:
|
||||||
<<: *shared
|
<<: *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
|
# Add here as you use it for more features
|
||||||
# Used for Round Robin, Conversation Emails & Online Presence
|
# Used for Round Robin, Conversation Emails & Online Presence
|
||||||
$alfred = Redis::Namespace.new('alfred', redis: redis, warning: true)
|
$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