Merge branch 'develop' into chore/pr/4162
This commit is contained in:
commit
07e5420db4
785 changed files with 12672 additions and 2389 deletions
|
@ -50,3 +50,6 @@ exclude_patterns:
|
|||
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
|
||||
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
|
||||
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
|
||||
- 'app/javascript/dashboard/i18n/index.js'
|
||||
- 'app/javascript/widget/i18n/index.js'
|
||||
- 'app/javascript/survey/i18n/index.js'
|
||||
|
|
|
@ -32,6 +32,11 @@ REDIS_SENTINELS=
|
|||
# You can find list of master using "SENTINEL masters" command
|
||||
REDIS_SENTINEL_MASTER_NAME=
|
||||
|
||||
# Redis premium breakage in heroku fix
|
||||
# enable the following configuration
|
||||
# ref: https://github.com/chatwoot/chatwoot/issues/2420
|
||||
# REDIS_OPENSSL_VERIFY_MODE=none
|
||||
|
||||
# Postgres Database config variables
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_USERNAME=postgres
|
||||
|
|
|
@ -29,8 +29,8 @@ module.exports = {
|
|||
'vue/html-self-closing': 'off',
|
||||
"vue/no-v-html": 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'import/extensions': ['off']
|
||||
|
||||
'import/extensions': ['off'],
|
||||
'no-console': 'error'
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
|
|
|
@ -16,6 +16,7 @@ Metrics/ClassLength:
|
|||
- 'app/models/message.rb'
|
||||
- 'app/builders/messages/facebook/message_builder.rb'
|
||||
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
|
||||
- 'app/listeners/action_cable_listener.rb'
|
||||
RSpec/ExampleLength:
|
||||
Max: 25
|
||||
Style/Documentation:
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -42,7 +42,7 @@ gem 'down', '~> 5.0'
|
|||
gem 'aws-sdk-s3', require: false
|
||||
gem 'azure-storage-blob', require: false
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'image_processing'
|
||||
gem 'image_processing', '~> 1.12.2'
|
||||
|
||||
##-- gems for database --#
|
||||
gem 'groupdate'
|
||||
|
|
116
Gemfile.lock
116
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)
|
||||
|
@ -688,7 +688,7 @@ DEPENDENCIES
|
|||
hairtrigger
|
||||
hashie
|
||||
html2text
|
||||
image_processing
|
||||
image_processing (~> 1.12.2)
|
||||
jbuilder
|
||||
json_refs
|
||||
json_schemer
|
||||
|
@ -751,4 +751,4 @@ RUBY VERSION
|
|||
ruby 3.0.2p107
|
||||
|
||||
BUNDLED WITH
|
||||
2.2.25
|
||||
2.3.8
|
||||
|
|
4
app.json
4
app.json
|
@ -32,6 +32,10 @@
|
|||
"INSTALLATION_ENV": {
|
||||
"description": "Installation method used for Chatwoot.",
|
||||
"value": "heroku"
|
||||
},
|
||||
"REDIS_OPENSSL_VERIFY_MODE":{
|
||||
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
|
||||
"value": "none"
|
||||
}
|
||||
},
|
||||
"formation": {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def destroy
|
||||
@agent_bot.destroy
|
||||
@agent_bot.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def destroy
|
||||
@agent.current_account_user.destroy
|
||||
@agent.current_account_user.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
@ -68,7 +68,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def agents
|
||||
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
|
|
|
@ -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,9 +32,9 @@ 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: [] }]
|
||||
actions: [:action_name, { action_params: [{}] }]
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -1,32 +1,6 @@
|
|||
class Api::V1::Accounts::BaseController < Api::BaseController
|
||||
include SwitchLocale
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :current_account
|
||||
around_action :switch_locale_using_account_locale
|
||||
|
||||
private
|
||||
|
||||
def current_account
|
||||
@current_account ||= ensure_current_account
|
||||
Current.account = @current_account
|
||||
end
|
||||
|
||||
def ensure_current_account
|
||||
account = Account.find(params[:account_id])
|
||||
if current_user
|
||||
account_accessible_for_user?(account)
|
||||
elsif @resource.is_a?(AgentBot)
|
||||
account_accessible_for_bot?(account)
|
||||
end
|
||||
account
|
||||
end
|
||||
|
||||
def account_accessible_for_user?(account)
|
||||
@current_account_user = account.account_users.find_by(user_id: current_user.id)
|
||||
Current.account_user = @current_account_user
|
||||
render_unauthorized('You are not authorized to access this account') unless @current_account_user
|
||||
end
|
||||
|
||||
def account_accessible_for_bot?(account)
|
||||
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -11,7 +11,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def destroy
|
||||
@campaign.destroy
|
||||
@campaign.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
|
|||
end
|
||||
|
||||
def destroy
|
||||
@canned_response.destroy
|
||||
@canned_response.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
|
|||
end
|
||||
|
||||
def destroy
|
||||
@note.destroy
|
||||
@note.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :conversation
|
||||
|
||||
private
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :current_account
|
||||
before_action :conversation
|
||||
|
||||
def create
|
||||
return if @conversation.nil? || @current_account.nil?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
|
|||
end
|
||||
|
||||
def destroy
|
||||
@custom_attribute_definition.destroy
|
||||
@custom_attribute_definition.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
|
|||
end
|
||||
|
||||
def destroy
|
||||
@custom_filter.destroy
|
||||
@custom_filter.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
|
|
|
@ -48,7 +48,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
# Inbox update doesn't necessarily need channel attributes
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
|
||||
if @inbox.inbox_type == 'Email'
|
||||
validate_email_channel(channel_attributes)
|
||||
@inbox.channel.reauthorized!
|
||||
end
|
||||
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
update_channel_feature_flags
|
||||
|
@ -70,7 +73,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def destroy
|
||||
@inbox.destroy
|
||||
@inbox.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
|||
end
|
||||
|
||||
def destroy
|
||||
@hook.destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
|
|||
end
|
||||
|
||||
def destroy
|
||||
@hook.destroy
|
||||
@hook.destroy!
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
|
|
@ -14,7 +14,7 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase:
|
|||
end
|
||||
|
||||
def destroy
|
||||
@category.destroy
|
||||
@category.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::Ba
|
|||
end
|
||||
|
||||
def destroy
|
||||
@portal.destroy
|
||||
@portal.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def destroy
|
||||
@label.destroy
|
||||
@label.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def destroy
|
||||
@team.destroy
|
||||
@team.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def destroy
|
||||
@webhook.destroy
|
||||
@webhook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
|
|||
|
||||
def destroy
|
||||
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription.destroy
|
||||
notification_subscription.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
class Api::V1::Widget::BaseController < ApplicationController
|
||||
include SwitchLocale
|
||||
include WebsiteTokenHelper
|
||||
|
||||
before_action :set_web_widget
|
||||
before_action :set_contact
|
||||
|
@ -19,23 +20,6 @@ class Api::V1::Widget::BaseController < ApplicationController
|
|||
@conversation ||= conversations.last
|
||||
end
|
||||
|
||||
def auth_token_params
|
||||
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
|
||||
end
|
||||
|
||||
def set_web_widget
|
||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||
@current_account = @web_widget.account
|
||||
end
|
||||
|
||||
def set_contact
|
||||
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
|
||||
source_id: auth_token_params[:source_id]
|
||||
)
|
||||
@contact = @contact_inbox&.contact
|
||||
raise ActiveRecord::RecordNotFound unless @contact
|
||||
end
|
||||
|
||||
def create_conversation
|
||||
::Conversation.create!(conversation_params)
|
||||
end
|
||||
|
@ -94,10 +78,6 @@ class Api::V1::Widget::BaseController < ApplicationController
|
|||
{ timestamp: permitted_params[:message][:timestamp] }
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:website_token)
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
|
|
|
@ -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)
|
||||
|
|
11
app/controllers/api/v1/widget/direct_uploads_controller.rb
Normal file
11
app/controllers/api/v1/widget/direct_uploads_controller.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController
|
||||
include WebsiteTokenHelper
|
||||
before_action :set_web_widget
|
||||
before_action :set_contact
|
||||
|
||||
def create
|
||||
return if @contact.nil? || @current_account.nil?
|
||||
|
||||
super
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
28
app/controllers/concerns/ensure_current_account_helper.rb
Normal file
28
app/controllers/concerns/ensure_current_account_helper.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
module EnsureCurrentAccountHelper
|
||||
private
|
||||
|
||||
def current_account
|
||||
@current_account ||= ensure_current_account
|
||||
Current.account = @current_account
|
||||
end
|
||||
|
||||
def ensure_current_account
|
||||
account = Account.find(params[:account_id])
|
||||
if current_user
|
||||
account_accessible_for_user?(account)
|
||||
elsif @resource.is_a?(AgentBot)
|
||||
account_accessible_for_bot?(account)
|
||||
end
|
||||
account
|
||||
end
|
||||
|
||||
def account_accessible_for_user?(account)
|
||||
@current_account_user = account.account_users.find_by(user_id: current_user.id)
|
||||
Current.account_user = @current_account_user
|
||||
render_unauthorized('You are not authorized to access this account') unless @current_account_user
|
||||
end
|
||||
|
||||
def account_accessible_for_bot?(account)
|
||||
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||
end
|
||||
end
|
24
app/controllers/concerns/website_token_helper.rb
Normal file
24
app/controllers/concerns/website_token_helper.rb
Normal file
|
@ -0,0 +1,24 @@
|
|||
module WebsiteTokenHelper
|
||||
def auth_token_params
|
||||
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
|
||||
end
|
||||
|
||||
def set_web_widget
|
||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||
@current_account = @web_widget.account
|
||||
end
|
||||
|
||||
def set_contact
|
||||
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
|
||||
source_id: auth_token_params[:source_id]
|
||||
)
|
||||
@contact = @contact_inbox&.contact
|
||||
raise ActiveRecord::RecordNotFound unless @contact
|
||||
|
||||
Current.contact = @contact
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:website_token)
|
||||
end
|
||||
end
|
|
@ -13,7 +13,7 @@ class Platform::Api::V1::AccountUsersController < PlatformController
|
|||
end
|
||||
|
||||
def destroy
|
||||
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy
|
||||
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ class Platform::Api::V1::UsersController < PlatformController
|
|||
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
|
||||
@resource.save!
|
||||
@resource.confirm
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
@platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
|
||||
end
|
||||
|
||||
def login
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,8 @@
|
|||
|
||||
code {
|
||||
border: 0;
|
||||
font-family: 'Monaco', Verdana;
|
||||
font-family: 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas',
|
||||
'"Liberation Mono"', '"Courier New"', 'monospace';
|
||||
font-size: $font-size-mini;
|
||||
|
||||
&.hljs {
|
||||
|
@ -55,7 +56,6 @@ code {
|
|||
padding-right: var(--space-normal);
|
||||
}
|
||||
|
||||
|
||||
.badge {
|
||||
border-radius: var(--border-radius-normal);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="preview-item__wrap">
|
||||
<div
|
||||
v-for="(attachment, index) in attachments"
|
||||
:key="attachment.id"
|
||||
|
@ -19,12 +19,13 @@
|
|||
</span>
|
||||
</div>
|
||||
<div class="file-size-wrap">
|
||||
<span class="item">
|
||||
<span class="item text-truncate">
|
||||
{{ formatFileSize(attachment.resource) }}
|
||||
</span>
|
||||
</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;
|
||||
},
|
||||
|
@ -65,15 +70,23 @@ export default {
|
|||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.preview-item__wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
margin-top: var(--space-normal);
|
||||
max-height: 20rem;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
display: flex;
|
||||
padding: var(--space-slab) 0 0;
|
||||
background: var(--color-background-light);
|
||||
background: var(--b-50);
|
||||
border-radius: var(--border-radius-normal);
|
||||
width: fit-content;
|
||||
width: 24rem;
|
||||
padding: var(--space-smaller);
|
||||
margin-top: var(--space-normal);
|
||||
margin-bottom: var(--space-one);
|
||||
}
|
||||
|
||||
.thumb-wrap {
|
||||
|
@ -109,6 +122,7 @@ export default {
|
|||
|
||||
> .item {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
@ -119,7 +133,8 @@ export default {
|
|||
}
|
||||
|
||||
.file-name-wrap {
|
||||
max-width: 50%;
|
||||
max-width: 60%;
|
||||
min-width: 50%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-left: var(--space-small);
|
||||
|
|
|
@ -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>
|
|
@ -186,6 +186,12 @@ export default {
|
|||
blur: () => {
|
||||
this.onBlur();
|
||||
},
|
||||
paste: (view, event) => {
|
||||
const data = event.clipboardData.files;
|
||||
if (data.length > 0) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
this.focusEditorInputField();
|
||||
|
|
|
@ -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);
|
||||
|
@ -417,12 +452,16 @@ export default {
|
|||
methods: {
|
||||
onPaste(e) {
|
||||
const data = e.clipboardData.files;
|
||||
if (!this.showRichContentEditor && data.length !== 0) {
|
||||
this.$refs.messageInput.$el.blur();
|
||||
}
|
||||
if (!data.length || !data[0]) {
|
||||
return;
|
||||
}
|
||||
const file = data[0];
|
||||
const { name, type, size } = file;
|
||||
this.onFileUpload({ name, type, size, file });
|
||||
data.forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
this.onFileUpload({ name, type, size, file: file });
|
||||
});
|
||||
},
|
||||
toggleUserMention(currentMentionState) {
|
||||
this.hasUserMention = currentMentionState;
|
||||
|
@ -518,6 +557,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 +572,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 +612,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;
|
||||
|
@ -577,10 +649,17 @@ export default {
|
|||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
const upload = new DirectUpload(
|
||||
file.file,
|
||||
'/rails/active_storage/direct_uploads',
|
||||
null,
|
||||
file.file.name
|
||||
`/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
this.currentUser.access_token
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
this.showAlert(error);
|
||||
|
@ -650,11 +729,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 +746,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 +774,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_not_present": "غير موجود",
|
||||
"is_greater_than": "هو أكبر من",
|
||||
"is_lesser_than": "هو أقل من"
|
||||
"is_less_than": "هو أقل من",
|
||||
"days_before": "قبل x أيام"
|
||||
},
|
||||
"ATTRIBUTE_LABELS": {
|
||||
"TRUE": "صحيح",
|
||||
"FALSE": "خاطئ"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"STATUS": "الحالة",
|
||||
|
@ -38,7 +43,9 @@
|
|||
"CUSTOM_ATTRIBUTE_TEXT": "النص",
|
||||
"CUSTOM_ATTRIBUTE_NUMBER": "العدد",
|
||||
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع"
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع",
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"LAST_ACTIVITY": "آخر نشاط"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "الفلاتر القياسية",
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
},
|
||||
"EDIT": {
|
||||
"TITLE": "تعديل قاعدة الأتمتة",
|
||||
"SUBMIT": "تعديل",
|
||||
"SUBMIT": "تحديث",
|
||||
"CANCEL_BUTTON_TEXT": "إلغاء",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "تم تحديث قاعدة الأتمتة بنجاح",
|
||||
|
@ -84,6 +84,24 @@
|
|||
"DELETE": "حذف",
|
||||
"CANCEL": "إلغاء",
|
||||
"RESET_MESSAGE": "تغيير نوع الحدث سوف يعيد تعيين الشروط والأحداث التي أضفتها أدناه"
|
||||
},
|
||||
"CONDITION": {
|
||||
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ"
|
||||
},
|
||||
"ACTION": {
|
||||
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ"
|
||||
},
|
||||
"TOGGLE": {
|
||||
"ACTIVATION_TITLE": "تفعيل قاعدة الأتمتة",
|
||||
"DEACTIVATION_TITLE": "تغطيل قاعدة الأتمتة",
|
||||
"ACTIVATION_DESCRIPTION": "سيؤدي هذا الإجراء إلى تنشيط قاعدة الأتمتة '{automationName}'. هل أنت متأكد من أنك تريد المتابعة؟",
|
||||
"DEACTIVATION_DESCRIPTION": "سيؤدي هذا الإجراء إلى إلغاء تنشيط قاعدة الأتمتة '{automationName}'. هل أنت متأكد من أنك تريد المتابعة؟",
|
||||
"ACTIVATION_SUCCESFUL": "تم تفعيل قاعدة الأتمتة بنجاح",
|
||||
"DEACTIVATION_SUCCESFUL": "تم تعطيل قاعدة الأتمتة بنجاح",
|
||||
"ACTIVATION_ERROR": "تعذر تنشيط قاعدة الأتمتة، الرجاء المحاولة مرة أخرى لاحقاً",
|
||||
"DEACTIVATION_ERROR": "تعذر إلغاء تنشيط قاعدة الأتمتة، الرجاء المحاولة مرة أخرى لاحقاً",
|
||||
"CONFIRMATION_LABEL": "نعم",
|
||||
"CANCEL_LABEL": "لا"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,6 +76,7 @@
|
|||
"RECEIVED_VIA_EMAIL": "تم تلقيه عبر البريد الإلكتروني",
|
||||
"VIEW_TWEET_IN_TWITTER": "عرض التغريدة في تويتر",
|
||||
"REPLY_TO_TWEET": "الرد على هذه التغريدة",
|
||||
"LINK_TO_STORY": "الذهاب إلى قصة الإنستقرام",
|
||||
"SENT": "Sent successfully",
|
||||
"NO_MESSAGES": "لا توجد رسائل",
|
||||
"NO_CONTENT": "لم يتم العثور على محتوى",
|
||||
|
|
|
@ -77,9 +77,8 @@
|
|||
"CONFIRM": {
|
||||
"TITLE": "تأكيد الحذف",
|
||||
"MESSAGE": "هل أنت متأكد من الحذف ",
|
||||
"PLACE_HOLDER": "الرجاء كتابة {contactName} للتأكيد",
|
||||
"YES": "نعم، احذف ",
|
||||
"NO": "لا، احتفظ "
|
||||
"YES": "نعم، احذف",
|
||||
"NO": "لا، احتفظ"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "تم حذف جهة الاتصال بنجاح",
|
||||
|
|
|
@ -22,7 +22,8 @@
|
|||
"is_present": "موجود",
|
||||
"is_not_present": "غير موجود",
|
||||
"is_greater_than": "هو أكبر من",
|
||||
"is_lesser_than": "هو أقل من"
|
||||
"is_lesser_than": "هو أقل من",
|
||||
"days_before": "قبل x أيام"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"NAME": "الاسم",
|
||||
|
@ -35,7 +36,9 @@
|
|||
"CUSTOM_ATTRIBUTE_TEXT": "النص",
|
||||
"CUSTOM_ATTRIBUTE_NUMBER": "العدد",
|
||||
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع"
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع",
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"LAST_ACTIVITY": "آخر نشاط"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "الفلاتر القياسية",
|
||||
|
|
|
@ -57,11 +57,13 @@
|
|||
}
|
||||
},
|
||||
"FOOTER": {
|
||||
"MESSAGE_SIGN_TOOLTIP": "Message signature",
|
||||
"ENABLE_SIGN_TOOLTIP": "Enable signature",
|
||||
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
||||
"MESSAGE_SIGN_TOOLTIP": "توقيع الرسالة",
|
||||
"ENABLE_SIGN_TOOLTIP": "تمكين التوقيع",
|
||||
"DISABLE_SIGN_TOOLTIP": "تعطيل التوقيع",
|
||||
"MSG_INPUT": "زر Shift + Enter لإضافة سطر جديد. ابدأ بزر / للاختيار من الردود السريعة.",
|
||||
"PRIVATE_MSG_INPUT": "زر Shift + Enter لإضافة سطر جديد. سيكون هذا مرئياً للموظفين فقط"
|
||||
"PRIVATE_MSG_INPUT": "زر Shift + Enter لإضافة سطر جديد. سيكون هذا مرئياً للموظفين فقط",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "لم يتم تكوين توقيع الرسالة، الرجاء تكوينه في إعدادات الملف الشخصي.",
|
||||
"CLICK_HERE": "انقر هنا للتحديث"
|
||||
},
|
||||
"REPLYBOX": {
|
||||
"REPLY": "إضافة رد",
|
||||
|
@ -72,8 +74,14 @@
|
|||
"TIP_FORMAT_ICON": "عرض محرر النصوص",
|
||||
"TIP_EMOJI_ICON": "إظهار قائمة الرموز التعبيرية",
|
||||
"TIP_ATTACH_ICON": "إرفاق الملفات",
|
||||
"TIP_AUDIORECORDER_ICON": "تسجيل الصوت",
|
||||
"TIP_AUDIORECORDER_PERMISSION": "السماح بالوصول إلى الصوت",
|
||||
"TIP_AUDIORECORDER_ERROR": "تعذر فتح الصوت",
|
||||
"ENTER_TO_SEND": "زر الإدخل للإرسال",
|
||||
"DRAG_DROP": "اسحب و أسقط هنا للإرفاق",
|
||||
"START_AUDIO_RECORDING": "بدء التسجيل الصوتي",
|
||||
"STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي",
|
||||
"": "",
|
||||
"EMAIL_HEAD": {
|
||||
"ADD_BCC": "إضافة bcc",
|
||||
"CC": {
|
||||
|
|
|
@ -47,7 +47,8 @@
|
|||
"CUSTOM_EMAIL_DOMAIN_ENABLED": "يمكنك تلقي رسائل البريد الإلكتروني في النطاق المخصص الخاص بك الآن."
|
||||
}
|
||||
},
|
||||
"UPDATE_CHATWOOT": "يتوفر تحديث %{latestChatwootVersion} لـ Chatwoot. الرجاء التحديث."
|
||||
"UPDATE_CHATWOOT": "يتوفر تحديث %{latestChatwootVersion} لـ Chatwoot. الرجاء التحديث.",
|
||||
"LEARN_MORE": "اعرف المزيد"
|
||||
},
|
||||
"FORMS": {
|
||||
"MULTISELECT": {
|
||||
|
|
|
@ -462,7 +462,8 @@
|
|||
"HOURS": "ساعات",
|
||||
"VALIDATION_ERROR": "يجب أن يكون وقت البدء قبل وقت الإغلاق.",
|
||||
"CHOOSE": "اختر"
|
||||
}
|
||||
},
|
||||
"ALL_DAY": "جميع الأيام"
|
||||
},
|
||||
"IMAP": {
|
||||
"TITLE": "IMAP",
|
||||
|
|
|
@ -59,7 +59,56 @@
|
|||
"CUSTOM_DATE_RANGE": {
|
||||
"CONFIRM": "تطبيق",
|
||||
"PLACEHOLDER": "اختر نطاق المدة"
|
||||
}
|
||||
},
|
||||
"GROUP_BY_FILTER_DROPDOWN_LABEL": "تجميع بواسطة",
|
||||
"GROUP_BY_DAY_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "اليوم"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_WEEK_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "اليوم"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "الأسبوع"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_MONTH_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "اليوم"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "الأسبوع"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"groupBy": "الشهر"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_YEAR_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "اليوم"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "الأسبوع"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"groupBy": "الشهر"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"groupBy": "السنة"
|
||||
}
|
||||
]
|
||||
},
|
||||
"AGENT_REPORTS": {
|
||||
"HEADER": "نظرة عامة للوكلاء",
|
||||
|
@ -316,6 +365,11 @@
|
|||
"CSAT_REPORTS": {
|
||||
"HEADER": "تقارير CSAT",
|
||||
"NO_RECORDS": "لا توجد ردود متوفرة على الدراسة الاستقصائية CSAT.",
|
||||
"FILTERS": {
|
||||
"AGENTS": {
|
||||
"PLACEHOLDER": "اختر الوكلاء"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"HEADER": {
|
||||
"CONTACT_NAME": "جهات الاتصال",
|
||||
|
|
|
@ -20,16 +20,16 @@
|
|||
"NOTE": "عنوان بريدك الإلكتروني هو المعرف الخاص بك الذي ستستخدمه لتسجيل الدخول."
|
||||
},
|
||||
"MESSAGE_SIGNATURE_SECTION": {
|
||||
"TITLE": "Personal message signature",
|
||||
"NOTE": "Create a personal message signature that would be added to all the messages you send from the platform. Use the rich content editor to create a highly personalised signature.",
|
||||
"BTN_TEXT": "Save message signature",
|
||||
"API_ERROR": "Couldn't save signature! Try again",
|
||||
"API_SUCCESS": "Signature saved successfully"
|
||||
"TITLE": "توقيع الرسالة الشخصية",
|
||||
"NOTE": "إنشاء توقيع رسالة شخصية يتم إضافتها إلى جميع الرسائل التي ترسلها من المنصة. استخدم محرر المحتوى الغني لإنشاء توقيع شديد التخصيص.",
|
||||
"BTN_TEXT": "حفظ توقيع الرسالة",
|
||||
"API_ERROR": "تعذر إرسال الرسالة! حاول مرة أخرى",
|
||||
"API_SUCCESS": "تم حفظ التوقيع بنجاح"
|
||||
},
|
||||
"MESSAGE_SIGNATURE": {
|
||||
"LABEL": "Message Signature",
|
||||
"ERROR": "Message Signature cannot be empty",
|
||||
"PLACEHOLDER": "Insert your personal message signature here."
|
||||
"LABEL": "توقيع الرسالة",
|
||||
"ERROR": "توقيع الرسالة لا يمكن أن يكون فارغاً",
|
||||
"PLACEHOLDER": "أدخل توقيع رسالتك الشخصية هنا."
|
||||
},
|
||||
"PASSWORD_SECTION": {
|
||||
"TITLE": "كلمة المرور",
|
||||
|
@ -146,6 +146,7 @@
|
|||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"CURRENTLY_VIEWING_ACCOUNT": "مشاهدة حاليا:",
|
||||
"CONVERSATIONS": "المحادثات",
|
||||
"ALL_CONVERSATIONS": "كل المحادثات",
|
||||
"MENTIONED_CONVERSATIONS": "الإشارات",
|
||||
|
@ -181,7 +182,8 @@
|
|||
"REPORTS_LABEL": "الوسوم",
|
||||
"REPORTS_INBOX": "صندوق الوارد",
|
||||
"REPORTS_TEAM": "الفريق",
|
||||
"SET_AVAILABILITY_TITLE": "تعيين نفسك كـ"
|
||||
"SET_AVAILABILITY_TITLE": "تعيين نفسك كـ",
|
||||
"BETA": "تجريبي"
|
||||
},
|
||||
"CREATE_ACCOUNT": {
|
||||
"NO_ACCOUNT_WARNING": "أوه! لم نتمكن من العثور على الحساب. الرجاء إنشاء حساب جديد للمتابعة.",
|
||||
|
|
|
@ -21,7 +21,12 @@
|
|||
"is_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": "Статус",
|
||||
|
@ -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": "Последна активност"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard Filters",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"AUTOMATION": {
|
||||
"HEADER": "Автоматизация",
|
||||
"HEADER": "Automations",
|
||||
"HEADER_BTN_TXT": "Добавяне правило за автоматизация",
|
||||
"LOADING": "Fetching automation rules",
|
||||
"SIDEBAR_TXT": "<p><b>Automation Rules</b> <p>Automation can replace and automate existing processes that require manual effort. You can do many things with automation, including adding labels and assigning conversation to the best agent. So the team focuses on what they do best and spends more little time on manual tasks.</p>",
|
||||
|
@ -64,7 +64,7 @@
|
|||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Edit Automation Rule",
|
||||
"SUBMIT": "Редактирай",
|
||||
"SUBMIT": "Обновяване",
|
||||
"CANCEL_BUTTON_TEXT": "Отмени",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Automation rule updated successfully",
|
||||
|
@ -84,6 +84,24 @@
|
|||
"DELETE": "Изтрий",
|
||||
"CANCEL": "Отмени",
|
||||
"RESET_MESSAGE": "Changing event type will reset the conditions and events you have added below"
|
||||
},
|
||||
"CONDITION": {
|
||||
"DELETE_MESSAGE": "You need to have atleast one condition to save"
|
||||
},
|
||||
"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": "Получено чрез имейл",
|
||||
"VIEW_TWEET_IN_TWITTER": "Виж туита в Twitter",
|
||||
"REPLY_TO_TWEET": "Отговори на този туит",
|
||||
"LINK_TO_STORY": "Go to instagram story",
|
||||
"SENT": "Успено изпратено",
|
||||
"NO_MESSAGES": "Няма съобщения",
|
||||
"NO_CONTENT": "Няма налично съдържание",
|
||||
|
|
|
@ -77,9 +77,8 @@
|
|||
"CONFIRM": {
|
||||
"TITLE": "Потвърди изтриването",
|
||||
"MESSAGE": "Сигурни ли сте за изтриването ",
|
||||
"PLACE_HOLDER": "Моля, въведете {contactName} за потвърждение",
|
||||
"YES": "Да, изтрий ",
|
||||
"NO": "Не, запази "
|
||||
"YES": "Да, изтрий",
|
||||
"NO": "Не, запази"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Контакта е изтрит успешно",
|
||||
|
|
|
@ -22,7 +22,8 @@
|
|||
"is_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": "Име",
|
||||
|
@ -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": "Последна активност"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard Filters",
|
||||
|
|
|
@ -61,7 +61,9 @@
|
|||
"ENABLE_SIGN_TOOLTIP": "Enable signature",
|
||||
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents"
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"CLICK_HERE": "Click here to update"
|
||||
},
|
||||
"REPLYBOX": {
|
||||
"REPLY": "Reply",
|
||||
|
@ -72,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": {
|
||||
|
|
|
@ -462,7 +462,8 @@
|
|||
"HOURS": "hours",
|
||||
"VALIDATION_ERROR": "Starting time should be before closing time.",
|
||||
"CHOOSE": "Choose"
|
||||
}
|
||||
},
|
||||
"ALL_DAY": "All-Day"
|
||||
},
|
||||
"IMAP": {
|
||||
"TITLE": "IMAP",
|
||||
|
|
|
@ -59,7 +59,56 @@
|
|||
"CUSTOM_DATE_RANGE": {
|
||||
"CONFIRM": "Apply",
|
||||
"PLACEHOLDER": "Select date range"
|
||||
}
|
||||
},
|
||||
"GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By",
|
||||
"GROUP_BY_DAY_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_WEEK_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_MONTH_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"groupBy": "Month"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_YEAR_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"groupBy": "Month"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"groupBy": "Year"
|
||||
}
|
||||
]
|
||||
},
|
||||
"AGENT_REPORTS": {
|
||||
"HEADER": "Agents Overview",
|
||||
|
@ -316,6 +365,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",
|
||||
|
|
|
@ -146,6 +146,7 @@
|
|||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:",
|
||||
"CONVERSATIONS": "Разговори",
|
||||
"ALL_CONVERSATIONS": "All Conversations",
|
||||
"MENTIONED_CONVERSATIONS": "Споменавания",
|
||||
|
@ -181,7 +182,8 @@
|
|||
"REPORTS_LABEL": "Labels",
|
||||
"REPORTS_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.",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue