Merge branch 'release/2.3.0'
This commit is contained in:
commit
a50ea9c88a
162 changed files with 2518 additions and 555 deletions
114
Gemfile.lock
114
Gemfile.lock
|
@ -9,63 +9,63 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (6.1.4.6)
|
||||
actionpack (= 6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
actioncable (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (6.1.4.6)
|
||||
actionpack (= 6.1.4.6)
|
||||
activejob (= 6.1.4.6)
|
||||
activerecord (= 6.1.4.6)
|
||||
activestorage (= 6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
actionmailbox (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
activejob (= 6.1.4.7)
|
||||
activerecord (= 6.1.4.7)
|
||||
activestorage (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
mail (>= 2.7.1)
|
||||
actionmailer (6.1.4.6)
|
||||
actionpack (= 6.1.4.6)
|
||||
actionview (= 6.1.4.6)
|
||||
activejob (= 6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
actionmailer (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
actionview (= 6.1.4.7)
|
||||
activejob (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (6.1.4.6)
|
||||
actionview (= 6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
actionpack (6.1.4.7)
|
||||
actionview (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
rack (~> 2.0, >= 2.0.9)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (6.1.4.6)
|
||||
actionpack (= 6.1.4.6)
|
||||
activerecord (= 6.1.4.6)
|
||||
activestorage (= 6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
actiontext (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
activerecord (= 6.1.4.7)
|
||||
activestorage (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
actionview (6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
activejob (6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
activerecord (6.1.4.6)
|
||||
activemodel (= 6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
activemodel (6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
activerecord (6.1.4.7)
|
||||
activemodel (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
activerecord-import (1.3.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (6.1.4.6)
|
||||
actionpack (= 6.1.4.6)
|
||||
activejob (= 6.1.4.6)
|
||||
activerecord (= 6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
activestorage (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
activejob (= 6.1.4.7)
|
||||
activerecord (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
marcel (~> 1.0.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (6.1.4.6)
|
||||
activesupport (6.1.4.7)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
|
@ -135,7 +135,7 @@ GEM
|
|||
byebug (11.1.3)
|
||||
climate_control (1.0.1)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.2)
|
||||
commonmarker (0.23.4)
|
||||
concurrent-ruby (1.1.9)
|
||||
connection_pool (2.2.5)
|
||||
crack (0.4.5)
|
||||
|
@ -303,7 +303,7 @@ GEM
|
|||
httpclient (2.8.3)
|
||||
i18n (1.10.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.1)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
jbuilder (2.11.5)
|
||||
|
@ -419,29 +419,29 @@ GEM
|
|||
rack-test (1.1.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-timeout (0.6.0)
|
||||
rails (6.1.4.6)
|
||||
actioncable (= 6.1.4.6)
|
||||
actionmailbox (= 6.1.4.6)
|
||||
actionmailer (= 6.1.4.6)
|
||||
actionpack (= 6.1.4.6)
|
||||
actiontext (= 6.1.4.6)
|
||||
actionview (= 6.1.4.6)
|
||||
activejob (= 6.1.4.6)
|
||||
activemodel (= 6.1.4.6)
|
||||
activerecord (= 6.1.4.6)
|
||||
activestorage (= 6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
rails (6.1.4.7)
|
||||
actioncable (= 6.1.4.7)
|
||||
actionmailbox (= 6.1.4.7)
|
||||
actionmailer (= 6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
actiontext (= 6.1.4.7)
|
||||
actionview (= 6.1.4.7)
|
||||
activejob (= 6.1.4.7)
|
||||
activemodel (= 6.1.4.7)
|
||||
activerecord (= 6.1.4.7)
|
||||
activestorage (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 6.1.4.6)
|
||||
railties (= 6.1.4.7)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.4.2)
|
||||
loofah (~> 2.3)
|
||||
railties (6.1.4.6)
|
||||
actionpack (= 6.1.4.6)
|
||||
activesupport (= 6.1.4.6)
|
||||
railties (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
method_source
|
||||
rake (>= 0.13)
|
||||
thor (~> 1.0)
|
||||
|
@ -574,7 +574,7 @@ GEM
|
|||
spring-watcher-listen (2.0.1)
|
||||
listen (>= 2.7, < 4.0)
|
||||
spring (>= 1.2, < 3.0)
|
||||
sprockets (4.0.2)
|
||||
sprockets (4.0.3)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (> 1, < 3)
|
||||
sprockets-rails (3.4.2)
|
||||
|
@ -751,4 +751,4 @@ RUBY VERSION
|
|||
ruby 3.0.2p107
|
||||
|
||||
BUNDLED WITH
|
||||
2.2.25
|
||||
2.3.8
|
||||
|
|
|
@ -52,12 +52,11 @@ class ContactIdentifyAction
|
|||
end
|
||||
|
||||
def update_contact
|
||||
custom_attributes = params[:custom_attributes] ? @contact.custom_attributes.merge(params[:custom_attributes]) : @contact.custom_attributes
|
||||
# blank identifier or email will throw unique index error
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v|
|
||||
v.blank?
|
||||
end.merge({ custom_attributes: custom_attributes }))
|
||||
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes }))
|
||||
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
||||
end
|
||||
|
||||
|
@ -68,4 +67,16 @@ class ContactIdentifyAction
|
|||
mergee_contact: merge_contact
|
||||
).perform
|
||||
end
|
||||
|
||||
def custom_attributes
|
||||
params[:custom_attributes] ? @contact.custom_attributes.deep_merge(params[:custom_attributes].stringify_keys) : @contact.custom_attributes
|
||||
end
|
||||
|
||||
def additional_attributes
|
||||
if params[:additional_attributes]
|
||||
@contact.additional_attributes.deep_merge(params[:additional_attributes].stringify_keys)
|
||||
else
|
||||
@contact.additional_attributes
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
class Messages::Messenger::MessageBuilder
|
||||
include ::FileTypeHelper
|
||||
|
||||
def process_attachment(attachment)
|
||||
return if attachment['type'].to_sym == :template
|
||||
|
||||
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
|
@ -22,7 +26,7 @@ class Messages::Messenger::MessageBuilder
|
|||
file_type = attachment['type'].to_sym
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video].include? file_type
|
||||
if [:image, :file, :audio, :video, :share, :story_mention].include? file_type
|
||||
params.merge!(file_type_params(attachment))
|
||||
elsif file_type == :location
|
||||
params.merge!(location_params(attachment))
|
||||
|
@ -39,4 +43,31 @@ class Messages::Messenger::MessageBuilder
|
|||
remote_file_url: attachment['payload']['url']
|
||||
}
|
||||
end
|
||||
|
||||
def update_attachment_file_type(attachment)
|
||||
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
|
||||
|
||||
attachment.file_type = file_type(attachment.file&.content_type)
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
def fetch_story_link(attachment)
|
||||
message = attachment.message
|
||||
begin
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
result = k.get_object(message.source_id, fields: %w[story from]) || {}
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue StandardError => e
|
||||
result = {}
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
story_id = result['story']['mention']['id']
|
||||
story_sender = result['from']['username']
|
||||
message.content_attributes[:story_sender] = story_sender
|
||||
message.content_attributes[:story_id] = story_id
|
||||
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
|
||||
message.save!
|
||||
end
|
||||
end
|
||||
|
|
|
@ -95,15 +95,15 @@ class V2::ReportBuilder
|
|||
end
|
||||
|
||||
def avg_first_response_time
|
||||
(get_grouped_values scope.events.where(name: 'first_response')).average(:value)
|
||||
(get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value)
|
||||
end
|
||||
|
||||
def avg_resolution_time
|
||||
(get_grouped_values scope.events.where(name: 'conversation_resolved')).average(:value)
|
||||
(get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value)
|
||||
end
|
||||
|
||||
def avg_resolution_time_summary
|
||||
avg_rt = scope.events
|
||||
avg_rt = scope.reporting_events
|
||||
.where(name: 'conversation_resolved', created_at: range)
|
||||
.average(:value)
|
||||
|
||||
|
@ -113,7 +113,7 @@ class V2::ReportBuilder
|
|||
end
|
||||
|
||||
def avg_first_response_time_summary
|
||||
avg_frt = scope.events
|
||||
avg_frt = scope.reporting_events
|
||||
.where(name: 'first_response', created_at: range)
|
||||
.average(:value)
|
||||
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
class RoomChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
# TODO: should we only do ensure stream if current account is present?
|
||||
# for now going ahead with guard clauses in update_subscription and broadcast_presence
|
||||
|
||||
ensure_stream
|
||||
current_user
|
||||
current_account
|
||||
|
@ -15,6 +18,8 @@ class RoomChannel < ApplicationCable::Channel
|
|||
private
|
||||
|
||||
def broadcast_presence
|
||||
return if @current_account.blank?
|
||||
|
||||
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
|
||||
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
|
||||
ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
|
||||
|
@ -26,6 +31,8 @@ class RoomChannel < ApplicationCable::Channel
|
|||
end
|
||||
|
||||
def update_subscription
|
||||
return if @current_account.blank?
|
||||
|
||||
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
|
||||
end
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
||||
|
||||
def index
|
||||
@automation_rules = Current.account.automation_rules.active
|
||||
@automation_rules = Current.account.automation_rules
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -32,7 +32,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||
|
||||
def automation_rules_permit
|
||||
params.permit(
|
||||
:name, :description, :event_name, :account_id,
|
||||
:name, :description, :event_name, :account_id, :active,
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
|
|
|
@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
|||
|
||||
def destroy
|
||||
ActiveRecord::Base.transaction do
|
||||
message.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
|
||||
message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
|
||||
message.attachments.destroy_all
|
||||
end
|
||||
end
|
||||
|
|
|
@ -30,8 +30,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
|
|||
def set_csat_survey_responses
|
||||
@csat_survey_responses = filtrate(
|
||||
Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
|
||||
)
|
||||
@csat_survey_responses = @csat_survey_responses.where(created_at: range) if range.present?
|
||||
).filter_by_created_at(range).filter_by_assigned_agent_id(params[:user_ids])
|
||||
end
|
||||
|
||||
def set_current_page_surveys
|
||||
|
|
|
@ -34,6 +34,8 @@ class Api::V1::Widget::BaseController < ApplicationController
|
|||
)
|
||||
@contact = @contact_inbox&.contact
|
||||
raise ActiveRecord::RecordNotFound unless @contact
|
||||
|
||||
Current.contact = @contact
|
||||
end
|
||||
|
||||
def create_conversation
|
||||
|
|
|
@ -46,6 +46,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
|||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
|
||||
params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {},
|
||||
additional_attributes: {})
|
||||
end
|
||||
end
|
||||
|
|
|
@ -44,6 +44,15 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||
head :ok
|
||||
end
|
||||
|
||||
def toggle_status
|
||||
head :not_found && return if conversation.nil?
|
||||
unless conversation.resolved?
|
||||
conversation.status = 'resolved'
|
||||
conversation.save
|
||||
end
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trigger_typing_event(event)
|
||||
|
|
|
@ -41,12 +41,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
|
||||
def summary_params
|
||||
def current_summary_params
|
||||
{
|
||||
type: params[:type].to_sym,
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
id: params[:id],
|
||||
since: range[:current][:since],
|
||||
until: range[:current][:until],
|
||||
group_by: params[:group_by]
|
||||
}
|
||||
end
|
||||
|
||||
def previous_summary_params
|
||||
{
|
||||
type: params[:type].to_sym,
|
||||
id: params[:id],
|
||||
since: range[:previous][:since],
|
||||
until: range[:previous][:until],
|
||||
group_by: params[:group_by]
|
||||
}
|
||||
end
|
||||
|
@ -63,8 +73,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||
}
|
||||
end
|
||||
|
||||
def range
|
||||
{
|
||||
current: {
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
},
|
||||
previous: {
|
||||
since: (params[:since].to_i - (params[:until].to_i - params[:since].to_i)).to_s,
|
||||
until: params[:since]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def summary_metrics
|
||||
builder = V2::ReportBuilder.new(Current.account, summary_params)
|
||||
builder.summary
|
||||
summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary
|
||||
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
|
||||
summary
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,10 +12,10 @@ class AsyncDispatcher < BaseDispatcher
|
|||
[
|
||||
CampaignListener.instance,
|
||||
CsatSurveyListener.instance,
|
||||
EventListener.instance,
|
||||
HookListener.instance,
|
||||
InstallationWebhookListener.instance,
|
||||
NotificationListener.instance,
|
||||
ReportingEventListener.instance,
|
||||
WebhookListener.instance,
|
||||
AutomationRuleListener.instance
|
||||
]
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
module Api::V1::ReportsHelper
|
||||
end
|
|
@ -3,7 +3,7 @@ module FileTypeHelper
|
|||
def file_type(content_type)
|
||||
return :image if image_file?(content_type)
|
||||
return :video if video_file?(content_type)
|
||||
return :audio if content_type.include?('audio/')
|
||||
return :audio if content_type&.include?('audio/')
|
||||
|
||||
:file
|
||||
end
|
||||
|
@ -14,7 +14,8 @@ module FileTypeHelper
|
|||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/webp'
|
||||
'image/webp',
|
||||
'image'
|
||||
].include?(content_type)
|
||||
end
|
||||
|
||||
|
@ -23,7 +24,8 @@ module FileTypeHelper
|
|||
'video/ogg',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/quicktime'
|
||||
'video/quicktime',
|
||||
'video'
|
||||
].include?(content_type)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<div id="app" class="app-wrapper app-root">
|
||||
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view></router-view>
|
||||
</transition>
|
||||
|
@ -13,24 +14,27 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { accountIdFromPathname } from './helper/URLHelper';
|
||||
import { mapGetters } from 'vuex';
|
||||
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
|
||||
import WootSnackbarBox from './components/SnackbarContainer';
|
||||
import NetworkNotification from './components/NetworkNotification';
|
||||
import { accountIdFromPathname } from './helper/URLHelper';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import WootSnackbarBox from './components/SnackbarContainer';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
|
||||
components: {
|
||||
WootSnackbarBox,
|
||||
AddAccountModal,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
WootSnackbarBox,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showAddAccountModal: false,
|
||||
latestChatwootVersion: null,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -38,6 +42,7 @@ export default {
|
|||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
currentUser: 'getCurrentUser',
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
hasAccounts() {
|
||||
return (
|
||||
|
@ -72,8 +77,12 @@ export default {
|
|||
|
||||
if (accountId) {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
const { locale } = this.getAccount(accountId);
|
||||
const {
|
||||
locale,
|
||||
latest_chatwoot_version: latestChatwootVersion,
|
||||
} = this.getAccount(accountId);
|
||||
this.setLocale(locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
@ -82,6 +91,11 @@ export default {
|
|||
|
||||
<style lang="scss">
|
||||
@import './assets/scss/app';
|
||||
.update-banner {
|
||||
height: var(--space-larger);
|
||||
align-items: center;
|
||||
font-size: var(--font-size-small) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||
|
|
|
@ -13,7 +13,7 @@ export default {
|
|||
.post('auth/sign_in', creds)
|
||||
.then(response => {
|
||||
setAuthCredentials(response);
|
||||
resolve();
|
||||
resolve(response.data);
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error.response);
|
||||
|
|
|
@ -6,15 +6,21 @@ class CSATReportsAPI extends ApiClient {
|
|||
super('csat_survey_responses', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page, from, to } = {}) {
|
||||
get({ page, from, to, user_ids } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: { page, since: from, until: to, sort: '-created_at' },
|
||||
params: {
|
||||
page,
|
||||
since: from,
|
||||
until: to,
|
||||
sort: '-created_at',
|
||||
user_ids,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({ from, to } = {}) {
|
||||
getMetrics({ from, to, user_ids } = {}) {
|
||||
return axios.get(`${this.url}/metrics`, {
|
||||
params: { since: from, until: to },
|
||||
params: { since: from, until: to, user_ids },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,10 +10,24 @@ body {
|
|||
|
||||
.app-wrapper {
|
||||
@include full-height;
|
||||
flex-grow: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
.banner + .app-wrapper {
|
||||
.button--fixed-right-top {
|
||||
top: 5.6 * $space-one;
|
||||
}
|
||||
|
||||
.off-canvas-content {
|
||||
.button--fixed-right-top {
|
||||
top: $space-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is-closed .app-root {
|
||||
@include flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
@ -21,6 +35,7 @@ body {
|
|||
.app-content {
|
||||
@include flex;
|
||||
@include full-height;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
|
@ -20,9 +20,28 @@
|
|||
color: $color-heading;
|
||||
}
|
||||
|
||||
.metric-wrap {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.metric {
|
||||
font-size: $font-size-bigger;
|
||||
font-size: $font-size-big;
|
||||
font-weight: $font-weight-feather;
|
||||
margin-top: $space-smaller;
|
||||
}
|
||||
|
||||
.metric-trend {
|
||||
font-size: $font-size-small;
|
||||
margin-left: $space-small;
|
||||
}
|
||||
|
||||
.metric-up {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
.metric-down {
|
||||
color: $alert-color;
|
||||
}
|
||||
|
||||
.desc {
|
||||
|
|
74
app/javascript/dashboard/components/app/UpdateBanner.vue
Normal file
74
app/javascript/dashboard/components/app/UpdateBanner.vue
Normal file
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<banner
|
||||
v-if="shouldShowBanner"
|
||||
class="update-banner"
|
||||
color-scheme="primary"
|
||||
:banner-message="bannerMessage"
|
||||
href-link="https://github.com/chatwoot/chatwoot/releases"
|
||||
:href-link-text="$t('GENERAL_SETTINGS.LEARN_MORE')"
|
||||
has-close-button
|
||||
@close="dismissUpdateBanner"
|
||||
/>
|
||||
</template>
|
||||
<script>
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import LocalStorage from '../../helper/localStorage';
|
||||
import { mapGetters } from 'vuex';
|
||||
import adminMixin from 'dashboard/mixins/isAdmin';
|
||||
|
||||
const semver = require('semver');
|
||||
const dismissedUpdates = new LocalStorage('dismissedUpdates');
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
},
|
||||
mixins: [adminMixin],
|
||||
props: {
|
||||
latestChatwootVersion: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ globalConfig: 'globalConfig/get' }),
|
||||
hasAnUpdateAvailable() {
|
||||
if (!semver.valid(this.latestChatwootVersion)) {
|
||||
return false;
|
||||
}
|
||||
return semver.lt(
|
||||
this.globalConfig.appVersion,
|
||||
this.latestChatwootVersion
|
||||
);
|
||||
},
|
||||
bannerMessage() {
|
||||
return this.$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
|
||||
latestChatwootVersion: this.latestChatwootVersion,
|
||||
});
|
||||
},
|
||||
shouldShowBanner() {
|
||||
return (
|
||||
this.globalConfig.displayManifest &&
|
||||
this.hasAnUpdateAvailable &&
|
||||
!this.isVersionNotificationDismissed(this.latestChatwootVersion) &&
|
||||
this.isAdmin
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isVersionNotificationDismissed(version) {
|
||||
return dismissedUpdates.get().includes(version);
|
||||
},
|
||||
dismissUpdateBanner() {
|
||||
let updatedDismissedItems = dismissedUpdates.get();
|
||||
if (updatedDismissedItems instanceof Array) {
|
||||
updatedDismissedItems.push(this.latestChatwootVersion);
|
||||
} else {
|
||||
updatedDismissedItems = [this.latestChatwootVersion];
|
||||
}
|
||||
dismissedUpdates.store(updatedDismissedItems);
|
||||
this.latestChatwootVersion = this.globalConfig.appVersion;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -21,6 +21,7 @@ import SubmitButton from './buttons/FormSubmitButton';
|
|||
import Tabs from './ui/Tabs/Tabs';
|
||||
import TabsItem from './ui/Tabs/TabsItem';
|
||||
import Thumbnail from './widgets/Thumbnail.vue';
|
||||
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
|
||||
|
||||
const WootUIKit = {
|
||||
AvatarUploader,
|
||||
|
@ -45,6 +46,7 @@ const WootUIKit = {
|
|||
Tabs,
|
||||
TabsItem,
|
||||
Thumbnail,
|
||||
ConfirmModal,
|
||||
install(Vue) {
|
||||
const keys = Object.keys(this);
|
||||
keys.pop(); // remove 'install' from keys
|
||||
|
|
|
@ -19,23 +19,6 @@
|
|||
:current-role="currentRole"
|
||||
@add-label="showAddLabelPopup"
|
||||
/>
|
||||
<woot-key-shortcut-modal
|
||||
v-if="showShortcutModal"
|
||||
@close="closeKeyShortcutModal"
|
||||
@clickaway="closeKeyShortcutModal"
|
||||
/>
|
||||
<account-selector
|
||||
:show-account-modal="showAccountModal"
|
||||
@close-account-modal="toggleAccountModal"
|
||||
@show-create-account-modal="openCreateAccountModal"
|
||||
/>
|
||||
<add-account-modal
|
||||
:show="showCreateAccountModal"
|
||||
@close-account-create-modal="closeCreateAccountModal"
|
||||
/>
|
||||
<woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup">
|
||||
<add-label-modal @close="hideAddLabelPopup" />
|
||||
</woot-modal>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
@ -46,12 +29,8 @@ import adminMixin from '../../mixins/isAdmin';
|
|||
import { getSidebarItems } from './config/default-sidebar';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
import AccountSelector from './sidebarComponents/AccountSelector.vue';
|
||||
import AddAccountModal from './sidebarComponents/AddAccountModal.vue';
|
||||
import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
|
||||
import PrimarySidebar from './sidebarComponents/Primary';
|
||||
import SecondarySidebar from './sidebarComponents/Secondary';
|
||||
import WootKeyShortcutModal from 'components/widgets/modal/WootKeyShortcutModal';
|
||||
import {
|
||||
hasPressedAltAndCKey,
|
||||
hasPressedAltAndRKey,
|
||||
|
@ -65,21 +44,13 @@ import router from '../../routes';
|
|||
|
||||
export default {
|
||||
components: {
|
||||
AccountSelector,
|
||||
AddAccountModal,
|
||||
AddLabelModal,
|
||||
PrimarySidebar,
|
||||
SecondarySidebar,
|
||||
WootKeyShortcutModal,
|
||||
},
|
||||
mixins: [adminMixin, alertMixin, eventListenerMixins],
|
||||
data() {
|
||||
return {
|
||||
showOptionsMenu: false,
|
||||
showAccountModal: false,
|
||||
showCreateAccountModal: false,
|
||||
showAddLabelModal: false,
|
||||
showShortcutModal: false,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -162,10 +133,10 @@ export default {
|
|||
}
|
||||
},
|
||||
toggleKeyShortcutModal() {
|
||||
this.showShortcutModal = true;
|
||||
this.$emit('open-key-shortcut-modal');
|
||||
},
|
||||
closeKeyShortcutModal() {
|
||||
this.showShortcutModal = false;
|
||||
this.$emit('close-key-shortcut-modal');
|
||||
},
|
||||
handleKeyEvents(e) {
|
||||
if (hasPressedCommandAndForwardSlash(e)) {
|
||||
|
@ -200,20 +171,10 @@ export default {
|
|||
window.$chatwoot.toggle();
|
||||
},
|
||||
toggleAccountModal() {
|
||||
this.showAccountModal = !this.showAccountModal;
|
||||
},
|
||||
openCreateAccountModal() {
|
||||
this.showAccountModal = false;
|
||||
this.showCreateAccountModal = true;
|
||||
},
|
||||
closeCreateAccountModal() {
|
||||
this.showCreateAccountModal = false;
|
||||
this.$emit('toggle-account-modal');
|
||||
},
|
||||
showAddLabelPopup() {
|
||||
this.showAddLabelModal = true;
|
||||
},
|
||||
hideAddLabelPopup() {
|
||||
this.showAddLabelModal = false;
|
||||
this.$emit('show-add-label-popup');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -223,6 +184,8 @@ export default {
|
|||
.woot-sidebar {
|
||||
background: var(--white);
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
@ -93,7 +93,7 @@ export default {
|
|||
width: var(--space-jumbo);
|
||||
border-right: 1px solid var(--s-50);
|
||||
box-sizing: content-box;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -225,7 +225,7 @@ export default {
|
|||
.secondary-menu {
|
||||
background: var(--white);
|
||||
border-right: 1px solid var(--s-50);
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
width: 19rem;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
|
|
|
@ -20,7 +20,8 @@
|
|||
data-view-component="true"
|
||||
label="Beta"
|
||||
class="beta"
|
||||
>Beta
|
||||
>
|
||||
{{ $t('SIDEBAR.BETA') }}
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
|
@ -233,7 +234,7 @@ export default {
|
|||
padding-left: var(--space-smaller) !important;
|
||||
margin-left: var(--space-half) !important;
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-mini);
|
||||
font-size: var(--font-size-micro);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18px;
|
||||
border: 1px solid transparent;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="banner" :class="bannerClasses">
|
||||
<span>
|
||||
<span class="banner-message">
|
||||
{{ bannerMessage }}
|
||||
<a
|
||||
v-if="hrefLink"
|
||||
|
@ -26,7 +26,7 @@
|
|||
v-if="hasCloseButton"
|
||||
size="small"
|
||||
variant="link"
|
||||
color-scheme="warning"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss-circle"
|
||||
class-names="banner-action__button"
|
||||
@click="onClickClose"
|
||||
|
@ -92,8 +92,19 @@ export default {
|
|||
justify-content: center;
|
||||
position: sticky;
|
||||
|
||||
&.primary {
|
||||
background: var(--w-500);
|
||||
.banner-action__button {
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: var(--s-300);
|
||||
background: var(--s-200);
|
||||
color: var(--s-800);
|
||||
a {
|
||||
color: var(--s-800);
|
||||
}
|
||||
}
|
||||
|
||||
&.alert {
|
||||
|
@ -110,9 +121,13 @@ export default {
|
|||
|
||||
&.gray {
|
||||
background: var(--b-500);
|
||||
.banner-action__button {
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
margin-left: var(--space-smaller);
|
||||
text-decoration: underline;
|
||||
color: var(--white);
|
||||
font-size: var(--font-size-mini);
|
||||
|
@ -125,5 +140,10 @@ export default {
|
|||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
</div>
|
||||
<div class="remove-file-wrap">
|
||||
<woot-button
|
||||
v-if="!isTypeAudio(attachment.resource)"
|
||||
class="remove--attachment clear secondary"
|
||||
icon="dismiss"
|
||||
@click="() => onRemoveAttachment(index)"
|
||||
|
@ -58,6 +59,10 @@ export default {
|
|||
const type = file.content_type || file.type;
|
||||
return type.includes('image');
|
||||
},
|
||||
isTypeAudio(file) {
|
||||
const type = file.content_type || file.type;
|
||||
return type.includes('audio');
|
||||
},
|
||||
fileName(file) {
|
||||
return file.filename || file.name;
|
||||
},
|
||||
|
|
|
@ -69,7 +69,22 @@ export const OPERATOR_TYPES_4 = [
|
|||
label: 'Is greater than',
|
||||
},
|
||||
{
|
||||
value: 'is_lesser_than',
|
||||
label: 'Is lesser than',
|
||||
value: 'is_less_than',
|
||||
label: 'Is less than',
|
||||
},
|
||||
];
|
||||
|
||||
export const OPERATOR_TYPES_5 = [
|
||||
{
|
||||
value: 'is_greater_than',
|
||||
label: 'Is greater than',
|
||||
},
|
||||
{
|
||||
value: 'is_less_than',
|
||||
label: 'Is less than',
|
||||
},
|
||||
{
|
||||
value: 'days_before',
|
||||
label: 'Is x days before',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import {
|
||||
OPERATOR_TYPES_1,
|
||||
OPERATOR_TYPES_2,
|
||||
OPERATOR_TYPES_3,
|
||||
OPERATOR_TYPES_4,
|
||||
} from './FilterOperatorTypes';
|
||||
|
||||
describe('#filterOperators', () => {
|
||||
it('Matches the correct Operators', () => {
|
||||
expect(OPERATOR_TYPES_1).toMatchObject(OPERATOR_TYPES_1);
|
||||
expect(OPERATOR_TYPES_2).toMatchObject(OPERATOR_TYPES_2);
|
||||
expect(OPERATOR_TYPES_3).toMatchObject(OPERATOR_TYPES_3);
|
||||
expect(OPERATOR_TYPES_4).toMatchObject(OPERATOR_TYPES_4);
|
||||
});
|
||||
});
|
|
@ -141,6 +141,10 @@ export default {
|
|||
type: String,
|
||||
default: 'plain_text',
|
||||
},
|
||||
dataType: {
|
||||
type: String,
|
||||
default: 'plain_text',
|
||||
},
|
||||
operators: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
|
|
|
@ -7,9 +7,12 @@
|
|||
<h3 class="heading">
|
||||
{{ heading }}
|
||||
</h3>
|
||||
<h4 class="metric">
|
||||
{{ point }}
|
||||
</h4>
|
||||
<div class="metric-wrap">
|
||||
<h4 class="metric">
|
||||
{{ point }}
|
||||
</h4>
|
||||
<span v-if="trend !== 0" :class="trendClass">{{ trendValue }}</span>
|
||||
</div>
|
||||
<p class="desc">
|
||||
{{ desc }}
|
||||
</p>
|
||||
|
@ -20,10 +23,27 @@ export default {
|
|||
props: {
|
||||
heading: { type: String, default: '' },
|
||||
point: { type: [Number, String], default: '' },
|
||||
trend: { type: Number, default: null },
|
||||
index: { type: Number, default: null },
|
||||
desc: { type: String, default: '' },
|
||||
selected: Boolean,
|
||||
onClick: { type: Function, default: () => {} },
|
||||
},
|
||||
computed: {
|
||||
trendClass() {
|
||||
if (this.trend > 0) {
|
||||
return 'metric-trend metric-up';
|
||||
}
|
||||
|
||||
return 'metric-trend metric-down';
|
||||
},
|
||||
trendValue() {
|
||||
if (this.trend > 0) {
|
||||
return `+${this.trend}%`;
|
||||
}
|
||||
|
||||
return `${this.trend}%`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,223 @@
|
|||
<template>
|
||||
<div class="audio-wave-wrapper">
|
||||
<div id="audio-wave"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
|
||||
import RecordRTC from 'recordrtc';
|
||||
import inboxMixin from '../../../../shared/mixins/inboxMixin';
|
||||
import alertMixin from '../../../../shared/mixins/alertMixin';
|
||||
|
||||
WaveSurfer.microphone = MicrophonePlugin;
|
||||
|
||||
export default {
|
||||
name: 'WootAudioRecorder',
|
||||
mixins: [inboxMixin, alertMixin],
|
||||
data() {
|
||||
return {
|
||||
wavesurfer: false,
|
||||
recorder: false,
|
||||
recordingInterval: false,
|
||||
recordingDateStarted: new Date().getTime(),
|
||||
timeDuration: '00:00',
|
||||
initialTimeDuration: '00:00',
|
||||
options: {
|
||||
container: '#audio-wave',
|
||||
backend: 'WebAudio',
|
||||
interact: true,
|
||||
cursorWidth: 1,
|
||||
plugins: [
|
||||
WaveSurfer.microphone.create({
|
||||
bufferSize: 4096,
|
||||
numberOfInputChannels: 1,
|
||||
numberOfOutputChannels: 1,
|
||||
constraints: {
|
||||
video: false,
|
||||
audio: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
optionsRecorder: {
|
||||
type: 'audio',
|
||||
mimeType: 'audio/wav',
|
||||
disableLogs: true,
|
||||
recorderType: RecordRTC.StereoAudioRecorder,
|
||||
sampleRate: 44100,
|
||||
numberOfAudioChannels: 2,
|
||||
checkForInactiveTracks: true,
|
||||
bufferSize: 4096,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isRecording() {
|
||||
if (this.recorder) {
|
||||
return this.recorder.getState() === 'recording';
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.wavesurfer = WaveSurfer.create(this.options);
|
||||
this.wavesurfer.on('play', this.playingRecorder);
|
||||
this.wavesurfer.on('pause', this.pausedRecorder);
|
||||
this.wavesurfer.microphone.on('deviceReady', this.startRecording);
|
||||
this.wavesurfer.microphone.on('deviceError', this.deviceError);
|
||||
this.wavesurfer.microphone.start();
|
||||
this.fireStateRecorderTimerChanged(this.initialTimeDuration);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.recorder) {
|
||||
this.recorder.destroy();
|
||||
}
|
||||
if (this.wavesurfer) {
|
||||
this.wavesurfer.destroy();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startRecording(stream) {
|
||||
this.recorder = RecordRTC(stream, this.optionsRecorder);
|
||||
this.recorder.onStateChanged = this.onStateRecorderChanged;
|
||||
this.recorder.startRecording();
|
||||
},
|
||||
stopAudioRecording() {
|
||||
if (this.isRecording) {
|
||||
this.recorder.stopRecording(() => {
|
||||
this.wavesurfer.microphone.stopDevice();
|
||||
this.wavesurfer.loadBlob(this.recorder.getBlob());
|
||||
this.wavesurfer.stop();
|
||||
this.fireRecorderBlob(this.getAudioFile());
|
||||
});
|
||||
}
|
||||
},
|
||||
getAudioFile() {
|
||||
if (this.hasAudio()) {
|
||||
return new File([this.recorder.getBlob()], this.getAudioFileName(), {
|
||||
type: 'audio/wav',
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
hasAudio() {
|
||||
return !(this.isRecording || this.wavesurfer.isPlaying());
|
||||
},
|
||||
playingRecorder() {
|
||||
this.fireStateRecorderChanged('playing');
|
||||
},
|
||||
pausedRecorder() {
|
||||
this.fireStateRecorderChanged('paused');
|
||||
},
|
||||
deviceError(err) {
|
||||
if (
|
||||
err?.name &&
|
||||
(err.name.toLowerCase().includes('notallowederror') ||
|
||||
err.name.toLowerCase().includes('permissiondeniederror'))
|
||||
) {
|
||||
this.showAlert(
|
||||
this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_PERMISSION')
|
||||
);
|
||||
this.fireStateRecorderChanged('notallowederror');
|
||||
} else {
|
||||
this.showAlert(
|
||||
this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ERROR')
|
||||
);
|
||||
}
|
||||
},
|
||||
onStateRecorderChanged(state) {
|
||||
// recording stopped inactive destroyed
|
||||
switch (state) {
|
||||
case 'recording':
|
||||
this.timerDurationChanged();
|
||||
break;
|
||||
case 'stopped':
|
||||
this.timerDurationChanged();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this.fireStateRecorderChanged(state);
|
||||
},
|
||||
timerDurationChanged() {
|
||||
if (this.isRecording) {
|
||||
this.recordingInterval = setInterval(() => {
|
||||
this.calculateTimeDuration(
|
||||
(new Date().getTime() - this.recordingDateStarted) / 1000
|
||||
);
|
||||
this.fireStateRecorderTimerChanged(this.timeDuration);
|
||||
}, 1000);
|
||||
} else {
|
||||
clearInterval(this.recordingInterval);
|
||||
}
|
||||
},
|
||||
calculateTimeDuration(secs) {
|
||||
let hr = Math.floor(secs / 3600);
|
||||
let min = Math.floor((secs - hr * 3600) / 60);
|
||||
let sec = Math.floor(secs - hr * 3600 - min * 60);
|
||||
if (min < 10) {
|
||||
min = '0' + min;
|
||||
}
|
||||
if (sec < 10) {
|
||||
sec = '0' + sec;
|
||||
}
|
||||
if (hr <= 0) {
|
||||
this.timeDuration = min + ':' + sec;
|
||||
} else {
|
||||
if (hr < 10) {
|
||||
hr = '0' + hr;
|
||||
}
|
||||
this.timeDuration = hr + ':' + min + ':' + sec;
|
||||
}
|
||||
},
|
||||
playPause() {
|
||||
this.wavesurfer.playPause();
|
||||
},
|
||||
fireRecorderBlob(blob) {
|
||||
this.$emit('recorder-blob', {
|
||||
name: blob.name,
|
||||
type: blob.type,
|
||||
size: blob.size,
|
||||
file: blob,
|
||||
});
|
||||
},
|
||||
fireStateRecorderChanged(state) {
|
||||
this.$emit('state-recorder-changed', state);
|
||||
},
|
||||
fireStateRecorderTimerChanged(duration) {
|
||||
this.$emit('state-recorder-timer-changed', duration);
|
||||
},
|
||||
getAudioFileName() {
|
||||
const d = new Date();
|
||||
return `audio-${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${this.getRandomString()}.wav`;
|
||||
},
|
||||
getRandomString() {
|
||||
if (
|
||||
window.crypto &&
|
||||
window.crypto.getRandomValues &&
|
||||
navigator.userAgent.indexOf('Safari') === -1
|
||||
) {
|
||||
let a = window.crypto.getRandomValues(new Uint32Array(3));
|
||||
let token = '';
|
||||
for (let i = 0, l = a.length; i < l; i += 1) {
|
||||
token += a[i].toString(36);
|
||||
}
|
||||
return token.toLowerCase();
|
||||
}
|
||||
return (Math.random() * new Date().getTime())
|
||||
.toString(36)
|
||||
.replace(/\./g, '');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.audio-wave-wrapper {
|
||||
min-height: 8rem;
|
||||
max-height: 12rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
|
@ -11,7 +11,6 @@
|
|||
size="small"
|
||||
@click="toggleEmojiPicker"
|
||||
/>
|
||||
|
||||
<!-- ensure the same validations for attachment types are implemented in backend models as well -->
|
||||
<file-upload
|
||||
ref="upload"
|
||||
|
@ -49,6 +48,27 @@
|
|||
:title="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||
@click="toggleFormatMode"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="showAudioRecorderButton"
|
||||
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
|
||||
emoji="🎤"
|
||||
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||
@click="toggleAudioRecorder"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="showAudioPlayStopButton"
|
||||
:icon="audioRecorderPlayStopIcon"
|
||||
emoji="🎤"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
@click="toggleAudioRecorderPlayPause"
|
||||
>
|
||||
<span>{{ recordingAudioDurationText }}</span>
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-if="showMessageSignatureButton"
|
||||
v-tooltip.top-end="signatureToggleTooltip"
|
||||
|
@ -126,6 +146,10 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
recordingAudioDurationText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
|
@ -134,6 +158,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showAudioRecorder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onFileUpload: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
|
@ -146,6 +174,22 @@ export default {
|
|||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
toggleAudioRecorder: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
toggleAudioRecorderPlayPause: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
isRecordingAudio: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recordingAudioState: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isSendDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
@ -192,9 +236,28 @@ export default {
|
|||
showAttachButton() {
|
||||
return this.showFileUpload || this.isNote;
|
||||
},
|
||||
showAudioRecorderButton() {
|
||||
return this.showAudioRecorder;
|
||||
},
|
||||
showAudioPlayStopButton() {
|
||||
return this.showAudioRecorder && this.isRecordingAudio;
|
||||
},
|
||||
allowedFileTypes() {
|
||||
return ALLOWED_FILE_TYPES;
|
||||
},
|
||||
audioRecorderPlayStopIcon() {
|
||||
switch (this.recordingAudioState) {
|
||||
// playing paused recording stopped inactive destroyed
|
||||
case 'playing':
|
||||
return 'microphone-pause';
|
||||
case 'paused':
|
||||
return 'microphone-play';
|
||||
case 'stopped':
|
||||
return 'microphone-play';
|
||||
default:
|
||||
return 'microphone-stop';
|
||||
}
|
||||
},
|
||||
showMessageSignatureButton() {
|
||||
return !this.isPrivate && this.isAnEmailChannel;
|
||||
},
|
||||
|
|
|
@ -10,7 +10,12 @@
|
|||
:key="i"
|
||||
v-model="appliedFilters[i]"
|
||||
:filter-groups="filterGroups"
|
||||
:input-type="getInputType(appliedFilters[i].attribute_key)"
|
||||
:input-type="
|
||||
getInputType(
|
||||
appliedFilters[i].attribute_key,
|
||||
appliedFilters[i].filter_operator
|
||||
)
|
||||
"
|
||||
:operators="getOperators(appliedFilters[i].attribute_key)"
|
||||
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
|
||||
:show-query-operator="i !== appliedFilters.length - 1"
|
||||
|
@ -56,6 +61,7 @@ import { mapGetters } from 'vuex';
|
|||
import { filterAttributeGroups } from './advancedFilterItems';
|
||||
import filterMixin from 'shared/mixins/filterMixin';
|
||||
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterInputBox,
|
||||
|
@ -76,6 +82,12 @@ export default {
|
|||
required,
|
||||
$each: {
|
||||
values: {
|
||||
ensureBetween0to999(value, prop) {
|
||||
if (prop.filter_operator === 'days_before') {
|
||||
return parseInt(value, 10) > 0 && parseInt(value, 10) < 999;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
required: requiredIf(prop => {
|
||||
return !(
|
||||
prop.filter_operator === 'is_present' ||
|
||||
|
@ -141,6 +153,12 @@ export default {
|
|||
switch (key) {
|
||||
case 'date':
|
||||
return 'date';
|
||||
case 'text':
|
||||
return 'plain_text';
|
||||
case 'list':
|
||||
return 'search_select';
|
||||
case 'checkbox':
|
||||
return 'search_select';
|
||||
default:
|
||||
return 'plain_text';
|
||||
}
|
||||
|
@ -149,7 +167,9 @@ export default {
|
|||
const type = this.filterTypes.find(filter => filter.attributeKey === key);
|
||||
return type.attributeModel;
|
||||
},
|
||||
getInputType(key) {
|
||||
getInputType(key, operator) {
|
||||
if (key === 'created_at' || key === 'last_activity_at')
|
||||
if (operator === 'days_before') return 'plain_text';
|
||||
const type = this.filterTypes.find(filter => filter.attributeKey === key);
|
||||
return type.inputType;
|
||||
},
|
||||
|
@ -159,6 +179,47 @@ export default {
|
|||
},
|
||||
getDropdownValues(type) {
|
||||
const statusFilters = this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
|
||||
const allCustomAttributes = this.$store.getters[
|
||||
'attributes/getAttributesByModel'
|
||||
](this.attributeModel);
|
||||
|
||||
const isCustomAttributeCheckbox = allCustomAttributes.find(attr => {
|
||||
return (
|
||||
attr.attribute_key === type &&
|
||||
attr.attribute_display_type === 'checkbox'
|
||||
);
|
||||
});
|
||||
|
||||
if (isCustomAttributeCheckbox) {
|
||||
return [
|
||||
{
|
||||
id: true,
|
||||
name: this.$t('FILTER.ATTRIBUTE_LABELS.TRUE'),
|
||||
},
|
||||
{
|
||||
id: false,
|
||||
name: this.$t('FILTER.ATTRIBUTE_LABELS.FALSE'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const isCustomAttributeList = allCustomAttributes.find(attr => {
|
||||
return (
|
||||
attr.attribute_key === type && attr.attribute_display_type === 'list'
|
||||
);
|
||||
});
|
||||
|
||||
if (isCustomAttributeList) {
|
||||
return allCustomAttributes
|
||||
.find(attr => attr.attribute_key === type)
|
||||
.attribute_values.map(item => {
|
||||
return {
|
||||
id: item,
|
||||
name: item,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'status':
|
||||
return [
|
||||
|
|
|
@ -100,7 +100,8 @@ export default {
|
|||
display: flex;
|
||||
background: var(--color-background-light);
|
||||
margin: 0;
|
||||
height: calc(100vh - var(--space-jumbo));
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.conversation-sidebar-wrap {
|
||||
|
|
|
@ -50,7 +50,10 @@
|
|||
<bubble-actions
|
||||
:id="data.id"
|
||||
:sender="data.sender"
|
||||
:story-sender="storySender"
|
||||
:story-id="storyId"
|
||||
:is-a-tweet="isATweet"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:is-email="isEmailContentType"
|
||||
:is-private="data.private"
|
||||
:message-type="data.message_type"
|
||||
|
@ -146,6 +149,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasInstagramStory: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -209,6 +216,12 @@ export default {
|
|||
sender() {
|
||||
return this.data.sender || {};
|
||||
},
|
||||
storySender() {
|
||||
return this.contentAttributes.story_sender || null;
|
||||
},
|
||||
storyId() {
|
||||
return this.contentAttributes.story_id || null;
|
||||
},
|
||||
contentType() {
|
||||
const {
|
||||
data: { content_type: contentType },
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
class="message--read"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
/>
|
||||
<li v-show="getUnreadCount != 0" class="unread--toast">
|
||||
<span class="text-uppercase">
|
||||
|
@ -64,6 +65,7 @@
|
|||
class="message--unread"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
|
@ -215,6 +217,10 @@ export default {
|
|||
return this.conversationType === 'tweet';
|
||||
},
|
||||
|
||||
hasInstagramStory() {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
|
||||
selectedTweet() {
|
||||
if (this.selectedTweetId) {
|
||||
const { messages = [] } = this.getMessages;
|
||||
|
|
|
@ -33,8 +33,15 @@
|
|||
:cc-emails.sync="ccEmails"
|
||||
:bcc-emails.sync="bccEmails"
|
||||
/>
|
||||
<woot-audio-recorder
|
||||
v-if="showAudioRecorderEditor"
|
||||
ref="audioRecorderInput"
|
||||
@state-recorder-timer-changed="onStateRecorderTimerChanged"
|
||||
@state-recorder-changed="onStateRecorderChanged"
|
||||
@recorder-blob="onRecorderBlob"
|
||||
/>
|
||||
<resizable-text-area
|
||||
v-if="!showRichContentEditor"
|
||||
v-else-if="!showRichContentEditor"
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="input"
|
||||
|
@ -89,10 +96,16 @@
|
|||
:send-button-text="replyButtonLabel"
|
||||
:on-file-upload="onFileUpload"
|
||||
:show-file-upload="showFileUpload"
|
||||
:show-audio-recorder="showAudioRecorder"
|
||||
:toggle-emoji-picker="toggleEmojiPicker"
|
||||
:toggle-audio-recorder="toggleAudioRecorder"
|
||||
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:on-send="sendMessage"
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:recording-audio-duration-text="recordingAudioDuration"
|
||||
:recording-audio-state="recordingAudioState"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
:set-format-mode="setFormatMode"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-format-mode="showRichContentEditor"
|
||||
|
@ -119,6 +132,7 @@ import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBotto
|
|||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
|
||||
|
@ -146,6 +160,7 @@ export default {
|
|||
ReplyEmailHead,
|
||||
ReplyBottomPanel,
|
||||
WootMessageEditor,
|
||||
WootAudioRecorder,
|
||||
Banner,
|
||||
},
|
||||
mixins: [
|
||||
|
@ -176,6 +191,9 @@ export default {
|
|||
showEmojiPicker: false,
|
||||
showMentions: false,
|
||||
attachedFiles: [],
|
||||
isRecordingAudio: false,
|
||||
recordingAudioState: '',
|
||||
recordingAudioDuration: '',
|
||||
isUploading: false,
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
mentionSearchKey: '',
|
||||
|
@ -190,6 +208,7 @@ export default {
|
|||
currentChat: 'getSelectedChat',
|
||||
messageSignature: 'getMessageSignature',
|
||||
currentUser: 'getCurrentUser',
|
||||
lastEmail: 'getLastEmailInSelectedChat',
|
||||
globalConfig: 'globalConfig/get',
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
|
@ -269,7 +288,7 @@ export default {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (this.hasAttachments) return false;
|
||||
if (this.hasAttachments || this.hasRecordedAudio) return false;
|
||||
|
||||
return (
|
||||
this.isMessageEmpty ||
|
||||
|
@ -292,7 +311,7 @@ export default {
|
|||
if (this.isAWhatsappChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
||||
}
|
||||
if (this.isATwilioSMSChannel) {
|
||||
if (this.isASmsInbox) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
||||
}
|
||||
if (this.isATwitterInbox) {
|
||||
|
@ -309,7 +328,7 @@ export default {
|
|||
this.isAWhatsappChannel ||
|
||||
this.isAPIInbox ||
|
||||
this.isAnEmailChannel ||
|
||||
this.isATwilioSMSChannel ||
|
||||
this.isASmsInbox ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
|
@ -331,9 +350,21 @@ export default {
|
|||
hasAttachments() {
|
||||
return this.attachedFiles.length;
|
||||
},
|
||||
hasRecordedAudio() {
|
||||
return (
|
||||
this.$refs.audioRecorderInput &&
|
||||
this.$refs.audioRecorderInput.hasAudio()
|
||||
);
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel;
|
||||
},
|
||||
showAudioRecorder() {
|
||||
return !this.isOnPrivateNote && this.showFileUpload;
|
||||
},
|
||||
showAudioRecorderEditor() {
|
||||
return this.showAudioRecorder && this.isRecordingAudio;
|
||||
},
|
||||
isOnPrivateNote() {
|
||||
return this.replyType === REPLY_EDITOR_MODES.NOTE;
|
||||
},
|
||||
|
@ -388,6 +419,8 @@ export default {
|
|||
} else {
|
||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||
}
|
||||
|
||||
this.setCCEmailFromLastChat();
|
||||
},
|
||||
message(updatedMessage) {
|
||||
this.hasSlashCommand =
|
||||
|
@ -409,6 +442,8 @@ export default {
|
|||
// working even if input/textarea is focussed.
|
||||
document.addEventListener('keydown', this.handleKeyEvents);
|
||||
document.addEventListener('paste', this.onPaste);
|
||||
|
||||
this.setCCEmailFromLastChat();
|
||||
},
|
||||
destroyed() {
|
||||
document.removeEventListener('keydown', this.handleKeyEvents);
|
||||
|
@ -518,6 +553,9 @@ export default {
|
|||
|
||||
if (canReply || this.isAWhatsappChannel) this.replyType = mode;
|
||||
if (this.showRichContentEditor) {
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.$nextTick(() => this.$refs.messageInput.focus());
|
||||
|
@ -530,10 +568,26 @@ export default {
|
|||
this.attachedFiles = [];
|
||||
this.ccEmails = '';
|
||||
this.bccEmails = '';
|
||||
this.isRecordingAudio = false;
|
||||
},
|
||||
toggleEmojiPicker() {
|
||||
this.showEmojiPicker = !this.showEmojiPicker;
|
||||
},
|
||||
toggleAudioRecorder() {
|
||||
this.isRecordingAudio = !this.isRecordingAudio;
|
||||
this.isRecorderAudioStopped = !this.isRecordingAudio;
|
||||
if (!this.isRecordingAudio) {
|
||||
this.clearMessage();
|
||||
}
|
||||
},
|
||||
toggleAudioRecorderPlayPause() {
|
||||
if (this.isRecordingAudio && !this.isRecorderAudioStopped) {
|
||||
this.isRecorderAudioStopped = true;
|
||||
this.$refs.audioRecorderInput.stopAudioRecording();
|
||||
} else if (this.isRecordingAudio && this.isRecorderAudioStopped) {
|
||||
this.$refs.audioRecorderInput.playPause();
|
||||
}
|
||||
},
|
||||
hideEmojiPicker() {
|
||||
if (this.showEmojiPicker) {
|
||||
this.toggleEmojiPicker();
|
||||
|
@ -554,6 +608,20 @@ export default {
|
|||
onFocus() {
|
||||
this.isFocused = true;
|
||||
},
|
||||
onStateRecorderTimerChanged(time) {
|
||||
this.recordingAudioDuration = time;
|
||||
},
|
||||
onStateRecorderChanged(state) {
|
||||
this.recordingAudioState = state;
|
||||
if (state.includes('notallowederror')) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
},
|
||||
onRecorderBlob(file) {
|
||||
if (file) {
|
||||
this.onFileUpload(file);
|
||||
}
|
||||
},
|
||||
toggleTyping(status) {
|
||||
const conversationId = this.currentChat.id;
|
||||
const isPrivate = this.isPrivate;
|
||||
|
@ -650,11 +718,11 @@ export default {
|
|||
});
|
||||
}
|
||||
|
||||
if (this.ccEmails) {
|
||||
if (this.ccEmails && !this.isOnPrivateNote) {
|
||||
messagePayload.ccEmails = this.ccEmails;
|
||||
}
|
||||
|
||||
if (this.bccEmails) {
|
||||
if (this.bccEmails && !this.isOnPrivateNote) {
|
||||
messagePayload.bccEmails = this.bccEmails;
|
||||
}
|
||||
|
||||
|
@ -667,6 +735,17 @@ export default {
|
|||
this.bccEmails = value.bccEmails;
|
||||
this.ccEmails = value.ccEmails;
|
||||
},
|
||||
setCCEmailFromLastChat() {
|
||||
if (this.lastEmail) {
|
||||
const {
|
||||
content_attributes: { email: emailAttributes = {} },
|
||||
} = this.lastEmail;
|
||||
const cc = emailAttributes.cc || [];
|
||||
const bcc = emailAttributes.bcc || [];
|
||||
this.ccEmails = cc.join(', ');
|
||||
this.bccEmails = bcc.join(', ');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -684,6 +763,8 @@ export default {
|
|||
justify-content: space-between;
|
||||
border: 1px dashed var(--s-100);
|
||||
border-radius: var(--border-radius-small);
|
||||
max-height: 8vh;
|
||||
overflow: auto;
|
||||
|
||||
&:hover {
|
||||
background: var(--s-25);
|
||||
|
|
|
@ -83,6 +83,10 @@ export default {
|
|||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.ccEmailsVal = this.ccEmails;
|
||||
this.bccEmailsVal = this.bccEmails;
|
||||
},
|
||||
validations: {
|
||||
ccEmailsVal: {
|
||||
hasValidEmails(value) {
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import defaultFilters from './index';
|
||||
import { filterAttributeGroups } from './index';
|
||||
|
||||
describe('#filterItems', () => {
|
||||
it('Matches the correct filterItems', () => {
|
||||
expect(defaultFilters).toMatchObject(defaultFilters);
|
||||
expect(filterAttributeGroups).toMatchObject(filterAttributeGroups);
|
||||
});
|
||||
});
|
|
@ -2,6 +2,7 @@ import {
|
|||
OPERATOR_TYPES_1,
|
||||
OPERATOR_TYPES_2,
|
||||
OPERATOR_TYPES_3,
|
||||
OPERATOR_TYPES_5,
|
||||
} from '../../FilterInput/FilterOperatorTypes';
|
||||
|
||||
const filterTypes = [
|
||||
|
@ -85,6 +86,30 @@ const filterTypes = [
|
|||
filterOperators: OPERATOR_TYPES_3,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
attributeI18nKey: 'CREATED_AT',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity_at',
|
||||
attributeI18nKey: 'LAST_ACTIVITY',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
attributeI18nKey: 'REFERER_LINK',
|
||||
inputType: 'plain_text',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
];
|
||||
|
||||
export const filterAttributeGroups = [
|
||||
|
@ -120,6 +145,14 @@ export const filterAttributeGroups = [
|
|||
key: 'labels',
|
||||
i18nKey: 'LABELS',
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
i18nKey: 'CREATED_AT',
|
||||
},
|
||||
{
|
||||
key: 'last_activity_at',
|
||||
i18nKey: 'LAST_ACTIVITY',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -35,6 +35,19 @@
|
|||
size="16"
|
||||
/>
|
||||
</button>
|
||||
<a
|
||||
v-if="hasInstagramStory && (isIncoming || isOutgoing) && linkToStory"
|
||||
:href="linkToStory"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.LINK_TO_STORY')"
|
||||
icon="open"
|
||||
class="action--icon cursor-pointer"
|
||||
size="16"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
|
||||
:href="linkToTweet"
|
||||
|
@ -67,6 +80,14 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
storySender: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
storyId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isEmail: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
|
@ -79,6 +100,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasInstagramStory: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
messageType: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
|
@ -119,6 +144,13 @@ export default {
|
|||
return `https://twitter.com/${screenName ||
|
||||
this.inbox.name}/status/${sourceId}`;
|
||||
},
|
||||
linkToStory() {
|
||||
if (!this.storyId || !this.storySender) {
|
||||
return '';
|
||||
}
|
||||
const { storySender, storyId } = this;
|
||||
return `https://www.instagram.com/stories/${storySender}/${storyId}`;
|
||||
},
|
||||
showSentIndicator() {
|
||||
return this.isOutgoing && this.sourceId && this.isAnEmailChannel;
|
||||
},
|
||||
|
|
|
@ -70,7 +70,7 @@ export default {
|
|||
return this.emailAttributes.subject || '';
|
||||
},
|
||||
showHead() {
|
||||
return this.toMails || this.ccMails || this.bccMails;
|
||||
return this.toMails || this.ccMails || this.bccMails || this.fromMail;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<modal :show.sync="show" :on-close="cancel">
|
||||
<div class="column content-box">
|
||||
<woot-modal-header :header-title="title"> </woot-modal-header>
|
||||
<div class="row modal-content">
|
||||
<div class="medium-12 columns">
|
||||
<p>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="medium-12 columns">
|
||||
<woot-button @click="confirm">
|
||||
{{ confirmLabel }}
|
||||
</woot-button>
|
||||
<button class="button clear" @click="cancel">
|
||||
{{ cancelLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
<script>
|
||||
import Modal from '../../Modal';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: 'This is a title',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'This is your description',
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: 'Yes',
|
||||
},
|
||||
cancelLabel: {
|
||||
type: String,
|
||||
default: 'No',
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
show: false,
|
||||
resolvePromise: undefined,
|
||||
rejectPromise: undefined,
|
||||
}),
|
||||
|
||||
methods: {
|
||||
showConfirmation() {
|
||||
this.show = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.resolvePromise = resolve;
|
||||
this.rejectPromise = reject;
|
||||
});
|
||||
},
|
||||
confirm() {
|
||||
this.resolvePromise(true);
|
||||
this.show = false;
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.resolvePromise(false);
|
||||
this.show = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -1,10 +1,22 @@
|
|||
import queryString from 'query-string';
|
||||
import { DEFAULT_REDIRECT_URL } from '../constants';
|
||||
|
||||
export const frontendURL = (path, params) => {
|
||||
const stringifiedParams = params ? `?${queryString.stringify(params)}` : '';
|
||||
return `/app/${path}${stringifiedParams}`;
|
||||
};
|
||||
|
||||
export const getLoginRedirectURL = (ssoAccountId, user) => {
|
||||
const { accounts = [] } = user || {};
|
||||
const ssoAccount = accounts.find(
|
||||
account => account.id === Number(ssoAccountId)
|
||||
);
|
||||
if (ssoAccount) {
|
||||
return frontendURL(`accounts/${ssoAccountId}/dashboard`);
|
||||
}
|
||||
return DEFAULT_REDIRECT_URL;
|
||||
};
|
||||
|
||||
export const conversationUrl = ({
|
||||
accountId,
|
||||
activeInbox,
|
||||
|
|
17
app/javascript/dashboard/helper/localStorage.js
Normal file
17
app/javascript/dashboard/helper/localStorage.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
class LocalStorage {
|
||||
constructor(key) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
store(allItems) {
|
||||
localStorage.setItem(this.key, JSON.stringify(allItems));
|
||||
localStorage.setItem(this.key + ':ts', Date.now());
|
||||
}
|
||||
|
||||
get() {
|
||||
let stored = localStorage.getItem(this.key);
|
||||
return JSON.parse(stored) || [];
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalStorage;
|
|
@ -3,6 +3,7 @@ import {
|
|||
conversationUrl,
|
||||
accountIdFromPathname,
|
||||
isValidURL,
|
||||
getLoginRedirectURL,
|
||||
} from '../URLHelper';
|
||||
|
||||
describe('#URL Helpers', () => {
|
||||
|
@ -58,4 +59,24 @@ describe('#URL Helpers', () => {
|
|||
expect(isValidURL('alert.window')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLoginRedirectURL', () => {
|
||||
it('should return correct Account URL if account id is present', () => {
|
||||
expect(
|
||||
getLoginRedirectURL('7500', {
|
||||
accounts: [{ id: 7500, name: 'Test Account 7500' }],
|
||||
})
|
||||
).toBe('/app/accounts/7500/dashboard');
|
||||
});
|
||||
|
||||
it('should return default URL if account id is not present', () => {
|
||||
expect(getLoginRedirectURL('7500', {})).toBe('/app/');
|
||||
expect(
|
||||
getLoginRedirectURL('7500', {
|
||||
accounts: [{ id: '7501', name: 'Test Account 7501' }],
|
||||
})
|
||||
).toBe('/app/');
|
||||
expect(getLoginRedirectURL('7500', null)).toBe('/app/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,7 +21,12 @@
|
|||
"is_present": "Is present",
|
||||
"is_not_present": "Is not present",
|
||||
"is_greater_than": "Is greater than",
|
||||
"is_lesser_than": "Is lesser than"
|
||||
"is_less_than": "Is lesser than",
|
||||
"days_before": "Is x days before"
|
||||
},
|
||||
"ATTRIBUTE_LABELS": {
|
||||
"TRUE": "True",
|
||||
"FALSE": "False"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"STATUS": "Status",
|
||||
|
@ -38,7 +43,9 @@
|
|||
"CUSTOM_ATTRIBUTE_TEXT": "Text",
|
||||
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
|
||||
"CUSTOM_ATTRIBUTE_LINK": "Link",
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox"
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
|
||||
"CREATED_AT": "Created At",
|
||||
"LAST_ACTIVITY": "Last Activity"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard Filters",
|
||||
|
|
|
@ -90,6 +90,18 @@
|
|||
},
|
||||
"ACTION": {
|
||||
"DELETE_MESSAGE": "You need to have atleast one action to save"
|
||||
},
|
||||
"TOGGLE": {
|
||||
"ACTIVATION_TITLE": "Activate Automation Rule",
|
||||
"DEACTIVATION_TITLE": "Deactivate Automation Rule",
|
||||
"ACTIVATION_DESCRIPTION": "This action will activate the automation rule '{automationName}'. Are you sure you want to proceed?",
|
||||
"DEACTIVATION_DESCRIPTION": "This action will deactivate the automation rule '{automationName}'. Are you sure you want to proceed?",
|
||||
"ACTIVATION_SUCCESFUL": "Automation Rule Activated Successfully",
|
||||
"DEACTIVATION_SUCCESFUL": "Automation Rule Deactivated Successfully",
|
||||
"ACTIVATION_ERROR": "Could not Activate Automation, Please try again later",
|
||||
"DEACTIVATION_ERROR": "Could not Deactivate Automation, Please try again later",
|
||||
"CONFIRMATION_LABEL": "Yes",
|
||||
"CANCEL_LABEL": "No"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,6 +76,7 @@
|
|||
"RECEIVED_VIA_EMAIL": "Received via email",
|
||||
"VIEW_TWEET_IN_TWITTER": "View tweet in Twitter",
|
||||
"REPLY_TO_TWEET": "Reply to this tweet",
|
||||
"LINK_TO_STORY": "Go to instagram story",
|
||||
"SENT": "Sent successfully",
|
||||
"NO_MESSAGES": "No Messages",
|
||||
"NO_CONTENT": "No content available",
|
||||
|
|
|
@ -77,9 +77,8 @@
|
|||
"CONFIRM": {
|
||||
"TITLE": "Confirm Deletion",
|
||||
"MESSAGE": "Are you sure to delete ",
|
||||
"PLACE_HOLDER": "Please type {contactName} to confirm",
|
||||
"YES": "Yes, Delete ",
|
||||
"NO": "No, Keep "
|
||||
"YES": "Yes, Delete",
|
||||
"NO": "No, Keep"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Contact deleted successfully",
|
||||
|
|
|
@ -22,7 +22,8 @@
|
|||
"is_present": "Is present",
|
||||
"is_not_present": "Is not present",
|
||||
"is_greater_than": "Is greater than",
|
||||
"is_lesser_than": "Is lesser than"
|
||||
"is_lesser_than": "Is lesser than",
|
||||
"days_before": "Is x days before"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"NAME": "Name",
|
||||
|
@ -35,7 +36,9 @@
|
|||
"CUSTOM_ATTRIBUTE_TEXT": "Text",
|
||||
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
|
||||
"CUSTOM_ATTRIBUTE_LINK": "Link",
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox"
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
|
||||
"CREATED_AT": "Created At",
|
||||
"LAST_ACTIVITY": "Last Activity"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard Filters",
|
||||
|
|
|
@ -74,8 +74,14 @@
|
|||
"TIP_FORMAT_ICON": "Show rich text editor",
|
||||
"TIP_EMOJI_ICON": "Show emoji selector",
|
||||
"TIP_ATTACH_ICON": "Attach files",
|
||||
"TIP_AUDIORECORDER_ICON": "Record audio",
|
||||
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
|
||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||
"ENTER_TO_SEND": "Enter to send",
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"": "",
|
||||
"EMAIL_HEAD": {
|
||||
"ADD_BCC": "Add bcc",
|
||||
"CC": {
|
||||
|
|
|
@ -47,7 +47,8 @@
|
|||
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
|
||||
}
|
||||
},
|
||||
"UPDATE_CHATWOOT": "An update %{latestChatwootVersion} for Chatwoot is available. Please update your instance."
|
||||
"UPDATE_CHATWOOT": "An update %{latestChatwootVersion} for Chatwoot is available. Please update your instance.",
|
||||
"LEARN_MORE":"Learn more"
|
||||
},
|
||||
"FORMS": {
|
||||
"MULTISELECT": {
|
||||
|
|
|
@ -333,6 +333,11 @@
|
|||
"CSAT_REPORTS": {
|
||||
"HEADER": "CSAT Reports",
|
||||
"NO_RECORDS": "There are no CSAT survey responses available.",
|
||||
"FILTERS": {
|
||||
"AGENTS": {
|
||||
"PLACEHOLDER": "Choose Agents"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"HEADER": {
|
||||
"CONTACT_NAME": "Contact",
|
||||
|
|
|
@ -178,7 +178,8 @@
|
|||
"REPORTS_LABEL": "Labels",
|
||||
"REPORTS_INBOX": "Inbox",
|
||||
"REPORTS_TEAM": "Team",
|
||||
"SET_AVAILABILITY_TITLE": "Set yourself as"
|
||||
"SET_AVAILABILITY_TITLE": "Set yourself as",
|
||||
"BETA": "Beta"
|
||||
},
|
||||
"CREATE_ACCOUNT": {
|
||||
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
|
||||
|
|
33
app/javascript/dashboard/mixins/reportMixin.js
Normal file
33
app/javascript/dashboard/mixins/reportMixin.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { mapGetters } from 'vuex';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountSummary: 'getAccountSummary',
|
||||
}),
|
||||
calculateTrend() {
|
||||
return metric_key => {
|
||||
if (!this.accountSummary.previous[metric_key]) return 0;
|
||||
return Math.round(
|
||||
((this.accountSummary[metric_key] -
|
||||
this.accountSummary.previous[metric_key]) /
|
||||
this.accountSummary.previous[metric_key]) *
|
||||
100
|
||||
);
|
||||
};
|
||||
},
|
||||
displayMetric() {
|
||||
return metric_key => {
|
||||
if (
|
||||
['avg_first_response_time', 'avg_resolution_time'].includes(
|
||||
metric_key
|
||||
)
|
||||
) {
|
||||
return formatTime(this.accountSummary[metric_key]);
|
||||
}
|
||||
return this.accountSummary[metric_key];
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
41
app/javascript/dashboard/mixins/specs/reportMixin.spec.js
Normal file
41
app/javascript/dashboard/mixins/specs/reportMixin.spec.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import reportMixin from '../reportMixin';
|
||||
import reportFixtures from './reportMixinFixtures';
|
||||
import Vuex from 'vuex';
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
describe('reportMixin', () => {
|
||||
let getters;
|
||||
let store;
|
||||
beforeEach(() => {
|
||||
getters = {
|
||||
getAccountSummary: () => reportFixtures.summary,
|
||||
};
|
||||
store = new Vuex.Store({ getters });
|
||||
});
|
||||
|
||||
it('display the metric', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [reportMixin],
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.displayMetric('conversations_count')).toEqual(5);
|
||||
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
|
||||
'3 Min'
|
||||
);
|
||||
});
|
||||
|
||||
it('calculate the trend', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [reportMixin],
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25);
|
||||
expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0);
|
||||
});
|
||||
});
|
18
app/javascript/dashboard/mixins/specs/reportMixinFixtures.js
Normal file
18
app/javascript/dashboard/mixins/specs/reportMixinFixtures.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
export default {
|
||||
summary: {
|
||||
avg_first_response_time: '198.6666666666667',
|
||||
avg_resolution_time: '208.3333333333333',
|
||||
conversations_count: 5,
|
||||
incoming_messages_count: 5,
|
||||
outgoing_messages_count: 3,
|
||||
previous: {
|
||||
avg_first_response_time: '89.0',
|
||||
avg_resolution_time: '145.0',
|
||||
conversations_count: 4,
|
||||
incoming_messages_count: 5,
|
||||
outgoing_messages_count: 4,
|
||||
resolutions_count: 0,
|
||||
},
|
||||
resolutions_count: 3,
|
||||
},
|
||||
};
|
|
@ -1,9 +1,33 @@
|
|||
<template>
|
||||
<div class="row app-wrapper">
|
||||
<sidebar :route="currentRoute" :class="sidebarClassName"></sidebar>
|
||||
<sidebar
|
||||
:route="currentRoute"
|
||||
:class="sidebarClassName"
|
||||
@toggle-account-modal="toggleAccountModal"
|
||||
@open-key-shortcut-modal="toggleKeyShortcutModal"
|
||||
@close-key-shortcut-modal="closeKeyShortcutModal"
|
||||
@show-add-label-popup="showAddLabelPopup"
|
||||
></sidebar>
|
||||
<section class="app-content columns" :class="contentClassName">
|
||||
<router-view></router-view>
|
||||
<command-bar />
|
||||
<account-selector
|
||||
:show-account-modal="showAccountModal"
|
||||
@close-account-modal="toggleAccountModal"
|
||||
@show-create-account-modal="openCreateAccountModal"
|
||||
/>
|
||||
<add-account-modal
|
||||
:show="showCreateAccountModal"
|
||||
@close-account-create-modal="closeCreateAccountModal"
|
||||
/>
|
||||
<woot-key-shortcut-modal
|
||||
v-if="showShortcutModal"
|
||||
@close="closeKeyShortcutModal"
|
||||
@clickaway="closeKeyShortcutModal"
|
||||
/>
|
||||
<woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup">
|
||||
<add-label-modal @close="hideAddLabelPopup" />
|
||||
</woot-modal>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -12,16 +36,28 @@
|
|||
import Sidebar from '../../components/layout/Sidebar';
|
||||
import CommandBar from './commands/commandbar.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal';
|
||||
import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAccountModal';
|
||||
import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector';
|
||||
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Sidebar,
|
||||
CommandBar,
|
||||
WootKeyShortcutModal,
|
||||
AddAccountModal,
|
||||
AccountSelector,
|
||||
AddLabelModal,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isSidebarOpen: false,
|
||||
isOnDesktop: true,
|
||||
showAccountModal: false,
|
||||
showCreateAccountModal: false,
|
||||
showAddLabelModal: false,
|
||||
showShortcutModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -68,6 +104,28 @@ export default {
|
|||
toggleSidebar() {
|
||||
this.isSidebarOpen = !this.isSidebarOpen;
|
||||
},
|
||||
openCreateAccountModal() {
|
||||
this.showAccountModal = false;
|
||||
this.showCreateAccountModal = true;
|
||||
},
|
||||
closeCreateAccountModal() {
|
||||
this.showCreateAccountModal = false;
|
||||
},
|
||||
toggleAccountModal() {
|
||||
this.showAccountModal = !this.showAccountModal;
|
||||
},
|
||||
toggleKeyShortcutModal() {
|
||||
this.showShortcutModal = true;
|
||||
},
|
||||
closeKeyShortcutModal() {
|
||||
this.showShortcutModal = false;
|
||||
},
|
||||
showAddLabelPopup() {
|
||||
this.showAddLabelModal = true;
|
||||
},
|
||||
hideAddLabelPopup() {
|
||||
this.showAddLabelModal = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -11,7 +11,12 @@
|
|||
v-model="appliedFilters[i]"
|
||||
:filter-groups="filterGroups"
|
||||
:grouped-filters="true"
|
||||
:input-type="getInputType(appliedFilters[i].attribute_key)"
|
||||
:input-type="
|
||||
getInputType(
|
||||
appliedFilters[i].attribute_key,
|
||||
appliedFilters[i].filter_operator
|
||||
)
|
||||
"
|
||||
:operators="getOperators(appliedFilters[i].attribute_key)"
|
||||
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
|
||||
:show-query-operator="i !== appliedFilters.length - 1"
|
||||
|
@ -86,6 +91,12 @@ export default {
|
|||
$each: {
|
||||
values: {
|
||||
required,
|
||||
ensureBetween0to999(value, prop) {
|
||||
if (prop.filter_operator === 'days_before') {
|
||||
return parseInt(value, 10) > 0 && parseInt(value, 10) < 999;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -147,6 +158,12 @@ export default {
|
|||
switch (key) {
|
||||
case 'date':
|
||||
return 'date';
|
||||
case 'text':
|
||||
return 'plain_text';
|
||||
case 'list':
|
||||
return 'search_select';
|
||||
case 'checkbox':
|
||||
return 'search_select';
|
||||
default:
|
||||
return 'plain_text';
|
||||
}
|
||||
|
@ -155,7 +172,9 @@ export default {
|
|||
const type = this.filterTypes.find(filter => filter.attributeKey === key);
|
||||
return type.attributeModel;
|
||||
},
|
||||
getInputType(key) {
|
||||
getInputType(key, operator) {
|
||||
if (key === 'created_at' || key === 'last_activity_at')
|
||||
if (operator === 'days_before') return 'plain_text';
|
||||
const type = this.filterTypes.find(filter => filter.attributeKey === key);
|
||||
return type.inputType;
|
||||
},
|
||||
|
@ -164,6 +183,44 @@ export default {
|
|||
return type.filterOperators;
|
||||
},
|
||||
getDropdownValues(type) {
|
||||
const allCustomAttributes = this.$store.getters[
|
||||
'attributes/getAttributesByModel'
|
||||
](this.attributeModel);
|
||||
const isCustomAttributeCheckbox = allCustomAttributes.find(attr => {
|
||||
return (
|
||||
attr.attribute_key === type &&
|
||||
attr.attribute_display_type === 'checkbox'
|
||||
);
|
||||
});
|
||||
if (isCustomAttributeCheckbox) {
|
||||
return [
|
||||
{
|
||||
id: true,
|
||||
name: this.$t('FILTER.ATTRIBUTE_LABELS.TRUE'),
|
||||
},
|
||||
{
|
||||
id: false,
|
||||
name: this.$t('FILTER.ATTRIBUTE_LABELS.FALSE'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const isCustomAttributeList = allCustomAttributes.find(attr => {
|
||||
return (
|
||||
attr.attribute_key === type && attr.attribute_display_type === 'list'
|
||||
);
|
||||
});
|
||||
|
||||
if (isCustomAttributeList) {
|
||||
return allCustomAttributes
|
||||
.find(attr => attr.attribute_key === type)
|
||||
.attribute_values.map(item => {
|
||||
return {
|
||||
id: item,
|
||||
name: item,
|
||||
};
|
||||
});
|
||||
}
|
||||
switch (type) {
|
||||
case 'country_code':
|
||||
return countries;
|
||||
|
|
|
@ -253,7 +253,7 @@ export default {
|
|||
this.$store.dispatch('contacts/get', requestParams);
|
||||
} else {
|
||||
this.$store.dispatch('contacts/search', {
|
||||
search: value,
|
||||
search: encodeURIComponent(value),
|
||||
...requestParams,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
OPERATOR_TYPES_1,
|
||||
OPERATOR_TYPES_3,
|
||||
OPERATOR_TYPES_5,
|
||||
} from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
|
||||
const filterTypes = [
|
||||
{
|
||||
|
@ -51,6 +52,30 @@ const filterTypes = [
|
|||
filterOperators: OPERATOR_TYPES_3,
|
||||
attribute_type: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
attributeI18nKey: 'CREATED_AT',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity_at',
|
||||
attributeI18nKey: 'LAST_ACTIVITY',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
attributeI18nKey: 'REFERER_LINK',
|
||||
inputType: 'plain_text',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
];
|
||||
|
||||
export const filterAttributeGroups = [
|
||||
|
@ -82,6 +107,14 @@ export const filterAttributeGroups = [
|
|||
key: 'city',
|
||||
i18nKey: 'CITY',
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
i18nKey: 'CREATED_AT',
|
||||
},
|
||||
{
|
||||
key: 'last_activity_at',
|
||||
i18nKey: 'LAST_ACTIVITY',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
@ -130,11 +130,13 @@ export default {
|
|||
facebook: '',
|
||||
twitter: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
},
|
||||
socialProfileKeys: [
|
||||
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
|
||||
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
|
||||
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
|
||||
{ key: 'github', prefixURL: 'https://github.com/' },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
@ -183,6 +185,7 @@ export default {
|
|||
twitter: socialProfiles.twitter || twitterScreenName || '',
|
||||
facebook: socialProfiles.facebook || '',
|
||||
linkedin: socialProfiles.linkedin || '',
|
||||
github: socialProfiles.github || '',
|
||||
};
|
||||
},
|
||||
getContactObject() {
|
||||
|
|
|
@ -126,17 +126,15 @@
|
|||
@close="toggleMergeModal"
|
||||
/>
|
||||
</div>
|
||||
<woot-confirm-delete-modal
|
||||
<woot-delete-modal
|
||||
v-if="showDeleteModal"
|
||||
:show.sync="showDeleteModal"
|
||||
:on-close="closeDelete"
|
||||
:on-confirm="confirmDeletion"
|
||||
:title="$t('DELETE_CONTACT.CONFIRM.TITLE')"
|
||||
:message="confirmDeleteMessage"
|
||||
:confirm-text="deleteConfirmText"
|
||||
:reject-text="deleteRejectText"
|
||||
:confirm-value="contact.name"
|
||||
:confirm-place-holder-text="confirmPlaceHolderText"
|
||||
@on-confirm="confirmDeletion"
|
||||
@on-close="closeDelete"
|
||||
:confirm-text="$t('DELETE_CONTACT.CONFIRM.YES')"
|
||||
:reject-text="$t('DELETE_CONTACT.CONFIRM.NO')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -210,8 +208,7 @@ export default {
|
|||
if (!cityAndCountry) {
|
||||
return '';
|
||||
}
|
||||
const countryFlag = countryCode ? flag(countryCode) : '🌎';
|
||||
return `${cityAndCountry} ${countryFlag}`;
|
||||
return this.findCountryFlag(countryCode, cityAndCountry);
|
||||
},
|
||||
socialProfiles() {
|
||||
const {
|
||||
|
@ -222,22 +219,11 @@ export default {
|
|||
return { twitter: twitterScreenName, ...(socialProfiles || {}) };
|
||||
},
|
||||
// Delete Modal
|
||||
deleteConfirmText() {
|
||||
return `${this.$t('DELETE_CONTACT.CONFIRM.YES')} ${this.contact.name}`;
|
||||
},
|
||||
deleteRejectText() {
|
||||
return `${this.$t('DELETE_CONTACT.CONFIRM.NO')} ${this.contact.name}`;
|
||||
},
|
||||
confirmDeleteMessage() {
|
||||
return `${this.$t('DELETE_CONTACT.CONFIRM.MESSAGE')} ${
|
||||
this.contact.name
|
||||
} ?`;
|
||||
},
|
||||
confirmPlaceHolderText() {
|
||||
return `${this.$t('DELETE_CONTACT.CONFIRM.PLACE_HOLDER', {
|
||||
contactName: this.contact.name,
|
||||
})}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleMergeModal() {
|
||||
|
@ -261,6 +247,14 @@ export default {
|
|||
this.showConversationModal = false;
|
||||
this.showEditModal = false;
|
||||
},
|
||||
findCountryFlag(countryCode, cityAndCountry) {
|
||||
try {
|
||||
const countryFlag = countryCode ? flag(countryCode) : '🌎';
|
||||
return `${cityAndCountry} ${countryFlag}`;
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
async deleteContact({ id }) {
|
||||
try {
|
||||
await this.$store.dispatch('contacts/delete', id);
|
||||
|
|
|
@ -34,12 +34,19 @@
|
|||
<td>{{ automation.name }}</td>
|
||||
<td>{{ automation.description }}</td>
|
||||
<td>
|
||||
<fluent-icon
|
||||
v-if="automation.active"
|
||||
icon="checkmark-square"
|
||||
type="solid"
|
||||
/>
|
||||
<fluent-icon v-else icon="square" />
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-button"
|
||||
:class="{ active: automation.active }"
|
||||
role="switch"
|
||||
:aria-checked="automation.active.toString()"
|
||||
@click="toggleAutomation(automation, automation.active)"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="{ active: automation.active }"
|
||||
></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ readableTime(automation.created_on) }}</td>
|
||||
<td class="button-wrapper">
|
||||
|
@ -120,6 +127,11 @@
|
|||
@saveAutomation="submitAutomation"
|
||||
/>
|
||||
</woot-modal>
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="toggleModalTitle"
|
||||
:description="toggleModalDescription"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
@ -142,6 +154,10 @@ export default {
|
|||
showEditPopup: false,
|
||||
showDeleteConfirmationPopup: false,
|
||||
selectedResponse: {},
|
||||
toggleModalTitle: this.$t('AUTOMATION.TOGGLE.ACTIVATION_TITLE'),
|
||||
toggleModalDescription: this.$t(
|
||||
'AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION'
|
||||
),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -235,6 +251,34 @@ export default {
|
|||
this.showAlert(errorMessage);
|
||||
}
|
||||
},
|
||||
async toggleAutomation(automation, status) {
|
||||
try {
|
||||
this.toggleModalTitle = status
|
||||
? this.$t('AUTOMATION.TOGGLE.DEACTIVATION_TITLE')
|
||||
: this.$t('AUTOMATION.TOGGLE.ACTIVATION_TITLE');
|
||||
this.toggleModalDescription = status
|
||||
? this.$t('AUTOMATION.TOGGLE.DEACTIVATION_DESCRIPTION', {
|
||||
automationName: automation.name,
|
||||
})
|
||||
: this.$t('AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION', {
|
||||
automationName: automation.name,
|
||||
});
|
||||
// Check if uses confirms to proceed
|
||||
const ok = await this.$refs.confirmDialog.showConfirmation();
|
||||
if (ok) {
|
||||
await await this.$store.dispatch('automations/update', {
|
||||
id: automation.id,
|
||||
active: !status,
|
||||
});
|
||||
const message = status
|
||||
? this.$t('AUTOMATION.TOGGLE.DEACTIVATION_SUCCESFUL')
|
||||
: this.$t('AUTOMATION.TOGGLE.ACTIVATION_SUCCESFUL');
|
||||
this.showAlert(message);
|
||||
}
|
||||
} catch (error) {
|
||||
this.showAlert(this.$t('AUTOMATION.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
readableTime(date) {
|
||||
return this.messageStamp(new Date(date), 'LLL d, h:mm a');
|
||||
},
|
||||
|
@ -246,4 +290,41 @@ export default {
|
|||
.automation__status-checkbox {
|
||||
margin: 0;
|
||||
}
|
||||
.toggle-button {
|
||||
background-color: var(--s-200);
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
height: 19px;
|
||||
width: 34px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--border-radius-large);
|
||||
cursor: pointer;
|
||||
transition-property: background-color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 200ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-button.active {
|
||||
background-color: var(--w-500);
|
||||
}
|
||||
|
||||
.toggle-button span {
|
||||
--space-one-point-five: 1.5rem;
|
||||
height: var(--space-one-point-five);
|
||||
width: var(--space-one-point-five);
|
||||
display: inline-block;
|
||||
background-color: var(--white);
|
||||
box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
|
||||
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
|
||||
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
|
||||
transform: translate(0, 0);
|
||||
border-radius: 100%;
|
||||
transition-property: transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.toggle-button span.active {
|
||||
transform: translate(var(--space-one-point-five), var(--space-zero));
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -93,21 +93,21 @@ export default {
|
|||
}
|
||||
return color;
|
||||
},
|
||||
addLabel() {
|
||||
this.$store
|
||||
.dispatch('labels/create', {
|
||||
async addLabel() {
|
||||
try {
|
||||
await this.$store.dispatch('labels/create', {
|
||||
color: this.color,
|
||||
description: this.description,
|
||||
title: this.title,
|
||||
show_on_sidebar: this.showOnSidebar,
|
||||
})
|
||||
.then(() => {
|
||||
this.showAlert(this.$t('LABEL_MGMT.ADD.API.SUCCESS_MESSAGE'));
|
||||
this.onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
this.showAlert(this.$t('LABEL_MGMT.ADD.API.ERROR_MESSAGE'));
|
||||
});
|
||||
this.showAlert(this.$t('LABEL_MGMT.ADD.API.SUCCESS_MESSAGE'));
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.message || this.$t('LABEL_MGMT.ADD.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
<template>
|
||||
<div class="column content-box">
|
||||
<report-filter-selector @date-range-change="onDateRangeChange" />
|
||||
<report-filter-selector
|
||||
agents-filter
|
||||
:agents-filter-items-list="agentList"
|
||||
@date-range-change="onDateRangeChange"
|
||||
@agents-filter-change="onAgentsFilterChange"
|
||||
/>
|
||||
<csat-metrics />
|
||||
<csat-table :page-index="pageIndex" @page-change="onPageNumberChange" />
|
||||
</div>
|
||||
|
@ -9,6 +14,7 @@
|
|||
import CsatMetrics from './components/CsatMetrics';
|
||||
import CsatTable from './components/CsatTable';
|
||||
import ReportFilterSelector from './components/FilterSelector';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'CsatResponses',
|
||||
|
@ -18,11 +24,23 @@ export default {
|
|||
ReportFilterSelector,
|
||||
},
|
||||
data() {
|
||||
return { pageIndex: 1, from: 0, to: 0 };
|
||||
return { pageIndex: 1, from: 0, to: 0, user_ids: [] };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
agentList: 'agents/getAgents',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
getAllData() {
|
||||
this.$store.dispatch('csat/getMetrics', { from: this.from, to: this.to });
|
||||
this.$store.dispatch('csat/getMetrics', {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
user_ids: this.user_ids,
|
||||
});
|
||||
this.getResponses();
|
||||
},
|
||||
getResponses() {
|
||||
|
@ -30,6 +48,7 @@ export default {
|
|||
page: this.pageIndex,
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
user_ids: this.user_ids,
|
||||
});
|
||||
},
|
||||
onPageNumberChange(pageIndex) {
|
||||
|
@ -41,6 +60,10 @@ export default {
|
|||
this.to = to;
|
||||
this.getAllData();
|
||||
},
|
||||
onAgentsFilterChange(agents) {
|
||||
this.user_ids = agents.map(el => el.id);
|
||||
this.getAllData();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
</woot-button>
|
||||
|
||||
<report-filter-selector
|
||||
group-by-filter
|
||||
:selected-group-by-filter="selectedGroupByFilter"
|
||||
:filter-items-list="filterItemsList"
|
||||
@date-range-change="onDateRangeChange"
|
||||
|
@ -23,7 +24,8 @@
|
|||
:heading="metric.NAME"
|
||||
:index="index"
|
||||
:on-click="changeSelection"
|
||||
:point="accountSummary[metric.KEY]"
|
||||
:point="displayMetric(metric.KEY)"
|
||||
:trend="calculateTrend(metric.KEY)"
|
||||
:selected="index === currentSelection"
|
||||
/>
|
||||
</div>
|
||||
|
@ -48,6 +50,7 @@ import fromUnixTime from 'date-fns/fromUnixTime';
|
|||
import format from 'date-fns/format';
|
||||
import ReportFilterSelector from './components/FilterSelector';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import reportMixin from '../../../../mixins/reportMixin';
|
||||
|
||||
const REPORTS_KEYS = {
|
||||
CONVERSATIONS: 'conversations_count',
|
||||
|
@ -62,6 +65,7 @@ export default {
|
|||
components: {
|
||||
ReportFilterSelector,
|
||||
},
|
||||
mixins: [reportMixin],
|
||||
data() {
|
||||
return {
|
||||
from: 0,
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
@change="onChange"
|
||||
/>
|
||||
<div
|
||||
v-if="notLast7Days"
|
||||
v-if="notLast7Days && groupByFilter"
|
||||
class="small-12 medium-3 pull-right margin-left-small"
|
||||
>
|
||||
<p aria-hidden="true" class="hide">
|
||||
|
@ -41,6 +41,26 @@
|
|||
@input="changeFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="agentsFilter"
|
||||
class="small-12 medium-3 pull-right margin-left-small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="selectedAgents"
|
||||
:options="agentsFilterItemsList"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:multiple="true"
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
:hide-selected="true"
|
||||
:placeholder="$t('CSAT_REPORTS.FILTERS.AGENTS.PLACEHOLDER')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
@input="handleAgentsFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
@ -61,10 +81,22 @@ export default {
|
|||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
agentsFilterItemsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedGroupByFilter: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
groupByFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
agentsFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -72,6 +104,7 @@ export default {
|
|||
dateRange: this.$t('REPORT.DATE_RANGE'),
|
||||
customDateRange: [new Date(), new Date()],
|
||||
currentSelectedFilter: null,
|
||||
selectedAgents: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -149,6 +182,9 @@ export default {
|
|||
changeFilterSelection() {
|
||||
this.$emit('filter-change', this.currentSelectedFilter);
|
||||
},
|
||||
handleAgentsFilterSelection() {
|
||||
this.$emit('agents-filter-change', this.selectedAgents);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -255,7 +255,6 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
onDateRangeChange() {
|
||||
console.log(this.from, this.to);
|
||||
this.$emit('date-range-change', {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
|
|
|
@ -27,7 +27,8 @@
|
|||
:heading="metric.NAME"
|
||||
:index="index"
|
||||
:on-click="changeSelection"
|
||||
:point="accountSummary[metric.KEY]"
|
||||
:point="displayMetric(metric.KEY)"
|
||||
:trend="calculateTrend(metric.KEY)"
|
||||
:selected="index === currentSelection"
|
||||
/>
|
||||
</div>
|
||||
|
@ -55,6 +56,7 @@ import ReportFilters from './ReportFilters';
|
|||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
import reportMixin from '../../../../../mixins/reportMixin';
|
||||
|
||||
const REPORTS_KEYS = {
|
||||
CONVERSATIONS: 'conversations_count',
|
||||
|
@ -68,6 +70,7 @@ export default {
|
|||
components: {
|
||||
ReportFilters,
|
||||
},
|
||||
mixins: [reportMixin],
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
|
|
|
@ -5,6 +5,7 @@ import login from './login/login.routes';
|
|||
import dashboard from './dashboard/dashboard.routes';
|
||||
import authRoute from './auth/auth.routes';
|
||||
import { frontendURL } from '../helper/URLHelper';
|
||||
import { clearBrowserSessionCookies } from '../store/utils/api';
|
||||
|
||||
const routes = [
|
||||
...login.routes,
|
||||
|
@ -101,6 +102,13 @@ export const validateAuthenticateRoutePermission = (to, from, next) => {
|
|||
return nextRoute ? next(frontendURL(nextRoute)) : next();
|
||||
};
|
||||
|
||||
const validateSSOLoginParams = to => {
|
||||
const isLoginRoute = to.name === 'login';
|
||||
const { email, sso_auth_token: ssoAuthToken } = to.query || {};
|
||||
const hasValidSSOParams = email && ssoAuthToken;
|
||||
return isLoginRoute && hasValidSSOParams;
|
||||
};
|
||||
|
||||
const validateRouteAccess = (to, from, next) => {
|
||||
if (
|
||||
window.chatwootConfig.signupEnabled !== 'true' &&
|
||||
|
@ -111,6 +119,11 @@ const validateRouteAccess = (to, from, next) => {
|
|||
next(frontendURL(`accounts/${user.account_id}/dashboard`));
|
||||
}
|
||||
|
||||
if (validateSSOLoginParams(to)) {
|
||||
clearBrowserSessionCookies();
|
||||
return next();
|
||||
}
|
||||
|
||||
if (authIgnoreRoutes.includes(to.name)) {
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -80,6 +80,7 @@ export default {
|
|||
mixins: [globalConfigMixin],
|
||||
props: {
|
||||
ssoAuthToken: { type: String, default: '' },
|
||||
ssoAccountId: { type: String, default: '' },
|
||||
redirectUrl: { type: String, default: '' },
|
||||
config: { type: String, default: '' },
|
||||
email: { type: String, default: '' },
|
||||
|
@ -138,6 +139,7 @@ export default {
|
|||
: this.credentials.email,
|
||||
password: this.credentials.password,
|
||||
sso_auth_token: this.ssoAuthToken,
|
||||
ssoAccountId: this.ssoAccountId,
|
||||
};
|
||||
this.$store
|
||||
.dispatch('login', credentials)
|
||||
|
|
|
@ -12,6 +12,7 @@ export default {
|
|||
email: route.query.email,
|
||||
ssoAuthToken: route.query.sso_auth_token,
|
||||
redirectUrl: route.query.route_url,
|
||||
ssoAccountId: route.query.sso_account_id,
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
|
|
@ -6,7 +6,7 @@ import authAPI from '../../api/auth';
|
|||
import createAxios from '../../helper/APIHelper';
|
||||
import actionCable from '../../helper/actionCable';
|
||||
import { setUser, getHeaderExpiry, clearCookiesOnLogout } from '../utils/api';
|
||||
import { DEFAULT_REDIRECT_URL } from '../../constants';
|
||||
import { getLoginRedirectURL } from '../../helper/URLHelper';
|
||||
|
||||
const state = {
|
||||
currentUser: {
|
||||
|
@ -88,15 +88,16 @@ export const getters = {
|
|||
|
||||
// actions
|
||||
export const actions = {
|
||||
login({ commit }, credentials) {
|
||||
login({ commit }, { ssoAccountId, ...credentials }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
authAPI
|
||||
.login(credentials)
|
||||
.then(() => {
|
||||
.then(response => {
|
||||
commit(types.default.SET_CURRENT_USER);
|
||||
window.axios = createAxios(axios);
|
||||
actionCable.init(Vue);
|
||||
window.location = DEFAULT_REDIRECT_URL;
|
||||
|
||||
window.location = getLoginRedirectURL(ssoAccountId, response.data);
|
||||
resolve();
|
||||
})
|
||||
.catch(error => {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import authAPI from '../../../api/auth';
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import { applyPageFilters } from './helpers';
|
||||
|
||||
export const getSelectedChatConversation = ({
|
||||
|
@ -19,6 +20,26 @@ const getters = {
|
|||
);
|
||||
return selectedChat || {};
|
||||
},
|
||||
getLastEmailInSelectedChat: (stage, _getters) => {
|
||||
const selectedChat = _getters.getSelectedChat;
|
||||
const { messages = [] } = selectedChat;
|
||||
const lastEmail = [...messages].reverse().find(message => {
|
||||
const {
|
||||
content_attributes: contentAttributes = {},
|
||||
message_type: messageType,
|
||||
} = message;
|
||||
const { email = {} } = contentAttributes;
|
||||
const isIncomingOrOutgoing =
|
||||
messageType === MESSAGE_TYPE.OUTGOING ||
|
||||
messageType === MESSAGE_TYPE.INCOMING;
|
||||
if (email.from && isIncomingOrOutgoing) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return lastEmail;
|
||||
},
|
||||
getMineChats: _state => activeFilters => {
|
||||
const currentUserID = authAPI.getCurrentUser().id;
|
||||
|
||||
|
|
|
@ -82,10 +82,13 @@ export const getters = {
|
|||
};
|
||||
|
||||
export const actions = {
|
||||
get: async function getResponses({ commit }, { page = 1, from, to } = {}) {
|
||||
get: async function getResponses(
|
||||
{ commit },
|
||||
{ page = 1, from, to, user_ids } = {}
|
||||
) {
|
||||
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const response = await CSATReports.get({ page, from, to });
|
||||
const response = await CSATReports.get({ page, from, to, user_ids });
|
||||
commit(types.SET_CSAT_RESPONSE, response.data);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
|
@ -93,10 +96,10 @@ export const actions = {
|
|||
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
getMetrics: async function getMetrics({ commit }, { from, to }) {
|
||||
getMetrics: async function getMetrics({ commit }, { from, to, user_ids }) {
|
||||
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetchingMetrics: true });
|
||||
try {
|
||||
const response = await CSATReports.getMetrics({ from, to });
|
||||
const response = await CSATReports.getMetrics({ from, to, user_ids });
|
||||
commit(types.SET_CSAT_RESPONSE_METRICS, response.data);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
|
|
|
@ -45,7 +45,8 @@ export const actions = {
|
|||
const response = await LabelsAPI.create(cannedObj);
|
||||
commit(types.ADD_LABEL, response.data);
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
const errorMessage = error?.response?.data?.message;
|
||||
throw new Error(errorMessage);
|
||||
} finally {
|
||||
commit(types.SET_LABEL_UI_FLAG, { isCreating: false });
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ const state = {
|
|||
incoming_messages_count: 0,
|
||||
outgoing_messages_count: 0,
|
||||
resolutions_count: 0,
|
||||
previous: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -124,24 +125,6 @@ const mutations = {
|
|||
},
|
||||
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
|
||||
_state.accountSummary = summaryData;
|
||||
// Average First Response Time
|
||||
let avgFirstResTimeInHr = 0;
|
||||
if (summaryData.avg_first_response_time) {
|
||||
avgFirstResTimeInHr = (
|
||||
summaryData.avg_first_response_time / 3600
|
||||
).toFixed(2);
|
||||
avgFirstResTimeInHr = `${avgFirstResTimeInHr} Hr`;
|
||||
}
|
||||
// Average Resolution Time
|
||||
let avgResolutionTimeInHr = 0;
|
||||
if (summaryData.avg_resolution_time) {
|
||||
avgResolutionTimeInHr = (summaryData.avg_resolution_time / 3600).toFixed(
|
||||
2
|
||||
);
|
||||
avgResolutionTimeInHr = `${avgResolutionTimeInHr} Hr`;
|
||||
}
|
||||
_state.accountSummary.avg_first_response_time = avgFirstResTimeInHr;
|
||||
_state.accountSummary.avg_resolution_time = avgResolutionTimeInHr;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -131,4 +131,34 @@ describe('#getters', () => {
|
|||
expect(getters.getAppliedConversationFilters(state)).toEqual(filtersList);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getLastEmailInSelectedChat', () => {
|
||||
it('Returns cc in last email', () => {
|
||||
const state = {};
|
||||
const getSelectedChat = {
|
||||
messages: [
|
||||
{
|
||||
message_type: 1,
|
||||
content_attributes: {
|
||||
email: {
|
||||
from: 'why@how.my',
|
||||
cc: ['nithin@me.co', 'we@who.why'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
getters.getLastEmailInSelectedChat(state, { getSelectedChat })
|
||||
).toEqual({
|
||||
message_type: 1,
|
||||
content_attributes: {
|
||||
email: {
|
||||
from: 'why@how.my',
|
||||
cc: ['nithin@me.co', 'we@who.why'],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -38,13 +38,15 @@ export const setAuthCredentials = response => {
|
|||
setUser(response.data.data, expiryDate);
|
||||
};
|
||||
|
||||
export const clearBrowserSessionCookies = () => {
|
||||
Cookies.remove('auth_data');
|
||||
Cookies.remove('user');
|
||||
};
|
||||
|
||||
export const clearCookiesOnLogout = () => {
|
||||
window.bus.$emit(CHATWOOT_RESET);
|
||||
window.bus.$emit(ANALYTICS_RESET);
|
||||
|
||||
Cookies.remove('auth_data');
|
||||
Cookies.remove('user');
|
||||
|
||||
clearBrowserSessionCookies();
|
||||
const globalConfig = window.globalConfig || {};
|
||||
const logoutRedirectLink =
|
||||
globalConfig.LOGOUT_REDIRECT_LINK || frontendURL('login');
|
||||
|
|
|
@ -21,7 +21,10 @@ import App from '../dashboard/App';
|
|||
import i18n from '../dashboard/i18n';
|
||||
import createAxios from '../dashboard/helper/APIHelper';
|
||||
import commonHelpers, { isJSONValid } from '../dashboard/helper/commons';
|
||||
import { getAlertAudio } from '../shared/helpers/AudioNotificationHelper';
|
||||
import {
|
||||
getAlertAudio,
|
||||
initOnEvents,
|
||||
} from '../shared/helpers/AudioNotificationHelper';
|
||||
import { initFaviconSwitcher } from '../shared/helpers/faviconHelper';
|
||||
import router from '../dashboard/routes';
|
||||
import store from '../dashboard/store';
|
||||
|
@ -102,6 +105,13 @@ window.onload = () => {
|
|||
vueActionCable.init();
|
||||
};
|
||||
|
||||
const setupAudioListeners = () => {
|
||||
getAlertAudio().then(() => {
|
||||
initOnEvents.forEach(event => {
|
||||
document.removeEventListener(event, setupAudioListeners, false);
|
||||
});
|
||||
});
|
||||
};
|
||||
window.addEventListener('load', () => {
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
|
@ -110,6 +120,9 @@ window.addEventListener('load', () => {
|
|||
}
|
||||
})
|
||||
);
|
||||
getAlertAudio();
|
||||
window.playAudioAlert = () => {};
|
||||
initOnEvents.forEach(e => {
|
||||
document.addEventListener(e, setupAudioListeners, false);
|
||||
});
|
||||
initFaviconSwitcher();
|
||||
});
|
||||
|
|
|
@ -25,6 +25,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
|||
launcherTitle: chatwootSettings.launcherTitle || '',
|
||||
showPopoutButton: chatwootSettings.showPopoutButton || false,
|
||||
widgetStyle: chatwootSettings.widgetStyle || 'standard',
|
||||
resetTriggered: false,
|
||||
|
||||
toggle(state) {
|
||||
IFrameHelper.events.toggleBubble(state);
|
||||
|
@ -100,6 +101,8 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
|||
baseUrl: window.$chatwoot.baseUrl,
|
||||
websiteToken: window.$chatwoot.websiteToken,
|
||||
});
|
||||
|
||||
window.$chatwoot.resetTriggered = true;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@ import VueI18n from 'vue-i18n';
|
|||
import store from '../widget/store';
|
||||
import App from '../widget/App.vue';
|
||||
import ActionCableConnector from '../widget/helpers/actionCable';
|
||||
import { getAlertAudio } from 'shared/helpers/AudioNotificationHelper';
|
||||
import i18n from '../widget/i18n';
|
||||
|
||||
import router from '../widget/router';
|
||||
|
@ -33,5 +32,4 @@ window.onload = () => {
|
|||
window.WOOT_WIDGET,
|
||||
window.chatwootPubsubToken
|
||||
);
|
||||
getAlertAudio();
|
||||
};
|
||||
|
|
|
@ -26,6 +26,10 @@ import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
|
|||
import { CHATWOOT_ERROR, CHATWOOT_READY } from '../widget/constants/sdkEvents';
|
||||
import { SET_USER_ERROR } from '../widget/constants/errorTypes';
|
||||
import { getUserCookieName } from './cookieHelpers';
|
||||
import {
|
||||
getAlertAudio,
|
||||
initOnEvents,
|
||||
} from 'shared/helpers/AudioNotificationHelper';
|
||||
import { isFlatWidgetStyle } from './settingsHelper';
|
||||
|
||||
export const IFrameHelper = {
|
||||
|
@ -114,6 +118,19 @@ export const IFrameHelper = {
|
|||
iframe.setAttribute('style', `height: ${updatedIframeHeight} !important`);
|
||||
},
|
||||
|
||||
setupAudioListeners: () => {
|
||||
const { baseUrl = '' } = window.$chatwoot;
|
||||
getAlertAudio(baseUrl).then(() =>
|
||||
initOnEvents.forEach(event => {
|
||||
document.removeEventListener(
|
||||
event,
|
||||
IFrameHelper.setupAudioListeners,
|
||||
false
|
||||
);
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
events: {
|
||||
loaded: message => {
|
||||
Cookies.set('cw_conversation', message.config.authToken, {
|
||||
|
@ -136,7 +153,18 @@ export const IFrameHelper = {
|
|||
if (window.$chatwoot.user) {
|
||||
IFrameHelper.sendMessage('set-user', window.$chatwoot.user);
|
||||
}
|
||||
|
||||
dispatchWindowEvent({ eventName: CHATWOOT_READY });
|
||||
|
||||
window.playAudioAlert = () => {};
|
||||
|
||||
initOnEvents.forEach(e => {
|
||||
document.addEventListener(e, IFrameHelper.setupAudioListeners, false);
|
||||
});
|
||||
|
||||
if (!window.$chatwoot.resetTriggered) {
|
||||
dispatchWindowEvent({ eventName: CHATWOOT_READY });
|
||||
}
|
||||
},
|
||||
error: ({ errorType, data }) => {
|
||||
dispatchWindowEvent({ eventName: CHATWOOT_ERROR, data: data });
|
||||
|
@ -212,6 +240,10 @@ export const IFrameHelper = {
|
|||
closeChat: () => {
|
||||
onBubbleClick({ toggleValue: false });
|
||||
},
|
||||
|
||||
playAudio: () => {
|
||||
window.playAudioAlert();
|
||||
},
|
||||
},
|
||||
pushEvent: eventName => {
|
||||
IFrameHelper.sendMessage('push-event', { eventName });
|
||||
|
|
|
@ -85,6 +85,11 @@
|
|||
"merge-outline": "M3 6.75A.75.75 0 0 1 3.75 6h4.5a.75.75 0 0 1 .53.22L13.56 11h5.878L15.72 7.28a.75.75 0 1 1 1.06-1.06l4.998 5a.75.75 0 0 1 0 1.06l-4.998 5a.75.75 0 1 1-1.06-1.06l3.718-3.72H13.56l-4.78 4.78a.75.75 0 0 1-.531.22h-4.5a.75.75 0 0 1 0-1.5h4.19l4.25-4.25L7.94 7.5H3.75A.75.75 0 0 1 3 6.75Z",
|
||||
"more-horizontal-outline": "M7.75 12a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0ZM13.75 12a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0ZM18 13.75a1.75 1.75 0 1 0 0-3.5 1.75 1.75 0 0 0 0 3.5Z",
|
||||
"more-vertical-outline": "M12 7.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM12 13.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM10.25 18a1.75 1.75 0 1 0 3.5 0 1.75 1.75 0 0 0-3.5 0Z",
|
||||
"microphone-outline": "M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z",
|
||||
"microphone-off-outline": "M19,11C19,12.19 18.66,13.3 18.1,14.28L16.87,13.05C17.14,12.43 17.3,11.74 17.3,11H19M15,11.16L9,5.18V5A3,3 0 0,1 12,2A3,3 0 0,1 15,5V11L15,11.16M4.27,3L21,19.73L19.73,21L15.54,16.81C14.77,17.27 13.91,17.58 13,17.72V21H11V17.72C7.72,17.23 5,14.41 5,11H6.7C6.7,14 9.24,16.1 12,16.1C12.81,16.1 13.6,15.91 14.31,15.58L12.65,13.92L12,14A3,3 0 0,1 9,11V10.28L3,4.27L4.27,3Z",
|
||||
"microphone-stop-outline": "M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4M9,9V15H15V9",
|
||||
"microphone-pause-outline": "M13,16V8H15V16H13M9,16V8H11V16H9M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z",
|
||||
"microphone-play-outline": "M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z",
|
||||
"number-symbol-outline": "M10.987 2.89a.75.75 0 1 0-1.474-.28L8.494 7.999 3.75 8a.75.75 0 1 0 0 1.5l4.46-.002-.946 5-4.514.002a.75.75 0 0 0 0 1.5l4.23-.002-.967 5.116a.75.75 0 1 0 1.474.278l1.02-5.395 5.474-.002-.968 5.119a.75.75 0 1 0 1.474.278l1.021-5.398 4.742-.002a.75.75 0 1 0 0-1.5l-4.458.002.946-5 4.512-.002a.75.75 0 1 0 0-1.5l-4.229.002.966-5.104a.75.75 0 0 0-1.474-.28l-1.018 5.385-5.474.002.966-5.107Zm-1.25 6.608 5.474-.003-.946 5-5.474.002.946-5Z",
|
||||
"open-outline": "M6.25 4.5A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .966.783 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-4a.75.75 0 0 1 1.5 0v4A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h4a.75.75 0 0 1 0 1.5h-4ZM13 3.75a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0V5.56l-5.22 5.22a.75.75 0 0 1-1.06-1.06l5.22-5.22h-4.69a.75.75 0 0 1-.75-.75Z",
|
||||
"people-outline": "M4 13.999 13 14a2 2 0 0 1 1.995 1.85L15 16v1.5C14.999 21 11.284 22 8.5 22c-2.722 0-6.335-.956-6.495-4.27L2 17.5v-1.501c0-1.054.816-1.918 1.85-1.995L4 14ZM15.22 14H20c1.054 0 1.918.816 1.994 1.85L22 16v1c-.001 3.062-2.858 4-5 4a7.16 7.16 0 0 1-2.14-.322c.336-.386.607-.827.802-1.327A6.19 6.19 0 0 0 17 19.5l.267-.006c.985-.043 3.086-.363 3.226-2.289L20.5 17v-1a.501.501 0 0 0-.41-.492L20 15.5h-4.051a2.957 2.957 0 0 0-.595-1.34L15.22 14H20h-4.78ZM4 15.499l-.1.01a.51.51 0 0 0-.254.136.506.506 0 0 0-.136.253l-.01.101V17.5c0 1.009.45 1.722 1.417 2.242.826.445 2.003.714 3.266.753l.317.005.317-.005c1.263-.039 2.439-.308 3.266-.753.906-.488 1.359-1.145 1.412-2.057l.005-.186V16a.501.501 0 0 0-.41-.492L13 15.5l-9-.001ZM8.5 3a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Zm9 2a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm-9-.5c-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3-1.346-3-3-3Zm9 2c-1.103 0-2 .897-2 2s.897 2 2 2 2-.897 2-2-.897-2-2-2Z",
|
||||
|
@ -119,6 +124,7 @@
|
|||
"brand-telegram-outline": "M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z",
|
||||
"brand-twitter-outline": "M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z",
|
||||
"brand-whatsapp-outline": "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z",
|
||||
"brand-github-outline": "M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385c.6.105.825-.255.825-.57c0-.285-.015-1.23-.015-2.235c-3.015.555-3.795-.735-4.035-1.41c-.135-.345-.72-1.41-1.23-1.695c-.42-.225-1.02-.78-.015-.795c.945-.015 1.62.87 1.845 1.23c1.08 1.815 2.805 1.305 3.495.99c.105-.78.42-1.305.765-1.605c-2.67-.3-5.46-1.335-5.46-5.925c0-1.305.465-2.385 1.23-3.225c-.12-.3-.54-1.53.12-3.18c0 0 1.005-.315 3.3 1.23c.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23c.66 1.65.24 2.88.12 3.18c.765.84 1.23 1.905 1.23 3.225c0 4.605-2.805 5.625-5.475 5.925c.435.375.81 1.095.81 2.22c0 1.605-.015 2.895-.015 3.3c0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z",
|
||||
"add-solid": "M11.883 3.007 12 3a1 1 0 0 1 .993.883L13 4v7h7a1 1 0 0 1 .993.883L21 12a1 1 0 0 1-.883.993L20 13h-7v7a1 1 0 0 1-.883.993L12 21a1 1 0 0 1-.993-.883L11 20v-7H4a1 1 0 0 1-.993-.883L3 12a1 1 0 0 1 .883-.993L4 11h7V4a1 1 0 0 1 .883-.993L12 3l-.117.007Z",
|
||||
"subtract-solid": "M3.997 13H20a1 1 0 1 0 0-2H3.997a1 1 0 1 0 0 2Z"
|
||||
}
|
||||
|
|
|
@ -2,12 +2,15 @@
|
|||
"arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z",
|
||||
"arrow-right-outline": "M13.267 4.209a.75.75 0 0 0-1.034 1.086l6.251 5.955H3.75a.75.75 0 0 0 0 1.5h14.734l-6.251 5.954a.75.75 0 0 0 1.034 1.087l7.42-7.067a.996.996 0 0 0 .3-.58.758.758 0 0 0-.001-.29.995.995 0 0 0-.3-.578l-7.419-7.067Z",
|
||||
"attach-outline": "M11.772 3.743a6 6 0 0 1 8.66 8.302l-.19.197-8.8 8.798-.036.03a3.723 3.723 0 0 1-5.489-4.973.764.764 0 0 1 .085-.13l.054-.06.086-.088.142-.148.002.003 7.436-7.454a.75.75 0 0 1 .977-.074l.084.073a.75.75 0 0 1 .074.976l-.073.084-7.594 7.613a2.23 2.23 0 0 0 3.174 3.106l8.832-8.83A4.502 4.502 0 0 0 13 4.644l-.168.16-.013.014-9.536 9.536a.75.75 0 0 1-1.133-.977l.072-.084 9.549-9.55h.002Z",
|
||||
"checkmark-outline": "M4.53 12.97a.75.75 0 0 0-1.06 1.06l4.5 4.5a.75.75 0 0 0 1.06 0l11-11a.75.75 0 0 0-1.06-1.06L8.5 16.94l-3.97-3.97Z",
|
||||
"chevron-left-outline": "M15.53 4.22a.75.75 0 0 1 0 1.06L8.81 12l6.72 6.72a.75.75 0 1 1-1.06 1.06l-7.25-7.25a.75.75 0 0 1 0-1.06l7.25-7.25a.75.75 0 0 1 1.06 0Z",
|
||||
"chevron-right-outline": "M8.293 4.293a1 1 0 0 0 0 1.414L14.586 12l-6.293 6.293a1 1 0 1 0 1.414 1.414l7-7a1 1 0 0 0 0-1.414l-7-7a1 1 0 0 0-1.414 0Z",
|
||||
"dismiss-outline": "m4.397 4.554.073-.084a.75.75 0 0 1 .976-.073l.084.073L12 10.939l6.47-6.47a.75.75 0 1 1 1.06 1.061L13.061 12l6.47 6.47a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L12 13.061l-6.47 6.47a.75.75 0 0 1-1.06-1.061L10.939 12l-6.47-6.47a.75.75 0 0 1-.072-.976l.073-.084-.073.084Z",
|
||||
"document-outline": "M18.5 20a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5h6V8a2 2 0 0 0 2 2h4.5v10Zm-5-15.379L17.378 8.5H14a.5.5 0 0 1-.5-.5V4.621Zm5.914 3.793-5.829-5.828c-.026-.026-.058-.046-.085-.07a2.072 2.072 0 0 0-.219-.18c-.04-.027-.086-.045-.128-.068-.071-.04-.141-.084-.216-.116a1.977 1.977 0 0 0-.624-.138C12.266 2.011 12.22 2 12.172 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9.828a2 2 0 0 0-.586-1.414Z",
|
||||
"emoji-outline": "M12 1.999c5.524 0 10.002 4.478 10.002 10.002 0 5.523-4.478 10.001-10.002 10.001-5.524 0-10.002-4.478-10.002-10.001C1.998 6.477 6.476 1.999 12 1.999Zm0 1.5a8.502 8.502 0 1 0 0 17.003A8.502 8.502 0 0 0 12 3.5ZM8.462 14.784A4.491 4.491 0 0 0 12 16.502a4.492 4.492 0 0 0 3.535-1.714.75.75 0 1 1 1.177.93A5.991 5.991 0 0 1 12 18.002a5.991 5.991 0 0 1-4.716-2.29.75.75 0 0 1 1.178-.928ZM9 8.75a1.25 1.25 0 1 1 0 2.499A1.25 1.25 0 0 1 9 8.75Zm6 0a1.25 1.25 0 1 1 0 2.499 1.25 1.25 0 0 1 0-2.499Z",
|
||||
"link-outline": "M9.25 7a.75.75 0 0 1 .11 1.492l-.11.008H7a3.5 3.5 0 0 0-.206 6.994L7 15.5h2.25a.75.75 0 0 1 .11 1.492L9.25 17H7a5 5 0 0 1-.25-9.994L7 7h2.25ZM17 7a5 5 0 0 1 .25 9.994L17 17h-2.25a.75.75 0 0 1-.11-1.492l.11-.008H17a3.5 3.5 0 0 0 .206-6.994L17 8.5h-2.25a.75.75 0 0 1-.11-1.492L14.75 7H17ZM7 11.25h10a.75.75 0 0 1 .102 1.493L17 12.75H7a.75.75 0 0 1-.102-1.493L7 11.25h10H7Z",
|
||||
"more-vertical-outline": "M12 7.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM12 13.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM10.25 18a1.75 1.75 0 1 0 3.5 0 1.75 1.75 0 0 0-3.5 0Z",
|
||||
"open-outline": "M6.25 4.5A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .966.783 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-4a.75.75 0 0 1 1.5 0v4A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h4a.75.75 0 0 1 0 1.5h-4ZM13 3.75a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0V5.56l-5.22 5.22a.75.75 0 0 1-1.06-1.06l5.22-5.22h-4.69a.75.75 0 0 1-.75-.75Z",
|
||||
"send-outline": "M5.694 12 2.299 3.272c-.236-.607.356-1.188.942-.982l.093.04 18 9a.75.75 0 0 1 .097 1.283l-.097.058-18 9c-.583.291-1.217-.244-1.065-.847l.03-.096L5.694 12 2.299 3.272 5.694 12ZM4.402 4.54l2.61 6.71h6.627a.75.75 0 0 1 .743.648l.007.102a.75.75 0 0 1-.649.743l-.101.007H7.01l-2.609 6.71L19.322 12 4.401 4.54Z"
|
||||
"send-outline": "M5.694 12 2.299 3.272c-.236-.607.356-1.188.942-.982l.093.04 18 9a.75.75 0 0 1 .097 1.283l-.097.058-18 9c-.583.291-1.217-.244-1.065-.847l.03-.096L5.694 12 2.299 3.272 5.694 12ZM4.402 4.54l2.61 6.71h6.627a.75.75 0 0 1 .743.648l.007.102a.75.75 0 0 1-.649.743l-.101.007H7.01l-2.609 6.71L19.322 12 4.401 4.54Z",
|
||||
"sign-out-outline": ["M8.502 11.5a1.002 1.002 0 1 1 0 2.004 1.002 1.002 0 0 1 0-2.004Z","M12 4.354v6.651l7.442-.001L17.72 9.28a.75.75 0 0 1-.073-.976l.073-.084a.75.75 0 0 1 .976-.073l.084.073 2.997 2.997a.75.75 0 0 1 .073.976l-.073.084-2.996 3.004a.75.75 0 0 1-1.134-.975l.072-.085 1.713-1.717-7.431.001L12 19.25a.75.75 0 0 1-.88.739l-8.5-1.502A.75.75 0 0 1 2 17.75V5.75a.75.75 0 0 1 .628-.74l8.5-1.396a.75.75 0 0 1 .872.74Zm-1.5.883-7 1.15V17.12l7 1.236V5.237Z","M13 18.501h.765l.102-.006a.75.75 0 0 0 .648-.745l-.007-4.25H13v5.001ZM13.002 10 13 8.725V5h.745a.75.75 0 0 1 .743.647l.007.102.007 4.251h-1.5Z"]
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import axios from 'axios';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
|
||||
import { showBadgeOnFavicon } from './faviconHelper';
|
||||
|
||||
export const getAlertAudio = async () => {
|
||||
window.playAudioAlert = () => {};
|
||||
export const initOnEvents = ['click', 'touchstart', 'keypress'];
|
||||
export const getAlertAudio = async (baseUrl = '') => {
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const playsound = audioBuffer => {
|
||||
window.playAudioAlert = () => {
|
||||
|
@ -16,11 +17,15 @@ export const getAlertAudio = async () => {
|
|||
};
|
||||
|
||||
try {
|
||||
const response = await axios.get('/dashboard/audios/ding.mp3', {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
const resourceUrl = `${baseUrl}/dashboard/audios/ding.mp3`;
|
||||
const audioRequest = new Request(resourceUrl);
|
||||
|
||||
audioCtx.decodeAudioData(response.data).then(playsound);
|
||||
fetch(audioRequest)
|
||||
.then(response => response.arrayBuffer())
|
||||
.then(buffer => {
|
||||
audioCtx.decodeAudioData(buffer).then(playsound);
|
||||
return new Promise(res => res());
|
||||
});
|
||||
} catch (error) {
|
||||
// error
|
||||
}
|
||||
|
@ -89,6 +94,7 @@ export const newMessageNotification = data => {
|
|||
currentUserId,
|
||||
assigneeId
|
||||
);
|
||||
|
||||
if (playAudio && isNotificationEnabled) {
|
||||
window.playAudioAlert();
|
||||
showBadgeOnFavicon();
|
||||
|
@ -96,5 +102,7 @@ export const newMessageNotification = data => {
|
|||
};
|
||||
|
||||
export const playNewMessageNotificationInWidget = () => {
|
||||
window.playAudioAlert();
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'playAudio',
|
||||
});
|
||||
};
|
||||
|
|
|
@ -19,7 +19,7 @@ export const fileSizeInMegaBytes = bytes => {
|
|||
};
|
||||
|
||||
export const checkFileSizeLimit = (file, maximumUploadLimit) => {
|
||||
const fileSize = file?.file?.size;
|
||||
const fileSize = file?.file?.size || file?.size;
|
||||
const fileSizeInMB = fileSizeInMegaBytes(fileSize);
|
||||
return fileSizeInMB <= maximumUploadLimit;
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import marked from 'marked';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { escapeHtml } from './HTMLSanitizer';
|
||||
|
||||
|
@ -47,7 +47,12 @@ class MessageFormatter {
|
|||
const markedDownOutput = marked(withHash);
|
||||
return markedDownOutput;
|
||||
}
|
||||
return marked(this.message, { breaks: true, gfm: true });
|
||||
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
||||
if ('target' in node) node.setAttribute('target', '_blank');
|
||||
});
|
||||
return DOMPurify.sanitize(
|
||||
marked(this.message, { breaks: true, gfm: true })
|
||||
);
|
||||
}
|
||||
|
||||
get formattedMessage() {
|
||||
|
|
|
@ -6,14 +6,14 @@ describe('#MessageFormatter', () => {
|
|||
const message =
|
||||
'Chatwoot is an opensource tool. [Chatwoot](https://www.chatwoot.com)';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(
|
||||
'<p>Chatwoot is an opensource tool. <a rel="noreferrer noopener nofollow" href="https://www.chatwoot.com" class="link" title="" target="_blank">Chatwoot</a></p>'
|
||||
'<p>Chatwoot is an opensource tool. <a title="" class="link" href="https://www.chatwoot.com" rel="noreferrer noopener nofollow" target="_blank">Chatwoot</a></p>'
|
||||
);
|
||||
});
|
||||
it('should format correctly', () => {
|
||||
const message =
|
||||
'Chatwoot is an opensource tool. https://www.chatwoot.com';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(
|
||||
'<p>Chatwoot is an opensource tool. <a rel="noreferrer noopener nofollow" href="https://www.chatwoot.com" class="link" title="" target="_blank">https://www.chatwoot.com</a></p>'
|
||||
'<p>Chatwoot is an opensource tool. <a title="" class="link" href="https://www.chatwoot.com" rel="noreferrer noopener nofollow" target="_blank">https://www.chatwoot.com</a></p>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -58,4 +58,14 @@ describe('#MessageFormatter', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sanitize', () => {
|
||||
it('sanitizes markup and removes all unnecessary elements', () => {
|
||||
const message =
|
||||
'[xssLink](javascript:alert(document.cookie))\n[normalLink](https://google.com)**I am a bold text paragraph**';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(
|
||||
'<p><a title="" class="link" rel="noreferrer noopener nofollow" target="_blank">xssLink</a><br><a title="" class="link" href="https://google.com" rel="noreferrer noopener nofollow" target="_blank">normalLink</a><strong>I am a bold text paragraph</strong></p>'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -44,6 +44,9 @@ export default {
|
|||
const { medium: medium = '' } = this.inbox;
|
||||
return this.isATwilioChannel && medium === 'sms';
|
||||
},
|
||||
isASmsInbox() {
|
||||
return this.channelType === INBOX_TYPES.SMS || this.isATwilioSMSChannel;
|
||||
},
|
||||
isATwilioWhatsappChannel() {
|
||||
const { medium: medium = '' } = this.inbox;
|
||||
return this.isATwilioChannel && medium === 'whatsapp';
|
||||
|
|
|
@ -62,6 +62,18 @@ describe('inboxMixin', () => {
|
|||
expect(wrapper.vm.isAWebWidgetInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isASmsInbox returns true if channel type is sms', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return { inbox: { channel_type: 'Channel::Sms' } };
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isASmsInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isATwilioChannel returns true if channel type is Twilio', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
|
@ -94,6 +106,7 @@ describe('inboxMixin', () => {
|
|||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(true);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
|
||||
expect(wrapper.vm.isASmsInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isATwilioWhatsappChannel returns true if channel type is Twilio and medium is whatsapp', () => {
|
||||
|
|
|
@ -48,6 +48,11 @@ const sendEmailTranscript = async ({ email }) => {
|
|||
{ email }
|
||||
);
|
||||
};
|
||||
const toggleStatus = async () => {
|
||||
return API.get(
|
||||
`/api/v1/widget/conversations/toggle_status${window.location.search}`
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
createConversationAPI,
|
||||
|
@ -58,4 +63,5 @@ export {
|
|||
toggleTyping,
|
||||
setUserLastSeenAt,
|
||||
sendEmailTranscript,
|
||||
toggleStatus,
|
||||
};
|
||||
|
|
|
@ -37,12 +37,13 @@ import CustomButton from 'shared/components/Button';
|
|||
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { sendEmailTranscript } from 'widget/api/conversation';
|
||||
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
CustomButton,
|
||||
},
|
||||
mixins: [routerMixin],
|
||||
props: {
|
||||
msg: {
|
||||
type: String,
|
||||
|
@ -53,7 +54,7 @@ export default {
|
|||
...mapGetters({
|
||||
conversationAttributes: 'conversationAttributes/getConversationParams',
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
getConversationSize: 'conversation/getConversationSize',
|
||||
conversationSize: 'conversation/getConversationSize',
|
||||
currentUser: 'contacts/getCurrentUser',
|
||||
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
|
||||
}),
|
||||
|
@ -80,12 +81,11 @@ export default {
|
|||
'clearConversationAttributes',
|
||||
]),
|
||||
async handleSendMessage(content) {
|
||||
const conversationSize = this.getConversationSize;
|
||||
await this.sendMessage({
|
||||
content,
|
||||
});
|
||||
// Update conversation attributes on new conversation
|
||||
if (conversationSize === 0) {
|
||||
if (this.conversationSize === 0) {
|
||||
this.getAttributes();
|
||||
}
|
||||
},
|
||||
|
@ -95,7 +95,12 @@ export default {
|
|||
startNewConversation() {
|
||||
this.clearConversations();
|
||||
this.clearConversationAttributes();
|
||||
window.bus.$emit(BUS_EVENTS.START_NEW_CONVERSATION);
|
||||
|
||||
// To create a new conversation, we are redirecting
|
||||
// the user to pre-chat with contact fields disabled
|
||||
// Pass disableContactFields params to the route
|
||||
// This would disable the contact fields in the pre-chat form
|
||||
this.replaceRoute('prechat-form', { disableContactFields: true });
|
||||
},
|
||||
async sendTranscript() {
|
||||
const { email } = this.currentUser;
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
<template>
|
||||
<div v-if="showHeaderActions" class="actions flex items-center">
|
||||
<button
|
||||
v-if="conversationStatus === 'open'"
|
||||
class="button transparent compact"
|
||||
:title="$t('END_CONVERSATION')"
|
||||
@click="resolveConversation"
|
||||
>
|
||||
<fluent-icon icon="sign-out" size="22" class="text-black-900" />
|
||||
</button>
|
||||
<button
|
||||
v-if="showPopoutButton"
|
||||
class="button transparent compact new-window--button "
|
||||
|
@ -19,6 +27,7 @@
|
|||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
|
||||
import { buildPopoutURL } from '../helpers/urlParamsHelper';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
@ -33,6 +42,9 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
conversationAttributes: 'conversationAttributes/getConversationParams',
|
||||
}),
|
||||
isIframe() {
|
||||
return IFrameHelper.isIFrame();
|
||||
},
|
||||
|
@ -40,7 +52,13 @@ export default {
|
|||
return RNHelper.isRNWebView();
|
||||
},
|
||||
showHeaderActions() {
|
||||
return this.isIframe || this.isRNWebView;
|
||||
return this.isIframe || this.isRNWebView || this.hasWidgetOptions;
|
||||
},
|
||||
conversationStatus() {
|
||||
return this.conversationAttributes.status;
|
||||
},
|
||||
hasWidgetOptions() {
|
||||
return this.showPopoutButton || this.conversationStatus === 'open';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -72,6 +90,9 @@ export default {
|
|||
RNHelper.sendMessage({ type: 'close-widget' });
|
||||
}
|
||||
},
|
||||
resolveConversation() {
|
||||
this.$store.dispatch('conversation/resolveConversation');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
{{ headerMessage }}
|
||||
</div>
|
||||
<form-input
|
||||
v-if="options.requireEmail"
|
||||
v-if="areContactFieldsVisible"
|
||||
v-model="fullName"
|
||||
class="mt-5"
|
||||
:label="$t('PRE_CHAT_FORM.FIELDS.FULL_NAME.LABEL')"
|
||||
|
@ -21,7 +21,7 @@
|
|||
"
|
||||
/>
|
||||
<form-input
|
||||
v-if="options.requireEmail"
|
||||
v-if="areContactFieldsVisible"
|
||||
v-model="emailAddress"
|
||||
class="mt-5"
|
||||
:label="$t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.LABEL')"
|
||||
|
@ -77,6 +77,10 @@ export default {
|
|||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
disableContactFields: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
validations() {
|
||||
const identityValidations = {
|
||||
|
@ -99,7 +103,7 @@ export default {
|
|||
if (this.hasActiveCampaign) {
|
||||
return identityValidations;
|
||||
}
|
||||
if (this.options.requireEmail) {
|
||||
if (this.areContactFieldsVisible) {
|
||||
return {
|
||||
...identityValidations,
|
||||
...messageValidation,
|
||||
|
@ -135,6 +139,9 @@ export default {
|
|||
}
|
||||
return this.options.preChatMessage;
|
||||
},
|
||||
areContactFieldsVisible() {
|
||||
return this.options.requireEmail && !this.disableContactFields;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
|
|
83
app/javascript/widget/components/dropdown/DropdownMenu.vue
Normal file
83
app/javascript/widget/components/dropdown/DropdownMenu.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="relative">
|
||||
<button class="z-10 focus:outline-none select-none" @click="toggleMenu">
|
||||
<slot name="button"></slot>
|
||||
</button>
|
||||
|
||||
<!-- to close when clicked on space around it-->
|
||||
<button
|
||||
v-if="isOpen"
|
||||
tabindex="-1"
|
||||
class="fixed inset-0 h-full w-full cursor-default focus:outline-none"
|
||||
@click="toggleMenu"
|
||||
></button>
|
||||
|
||||
<!--dropdown menu-->
|
||||
<transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
leave-active-class="transition-all duration-750 ease-in"
|
||||
enter-class="opacity-0 scale-75"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-75"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="menu-content absolute shadow-xl rounded-md border-solid border border-slate-100 mt-1 py-1 px-2 bg-white z-10"
|
||||
:class="menuPlacement === 'right' ? 'right-0' : 'left-0'"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
menuPlacement: {
|
||||
type: String,
|
||||
default: 'right',
|
||||
validator: value => ['right', 'left'].indexOf(value) !== -1,
|
||||
},
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
toggleMenu: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
open() {
|
||||
this.isOpen = !this.isOpen;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.onEscape);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.onEscape);
|
||||
},
|
||||
methods: {
|
||||
onEscape(e) {
|
||||
if (e.key === 'Esc' || e.key === 'Escape') {
|
||||
this.isOpen = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import '~widget/assets/scss/variables.scss';
|
||||
|
||||
.menu-content {
|
||||
width: max-content;
|
||||
}
|
||||
</style>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue