Merge branch 'release/2.6.0'

This commit is contained in:
Sojan Jose 2022-06-15 12:09:13 +00:00 committed by GitHub
commit 374b367115
715 changed files with 10996 additions and 3230 deletions

View file

@ -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

View file

@ -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,
},
};

View file

@ -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

View file

@ -1 +1 @@
3.0.2
3.0.4

10
Gemfile
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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')

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]}%")

View file

@ -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

View 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

View file

@ -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"

View 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();

View file

@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class BulkActionsAPI extends ApiClient {
constructor() {
super('bulk_actions', { accountScoped: true });
}
}
export default new BulkActionsAPI();

View file

@ -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 },

View file

@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class DashboardAppsAPI extends ApiClient {
constructor() {
super('dashboard_apps', { accountScoped: true });
}
}
export default new DashboardAppsAPI();

View file

@ -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,
}),
});
}

View file

@ -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`);
}

View file

@ -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 },
});
}
}

View 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],
},
}
);
});
});
});

View 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');
});
});

View file

@ -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',
},
}
);
});
});
});

View 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');
});
});

View file

@ -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(

View file

@ -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',
{

View file

@ -60,3 +60,9 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.flex-between {
align-items: center;
display: flex;
justify-content: space-between;
}

View file

@ -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;
}

View file

@ -98,4 +98,7 @@ export default {
width: 48rem;
}
}
.modal-big {
width: 60%;
}
</style>

View file

@ -7,7 +7,7 @@
<p v-if="headerContent" class="small-12 column">
{{ headerContent }}
</p>
<slot></slot>
<slot />
</div>
</template>

View file

@ -20,8 +20,7 @@
color-scheme="warning"
icon="dismiss-circle"
@click="closeNotification"
>
</woot-button>
/>
</div>
</div>
</transition>

View file

@ -13,7 +13,7 @@
</p>
</div>
<div class="medium-6 small-12">
<slot></slot>
<slot />
</div>
</div>
</template>

View file

@ -7,7 +7,7 @@
:icon="icon"
/>
<spinner v-if="isLoading" />
<slot></slot>
<slot />
</button>
</template>

View file

@ -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

View file

@ -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: '',

View file

@ -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);

View file

@ -30,8 +30,7 @@
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
>
</woot-button>
/>
</div>
</template>

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -20,7 +20,7 @@
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
></textarea>
/>
</div>
</div>
</template>

View file

@ -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>

View file

@ -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() {

View file

@ -6,7 +6,7 @@
<p v-if="headerContent" class="small-12 column">
{{ headerContent }}
</p>
<slot></slot>
<slot />
</div>
</template>

View file

@ -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>

View file

@ -10,7 +10,7 @@
:search-key="cannedSearchTerm"
@click="insertCannedResponse"
/>
<div ref="editor"></div>
<div ref="editor" />
</div>
</template>

View file

@ -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() {

View file

@ -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);

View file

@ -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>

View file

@ -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: {

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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: {

View file

@ -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: {

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -22,7 +22,7 @@
accept="image/png, image/jpeg, image/gif"
@change="handleImageUpload"
/>
<slot></slot>
<slot />
</label>
</div>
</template>

View file

@ -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) {

View file

@ -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>

View file

@ -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();
};

View 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`;
};

View file

@ -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 '';
};

View file

@ -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);
});
});

View 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');
});
});

View file

@ -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'
);
});
});
});

View 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": "تحميل الوكلاء"
}
}

View file

@ -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": "فتح"

View file

@ -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": "تعذر حذف الرسالة! حاول مرة أخرى",

View file

@ -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,
};

View file

@ -386,6 +386,7 @@
"CSAT_REPORTS": {
"HEADER": "تقارير CSAT",
"NO_RECORDS": "لا توجد ردود متوفرة على الدراسة الاستقصائية CSAT.",
"DOWNLOAD": "تحميل تقرير رضاء خدمة العملاء",
"FILTERS": {
"AGENTS": {
"PLACEHOLDER": "اختر الوكلاء"

View file

@ -189,7 +189,8 @@
"REPORTS_TEAM": "الفريق",
"SET_AVAILABILITY_TITLE": "تعيين نفسك كـ",
"BETA": "تجريبي",
"REPORTS_OVERVIEW": "نظرة عامة"
"REPORTS_OVERVIEW": "نظرة عامة",
"FACEBOOK_REAUTHORIZE": "انتهت صلاحية اتصال الفيسبوك الخاص بك، يرجى إعادة الاتصال بصفحة الفيسبوك الخاصة بك لمواصلة الخدمات"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "أوه! لم نتمكن من العثور على الحساب. الرجاء إنشاء حساب جديد للمتابعة.",

View file

@ -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": "يرجى ملء جميع المتغيرات قبل الإرسال"
}
}
}

View 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"
}
}

View file

@ -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": "Отворен"

View file

@ -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",

View file

@ -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,
};

View file

@ -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"

View file

@ -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.",

View file

@ -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"
}
}
}

View 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"
}
}

View file

@ -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"

View file

@ -1,6 +1,7 @@
{
"CONVERSATION": {
"404": "Si us plau, selecciona una conversa al panell de lesquerra",
"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