Merge branch 'release/2.6.0'
This commit is contained in:
commit
374b367115
715 changed files with 10996 additions and 3230 deletions
|
@ -7,7 +7,7 @@ defaults: &defaults
|
|||
working_directory: ~/build
|
||||
docker:
|
||||
# specify the version you desire here
|
||||
- image: cimg/ruby:3.0.2-browsers
|
||||
- image: cimg/ruby:3.0.4-browsers
|
||||
|
||||
# Specify service dependencies here if necessary
|
||||
# CircleCI maintains a library of pre-built images
|
||||
|
@ -40,14 +40,13 @@ jobs:
|
|||
|
||||
- restore_cache:
|
||||
keys:
|
||||
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }}
|
||||
- chatwoot-bundle
|
||||
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
|
||||
|
||||
- run: bundle install --frozen --path ~/.bundle
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/.bundle
|
||||
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }}
|
||||
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
|
||||
|
||||
|
||||
# Only necessary if app uses webpacker or yarn in some other way
|
||||
|
|
36
.eslintrc.js
36
.eslintrc.js
|
@ -19,18 +19,32 @@ module.exports = {
|
|||
'jsx-a11y/label-has-for': 'off',
|
||||
'jsx-a11y/anchor-is-valid': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'vue/max-attributes-per-line': ['error', {
|
||||
'singleline': 20,
|
||||
'multiline': {
|
||||
'max': 1,
|
||||
'allowFirstLine': false
|
||||
'vue/max-attributes-per-line': [
|
||||
'error',
|
||||
{
|
||||
singleline: 20,
|
||||
multiline: {
|
||||
max: 1,
|
||||
allowFirstLine: false,
|
||||
},
|
||||
},
|
||||
}],
|
||||
'vue/html-self-closing': 'off',
|
||||
"vue/no-v-html": 'off',
|
||||
],
|
||||
'vue/html-self-closing': [
|
||||
'error',
|
||||
{
|
||||
html: {
|
||||
void: 'always',
|
||||
normal: 'always',
|
||||
component: 'always',
|
||||
},
|
||||
svg: 'always',
|
||||
math: 'always',
|
||||
},
|
||||
],
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'import/extensions': ['off'],
|
||||
'no-console': 'error'
|
||||
'no-console': 'error',
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
|
@ -41,12 +55,10 @@ module.exports = {
|
|||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
jest: true,
|
||||
jasmine: true
|
||||
node: true,
|
||||
},
|
||||
globals: {
|
||||
__WEBPACK_ENV__: true,
|
||||
bus: true,
|
||||
},
|
||||
};
|
||||
|
|
2
.github/workflows/run_foss_spec.yml
vendored
2
.github/workflows/run_foss_spec.yml
vendored
|
@ -47,7 +47,7 @@ jobs:
|
|||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.0.2 # Not needed with a .ruby-version file
|
||||
ruby-version: 3.0.4 # Not needed with a .ruby-version file
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- name: yarn
|
||||
|
|
|
@ -1 +1 @@
|
|||
3.0.2
|
||||
3.0.4
|
||||
|
|
10
Gemfile
10
Gemfile
|
@ -1,6 +1,6 @@
|
|||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.0.2'
|
||||
ruby '3.0.4'
|
||||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
|
@ -128,6 +128,9 @@ gem 'html2text'
|
|||
# to calculate working hours
|
||||
gem 'working_hours'
|
||||
|
||||
# full text search for articles
|
||||
gem 'pg_search'
|
||||
|
||||
group :production, :staging do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
|
@ -156,11 +159,6 @@ group :test do
|
|||
end
|
||||
|
||||
group :development, :test do
|
||||
# TODO: is this needed ?
|
||||
# errors thrown by devise password gem
|
||||
gem 'flay'
|
||||
gem 'rspec'
|
||||
# for error thrown by devise password gem
|
||||
gem 'active_record_query_trace'
|
||||
gem 'bundle-audit', require: false
|
||||
gem 'byebug', platform: :mri
|
||||
|
|
35
Gemfile.lock
35
Gemfile.lock
|
@ -1,6 +1,6 @@
|
|||
GIT
|
||||
remote: https://github.com/chatwoot/devise-secure_password
|
||||
revision: de11e8765654b8242d42101ee9c8ffc8126f7975
|
||||
revision: d777b04f12652d576b1272b8f39857e3e0b3fc26
|
||||
specs:
|
||||
devise-secure_password (2.0.1)
|
||||
devise (>= 4.0.0, < 5.0.0)
|
||||
|
@ -182,7 +182,6 @@ GEM
|
|||
regexp_parser (~> 2.2)
|
||||
email_reply_trimmer (0.1.13)
|
||||
erubi (1.10.0)
|
||||
erubis (2.7.0)
|
||||
et-orbi (1.2.7)
|
||||
tzinfo
|
||||
execjs (2.8.1)
|
||||
|
@ -204,11 +203,6 @@ GEM
|
|||
faraday (~> 1)
|
||||
ffi (1.15.5)
|
||||
flag_shih_tzu (0.3.23)
|
||||
flay (2.12.1)
|
||||
erubis (~> 2.7.0)
|
||||
path_expander (~> 1.0)
|
||||
ruby_parser (~> 3.0)
|
||||
sexp_processor (~> 4.0)
|
||||
foreman (0.87.2)
|
||||
fugit (1.5.3)
|
||||
et-orbi (~> 1, >= 1.2.7)
|
||||
|
@ -309,7 +303,7 @@ GEM
|
|||
jbuilder (2.11.5)
|
||||
actionview (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
jmespath (1.6.0)
|
||||
jmespath (1.6.1)
|
||||
jquery-rails (4.4.0)
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
|
@ -378,14 +372,14 @@ GEM
|
|||
netrc (0.11.0)
|
||||
newrelic_rpm (8.7.0)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.13.5)
|
||||
nokogiri (1.13.6)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.5-arm64-darwin)
|
||||
nokogiri (1.13.6-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.5-x86_64-darwin)
|
||||
nokogiri (1.13.6-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.5-x86_64-linux)
|
||||
nokogiri (1.13.6-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (0.5.8)
|
||||
orm_adapter (0.5.0)
|
||||
|
@ -393,8 +387,10 @@ GEM
|
|||
parallel (1.21.0)
|
||||
parser (3.1.1.0)
|
||||
ast (~> 2.4.1)
|
||||
path_expander (1.1.0)
|
||||
pg (1.3.2)
|
||||
pg_search (2.3.6)
|
||||
activerecord (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
procore-sift (0.16.0)
|
||||
rails (> 4.2.0)
|
||||
pry (0.14.1)
|
||||
|
@ -409,7 +405,7 @@ GEM
|
|||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.6.0)
|
||||
rack (2.2.3)
|
||||
rack (2.2.3.1)
|
||||
rack-attack (6.6.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-cors (1.1.1)
|
||||
|
@ -468,10 +464,6 @@ GEM
|
|||
netrc (~> 0.8)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.5)
|
||||
rspec (3.11.0)
|
||||
rspec-core (~> 3.11.0)
|
||||
rspec-expectations (~> 3.11.0)
|
||||
rspec-mocks (~> 3.11.0)
|
||||
rspec-core (3.11.0)
|
||||
rspec-support (~> 3.11.0)
|
||||
rspec-expectations (3.11.0)
|
||||
|
@ -681,7 +673,6 @@ DEPENDENCIES
|
|||
faker
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
flay
|
||||
foreman
|
||||
geocoder
|
||||
google-cloud-dialogflow
|
||||
|
@ -706,6 +697,7 @@ DEPENDENCIES
|
|||
mock_redis
|
||||
newrelic_rpm
|
||||
pg
|
||||
pg_search
|
||||
procore-sift
|
||||
pry-rails
|
||||
puma
|
||||
|
@ -718,7 +710,6 @@ DEPENDENCIES
|
|||
redis-namespace
|
||||
responders
|
||||
rest-client
|
||||
rspec
|
||||
rspec-rails (~> 5.0.0)
|
||||
rubocop
|
||||
rubocop-performance
|
||||
|
@ -752,7 +743,7 @@ DEPENDENCIES
|
|||
working_hours
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.0.2p107
|
||||
ruby 3.0.4p208
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.10
|
||||
2.3.15
|
||||
|
|
|
@ -27,7 +27,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||
end
|
||||
ensure_contact_avatar
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}"
|
||||
@inbox.channel.authorization_error!
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
true
|
||||
|
|
|
@ -73,6 +73,10 @@ class Messages::MessageBuilder
|
|||
@params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {}
|
||||
end
|
||||
|
||||
def template_params
|
||||
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
|
||||
end
|
||||
|
||||
def message_sender
|
||||
return if @params[:sender_type] != 'AgentBot'
|
||||
|
||||
|
@ -91,6 +95,6 @@ class Messages::MessageBuilder
|
|||
items: @items,
|
||||
in_reply_to: @in_reply_to,
|
||||
echo_id: @params[:echo_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
end
|
||||
end
|
||||
|
|
48
app/controllers/api/v1/accounts/articles_controller.rb
Normal file
48
app/controllers/api/v1/accounts/articles_controller.rb
Normal file
|
@ -0,0 +1,48 @@
|
|||
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :fetch_article, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@articles = @portal.articles
|
||||
@articles.search(list_params) if params[:payload].present?
|
||||
end
|
||||
|
||||
def create
|
||||
@article = @portal.articles.create!(article_params)
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@article.update!(article_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@article.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_article
|
||||
@article = @portal.articles.find(params[:id])
|
||||
end
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def article_params
|
||||
params.require(:article).permit(
|
||||
:title, :content, :description, :position, :category_id, :author_id
|
||||
)
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.require(:payload).permit(
|
||||
:category_slug, :locale, :query
|
||||
)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,24 @@
|
|||
class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inboxes
|
||||
|
||||
def index
|
||||
agent_ids = @inboxes.map do |inbox|
|
||||
authorize inbox, :show?
|
||||
member_ids = inbox.members.pluck(:user_id)
|
||||
member_ids
|
||||
end
|
||||
agent_ids = agent_ids.inject(:&)
|
||||
agents = Current.account.users.where(id: agent_ids)
|
||||
@assignable_agents = (agents + Current.account.administrators).uniq
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inboxes
|
||||
@inboxes = Current.account.inboxes.find(permitted_params[:inbox_ids])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(inbox_ids: [])
|
||||
end
|
||||
end
|
|
@ -9,6 +9,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||
def create
|
||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions]
|
||||
@automation_rule.conditions = params[:conditions]
|
||||
|
||||
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
||||
|
||||
|
@ -31,9 +32,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
@automation_rule.update!(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions] if params[:actions]
|
||||
@automation_rule.save!
|
||||
automation_rule_update
|
||||
process_attachments
|
||||
|
||||
rescue StandardError => e
|
||||
|
@ -67,10 +66,17 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||
|
||||
private
|
||||
|
||||
def automation_rule_update
|
||||
@automation_rule.update!(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions] if params[:actions]
|
||||
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||
@automation_rule.save!
|
||||
end
|
||||
|
||||
def automation_rules_permit
|
||||
params.permit(
|
||||
:name, :description, :event_name, :account_id, :active,
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
end
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase::BaseController
|
||||
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :fetch_category, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@categories = @portal.categories
|
||||
@categories = @portal.categories.search(params)
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -24,9 +25,13 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase:
|
|||
@category = @portal.categories.find(params[:id])
|
||||
end
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:category).permit(
|
||||
:name, :description, :position
|
||||
:name, :description, :position, :slug, :locale
|
||||
)
|
||||
end
|
||||
end
|
|
@ -5,7 +5,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
|
|||
RESULTS_PER_PAGE = 25
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :set_csat_survey_responses, only: [:index, :metrics]
|
||||
before_action :set_csat_survey_responses, only: [:index, :metrics, :download]
|
||||
before_action :set_current_page, only: [:index]
|
||||
before_action :set_current_page_surveys, only: [:index]
|
||||
before_action :set_total_sent_messages_count, only: [:metrics]
|
||||
|
@ -19,6 +19,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
|
|||
@ratings_count = @csat_survey_responses.group(:rating).count
|
||||
end
|
||||
|
||||
def download
|
||||
response.headers['Content-Type'] = 'text/csv'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=csat_report.csv'
|
||||
render layout: false, template: 'api/v1/accounts/csat_survey_responses/download.csv.erb', format: 'csv'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_total_sent_messages_count
|
||||
|
|
44
app/controllers/api/v1/accounts/dashboard_apps_controller.rb
Normal file
44
app/controllers/api/v1/accounts/dashboard_apps_controller.rb
Normal file
|
@ -0,0 +1,44 @@
|
|||
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_dashboard_apps, except: [:create]
|
||||
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
|
||||
|
||||
def index; end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@dashboard_app = Current.account.dashboard_apps.create!(
|
||||
permitted_payload.merge(user_id: Current.user.id)
|
||||
)
|
||||
end
|
||||
|
||||
def update
|
||||
@dashboard_app.update!(permitted_payload)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@dashboard_app.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_dashboard_apps
|
||||
@dashboard_apps = Current.account.dashboard_apps
|
||||
end
|
||||
|
||||
def fetch_dashboard_app
|
||||
@dashboard_app = @dashboard_apps.find(permitted_params[:id])
|
||||
end
|
||||
|
||||
def permitted_payload
|
||||
params.require(:dashboard_app).permit(
|
||||
:title,
|
||||
content: [:url, :type]
|
||||
)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id)
|
||||
end
|
||||
end
|
|
@ -12,6 +12,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
|
||||
def show; end
|
||||
|
||||
# Deprecated: This API will be removed in 2.7.0
|
||||
def assignable_agents
|
||||
@assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq
|
||||
end
|
||||
|
@ -41,15 +42,19 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def update
|
||||
@inbox.update(permitted_params.except(:channel))
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
@inbox.update!(permitted_params.except(:channel))
|
||||
update_inbox_working_hours
|
||||
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
||||
|
||||
# Inbox update doesn't necessarily need channel attributes
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
if @inbox.inbox_type == 'Email'
|
||||
validate_email_channel(channel_attributes)
|
||||
begin
|
||||
validate_email_channel(channel_attributes)
|
||||
rescue StandardError => e
|
||||
render json: { message: e }, status: :unprocessable_entity and return
|
||||
end
|
||||
@inbox.channel.reauthorized!
|
||||
end
|
||||
|
||||
|
@ -57,6 +62,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
update_channel_feature_flags
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
end
|
||||
|
||||
def agent_bot
|
||||
@agent_bot = @inbox.agent_bot
|
||||
end
|
||||
|
@ -88,12 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def inbox_name(channel)
|
||||
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
|
||||
|
||||
permitted_params[:name]
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
|
||||
|
||||
|
@ -108,10 +111,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
@inbox.channel.save!
|
||||
end
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
params.permit(
|
||||
:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
*inbox_attributes,
|
||||
channel: [:type, *channel_attributes]
|
||||
)
|
||||
end
|
||||
|
@ -128,18 +135,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
def account_channels_method
|
||||
{
|
||||
'web_widget' => Current.account.web_widgets,
|
||||
'api' => Current.account.api_channels,
|
||||
'email' => Current.account.email_channels,
|
||||
'line' => Current.account.line_channels,
|
||||
'telegram' => Current.account.telegram_channels,
|
||||
'whatsapp' => Current.account.whatsapp_channels,
|
||||
'sms' => Current.account.sms_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
def get_channel_attributes(channel_type)
|
||||
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
||||
channel_type.constantize::EDITABLE_ATTRS.presence
|
||||
|
@ -147,10 +142,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
|
||||
|
||||
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
class Api::V1::Accounts::Kbase::BaseController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
|
||||
private
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.kbase_portals.find_by(slug: params[:portal_id])
|
||||
end
|
||||
end
|
|
@ -1,14 +1,14 @@
|
|||
class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_portal, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@portals = Current.account.kbase_portals
|
||||
@portals = Current.account.portals
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@portal = Current.account.kbase_portals.create!(portal_params)
|
||||
@portal = Current.account.portals.create!(portal_params)
|
||||
end
|
||||
|
||||
def update
|
||||
|
@ -23,7 +23,7 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::BaseContr
|
|||
private
|
||||
|
||||
def fetch_portal
|
||||
@portal = Current.account.kbase_portals.find_by(slug: permitted_params[:id])
|
||||
@portal = Current.account.portals.find_by(slug: permitted_params[:id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
@ -32,7 +32,7 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::BaseContr
|
|||
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug
|
||||
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived
|
||||
)
|
||||
end
|
||||
end
|
|
@ -54,6 +54,7 @@ class Api::V1::Widget::BaseController < ApplicationController
|
|||
).perform
|
||||
else
|
||||
@contact.update!(email: email)
|
||||
update_contact_name
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -67,9 +68,14 @@ class Api::V1::Widget::BaseController < ApplicationController
|
|||
).perform
|
||||
else
|
||||
@contact.update!(phone_number: phone_number)
|
||||
update_contact_name
|
||||
end
|
||||
end
|
||||
|
||||
def update_contact_name
|
||||
@contact.update!(name: contact_name) if contact_name.present?
|
||||
end
|
||||
|
||||
def contact_email
|
||||
permitted_params.dig(:contact, :email)&.downcase
|
||||
end
|
||||
|
|
|
@ -16,7 +16,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||
def process_update_contact
|
||||
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
|
||||
update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present?
|
||||
@contact.update!(name: contact_name) if contact_name.present?
|
||||
end
|
||||
|
||||
def update_last_seen
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
include Api::V2::Accounts::ReportsHelper
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
|
@ -12,27 +13,23 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def agents
|
||||
response.headers['Content-Type'] = 'text/csv'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=agents_report.csv'
|
||||
render layout: false, template: 'api/v2/accounts/reports/agents.csv.erb', format: 'csv'
|
||||
@report_data = generate_agents_report
|
||||
generate_csv('agents_report', 'api/v2/accounts/reports/agents.csv.erb')
|
||||
end
|
||||
|
||||
def inboxes
|
||||
response.headers['Content-Type'] = 'text/csv'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=inboxes_report.csv'
|
||||
render layout: false, template: 'api/v2/accounts/reports/inboxes.csv.erb', format: 'csv'
|
||||
@report_data = generate_inboxes_report
|
||||
generate_csv('inboxes_report', 'api/v2/accounts/reports/inboxes.csv.erb')
|
||||
end
|
||||
|
||||
def labels
|
||||
response.headers['Content-Type'] = 'text/csv'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=labels_report.csv'
|
||||
render layout: false, template: 'api/v2/accounts/reports/labels.csv.erb', format: 'csv'
|
||||
@report_data = generate_labels_report
|
||||
generate_csv('labels_report', 'api/v2/accounts/reports/labels.csv.erb')
|
||||
end
|
||||
|
||||
def teams
|
||||
response.headers['Content-Type'] = 'text/csv'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=teams_report.csv'
|
||||
render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv'
|
||||
@report_data = generate_teams_report
|
||||
generate_csv('teams_report', 'api/v2/accounts/reports/teams.csv.erb')
|
||||
end
|
||||
|
||||
def conversations
|
||||
|
@ -43,6 +40,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||
|
||||
private
|
||||
|
||||
def generate_csv(filename, template)
|
||||
response.headers['Content-Type'] = 'text/csv'
|
||||
response.headers['Content-Disposition'] = "attachment; filename=#{filename}.csv"
|
||||
render layout: false, template: template, format: 'csv'
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
class ApplicationController < ActionController::Base
|
||||
include DeviseTokenAuth::Concerns::SetUserByToken
|
||||
include RequestExceptionHandler
|
||||
include Pundit
|
||||
include Pundit::Authorization
|
||||
include SwitchLocale
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
|
|
@ -43,7 +43,8 @@ class DashboardController < ActionController::Base
|
|||
VAPID_PUBLIC_KEY: VapidService.public_key,
|
||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: 'v13.0'
|
||||
FACEBOOK_API_VERSION: 'v14.0',
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -51,6 +51,7 @@ class ConversationFinder
|
|||
filter_by_team if @team
|
||||
filter_by_labels if params[:labels]
|
||||
filter_by_query if params[:q]
|
||||
filter_by_reply_status
|
||||
end
|
||||
|
||||
def set_inboxes
|
||||
|
@ -90,6 +91,10 @@ class ConversationFinder
|
|||
@conversations
|
||||
end
|
||||
|
||||
def filter_by_reply_status
|
||||
@conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended'
|
||||
end
|
||||
|
||||
def filter_by_query
|
||||
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
|
||||
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
module Api::V1::InboxesHelper
|
||||
def inbox_name(channel)
|
||||
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
|
||||
|
||||
permitted_params[:name]
|
||||
end
|
||||
|
||||
def validate_email_channel(attributes)
|
||||
channel_data = permitted_params(attributes)[:channel]
|
||||
|
||||
|
@ -19,8 +25,7 @@ module Api::V1::InboxesHelper
|
|||
enable_ssl: channel_data[:imap_enable_ssl] }
|
||||
end
|
||||
|
||||
Mail.connection do # rubocop:disable:block
|
||||
end
|
||||
check_imap_connection(channel_data)
|
||||
end
|
||||
|
||||
def validate_smtp(channel_data)
|
||||
|
@ -32,6 +37,25 @@ module Api::V1::InboxesHelper
|
|||
check_smtp_connection(channel_data, smtp)
|
||||
end
|
||||
|
||||
def check_imap_connection(channel_data)
|
||||
Mail.connection {} # rubocop:disable:block
|
||||
rescue SocketError => e
|
||||
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
|
||||
rescue Net::IMAP::NoResponseError => e
|
||||
raise StandardError, I18n.t('errors.inboxes.imap.no_response_error')
|
||||
rescue Errno::EHOSTUNREACH => e
|
||||
raise StandardError, I18n.t('errors.inboxes.imap.host_unreachable_error')
|
||||
rescue Net::OpenTimeout => e
|
||||
raise StandardError,
|
||||
I18n.t('errors.inboxes.imap.connection_timed_out_error', address: channel_data[:imap_address], port: channel_data[:imap_port])
|
||||
rescue Net::IMAP::Error => e
|
||||
raise StandardError, I18n.t('errors.inboxes.imap.connection_closed_error')
|
||||
rescue StandardError => e
|
||||
raise StandardError, e.message
|
||||
ensure
|
||||
Rails.logger.error e if e.present?
|
||||
end
|
||||
|
||||
def check_smtp_connection(channel_data, smtp)
|
||||
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
||||
channel_data[:smtp_authentication]&.to_sym || :login)
|
||||
|
@ -74,4 +98,22 @@ module Api::V1::InboxesHelper
|
|||
context.verify_mode = openssl_verify_mode
|
||||
context
|
||||
end
|
||||
|
||||
def account_channels_method
|
||||
{
|
||||
'web_widget' => Current.account.web_widgets,
|
||||
'api' => Current.account.api_channels,
|
||||
'email' => Current.account.email_channels,
|
||||
'line' => Current.account.line_channels,
|
||||
'telegram' => Current.account.telegram_channels,
|
||||
'whatsapp' => Current.account.whatsapp_channels,
|
||||
'sms' => Current.account.sms_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
|
||||
|
||||
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
|
||||
end
|
||||
end
|
||||
|
|
56
app/helpers/api/v2/accounts/reports_helper.rb
Normal file
56
app/helpers/api/v2/accounts/reports_helper.rb
Normal file
|
@ -0,0 +1,56 @@
|
|||
module Api::V2::Accounts::ReportsHelper
|
||||
def generate_agents_report
|
||||
Current.account.users.map do |agent|
|
||||
agent_report = generate_report({ type: :agent, id: agent.id })
|
||||
[agent.name] + generate_readable_report_metrics(agent_report)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_inboxes_report
|
||||
Current.account.inboxes.map do |inbox|
|
||||
inbox_report = generate_report({ type: :inbox, id: inbox.id })
|
||||
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_teams_report
|
||||
Current.account.teams.map do |team|
|
||||
team_report = generate_report({ type: :team, id: team.id })
|
||||
[team.name] + generate_readable_report_metrics(team_report)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_labels_report
|
||||
Current.account.labels.map do |label|
|
||||
label_report = generate_report({ type: :label, id: label.id })
|
||||
[label.title] + generate_readable_report_metrics(label_report)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_report(report_params)
|
||||
V2::ReportBuilder.new(
|
||||
Current.account,
|
||||
report_params.merge(
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
}
|
||||
)
|
||||
).summary
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_readable_report_metrics(report_metric)
|
||||
[
|
||||
report_metric[:conversations_count],
|
||||
time_to_minutes(report_metric[:avg_first_response_time]),
|
||||
time_to_minutes(report_metric[:avg_resolution_time])
|
||||
]
|
||||
end
|
||||
|
||||
def time_to_minutes(time_in_seconds)
|
||||
(time_in_seconds / 60).to_i
|
||||
end
|
||||
end
|
|
@ -2,7 +2,7 @@
|
|||
<div v-if="!authUIFlags.isFetching" id="app" class="app-wrapper app-root">
|
||||
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view></router-view>
|
||||
<router-view />
|
||||
</transition>
|
||||
<add-account-modal
|
||||
:show="showAddAccountModal"
|
||||
|
|
16
app/javascript/dashboard/api/assignableAgents.js
Normal file
16
app/javascript/dashboard/api/assignableAgents.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AssignableAgents extends ApiClient {
|
||||
constructor() {
|
||||
super('assignable_agents', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(inboxIds) {
|
||||
return axios.get(this.url, {
|
||||
params: { inbox_ids: inboxIds },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new AssignableAgents();
|
9
app/javascript/dashboard/api/bulkActions.js
Normal file
9
app/javascript/dashboard/api/bulkActions.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import ApiClient from './ApiClient';
|
||||
|
||||
class BulkActionsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('bulk_actions', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new BulkActionsAPI();
|
|
@ -18,6 +18,17 @@ class CSATReportsAPI extends ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
download({ from, to, user_ids } = {}) {
|
||||
return axios.get(`${this.url}/download`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
sort: '-created_at',
|
||||
user_ids,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({ from, to, user_ids } = {}) {
|
||||
return axios.get(`${this.url}/metrics`, {
|
||||
params: { since: from, until: to, user_ids },
|
||||
|
|
9
app/javascript/dashboard/api/dashboardApps.js
Normal file
9
app/javascript/dashboard/api/dashboardApps.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import ApiClient from './ApiClient';
|
||||
|
||||
class DashboardAppsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('dashboard_apps', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new DashboardAppsAPI();
|
|
@ -10,6 +10,7 @@ export const buildCreatePayload = ({
|
|||
files,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
templateParams,
|
||||
}) => {
|
||||
let payload;
|
||||
if (files && files.length !== 0) {
|
||||
|
@ -32,6 +33,7 @@ export const buildCreatePayload = ({
|
|||
content_attributes: contentAttributes,
|
||||
cc_emails: ccEmails,
|
||||
bcc_emails: bccEmails,
|
||||
template_params: templateParams,
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
|
@ -51,6 +53,7 @@ class MessageApi extends ApiClient {
|
|||
files,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
templateParams,
|
||||
}) {
|
||||
return axios({
|
||||
method: 'post',
|
||||
|
@ -63,6 +66,7 @@ class MessageApi extends ApiClient {
|
|||
files,
|
||||
ccEmails,
|
||||
bccEmails,
|
||||
templateParams,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -6,10 +6,6 @@ class Inboxes extends ApiClient {
|
|||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
getAssignableAgents(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/assignable_agents`);
|
||||
}
|
||||
|
||||
getCampaigns(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/campaigns`);
|
||||
}
|
||||
|
|
|
@ -53,27 +53,27 @@ class ReportsAPI extends ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
getAgentReports(since, until) {
|
||||
getAgentReports({ from: since, to: until, businessHours }) {
|
||||
return axios.get(`${this.url}/agents`, {
|
||||
params: { since, until },
|
||||
params: { since, until, business_hours: businessHours },
|
||||
});
|
||||
}
|
||||
|
||||
getLabelReports(since, until) {
|
||||
getLabelReports({ from: since, to: until, businessHours }) {
|
||||
return axios.get(`${this.url}/labels`, {
|
||||
params: { since, until },
|
||||
params: { since, until, business_hours: businessHours },
|
||||
});
|
||||
}
|
||||
|
||||
getInboxReports(since, until) {
|
||||
getInboxReports({ from: since, to: until, businessHours }) {
|
||||
return axios.get(`${this.url}/inboxes`, {
|
||||
params: { since, until },
|
||||
params: { since, until, business_hours: businessHours },
|
||||
});
|
||||
}
|
||||
|
||||
getTeamReports(since, until) {
|
||||
getTeamReports({ from: since, to: until, businessHours }) {
|
||||
return axios.get(`${this.url}/teams`, {
|
||||
params: { since, until },
|
||||
params: { since, until, business_hours: businessHours },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
18
app/javascript/dashboard/api/specs/assignableAgents.spec.js
Normal file
18
app/javascript/dashboard/api/specs/assignableAgents.spec.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import assignableAgentsAPI from '../assignableAgents';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#AssignableAgentsAPI', () => {
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#getAssignableAgents', () => {
|
||||
assignableAgentsAPI.get([1]);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/assignable_agents',
|
||||
{
|
||||
params: {
|
||||
inbox_ids: [1],
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
9
app/javascript/dashboard/api/specs/bulkAction.spec.js
Normal file
9
app/javascript/dashboard/api/specs/bulkAction.spec.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import bulkActions from '../bulkActions';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#BulkActionsAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(bulkActions).toBeInstanceOf(ApiClient);
|
||||
expect(bulkActions).toHaveProperty('create');
|
||||
});
|
||||
});
|
|
@ -33,5 +33,23 @@ describe('#Reports API', () => {
|
|||
}
|
||||
);
|
||||
});
|
||||
it('#download', () => {
|
||||
csatReportsAPI.download({
|
||||
from: 1622485800,
|
||||
to: 1623695400,
|
||||
user_ids: 1,
|
||||
});
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/csat_survey_responses/download',
|
||||
{
|
||||
params: {
|
||||
since: 1622485800,
|
||||
until: 1623695400,
|
||||
user_ids: 1,
|
||||
sort: '-created_at',
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
13
app/javascript/dashboard/api/specs/dashboardApps.spec.js
Normal file
13
app/javascript/dashboard/api/specs/dashboardApps.spec.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import dashboardAppsAPI from '../dashboardApps';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#dashboardAppsAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(dashboardAppsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(dashboardAppsAPI).toHaveProperty('get');
|
||||
expect(dashboardAppsAPI).toHaveProperty('show');
|
||||
expect(dashboardAppsAPI).toHaveProperty('create');
|
||||
expect(dashboardAppsAPI).toHaveProperty('update');
|
||||
expect(dashboardAppsAPI).toHaveProperty('delete');
|
||||
});
|
||||
});
|
|
@ -10,17 +10,9 @@ describe('#InboxesAPI', () => {
|
|||
expect(inboxesAPI).toHaveProperty('create');
|
||||
expect(inboxesAPI).toHaveProperty('update');
|
||||
expect(inboxesAPI).toHaveProperty('delete');
|
||||
expect(inboxesAPI).toHaveProperty('getAssignableAgents');
|
||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#getAssignableAgents', () => {
|
||||
inboxesAPI.getAssignableAgents(1);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/1/assignable_agents'
|
||||
);
|
||||
});
|
||||
|
||||
it('#getCampaigns', () => {
|
||||
inboxesAPI.getCampaigns(2);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
|
|
|
@ -47,20 +47,25 @@ describe('#Reports API', () => {
|
|||
});
|
||||
|
||||
it('#getAgentReports', () => {
|
||||
reportsAPI.getAgentReports(1621103400, 1621621800);
|
||||
reportsAPI.getAgentReports({
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
businessHours: true,
|
||||
});
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/agents',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
business_hours: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getLabelReports', () => {
|
||||
reportsAPI.getLabelReports(1621103400, 1621621800);
|
||||
reportsAPI.getLabelReports({ from: 1621103400, to: 1621621800 });
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/labels',
|
||||
{
|
||||
|
@ -73,7 +78,7 @@ describe('#Reports API', () => {
|
|||
});
|
||||
|
||||
it('#getInboxReports', () => {
|
||||
reportsAPI.getInboxReports(1621103400, 1621621800);
|
||||
reportsAPI.getInboxReports({ from: 1621103400, to: 1621621800 });
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/inboxes',
|
||||
{
|
||||
|
@ -86,7 +91,7 @@ describe('#Reports API', () => {
|
|||
});
|
||||
|
||||
it('#getTeamReports', () => {
|
||||
reportsAPI.getTeamReports(1621103400, 1621621800);
|
||||
reportsAPI.getTeamReports({ from: 1621103400, to: 1621621800 });
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/teams',
|
||||
{
|
||||
|
|
|
@ -60,3 +60,9 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="conversations-list-wrap">
|
||||
<slot></slot>
|
||||
<slot />
|
||||
<div
|
||||
class="chat-list__top"
|
||||
:class="{ filter__applied: hasAppliedFiltersOrActiveFolders }"
|
||||
|
@ -53,8 +53,7 @@
|
|||
size="small"
|
||||
class="btn-filter"
|
||||
@click="onToggleAdvanceFiltersModal"
|
||||
>
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -85,7 +84,19 @@
|
|||
<p v-if="!chatListLoading && !conversationList.length" class="content-box">
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
|
||||
<conversation-bulk-actions
|
||||
v-if="selectedConversations.length"
|
||||
:conversations="selectedConversations"
|
||||
:all-conversations-selected="allConversationsSelected"
|
||||
:selected-inboxes="uniqueInboxes"
|
||||
:show-open-action="allSelectedConversationsStatus('open')"
|
||||
:show-resolved-action="allSelectedConversationsStatus('resolved')"
|
||||
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
|
||||
@select-all-conversations="selectAllConversations"
|
||||
@assign-agent="onAssignAgent"
|
||||
@update-conversations="onUpdateConversations"
|
||||
@assign-labels="onAssignLabels"
|
||||
/>
|
||||
<div ref="activeConversation" class="conversations-list">
|
||||
<conversation-card
|
||||
v-for="chat in conversationList"
|
||||
|
@ -96,10 +107,13 @@
|
|||
:chat="chat"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:selected="isConversationSelected(chat.id)"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
|
||||
<div v-if="chatListLoading" class="text-center">
|
||||
<span class="spinner"></span>
|
||||
<span class="spinner" />
|
||||
</div>
|
||||
|
||||
<woot-button
|
||||
|
@ -112,11 +126,7 @@
|
|||
</woot-button>
|
||||
|
||||
<p
|
||||
v-if="
|
||||
conversationList.length &&
|
||||
hasCurrentPageEndReached &&
|
||||
!chatListLoading
|
||||
"
|
||||
v-if="showEndOfListMessage"
|
||||
class="text-center text-muted end-of-list-text"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
|
@ -152,6 +162,8 @@ import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
|||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
import {
|
||||
hasPressedAltAndJKey,
|
||||
|
@ -166,8 +178,9 @@ export default {
|
|||
ChatFilter,
|
||||
ConversationAdvancedFilter,
|
||||
DeleteCustomViews,
|
||||
ConversationBulkActions,
|
||||
},
|
||||
mixins: [timeMixin, conversationMixin, eventListenerMixins],
|
||||
mixins: [timeMixin, conversationMixin, eventListenerMixins, alertMixin],
|
||||
props: {
|
||||
conversationInbox: {
|
||||
type: [String, Number],
|
||||
|
@ -202,6 +215,8 @@ export default {
|
|||
foldersQuery: {},
|
||||
showAddFoldersModal: false,
|
||||
showDeleteFoldersModal: false,
|
||||
selectedConversations: [],
|
||||
selectedInboxes: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -217,6 +232,7 @@ export default {
|
|||
conversationStats: 'conversationStats/getStats',
|
||||
appliedFilters: 'getAppliedConversationFilters',
|
||||
folders: 'customViews/getCustomViews',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
}),
|
||||
hasAppliedFilters() {
|
||||
return this.appliedFilters.length !== 0;
|
||||
|
@ -234,12 +250,24 @@ export default {
|
|||
}
|
||||
return {};
|
||||
},
|
||||
showEndOfListMessage() {
|
||||
return (
|
||||
this.conversationList.length &&
|
||||
this.hasCurrentPageEndReached &&
|
||||
!this.chatListLoading
|
||||
);
|
||||
},
|
||||
assigneeTabItems() {
|
||||
return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => {
|
||||
const count = this.conversationStats[item.COUNT_KEY] || 0;
|
||||
const ASSIGNEE_TYPE_TAB_KEYS = {
|
||||
me: 'mineCount',
|
||||
unassigned: 'unAssignedCount',
|
||||
all: 'allCount',
|
||||
};
|
||||
return Object.keys(ASSIGNEE_TYPE_TAB_KEYS).map(key => {
|
||||
const count = this.conversationStats[ASSIGNEE_TYPE_TAB_KEYS[key]] || 0;
|
||||
return {
|
||||
key: item.KEY,
|
||||
name: item.NAME,
|
||||
key,
|
||||
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
|
||||
count,
|
||||
};
|
||||
});
|
||||
|
@ -338,6 +366,17 @@ export default {
|
|||
}
|
||||
return {};
|
||||
},
|
||||
allConversationsSelected() {
|
||||
return (
|
||||
this.conversationList.length === this.selectedConversations.length &&
|
||||
this.conversationList.every(el =>
|
||||
this.selectedConversations.includes(el.id)
|
||||
)
|
||||
);
|
||||
},
|
||||
uniqueInboxes() {
|
||||
return [...new Set(this.selectedInboxes)];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
activeTeam() {
|
||||
|
@ -371,6 +410,7 @@ export default {
|
|||
if (this.$route.name !== 'home') {
|
||||
this.$router.push({ name: 'home' });
|
||||
}
|
||||
this.resetBulkActions();
|
||||
this.foldersQuery = filterQueryGenerator(payload);
|
||||
this.$store.dispatch('conversationPage/reset');
|
||||
this.$store.dispatch('emptyAllConversations');
|
||||
|
@ -436,6 +476,7 @@ export default {
|
|||
}
|
||||
},
|
||||
resetAndFetchData() {
|
||||
this.resetBulkActions();
|
||||
this.$store.dispatch('conversationPage/reset');
|
||||
this.$store.dispatch('emptyAllConversations');
|
||||
this.$store.dispatch('clearConversationFilters');
|
||||
|
@ -486,6 +527,7 @@ export default {
|
|||
},
|
||||
updateAssigneeTab(selectedTab) {
|
||||
if (this.activeAssigneeTab !== selectedTab) {
|
||||
this.resetBulkActions();
|
||||
bus.$emit('clearSearchInput');
|
||||
this.activeAssigneeTab = selectedTab;
|
||||
if (!this.currentPage) {
|
||||
|
@ -493,6 +535,10 @@ export default {
|
|||
}
|
||||
}
|
||||
},
|
||||
resetBulkActions() {
|
||||
this.selectedConversations = [];
|
||||
this.selectedInboxes = [];
|
||||
},
|
||||
updateStatusType(index) {
|
||||
if (this.activeStatus !== index) {
|
||||
this.activeStatus = index;
|
||||
|
@ -515,6 +561,80 @@ export default {
|
|||
this.fetchConversations();
|
||||
}
|
||||
},
|
||||
isConversationSelected(id) {
|
||||
return this.selectedConversations.includes(id);
|
||||
},
|
||||
selectConversation(conversationId, inboxId) {
|
||||
this.selectedConversations.push(conversationId);
|
||||
this.selectedInboxes.push(inboxId);
|
||||
},
|
||||
deSelectConversation(conversationId, inboxId) {
|
||||
this.selectedConversations = this.selectedConversations.filter(
|
||||
item => item !== conversationId
|
||||
);
|
||||
this.selectedInboxes = this.selectedInboxes.filter(
|
||||
item => item !== inboxId
|
||||
);
|
||||
},
|
||||
selectAllConversations(check) {
|
||||
if (check) {
|
||||
this.selectedConversations = this.conversationList.map(item => item.id);
|
||||
this.selectedInboxes = this.conversationList.map(item => item.inbox_id);
|
||||
} else {
|
||||
this.resetBulkActions();
|
||||
}
|
||||
},
|
||||
async onAssignAgent(agent) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: this.selectedConversations,
|
||||
fields: {
|
||||
assignee_id: agent.id,
|
||||
},
|
||||
});
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.ASSIGN_SUCCESFUL'));
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
|
||||
}
|
||||
},
|
||||
async onAssignLabels(labels) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: this.selectedConversations,
|
||||
labels: {
|
||||
add: labels,
|
||||
},
|
||||
});
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
|
||||
}
|
||||
},
|
||||
async onUpdateConversations(status) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: this.selectedConversations,
|
||||
fields: {
|
||||
status,
|
||||
},
|
||||
});
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||
}
|
||||
},
|
||||
allSelectedConversationsStatus(status) {
|
||||
if (!this.selectedConversations.length) return false;
|
||||
return this.selectedConversations.every(item => {
|
||||
return this.$store.getters.getConversationById(item).status === status;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -530,7 +650,7 @@ export default {
|
|||
.conversations-list-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 34rem;
|
||||
|
||||
overflow: hidden;
|
||||
@include breakpoint(large up) {
|
||||
width: 36rem;
|
||||
}
|
||||
|
|
|
@ -98,4 +98,7 @@ export default {
|
|||
width: 48rem;
|
||||
}
|
||||
}
|
||||
.modal-big {
|
||||
width: 60%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<p v-if="headerContent" class="small-12 column">
|
||||
{{ headerContent }}
|
||||
</p>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -20,8 +20,7 @@
|
|||
color-scheme="warning"
|
||||
icon="dismiss-circle"
|
||||
@click="closeNotification"
|
||||
>
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<div class="medium-6 small-12">
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
:icon="icon"
|
||||
/>
|
||||
<spinner v-if="isLoading" />
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -95,6 +95,7 @@ export default {
|
|||
),
|
||||
type: inbox.channel_type,
|
||||
phoneNumber: inbox.phone_number,
|
||||
reauthorizationRequired: inbox.reauthorization_required,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1
|
||||
|
|
|
@ -30,6 +30,14 @@
|
|||
<span v-if="count" class="badge" :class="{ secondary: !isActive }">
|
||||
{{ count }}
|
||||
</span>
|
||||
<span v-if="warningIcon" class="badge--icon">
|
||||
<fluent-icon
|
||||
v-tooltip.top-end="$t('SIDEBAR.FACEBOOK_REAUTHORIZE')"
|
||||
class="inbox-icon"
|
||||
:icon="warningIcon"
|
||||
size="12"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</router-link>
|
||||
|
@ -57,6 +65,10 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
warningIcon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
count: {
|
||||
type: String,
|
||||
default: '',
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
:label-color="child.color"
|
||||
:should-truncate="child.truncateLabel"
|
||||
:icon="computedInboxClass(child)"
|
||||
:warning-icon="computedInboxErrorClass(child)"
|
||||
/>
|
||||
<router-link
|
||||
v-if="showItem(menuItem)"
|
||||
|
@ -63,7 +64,10 @@
|
|||
import { mapGetters } from 'vuex';
|
||||
|
||||
import adminMixin from '../../../mixins/isAdmin';
|
||||
import { getInboxClassByType } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
getInboxClassByType,
|
||||
getInboxWarningIconClass,
|
||||
} from 'dashboard/helper/inbox';
|
||||
|
||||
import SecondaryChildNavItem from './SecondaryChildNavItem';
|
||||
|
||||
|
@ -136,6 +140,15 @@ export default {
|
|||
const classByType = getInboxClassByType(type, phoneNumber);
|
||||
return classByType;
|
||||
},
|
||||
computedInboxErrorClass(child) {
|
||||
const { type, reauthorizationRequired } = child;
|
||||
if (!type) return '';
|
||||
const warningClass = getInboxWarningIconClass(
|
||||
type,
|
||||
reauthorizationRequired
|
||||
);
|
||||
return warningClass;
|
||||
},
|
||||
newLinkClick(e, navigate) {
|
||||
if (this.menuItem.newLinkRouteName) {
|
||||
navigate(e);
|
||||
|
|
|
@ -30,8 +30,7 @@
|
|||
icon="dismiss-circle"
|
||||
class-names="banner-action__button"
|
||||
@click="onClickClose"
|
||||
>
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
:aria-checked="value.toString()"
|
||||
@click="onClick"
|
||||
>
|
||||
<span aria-hidden="true" :class="{ active: value }"></span>
|
||||
<span aria-hidden="true" :class="{ active: value }" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
:icon="icon"
|
||||
:icon-size="iconSize"
|
||||
/>
|
||||
<span v-if="$slots.default" class="button__content"><slot></slot></span>
|
||||
<span v-if="$slots.default" class="button__content"><slot /></span>
|
||||
</button>
|
||||
</template>
|
||||
<script>
|
||||
|
|
|
@ -77,7 +77,7 @@
|
|||
rows="4"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
class="action-message"
|
||||
></textarea>
|
||||
/>
|
||||
<p
|
||||
v-if="v.action_params.$dirty && v.action_params.$error"
|
||||
class="filter-error"
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
rows="4"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
@input="updateValue"
|
||||
></textarea>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div class="dashboard-app--container">
|
||||
<div
|
||||
v-for="(configItem, index) in config"
|
||||
:key="index"
|
||||
class="dashboard-app--list"
|
||||
>
|
||||
<iframe
|
||||
v-if="configItem.type === 'frame' && configItem.url"
|
||||
:id="`dashboard-app--frame-${index}`"
|
||||
:src="configItem.url"
|
||||
@load="() => onIframeLoad(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
config: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentChat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
dashboardAppContext() {
|
||||
return {
|
||||
conversation: this.currentChat,
|
||||
contact: this.$store.getters['contacts/getContact'](this.contactId),
|
||||
};
|
||||
},
|
||||
contactId() {
|
||||
return this.currentChat?.meta?.sender?.id;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onIframeLoad(index) {
|
||||
const frameElement = document.getElementById(
|
||||
`dashboard-app--frame-${index}`
|
||||
);
|
||||
const eventData = { event: 'appContext', data: this.dashboardAppContext };
|
||||
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-app--container,
|
||||
.dashboard-app--list,
|
||||
.dashboard-app--list iframe {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-app--list iframe {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
|
@ -215,6 +215,32 @@ export default {
|
|||
this.$emit('input', { ...payload, query_operator: value });
|
||||
},
|
||||
},
|
||||
custom_attribute_type: {
|
||||
get() {
|
||||
if (!this.customAttributeType) return '';
|
||||
return this.customAttributeType;
|
||||
},
|
||||
set() {
|
||||
const payload = this.value || {};
|
||||
this.$emit('input', {
|
||||
...payload,
|
||||
custom_attribute_type: this.customAttributeType,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
customAttributeType: {
|
||||
handler(value) {
|
||||
if (
|
||||
value === 'conversation_attribute' ||
|
||||
value === 'contact_attribute'
|
||||
) {
|
||||
this.value.custom_attribute_type = this.customAttributeType;
|
||||
} else this.value.custom_attribute_type = '';
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
removeFilter() {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<p v-if="headerContent" class="small-12 column">
|
||||
{{ headerContent }}
|
||||
</p>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="audio-wave-wrapper">
|
||||
<audio id="audio-wave" class="video-js vjs-fill vjs-default-skin"></audio>
|
||||
<audio id="audio-wave" class="video-js vjs-fill vjs-default-skin" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
:search-key="cannedSearchTerm"
|
||||
@click="insertCannedResponse"
|
||||
/>
|
||||
<div ref="editor"></div>
|
||||
<div ref="editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -79,6 +79,16 @@
|
|||
:title="signatureToggleTooltip"
|
||||
@click="toggleMessageSignature"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="hasWhatsappTemplates"
|
||||
v-tooltip.top-end="'Whatsapp Templates'"
|
||||
icon="whatsapp"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="'Whatsapp Templates'"
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="$refs.upload && $refs.upload.dropActive"
|
||||
|
@ -218,6 +228,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasWhatsappTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isNote() {
|
||||
|
|
|
@ -6,7 +6,20 @@
|
|||
:is-contact-panel-open="isContactPanelOpen"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<div class="messages-and-sidebar">
|
||||
<woot-tabs
|
||||
v-if="dashboardApps.length && currentChat.id"
|
||||
:index="activeIndex"
|
||||
class="dashboard-app--tabs"
|
||||
@change="onDashboardAppTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
v-for="tab in dashboardAppTabs"
|
||||
:key="tab.key"
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
/>
|
||||
</woot-tabs>
|
||||
<div v-if="!activeIndex" class="messages-and-sidebar">
|
||||
<messages-view
|
||||
v-if="currentChat.id"
|
||||
:inbox-id="inboxId"
|
||||
|
@ -14,7 +27,6 @@
|
|||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<empty-state v-else />
|
||||
|
||||
<div v-show="showContactPanel" class="conversation-sidebar-wrap">
|
||||
<contact-panel
|
||||
v-if="showContactPanel"
|
||||
|
@ -24,21 +36,29 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<dashboard-app-frame
|
||||
v-else
|
||||
:key="currentChat.id"
|
||||
:config="dashboardApps[activeIndex - 1].content"
|
||||
:current-chat="currentChat"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel';
|
||||
import ConversationHeader from './ConversationHeader';
|
||||
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
||||
import EmptyState from './EmptyState';
|
||||
import MessagesView from './MessagesView';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EmptyState,
|
||||
MessagesView,
|
||||
ContactPanel,
|
||||
ConversationHeader,
|
||||
DashboardAppFrame,
|
||||
EmptyState,
|
||||
MessagesView,
|
||||
},
|
||||
|
||||
props: {
|
||||
|
@ -52,8 +72,26 @@ export default {
|
|||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { activeIndex: 0 };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ currentChat: 'getSelectedChat' }),
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
dashboardApps: 'dashboardApps/getRecords',
|
||||
}),
|
||||
dashboardAppTabs() {
|
||||
return [
|
||||
{
|
||||
key: 'messages',
|
||||
name: this.$t('CONVERSATION.DASHBOARD_APP_TAB_MESSAGES'),
|
||||
},
|
||||
...this.dashboardApps.map(dashboardApp => ({
|
||||
key: `dashboard-${dashboardApp.id}`,
|
||||
name: dashboardApp.title,
|
||||
})),
|
||||
];
|
||||
},
|
||||
showContactPanel() {
|
||||
return this.isContactPanelOpen && this.currentChat.id;
|
||||
},
|
||||
|
@ -61,7 +99,7 @@ export default {
|
|||
watch: {
|
||||
'currentChat.inbox_id'(inboxId) {
|
||||
if (inboxId) {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', { inboxId });
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
|
||||
}
|
||||
},
|
||||
'currentChat.id'() {
|
||||
|
@ -70,6 +108,7 @@ export default {
|
|||
},
|
||||
mounted() {
|
||||
this.fetchLabels();
|
||||
this.$store.dispatch('dashboardApps/get');
|
||||
},
|
||||
methods: {
|
||||
fetchLabels() {
|
||||
|
@ -81,6 +120,9 @@ export default {
|
|||
onToggleContactPanel() {
|
||||
this.$emit('contact-panel-toggle');
|
||||
},
|
||||
onDashboardAppTabChange(index) {
|
||||
this.activeIndex = index;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -96,6 +138,11 @@ export default {
|
|||
background: var(--color-background-light);
|
||||
}
|
||||
|
||||
.dashboard-app--tabs {
|
||||
background: var(--white);
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.messages-and-sidebar {
|
||||
display: flex;
|
||||
background: var(--color-background-light);
|
||||
|
|
|
@ -5,11 +5,23 @@
|
|||
active: isActiveChat,
|
||||
'unread-chat': hasUnread,
|
||||
'has-inbox-name': showInboxName,
|
||||
'conversation-selected': selected,
|
||||
}"
|
||||
@mouseenter="onCardHover"
|
||||
@mouseleave="onCardLeave"
|
||||
@click="cardClick(chat)"
|
||||
>
|
||||
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
|
||||
<input
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onSelectConversation($event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
<thumbnail
|
||||
v-if="!hideThumbnail"
|
||||
v-if="bulkActionCheck"
|
||||
:src="currentContact.thumbnail"
|
||||
:badge="inboxBadge"
|
||||
class="columns"
|
||||
|
@ -142,8 +154,16 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hovered: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
|
@ -152,7 +172,9 @@ export default {
|
|||
currentUser: 'getCurrentUser',
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
|
||||
bulkActionCheck() {
|
||||
return !this.hideThumbnail && !this.hovered && !this.selected;
|
||||
},
|
||||
chatMetadata() {
|
||||
return this.chat.meta || {};
|
||||
},
|
||||
|
@ -260,6 +282,16 @@ export default {
|
|||
}
|
||||
router.push({ path: frontendURL(path) });
|
||||
},
|
||||
onCardHover() {
|
||||
this.hovered = !this.hideThumbnail;
|
||||
},
|
||||
onCardLeave() {
|
||||
this.hovered = false;
|
||||
},
|
||||
onSelectConversation(checked) {
|
||||
const action = checked ? 'select-conversation' : 'de-select-conversation';
|
||||
this.$emit(action, this.chat.id, this.inbox.id);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -272,6 +304,10 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
.conversation-selected {
|
||||
background: var(--color-background-light);
|
||||
}
|
||||
|
||||
.has-inbox-name {
|
||||
&::v-deep .user-thumbnail-box {
|
||||
margin-top: var(--space-normal);
|
||||
|
@ -320,4 +356,22 @@ export default {
|
|||
margin-top: var(--space-minus-micro);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.checkbox-wrapper {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100%;
|
||||
margin-top: var(--space-normal);
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--w-100);
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
margin: var(--space-zero);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,19 +1,11 @@
|
|||
<template>
|
||||
<div class="view-box fill-height">
|
||||
<banner
|
||||
v-if="!currentChat.can_reply && !isAWhatsappChannel"
|
||||
v-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
:banner-message="$t('CONVERSATION.CANNOT_REPLY')"
|
||||
:href-link="facebookReplyPolicy"
|
||||
:href-link-text="$t('CONVERSATION.24_HOURS_WINDOW')"
|
||||
/>
|
||||
|
||||
<banner
|
||||
v-if="!currentChat.can_reply && isAWhatsappChannel"
|
||||
color-scheme="alert"
|
||||
:banner-message="$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY')"
|
||||
:href-link="twilioWhatsAppReplyPolicy"
|
||||
:href-link-text="$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW')"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
:href-link="replyWindowLink"
|
||||
:href-link-text="replyWindowLinkText"
|
||||
/>
|
||||
|
||||
<banner
|
||||
|
@ -32,8 +24,7 @@
|
|||
class="sidebar-toggle--button"
|
||||
:icon="isRightOrLeftIcon"
|
||||
@click="onToggleContactPanel"
|
||||
>
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
<ul class="conversation-panel">
|
||||
<transition name="slide-up">
|
||||
|
@ -160,7 +151,6 @@ export default {
|
|||
hasSelectedTweetId() {
|
||||
return !!this.selectedTweetId;
|
||||
},
|
||||
|
||||
tweetBannerText() {
|
||||
return !this.selectedTweetId
|
||||
? this.$t('CONVERSATION.SELECT_A_TWEET_TO_REPLY')
|
||||
|
@ -238,12 +228,6 @@ export default {
|
|||
}
|
||||
return '';
|
||||
},
|
||||
facebookReplyPolicy() {
|
||||
return REPLY_POLICY.FACEBOOK;
|
||||
},
|
||||
twilioWhatsAppReplyPolicy() {
|
||||
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||
},
|
||||
isRightOrLeftIcon() {
|
||||
if (this.isContactPanelOpen) {
|
||||
return 'arrow-chevron-right';
|
||||
|
@ -255,6 +239,41 @@ export default {
|
|||
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
||||
return contactLastSeenAt;
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
if (this.isAWhatsappChannel) {
|
||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
const { additional_attributes: additionalAttributes = {} } = this.inbox;
|
||||
if (additionalAttributes) {
|
||||
const {
|
||||
agent_reply_time_window_message: agentReplyTimeWindowMessage,
|
||||
} = additionalAttributes;
|
||||
return agentReplyTimeWindowMessage;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return this.$t('CONVERSATION.CANNOT_REPLY');
|
||||
},
|
||||
replyWindowLink() {
|
||||
if (this.isAWhatsappChannel) {
|
||||
return REPLY_POLICY.FACEBOOK;
|
||||
}
|
||||
if (!this.isAPIInbox) {
|
||||
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
replyWindowLinkText() {
|
||||
if (this.isAWhatsappChannel) {
|
||||
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
||||
}
|
||||
if (!this.isAPIInbox) {
|
||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
|
|
@ -101,7 +101,7 @@
|
|||
:toggle-audio-recorder="toggleAudioRecorder"
|
||||
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:on-send="sendMessage"
|
||||
:on-send="onSendReply"
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:recording-audio-duration-text="recordingAudioDurationText"
|
||||
:recording-audio-state="recordingAudioState"
|
||||
|
@ -112,7 +112,16 @@
|
|||
:enable-rich-editor="isRichEditorEnabled"
|
||||
:enter-to-send-enabled="enterToSendEnabled"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:has-whatsapp-templates="hasWhatsappTemplates"
|
||||
@toggleEnterToSend="toggleEnterToSend"
|
||||
@selectWhatsappTemplate="openWhatsappTemplateModal"
|
||||
/>
|
||||
<whatsapp-templates
|
||||
:inbox-id="inbox.id"
|
||||
:show="showWhatsAppTemplatesModal"
|
||||
@close="hideWhatsappTemplatesModal"
|
||||
@on-send="onSendWhatsAppReply"
|
||||
@cancel="hideWhatsappTemplatesModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -137,7 +146,7 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
|||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import {
|
||||
isEscape,
|
||||
isEnter,
|
||||
|
@ -162,6 +171,7 @@ export default {
|
|||
WootMessageEditor,
|
||||
WootAudioRecorder,
|
||||
Banner,
|
||||
WhatsappTemplates,
|
||||
},
|
||||
mixins: [
|
||||
clickaway,
|
||||
|
@ -201,6 +211,7 @@ export default {
|
|||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
showWhatsAppTemplatesModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -212,7 +223,6 @@ export default {
|
|||
globalConfig: 'globalConfig/get',
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
|
||||
showRichContentEditor() {
|
||||
if (this.isOnPrivateNote) {
|
||||
return true;
|
||||
|
@ -256,7 +266,10 @@ export default {
|
|||
|
||||
return false;
|
||||
},
|
||||
|
||||
hasWhatsappTemplates() {
|
||||
return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
|
||||
.length;
|
||||
},
|
||||
enterToSendEnabled() {
|
||||
return !!this.uiSettings.enter_to_send_enabled;
|
||||
},
|
||||
|
@ -484,7 +497,7 @@ export default {
|
|||
hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused;
|
||||
if (shouldSendMessage) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
this.onSendReply();
|
||||
}
|
||||
} else if (hasPressedCommandPlusKKey(e)) {
|
||||
this.openCommandBar();
|
||||
|
@ -497,6 +510,12 @@ export default {
|
|||
toggleEnterToSend(enterToSendEnabled) {
|
||||
this.updateUISettings({ enter_to_send_enabled: enterToSendEnabled });
|
||||
},
|
||||
openWhatsappTemplateModal() {
|
||||
this.showWhatsAppTemplatesModal = true;
|
||||
},
|
||||
hideWhatsappTemplatesModal() {
|
||||
this.showWhatsAppTemplatesModal = false;
|
||||
},
|
||||
onClickSelfAssign() {
|
||||
const {
|
||||
account_id,
|
||||
|
@ -520,7 +539,7 @@ export default {
|
|||
};
|
||||
this.assignedAgent = selfAssign;
|
||||
},
|
||||
async sendMessage() {
|
||||
async onSendReply() {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
|
@ -531,22 +550,31 @@ export default {
|
|||
}
|
||||
const messagePayload = this.getMessagePayload(newMessage);
|
||||
this.clearMessage();
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
'createPendingMessageAndSend',
|
||||
messagePayload
|
||||
);
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.data?.error ||
|
||||
this.$t('CONVERSATION.MESSAGE_ERROR');
|
||||
this.showAlert(errorMessage);
|
||||
}
|
||||
this.sendMessage(messagePayload);
|
||||
this.hideEmojiPicker();
|
||||
this.$emit('update:popoutReplyBox', false);
|
||||
}
|
||||
},
|
||||
async sendMessage(messagePayload) {
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
'createPendingMessageAndSend',
|
||||
messagePayload
|
||||
);
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
||||
this.showAlert(errorMessage);
|
||||
}
|
||||
},
|
||||
async onSendWhatsAppReply(messagePayload) {
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
});
|
||||
this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
setTimeout(() => {
|
||||
this.message = message;
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<woot-modal :show.sync="show" :on-close="onClose" size="modal-big">
|
||||
<woot-modal-header
|
||||
:header-title="$t('WHATSAPP_TEMPLATES.MODAL.TITLE')"
|
||||
:header-content="modalHeaderContent"
|
||||
/>
|
||||
<div class="row modal-content">
|
||||
<templates-picker
|
||||
v-if="!selectedWaTemplate"
|
||||
:inbox-id="inboxId"
|
||||
@onSelect="pickTemplate"
|
||||
/>
|
||||
<template-parser
|
||||
v-else
|
||||
:template="selectedWaTemplate"
|
||||
@resetTemplate="onResetTemplate"
|
||||
@sendMessage="onSendMessage"
|
||||
/>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TemplatesPicker from './TemplatesPicker.vue';
|
||||
import TemplateParser from './TemplateParser.vue';
|
||||
export default {
|
||||
components: {
|
||||
TemplatesPicker,
|
||||
TemplateParser,
|
||||
},
|
||||
props: {
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedWaTemplate: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
modalHeaderContent() {
|
||||
return this.selectedWaTemplate
|
||||
? this.$t('WHATSAPP_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
|
||||
templateName: this.selectedWaTemplate.name,
|
||||
})
|
||||
: this.$t('WHATSAPP_TEMPLATES.MODAL.SUBTITLE');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
pickTemplate(template) {
|
||||
this.selectedWaTemplate = template;
|
||||
},
|
||||
onResetTemplate() {
|
||||
this.selectedWaTemplate = null;
|
||||
},
|
||||
onSendMessage(message) {
|
||||
this.$emit('on-send', message);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-content {
|
||||
padding: 2.5rem 3.2rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,173 @@
|
|||
<template>
|
||||
<div class="medium-12 columns">
|
||||
<textarea
|
||||
v-model="processedString"
|
||||
rows="4"
|
||||
readonly
|
||||
class="template-input"
|
||||
/>
|
||||
<div v-if="variables" class="template__variables-container">
|
||||
<p class="variables-label">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(variable, key) in processedParams"
|
||||
:key="key"
|
||||
class="template__variable-item"
|
||||
>
|
||||
<span class="variable-label">
|
||||
{{ key }}
|
||||
</span>
|
||||
<woot-input
|
||||
v-model="processedParams[key]"
|
||||
type="text"
|
||||
class="variable-input"
|
||||
:styles="{ marginBottom: 0 }"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="$v.$dirty && $v.$invalid" class="error">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<footer>
|
||||
<woot-button variant="smooth" @click="$emit('resetTemplate')">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL') }}
|
||||
</woot-button>
|
||||
<woot-button @click="sendMessage">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL') }}
|
||||
</woot-button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const allKeysRequired = value => {
|
||||
const keys = Object.keys(value);
|
||||
return keys.every(key => value[key]);
|
||||
};
|
||||
import { requiredIf } from 'vuelidate/lib/validators';
|
||||
export default {
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
processedParams: {
|
||||
requiredIfKeysPresent: requiredIf('variables'),
|
||||
allKeysRequired,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processedParams: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
variables() {
|
||||
const variables = this.templateString.match(/{{([^}]+)}}/g);
|
||||
return variables;
|
||||
},
|
||||
templateString() {
|
||||
return this.template.components.find(
|
||||
component => component.type === 'BODY'
|
||||
).text;
|
||||
},
|
||||
processedString() {
|
||||
return this.templateString.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||
const variableKey = this.processVariable(variable);
|
||||
return this.processedParams[variableKey] || `{{${variable}}}`;
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.generateVariables();
|
||||
},
|
||||
methods: {
|
||||
sendMessage() {
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) return;
|
||||
const payload = {
|
||||
message: this.processedString,
|
||||
templateParams: {
|
||||
name: this.template.name,
|
||||
category: this.template.category,
|
||||
language: this.template.language,
|
||||
namespace: this.template.namespace,
|
||||
processed_params: this.processedParams,
|
||||
},
|
||||
};
|
||||
this.$emit('sendMessage', payload);
|
||||
},
|
||||
processVariable(str) {
|
||||
return str.replace(/{{|}}/g, '');
|
||||
},
|
||||
generateVariables() {
|
||||
const matchedVariables = this.templateString.match(/{{([^}]+)}}/g);
|
||||
if (!matchedVariables) return;
|
||||
|
||||
const variables = matchedVariables.map(i => this.processVariable(i));
|
||||
this.processedParams = variables.reduce((acc, variable) => {
|
||||
acc[variable] = '';
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.template__variables-container {
|
||||
padding: var(--space-one);
|
||||
}
|
||||
|
||||
.variables-label {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-bottom: var(--space-one);
|
||||
}
|
||||
|
||||
.template__variable-item {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: var(--space-one);
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
|
||||
.variable-input {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-small);
|
||||
margin-left: var(--space-one);
|
||||
}
|
||||
|
||||
.variable-label {
|
||||
background-color: var(--s-75);
|
||||
border-radius: var(--border-radius-normal);
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-mini);
|
||||
padding: var(--space-one) var(--space-medium);
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
margin-left: var(--space-one);
|
||||
}
|
||||
}
|
||||
.error {
|
||||
background-color: var(--r-100);
|
||||
border-radius: var(--border-radius-normal);
|
||||
color: var(--r-800);
|
||||
padding: var(--space-one);
|
||||
text-align: center;
|
||||
}
|
||||
.template-input {
|
||||
background-color: var(--s-25);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,163 @@
|
|||
<template>
|
||||
<div class="medium-12 columns">
|
||||
<div class="templates__list-search">
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||
class="templates__search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="template__list-container">
|
||||
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
|
||||
<button
|
||||
class="template__list-item"
|
||||
@click="$emit('onSelect', template)"
|
||||
>
|
||||
<div>
|
||||
<div class="flex-between">
|
||||
<p class="label-title">
|
||||
{{ template.name }}
|
||||
</p>
|
||||
<span class="label-lang label">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
|
||||
{{ template.language }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="strong">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
|
||||
</p>
|
||||
<p class="label-body">{{ getTemplatebody(template) }}</p>
|
||||
</div>
|
||||
<div class="label-category">
|
||||
<p class="strong">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
||||
</p>
|
||||
<p>{{ template.category }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<hr v-if="i != filteredTemplateMessages.length - 1" :key="`hr-${i}`" />
|
||||
</div>
|
||||
<div v-if="!filteredTemplateMessages.length">
|
||||
<p>
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
<strong>{{ query }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
whatsAppTemplateMessages() {
|
||||
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId);
|
||||
},
|
||||
filteredTemplateMessages() {
|
||||
return this.whatsAppTemplateMessages.filter(template =>
|
||||
template.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getTemplatebody(template) {
|
||||
return template.components.find(component => component.type === 'BODY')
|
||||
.text;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-one);
|
||||
}
|
||||
|
||||
.templates__list-search {
|
||||
align-items: center;
|
||||
background-color: var(--s-25);
|
||||
border-radius: var(--border-radius-medium);
|
||||
border: 1px solid var(--s-100);
|
||||
display: flex;
|
||||
margin-bottom: var(--space-one);
|
||||
padding: 0 var(--space-one);
|
||||
|
||||
.search-icon {
|
||||
color: var(--s-400);
|
||||
}
|
||||
|
||||
.templates__search-input {
|
||||
background-color: transparent;
|
||||
border: var(--space-large);
|
||||
font-size: var(--font-size-mini);
|
||||
height: unset;
|
||||
margin: var(--space-zero);
|
||||
}
|
||||
}
|
||||
.template__list-container {
|
||||
background-color: var(--s-25);
|
||||
border-radius: var(--border-radius-medium);
|
||||
max-height: 30rem;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-one);
|
||||
|
||||
.template__list-item {
|
||||
border-radius: var(--border-radius-medium);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding: var(--space-one);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--w-50);
|
||||
}
|
||||
|
||||
.label-title {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.label-category {
|
||||
margin-top: var(--space-two);
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.label-body {
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
hr {
|
||||
border-bottom: 1px solid var(--s-100);
|
||||
margin: var(--space-one) auto;
|
||||
max-width: 95%;
|
||||
}
|
||||
</style>
|
|
@ -7,7 +7,7 @@
|
|||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
|
||||
icon="checkmark"
|
||||
size="16"
|
||||
size="14"
|
||||
/>
|
||||
</span>
|
||||
<fluent-icon
|
||||
|
@ -165,7 +165,11 @@ export default {
|
|||
return `https://www.instagram.com/stories/${storySender}/${storyId}`;
|
||||
},
|
||||
showSentIndicator() {
|
||||
return this.isOutgoing && this.sourceId && this.isAnEmailChannel;
|
||||
return (
|
||||
this.isOutgoing &&
|
||||
this.sourceId &&
|
||||
(this.isAnEmailChannel || this.isAWhatsappChannel)
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -29,8 +29,11 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
fileName() {
|
||||
const filename = this.url.substring(this.url.lastIndexOf('/') + 1);
|
||||
return filename;
|
||||
if (this.url) {
|
||||
const filename = this.url.substring(this.url.lastIndexOf('/') + 1);
|
||||
return filename || this.$t('CONVERSATION.UNKNOWN_FILE_TYPE');
|
||||
}
|
||||
return this.$t('CONVERSATION.UNKNOWN_FILE_TYPE');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
'hide--quoted': !showQuotedContent,
|
||||
}"
|
||||
>
|
||||
<div v-dompurify-html="message" class="text-content"></div>
|
||||
<div v-dompurify-html="message" class="text-content" />
|
||||
<button
|
||||
v-if="displayQuotedButton"
|
||||
class="quoted-text--button"
|
||||
|
|
|
@ -0,0 +1,268 @@
|
|||
<template>
|
||||
<div class="bulk-action__agents">
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path
|
||||
d="M20 12l-8-8-12 12"
|
||||
fill="var(--white)"
|
||||
fill-rule="evenodd"
|
||||
stroke="var(--s-50)"
|
||||
stroke-width="1px"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header flex-between">
|
||||
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
@click="onClose"
|
||||
/>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div v-if="uiFlags.isUpdating" class="agent__list-loading">
|
||||
<spinner />
|
||||
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
|
||||
</div>
|
||||
<div v-else class="agent__list-container">
|
||||
<ul v-if="!selectedAgent">
|
||||
<li class="search-container">
|
||||
<div class="agent-list-search flex-between">
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
class="agent--search_input"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li v-for="agent in filteredAgents" :key="agent.id">
|
||||
<div class="agent-list-item" @click="assignAgent(agent)">
|
||||
<thumbnail
|
||||
src="agent.thumbnail"
|
||||
:username="agent.name"
|
||||
size="22px"
|
||||
class="margin-right-small"
|
||||
/>
|
||||
<span class="reports-option__title">{{ agent.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="agent-confirmation-container">
|
||||
<p>
|
||||
{{
|
||||
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
|
||||
conversationCount,
|
||||
conversationLabel,
|
||||
})
|
||||
}}
|
||||
<strong>
|
||||
{{ selectedAgent.name }}
|
||||
</strong>
|
||||
</p>
|
||||
<div class="agent-confirmation-actions">
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
variant="smooth"
|
||||
@click="goBack"
|
||||
>
|
||||
{{ $t('BULK_ACTION.GO_BACK_LABEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
variant="flat"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('BULK_ACTION.ASSIGN_LABEL') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [clickaway],
|
||||
props: {
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedAgent: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'bulkActions/getUIFlags',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
}),
|
||||
filteredAgents() {
|
||||
if (this.query) {
|
||||
return this.assignableAgents.filter(agent =>
|
||||
agent.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
}
|
||||
return this.assignableAgents;
|
||||
},
|
||||
assignableAgents() {
|
||||
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
|
||||
this.selectedInboxes.join(',')
|
||||
);
|
||||
},
|
||||
conversationLabel() {
|
||||
return this.conversationCount > 1 ? 'conversations' : 'conversation';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.$emit('select', this.selectedAgent);
|
||||
},
|
||||
goBack() {
|
||||
this.selectedAgent = null;
|
||||
},
|
||||
assignAgent(agent) {
|
||||
this.selectedAgent = agent;
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__agents {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--border-radius-large);
|
||||
border: 1px solid var(--s-50);
|
||||
box-shadow: var(--shadow-dropdown-pane);
|
||||
max-width: 75%;
|
||||
position: absolute;
|
||||
right: var(--space-small);
|
||||
top: var(--space-larger);
|
||||
transform-origin: top right;
|
||||
width: auto;
|
||||
z-index: var(--z-index-twenty);
|
||||
|
||||
.header {
|
||||
padding: var(--space-one);
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
.agent__list-container {
|
||||
height: 100%;
|
||||
}
|
||||
.agent-list-search {
|
||||
padding: 0 var(--space-one);
|
||||
border: 1px solid var(--s-100);
|
||||
border-radius: var(--border-radius-medium);
|
||||
background-color: var(--s-50);
|
||||
.search-icon {
|
||||
color: var(--s-400);
|
||||
}
|
||||
|
||||
.agent--search_input {
|
||||
border: 0;
|
||||
font-size: var(--font-size-mini);
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
.triangle {
|
||||
display: block;
|
||||
z-index: var(--z-index-one);
|
||||
position: absolute;
|
||||
top: calc(var(--space-slab) * -1);
|
||||
right: var(--space-micro);
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.agent-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-one);
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--s-50);
|
||||
}
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
}
|
||||
|
||||
.agent-confirmation-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: var(--space-one);
|
||||
p {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.agent-confirmation-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-one);
|
||||
}
|
||||
}
|
||||
.search-container {
|
||||
padding: 0 var(--space-one);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-index-twenty);
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.agent__list-loading {
|
||||
height: calc(95% - var(--space-one));
|
||||
margin: var(--space-one);
|
||||
border-radius: var(--border-radius-medium);
|
||||
background-color: var(--s-50);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,211 @@
|
|||
<template>
|
||||
<div class="bulk-action__container">
|
||||
<div class="flex-between">
|
||||
<label class="bulk-action__panel flex-between">
|
||||
<input
|
||||
ref="selectAllCheck"
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:checked="allConversationsSelected"
|
||||
:indeterminate.prop="!allConversationsSelected"
|
||||
@change="selectAll($event)"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
|
||||
conversationCount: conversations.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
<div class="bulk-action__actions flex-between">
|
||||
<woot-button
|
||||
v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="tag"
|
||||
class="margin-right-smaller"
|
||||
@click="toggleLabelActions"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="repeat"
|
||||
class="margin-right-smaller"
|
||||
@click="toggleUpdateActions"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="person-assign"
|
||||
@click="toggleAgentList"
|
||||
/>
|
||||
</div>
|
||||
<transition name="popover-animation">
|
||||
<label-actions
|
||||
v-if="showLabelActions"
|
||||
@assign="assignLabels"
|
||||
@close="showLabelActions = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<agent-selector
|
||||
v-if="showAgentsList"
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="submit"
|
||||
@close="showAgentsList = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<update-actions
|
||||
v-if="showUpdateActions"
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
@update="updateConversations"
|
||||
@close="showUpdateActions = false"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<div v-if="allConversationsSelected" class="bulk-action__alert">
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AgentSelector from './AgentSelector.vue';
|
||||
import UpdateActions from './UpdateActions.vue';
|
||||
import LabelActions from './LabelActions.vue';
|
||||
export default {
|
||||
components: {
|
||||
AgentSelector,
|
||||
UpdateActions,
|
||||
LabelActions,
|
||||
},
|
||||
props: {
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allConversationsSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showOpenAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showResolvedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSnoozedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAgentsList: false,
|
||||
showUpdateActions: false,
|
||||
showLabelActions: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
selectAll(e) {
|
||||
this.$emit('select-all-conversations', e.target.checked);
|
||||
},
|
||||
submit(agent) {
|
||||
this.$emit('assign-agent', agent);
|
||||
},
|
||||
updateConversations(status) {
|
||||
this.$emit('update-conversations', status);
|
||||
},
|
||||
assignLabels(labels) {
|
||||
this.$emit('assign-labels', labels);
|
||||
},
|
||||
resolveConversations() {
|
||||
this.$emit('resolve-conversations');
|
||||
},
|
||||
toggleUpdateActions() {
|
||||
this.showUpdateActions = !this.showUpdateActions;
|
||||
},
|
||||
toggleLabelActions() {
|
||||
this.showLabelActions = !this.showLabelActions;
|
||||
},
|
||||
toggleAgentList() {
|
||||
this.showAgentsList = !this.showAgentsList;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__container {
|
||||
border-bottom: 1px solid var(--s-100);
|
||||
padding: var(--space-normal) var(--space-one);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bulk-action__panel {
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-mini);
|
||||
margin-left: var(--space-smaller);
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
margin: var(--space-zero);
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-action__alert {
|
||||
background-color: var(--y-50);
|
||||
border-radius: var(--border-radius-small);
|
||||
border: 1px solid var(--y-300);
|
||||
color: var(--y-700);
|
||||
font-size: var(--font-size-mini);
|
||||
margin-top: var(--space-small);
|
||||
padding: var(--space-half) var(--space-one);
|
||||
}
|
||||
|
||||
.popover-animation-enter-active,
|
||||
.popover-animation-leave-active {
|
||||
transition: transform ease-out 0.1s;
|
||||
}
|
||||
|
||||
.popover-animation-enter {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.popover-animation-enter-to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.popover-animation-leave {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.popover-animation-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,282 @@
|
|||
<template>
|
||||
<div v-on-clickaway="onClose" class="labels-container">
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path
|
||||
d="M20 12l-8-8-12 12"
|
||||
fill="var(--white)"
|
||||
fill-rule="evenodd"
|
||||
stroke="var(--s-50)"
|
||||
stroke-width="1px"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header flex-between">
|
||||
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span>
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
@click="onClose"
|
||||
/>
|
||||
</div>
|
||||
<div class="labels-list">
|
||||
<header class="labels-list__header">
|
||||
<div class="label-list-search flex-between">
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
class="label--search_input"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="labels-list__body">
|
||||
<li
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.id"
|
||||
class="label__list-item"
|
||||
>
|
||||
<label
|
||||
class="item"
|
||||
:class="{ 'label-selected': isLabelSelected(label.title) }"
|
||||
>
|
||||
<input
|
||||
v-model="selectedLabels"
|
||||
type="checkbox"
|
||||
:value="label.title"
|
||||
class="label-checkbox"
|
||||
/>
|
||||
<span class="label-title">{{ label.title }}</span>
|
||||
<span
|
||||
class="label-pill"
|
||||
:style="{ backgroundColor: label.color }"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<footer class="labels-list__footer">
|
||||
<woot-button
|
||||
size="small"
|
||||
color-scheme="primary"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="$emit('assign', selectedLabels)"
|
||||
>
|
||||
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS') }}</span>
|
||||
</woot-button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
mixins: [clickaway],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedLabels: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ labels: 'labels/getLabels' }),
|
||||
filteredLabels() {
|
||||
return this.labels.filter(label =>
|
||||
label.title.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isLabelSelected(label) {
|
||||
return this.selectedLabels.includes(label);
|
||||
},
|
||||
assignLabels(key) {
|
||||
this.$emit('update', key);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.labels-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 24rem;
|
||||
min-height: auto;
|
||||
|
||||
.labels-list__header {
|
||||
background-color: var(--white);
|
||||
padding: 0 var(--space-one);
|
||||
}
|
||||
|
||||
.labels-list__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-one) 0;
|
||||
}
|
||||
|
||||
.labels-list__footer {
|
||||
padding: var(--space-small);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-list-search {
|
||||
background-color: var(--s-50);
|
||||
border-radius: var(--border-radius-medium);
|
||||
border: 1px solid var(--s-100);
|
||||
padding: 0 var(--space-one);
|
||||
|
||||
.search-icon {
|
||||
color: var(--s-400);
|
||||
}
|
||||
|
||||
.label--search_input {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
font-size: var(--font-size-mini);
|
||||
height: unset;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.labels-container {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--border-radius-large);
|
||||
border: 1px solid var(--s-50);
|
||||
box-shadow: var(--shadow-dropdown-pane);
|
||||
max-width: 24rem;
|
||||
min-width: 24rem;
|
||||
position: absolute;
|
||||
right: 4.5rem;
|
||||
top: var(--space-larger);
|
||||
transform-origin: top right;
|
||||
width: auto;
|
||||
z-index: var(--z-index-twenty);
|
||||
|
||||
.header {
|
||||
padding: var(--space-one);
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
|
||||
.label__list-container {
|
||||
height: 100%;
|
||||
}
|
||||
.label-list-search {
|
||||
padding: 0 var(--space-one);
|
||||
border: 1px solid var(--s-100);
|
||||
border-radius: var(--border-radius-medium);
|
||||
background-color: var(--s-50);
|
||||
.search-icon {
|
||||
color: var(--s-400);
|
||||
}
|
||||
|
||||
.label--search_input {
|
||||
border: 0;
|
||||
font-size: var(--font-size-mini);
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.triangle {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 2rem;
|
||||
text-align: left;
|
||||
top: calc(var(--space-slab) * -1);
|
||||
z-index: var(--z-index-one);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.labels-placeholder {
|
||||
padding: var(--space-small);
|
||||
}
|
||||
|
||||
.label__list-item {
|
||||
margin: var(--space-smaller) 0;
|
||||
padding: 0 var(--space-one);
|
||||
|
||||
.item {
|
||||
align-items: center;
|
||||
border-radius: var(--border-radius-medium);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: var(--space-smaller) var(--space-one);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--s-50);
|
||||
}
|
||||
|
||||
&.label-selected {
|
||||
background-color: var(--s-50);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.label-checkbox {
|
||||
margin: 0 var(--space-one) 0 0;
|
||||
}
|
||||
|
||||
.label-title {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.label-pill {
|
||||
background-color: var(--s-50);
|
||||
border-radius: var(--border-radius-medium);
|
||||
height: var(--space-slab);
|
||||
width: var(--space-slab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
background-color: var(--white);
|
||||
padding: 0 var(--space-one);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-index-twenty);
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
background-color: var(--white);
|
||||
bottom: 0;
|
||||
padding: var(--space-small);
|
||||
position: sticky;
|
||||
z-index: var(--z-index-twenty);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,166 @@
|
|||
<template>
|
||||
<div v-on-clickaway="onClose" class="actions-container">
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path
|
||||
d="M20 12l-8-8-12 12"
|
||||
fill="var(--white)"
|
||||
fill-rule="evenodd"
|
||||
stroke="var(--s-50)"
|
||||
stroke-width="1px"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header flex-between">
|
||||
<span>{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}</span>
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
@click="onClose"
|
||||
/>
|
||||
</div>
|
||||
<div class="container">
|
||||
<woot-dropdown-menu>
|
||||
<template v-for="action in actions">
|
||||
<woot-dropdown-item v-if="showAction(action.key)" :key="action.key">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
:icon="action.icon"
|
||||
@click="updateConversations(action.key)"
|
||||
>
|
||||
{{ actionLabel(action.key) }}
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
</template>
|
||||
</woot-dropdown-menu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
export default {
|
||||
components: {
|
||||
WootDropdownItem,
|
||||
WootDropdownMenu,
|
||||
},
|
||||
mixins: [clickaway],
|
||||
props: {
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
showResolve: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showReopen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSnooze: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedAction: null,
|
||||
actions: [
|
||||
{
|
||||
icon: 'checkmark',
|
||||
key: 'resolved',
|
||||
},
|
||||
{
|
||||
icon: 'arrow-redo',
|
||||
key: 'open',
|
||||
},
|
||||
{
|
||||
icon: 'send-clock',
|
||||
key: 'snoozed',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
updateConversations(key) {
|
||||
this.$emit('update', key);
|
||||
},
|
||||
goBack() {
|
||||
this.selectedAgent = null;
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
showAction(key) {
|
||||
const actionsMap = {
|
||||
resolved: this.showResolve,
|
||||
open: this.showReopen,
|
||||
snoozed: this.showSnooze,
|
||||
};
|
||||
return actionsMap[key] || false;
|
||||
},
|
||||
actionLabel(key) {
|
||||
const labelsMap = {
|
||||
resolved: this.$t('CONVERSATION.HEADER.RESOLVE_ACTION'),
|
||||
open: this.$t('CONVERSATION.HEADER.REOPEN_ACTION'),
|
||||
snoozed: this.$t('BULK_ACTION.UPDATE.SNOOZE_UNTIL_NEXT_REPLY'),
|
||||
};
|
||||
return labelsMap[key] || '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.actions-container {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--border-radius-large);
|
||||
border: 1px solid var(--s-50);
|
||||
box-shadow: var(--shadow-dropdown-pane);
|
||||
position: absolute;
|
||||
right: var(--space-small);
|
||||
top: 48px;
|
||||
transform-origin: top right;
|
||||
width: auto;
|
||||
z-index: var(--z-index-twenty);
|
||||
|
||||
.header {
|
||||
padding: var(--space-one);
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
.container {
|
||||
padding: var(--space-one);
|
||||
padding-top: var(--space-zero);
|
||||
}
|
||||
|
||||
.triangle {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 2.8rem;
|
||||
text-align: left;
|
||||
top: calc(var(--space-slab) * -1);
|
||||
z-index: var(--z-index-one);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
|
@ -22,7 +22,7 @@
|
|||
accept="image/png, image/jpeg, image/gif"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -6,10 +6,11 @@
|
|||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:style="styles"
|
||||
@input="onChange"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<p v-if="helpText" class="help-text"></p>
|
||||
<p v-if="helpText" class="help-text" />
|
||||
<span v-if="error" class="message">
|
||||
{{ error }}
|
||||
</span>
|
||||
|
@ -47,6 +48,10 @@ export default {
|
|||
type: Boolean,
|
||||
deafaut: false,
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onChange(e) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<modal :show.sync="show" :on-close="cancel">
|
||||
<div class="column content-box">
|
||||
<woot-modal-header :header-title="title"> </woot-modal-header>
|
||||
<woot-modal-header :header-title="title" />
|
||||
<div class="row modal-content">
|
||||
<div class="medium-12 columns">
|
||||
<p>
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
export const downloadCsvFile = (fileName, fileContent) => {
|
||||
const link = document.createElement('a');
|
||||
link.download = fileName;
|
||||
link.href = `data:text/csv;charset=utf-8,` + encodeURI(fileContent);
|
||||
link.click();
|
||||
};
|
22
app/javascript/dashboard/helper/downloadHelper.js
Normal file
22
app/javascript/dashboard/helper/downloadHelper.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
|
||||
export const downloadCsvFile = (fileName, content) => {
|
||||
const contentType = 'data:text/csv;charset=utf-8;';
|
||||
const blob = new Blob([content], { type: contentType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('download', fileName);
|
||||
link.setAttribute('href', url);
|
||||
link.click();
|
||||
return link;
|
||||
};
|
||||
|
||||
export const generateFileName = ({ type, to, businessHours = false }) => {
|
||||
let name = `${type}-report-${format(fromUnixTime(to), 'dd-MM-yyyy')}`;
|
||||
if (businessHours) {
|
||||
name = `${name}-business-hours`;
|
||||
}
|
||||
return `${name}.csv`;
|
||||
};
|
|
@ -35,3 +35,10 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
|||
return 'chat';
|
||||
}
|
||||
};
|
||||
|
||||
export const getInboxWarningIconClass = (type, reauthorizationRequired) => {
|
||||
if (type === INBOX_TYPES.FB && reauthorizationRequired) {
|
||||
return 'warning';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import { downloadCsvFile } from '../downloadCsvFile';
|
||||
|
||||
const fileName = 'test.csv';
|
||||
const fileData = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
|
||||
Pranav,36,114,28411`;
|
||||
|
||||
describe('#downloadCsvFile', () => {
|
||||
it('should download the csv file', () => {
|
||||
const link = {
|
||||
click: jest.fn(),
|
||||
};
|
||||
jest.spyOn(document, 'createElement').mockImplementation(() => link);
|
||||
|
||||
downloadCsvFile(fileName, fileData);
|
||||
expect(link.download).toEqual(fileName);
|
||||
expect(link.href).toEqual(
|
||||
`data:text/csv;charset=utf-8,${encodeURI(fileData)}`
|
||||
);
|
||||
expect(link.click).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
13
app/javascript/dashboard/helper/specs/downloadHelper.spec.js
Normal file
13
app/javascript/dashboard/helper/specs/downloadHelper.spec.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { generateFileName } from '../downloadHelper';
|
||||
|
||||
describe('#generateFileName', () => {
|
||||
it('should generate the correct file name', () => {
|
||||
expect(generateFileName({ type: 'csat', to: 1652812199 })).toEqual(
|
||||
'csat-report-17-05-2022.csv'
|
||||
);
|
||||
|
||||
expect(
|
||||
generateFileName({ type: 'csat', to: 1652812199, businessHours: true })
|
||||
).toEqual('csat-report-17-05-2022-business-hours.csv');
|
||||
});
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import { getInboxClassByType } from '../inbox';
|
||||
import { getInboxClassByType, getInboxWarningIconClass } from '../inbox';
|
||||
|
||||
describe('#Inbox Helpers', () => {
|
||||
describe('getInboxClassByType', () => {
|
||||
|
@ -34,4 +34,12 @@ describe('#Inbox Helpers', () => {
|
|||
expect(getInboxClassByType('Channel::Email')).toEqual('mail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInboxWarningIconClass', () => {
|
||||
it('should return correct class for warning', () => {
|
||||
expect(getInboxWarningIconClass('Channel::FacebookPage', true)).toEqual(
|
||||
'warning'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
17
app/javascript/dashboard/i18n/locale/ar/bulkActions.json
Normal file
17
app/javascript/dashboard/i18n/locale/ar/bulkActions.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "%{conversationCount} المحادثات المحددة",
|
||||
"AGENT_SELECT_LABEL": "اختر وكيل",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من أنك تريد تعيين %{conversationCount} %{conversationLabel} إلى",
|
||||
"GO_BACK_LABEL": "العودة للخلف",
|
||||
"ASSIGN_LABEL": "تكليف",
|
||||
"ASSIGN_AGENT_TOOLTIP": "إسناد وكيل",
|
||||
"RESOLVE_TOOLTIP": "إغلاق المحادثة",
|
||||
"ASSIGN_SUCCESFUL": "تم تعيين المحادثات بنجاح",
|
||||
"ASSIGN_FAILED": "فشل في تعيين المحادثات، الرجاء المحاولة مرة أخرى",
|
||||
"RESOLVE_SUCCESFUL": "تم تسوية المحادثات بنجاح",
|
||||
"RESOLVE_FAILED": "فشل في حل المحادثات، يرجى المحاولة مرة أخرى",
|
||||
"ALL_CONVERSATIONS_SELECTED_ALERT": "المحادثات المرئية في هذه الصفحة هي المحددة فقط.",
|
||||
"AGENT_LIST_LOADING": "تحميل الوكلاء"
|
||||
}
|
||||
}
|
|
@ -12,33 +12,11 @@
|
|||
"INPUT": "البحث عن جهات الاتصال، المحادثات، قوالب الردود الجاهزة .."
|
||||
},
|
||||
"FILTER_ALL": "الكل",
|
||||
"STATUS_TABS": [
|
||||
{
|
||||
"NAME": "فتح",
|
||||
"KEY": "openCount"
|
||||
},
|
||||
{
|
||||
"NAME": "مغلقة",
|
||||
"KEY": "allConvCount"
|
||||
}
|
||||
],
|
||||
"ASSIGNEE_TYPE_TABS": [
|
||||
{
|
||||
"NAME": "محادثاتي",
|
||||
"KEY": "me",
|
||||
"COUNT_KEY": "mineCount"
|
||||
},
|
||||
{
|
||||
"NAME": "غير مسند",
|
||||
"KEY": "unassigned",
|
||||
"COUNT_KEY": "unAssignedCount"
|
||||
},
|
||||
{
|
||||
"NAME": "الكل",
|
||||
"KEY": "all",
|
||||
"COUNT_KEY": "allCount"
|
||||
}
|
||||
],
|
||||
"ASSIGNEE_TYPE_TABS": {
|
||||
"me": "محادثاتي",
|
||||
"unassigned": "غير مسند",
|
||||
"all": "الكل"
|
||||
},
|
||||
"CHAT_STATUS_FILTER_ITEMS": {
|
||||
"open": {
|
||||
"TEXT": "فتح"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"CONVERSATION": {
|
||||
"404": "الرجاء اختيار محادثة من قائمة المحادثات",
|
||||
"DASHBOARD_APP_TAB_MESSAGES": "الرسائل",
|
||||
"UNVERIFIED_SESSION": "لم يتم التحقق من هوية هذا المستخدم",
|
||||
"NO_MESSAGE_1": "لا توجد رسائل بعد من العملاء في صندوق الوارد الخاص بك.",
|
||||
"NO_MESSAGE_2": " لإرسال رسالة إلى الصفحة الخاصة بك!",
|
||||
|
@ -30,6 +31,7 @@
|
|||
"REPLYING_TO": "أنت ترد على:",
|
||||
"REMOVE_SELECTION": "إزالة التحديد",
|
||||
"DOWNLOAD": "تنزيل",
|
||||
"UNKNOWN_FILE_TYPE": "ملف غير معروف",
|
||||
"UPLOADING_ATTACHMENTS": "جاري تحميل المرفقات...",
|
||||
"SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح",
|
||||
"FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى",
|
||||
|
|
|
@ -2,6 +2,7 @@ import { default as _advancedFilters } from './advancedFilters.json';
|
|||
import { default as _agentMgmt } from './agentMgmt.json';
|
||||
import { default as _attributesMgmt } from './attributesMgmt.json';
|
||||
import { default as _automation } from './automation.json';
|
||||
import { default as _bulkActions } from './bulkActions.json';
|
||||
import { default as _campaign } from './campaign.json';
|
||||
import { default as _cannedMgmt } from './cannedMgmt.json';
|
||||
import { default as _chatlist } from './chatlist.json';
|
||||
|
@ -21,6 +22,7 @@ import { default as _setNewPassword } from './setNewPassword.json';
|
|||
import { default as _settings } from './settings.json';
|
||||
import { default as _signup } from './signup.json';
|
||||
import { default as _teamsSettings } from './teamsSettings.json';
|
||||
import { default as _whatsappTemplates } from './whatsappTemplates.json';
|
||||
|
||||
export default {
|
||||
..._advancedFilters,
|
||||
|
@ -46,4 +48,6 @@ export default {
|
|||
..._settings,
|
||||
..._signup,
|
||||
..._teamsSettings,
|
||||
..._whatsappTemplates,
|
||||
..._bulkActions,
|
||||
};
|
||||
|
|
|
@ -386,6 +386,7 @@
|
|||
"CSAT_REPORTS": {
|
||||
"HEADER": "تقارير CSAT",
|
||||
"NO_RECORDS": "لا توجد ردود متوفرة على الدراسة الاستقصائية CSAT.",
|
||||
"DOWNLOAD": "تحميل تقرير رضاء خدمة العملاء",
|
||||
"FILTERS": {
|
||||
"AGENTS": {
|
||||
"PLACEHOLDER": "اختر الوكلاء"
|
||||
|
|
|
@ -189,7 +189,8 @@
|
|||
"REPORTS_TEAM": "الفريق",
|
||||
"SET_AVAILABILITY_TITLE": "تعيين نفسك كـ",
|
||||
"BETA": "تجريبي",
|
||||
"REPORTS_OVERVIEW": "نظرة عامة"
|
||||
"REPORTS_OVERVIEW": "نظرة عامة",
|
||||
"FACEBOOK_REAUTHORIZE": "انتهت صلاحية اتصال الفيسبوك الخاص بك، يرجى إعادة الاتصال بصفحة الفيسبوك الخاصة بك لمواصلة الخدمات"
|
||||
},
|
||||
"CREATE_ACCOUNT": {
|
||||
"NO_ACCOUNT_WARNING": "أوه! لم نتمكن من العثور على الحساب. الرجاء إنشاء حساب جديد للمتابعة.",
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"WHATSAPP_TEMPLATES": {
|
||||
"MODAL": {
|
||||
"TITLE": "قوالب Whatsapp",
|
||||
"SUBTITLE": "حدد قالب ما تريد إرساله",
|
||||
"TEMPLATE_SELECTED_SUBTITLE": "معالجة %{templateName}"
|
||||
},
|
||||
"PICKER": {
|
||||
"SEARCH_PLACEHOLDER": "نماذج البحث",
|
||||
"NO_TEMPLATES_FOUND": "لم يتم العثور على قوالب",
|
||||
"LABELS": {
|
||||
"LANGUAGE": "اللغة",
|
||||
"TEMPLATE_BODY": "نص القالب",
|
||||
"CATEGORY": "الفئة"
|
||||
}
|
||||
},
|
||||
"PARSER": {
|
||||
"VARIABLES_LABEL": "المتغيرات",
|
||||
"VARIABLE_PLACEHOLDER": "أدخل قيمة %{variable}",
|
||||
"GO_BACK_LABEL": "العودة للخلف",
|
||||
"SEND_MESSAGE_LABEL": "إرسال الرسالة",
|
||||
"FORM_ERROR_MESSAGE": "يرجى ملء جميع المتغيرات قبل الإرسال"
|
||||
}
|
||||
}
|
||||
}
|
17
app/javascript/dashboard/i18n/locale/bg/bulkActions.json
Normal file
17
app/javascript/dashboard/i18n/locale/bg/bulkActions.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
|
||||
"AGENT_SELECT_LABEL": "Select Agent",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
|
||||
"GO_BACK_LABEL": "Go back",
|
||||
"ASSIGN_LABEL": "Assign",
|
||||
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
|
||||
"RESOLVE_TOOLTIP": "Resolve",
|
||||
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
|
||||
"ASSIGN_FAILED": "Failed to assign conversations, please try again",
|
||||
"RESOLVE_SUCCESFUL": "Conversations resolved successfully",
|
||||
"RESOLVE_FAILED": "Failed to resolve conversations, please try again",
|
||||
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
|
||||
"AGENT_LIST_LOADING": "Loading Agents"
|
||||
}
|
||||
}
|
|
@ -12,33 +12,11 @@
|
|||
"INPUT": "Търсене на хора, чатове, запазени отговори .."
|
||||
},
|
||||
"FILTER_ALL": "Всички",
|
||||
"STATUS_TABS": [
|
||||
{
|
||||
"NAME": "Отворен",
|
||||
"KEY": "openCount"
|
||||
},
|
||||
{
|
||||
"NAME": "Разрешен",
|
||||
"KEY": "allConvCount"
|
||||
}
|
||||
],
|
||||
"ASSIGNEE_TYPE_TABS": [
|
||||
{
|
||||
"NAME": "Мой",
|
||||
"KEY": "me",
|
||||
"COUNT_KEY": "mineCount"
|
||||
},
|
||||
{
|
||||
"NAME": "Неназначен",
|
||||
"KEY": "unassigned",
|
||||
"COUNT_KEY": "unAssignedCount"
|
||||
},
|
||||
{
|
||||
"NAME": "Всички",
|
||||
"KEY": "all",
|
||||
"COUNT_KEY": "allCount"
|
||||
}
|
||||
],
|
||||
"ASSIGNEE_TYPE_TABS": {
|
||||
"me": "Мой",
|
||||
"unassigned": "Неназначен",
|
||||
"all": "Всички"
|
||||
},
|
||||
"CHAT_STATUS_FILTER_ITEMS": {
|
||||
"open": {
|
||||
"TEXT": "Отворен"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"CONVERSATION": {
|
||||
"404": "Please select a conversation from left pane",
|
||||
"DASHBOARD_APP_TAB_MESSAGES": "Messages",
|
||||
"UNVERIFIED_SESSION": "The identity of this user is not verified",
|
||||
"NO_MESSAGE_1": "Uh oh! Looks like there are no messages from customers in your inbox.",
|
||||
"NO_MESSAGE_2": " to send a message to your page!",
|
||||
|
@ -30,6 +31,7 @@
|
|||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
"UNKNOWN_FILE_TYPE": "Unknown File",
|
||||
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
|
|
|
@ -2,6 +2,7 @@ import { default as _advancedFilters } from './advancedFilters.json';
|
|||
import { default as _agentMgmt } from './agentMgmt.json';
|
||||
import { default as _attributesMgmt } from './attributesMgmt.json';
|
||||
import { default as _automation } from './automation.json';
|
||||
import { default as _bulkActions } from './bulkActions.json';
|
||||
import { default as _campaign } from './campaign.json';
|
||||
import { default as _cannedMgmt } from './cannedMgmt.json';
|
||||
import { default as _chatlist } from './chatlist.json';
|
||||
|
@ -21,6 +22,7 @@ import { default as _setNewPassword } from './setNewPassword.json';
|
|||
import { default as _settings } from './settings.json';
|
||||
import { default as _signup } from './signup.json';
|
||||
import { default as _teamsSettings } from './teamsSettings.json';
|
||||
import { default as _whatsappTemplates } from './whatsappTemplates.json';
|
||||
|
||||
export default {
|
||||
..._advancedFilters,
|
||||
|
@ -46,4 +48,6 @@ export default {
|
|||
..._settings,
|
||||
..._signup,
|
||||
..._teamsSettings,
|
||||
..._whatsappTemplates,
|
||||
..._bulkActions,
|
||||
};
|
||||
|
|
|
@ -386,6 +386,7 @@
|
|||
"CSAT_REPORTS": {
|
||||
"HEADER": "CSAT Reports",
|
||||
"NO_RECORDS": "There are no CSAT survey responses available.",
|
||||
"DOWNLOAD": "Download CSAT Reports",
|
||||
"FILTERS": {
|
||||
"AGENTS": {
|
||||
"PLACEHOLDER": "Choose Agents"
|
||||
|
|
|
@ -189,7 +189,8 @@
|
|||
"REPORTS_TEAM": "Team",
|
||||
"SET_AVAILABILITY_TITLE": "Set yourself as",
|
||||
"BETA": "Beta",
|
||||
"REPORTS_OVERVIEW": "Overview"
|
||||
"REPORTS_OVERVIEW": "Overview",
|
||||
"FACEBOOK_REAUTHORIZE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services"
|
||||
},
|
||||
"CREATE_ACCOUNT": {
|
||||
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"WHATSAPP_TEMPLATES": {
|
||||
"MODAL": {
|
||||
"TITLE": "Whatsapp Templates",
|
||||
"SUBTITLE": "Select the whatsapp template you want to send",
|
||||
"TEMPLATE_SELECTED_SUBTITLE": "Process %{templateName}"
|
||||
},
|
||||
"PICKER": {
|
||||
"SEARCH_PLACEHOLDER": "Search Templates",
|
||||
"NO_TEMPLATES_FOUND": "No templates found for",
|
||||
"LABELS": {
|
||||
"LANGUAGE": "Language",
|
||||
"TEMPLATE_BODY": "Template Body",
|
||||
"CATEGORY": "Category"
|
||||
}
|
||||
},
|
||||
"PARSER": {
|
||||
"VARIABLES_LABEL": "Variables",
|
||||
"VARIABLE_PLACEHOLDER": "Enter %{variable} value",
|
||||
"GO_BACK_LABEL": "Go Back",
|
||||
"SEND_MESSAGE_LABEL": "Send Message",
|
||||
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
|
||||
}
|
||||
}
|
||||
}
|
17
app/javascript/dashboard/i18n/locale/ca/bulkActions.json
Normal file
17
app/javascript/dashboard/i18n/locale/ca/bulkActions.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
|
||||
"AGENT_SELECT_LABEL": "Seleccionar Agent",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
|
||||
"GO_BACK_LABEL": "Go back",
|
||||
"ASSIGN_LABEL": "Assignar",
|
||||
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
|
||||
"RESOLVE_TOOLTIP": "Resoldre",
|
||||
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
|
||||
"ASSIGN_FAILED": "Failed to assign conversations, please try again",
|
||||
"RESOLVE_SUCCESFUL": "Conversations resolved successfully",
|
||||
"RESOLVE_FAILED": "Failed to resolve conversations, please try again",
|
||||
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
|
||||
"AGENT_LIST_LOADING": "Loading Agents"
|
||||
}
|
||||
}
|
|
@ -12,33 +12,11 @@
|
|||
"INPUT": "Cerca persones, xats, respostes desades .."
|
||||
},
|
||||
"FILTER_ALL": "Totes",
|
||||
"STATUS_TABS": [
|
||||
{
|
||||
"NAME": "Obrir",
|
||||
"KEY": "openCount"
|
||||
},
|
||||
{
|
||||
"NAME": "Resoltes",
|
||||
"KEY": "allConvCount"
|
||||
}
|
||||
],
|
||||
"ASSIGNEE_TYPE_TABS": [
|
||||
{
|
||||
"NAME": "Meves",
|
||||
"KEY": "me",
|
||||
"COUNT_KEY": "mineCount"
|
||||
},
|
||||
{
|
||||
"NAME": "Sense assignar",
|
||||
"KEY": "unassigned",
|
||||
"COUNT_KEY": "unAssignedCount"
|
||||
},
|
||||
{
|
||||
"NAME": "Totes",
|
||||
"KEY": "all",
|
||||
"COUNT_KEY": "allCount"
|
||||
}
|
||||
],
|
||||
"ASSIGNEE_TYPE_TABS": {
|
||||
"me": "Meves",
|
||||
"unassigned": "Sense assignar",
|
||||
"all": "Totes"
|
||||
},
|
||||
"CHAT_STATUS_FILTER_ITEMS": {
|
||||
"open": {
|
||||
"TEXT": "Obrir"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"CONVERSATION": {
|
||||
"404": "Si us plau, selecciona una conversa al panell de l’esquerra",
|
||||
"DASHBOARD_APP_TAB_MESSAGES": "Messages",
|
||||
"UNVERIFIED_SESSION": "The identity of this user is not verified",
|
||||
"NO_MESSAGE_1": "Uh oh! Sembla que no hi ha missatges de clients a la safata d'entrada.",
|
||||
"NO_MESSAGE_2": " per enviar un missatge a la vostra pàgina!",
|
||||
|
@ -30,6 +31,7 @@
|
|||
"REPLYING_TO": "Estas responent a:",
|
||||
"REMOVE_SELECTION": "Elimina la selecció",
|
||||
"DOWNLOAD": "Descarrega",
|
||||
"UNKNOWN_FILE_TYPE": "Unknown File",
|
||||
"UPLOADING_ATTACHMENTS": "Pujant fitxers adjunts...",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue