Merge branch 'release/2.2.0'

This commit is contained in:
Sojan
2022-02-15 23:41:48 +05:30
867 changed files with 24336 additions and 4691 deletions

View File

@@ -22,6 +22,9 @@ checks:
enabled: true
config:
threshold: 300
method-lines:
config:
threshold: 50
exclude_patterns:
- 'spec/'
- '**/specs/'
@@ -44,3 +47,6 @@ exclude_patterns:
- 'app/javascript/shared/constants/countries.js'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js'
- 'app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js'
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'

View File

@@ -102,7 +102,7 @@ gem 'sentry-ruby'
gem 'sentry-sidekiq'
##-- background job processing --##
gem 'sidekiq'
gem 'sidekiq', '~> 6.4.0'
# We want cron jobs
gem 'sidekiq-cron'

View File

@@ -9,63 +9,63 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.4.3)
actionpack (= 6.1.4.3)
activesupport (= 6.1.4.3)
actioncable (6.1.4.6)
actionpack (= 6.1.4.6)
activesupport (= 6.1.4.6)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.3)
actionpack (= 6.1.4.3)
activejob (= 6.1.4.3)
activerecord (= 6.1.4.3)
activestorage (= 6.1.4.3)
activesupport (= 6.1.4.3)
actionmailbox (6.1.4.6)
actionpack (= 6.1.4.6)
activejob (= 6.1.4.6)
activerecord (= 6.1.4.6)
activestorage (= 6.1.4.6)
activesupport (= 6.1.4.6)
mail (>= 2.7.1)
actionmailer (6.1.4.3)
actionpack (= 6.1.4.3)
actionview (= 6.1.4.3)
activejob (= 6.1.4.3)
activesupport (= 6.1.4.3)
actionmailer (6.1.4.6)
actionpack (= 6.1.4.6)
actionview (= 6.1.4.6)
activejob (= 6.1.4.6)
activesupport (= 6.1.4.6)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4.3)
actionview (= 6.1.4.3)
activesupport (= 6.1.4.3)
actionpack (6.1.4.6)
actionview (= 6.1.4.6)
activesupport (= 6.1.4.6)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.3)
actionpack (= 6.1.4.3)
activerecord (= 6.1.4.3)
activestorage (= 6.1.4.3)
activesupport (= 6.1.4.3)
actiontext (6.1.4.6)
actionpack (= 6.1.4.6)
activerecord (= 6.1.4.6)
activestorage (= 6.1.4.6)
activesupport (= 6.1.4.6)
nokogiri (>= 1.8.5)
actionview (6.1.4.3)
activesupport (= 6.1.4.3)
actionview (6.1.4.6)
activesupport (= 6.1.4.6)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (6.1.4.3)
activesupport (= 6.1.4.3)
activejob (6.1.4.6)
activesupport (= 6.1.4.6)
globalid (>= 0.3.6)
activemodel (6.1.4.3)
activesupport (= 6.1.4.3)
activerecord (6.1.4.3)
activemodel (= 6.1.4.3)
activesupport (= 6.1.4.3)
activemodel (6.1.4.6)
activesupport (= 6.1.4.6)
activerecord (6.1.4.6)
activemodel (= 6.1.4.6)
activesupport (= 6.1.4.6)
activerecord-import (1.2.0)
activerecord (>= 3.2)
activestorage (6.1.4.3)
actionpack (= 6.1.4.3)
activejob (= 6.1.4.3)
activerecord (= 6.1.4.3)
activesupport (= 6.1.4.3)
activestorage (6.1.4.6)
actionpack (= 6.1.4.6)
activejob (= 6.1.4.6)
activerecord (= 6.1.4.6)
activesupport (= 6.1.4.6)
marcel (~> 1.0.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4.3)
activesupport (6.1.4.6)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -300,7 +300,7 @@ GEM
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.8.11)
i18n (1.9.1)
concurrent-ruby (~> 1.0)
image_processing (1.12.1)
mini_magick (>= 4.9.5, < 5)
@@ -346,7 +346,7 @@ GEM
listen (3.7.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.13.0)
loofah (2.14.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@@ -400,7 +400,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.6)
puma (5.5.2)
puma (5.6.2)
nio4r (~> 2.0)
pundit (2.1.1)
activesupport (>= 3.0.0)
@@ -416,29 +416,29 @@ GEM
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.6.0)
rails (6.1.4.3)
actioncable (= 6.1.4.3)
actionmailbox (= 6.1.4.3)
actionmailer (= 6.1.4.3)
actionpack (= 6.1.4.3)
actiontext (= 6.1.4.3)
actionview (= 6.1.4.3)
activejob (= 6.1.4.3)
activemodel (= 6.1.4.3)
activerecord (= 6.1.4.3)
activestorage (= 6.1.4.3)
activesupport (= 6.1.4.3)
rails (6.1.4.6)
actioncable (= 6.1.4.6)
actionmailbox (= 6.1.4.6)
actionmailer (= 6.1.4.6)
actionpack (= 6.1.4.6)
actiontext (= 6.1.4.6)
actionview (= 6.1.4.6)
activejob (= 6.1.4.6)
activemodel (= 6.1.4.6)
activerecord (= 6.1.4.6)
activestorage (= 6.1.4.6)
activesupport (= 6.1.4.6)
bundler (>= 1.15.0)
railties (= 6.1.4.3)
railties (= 6.1.4.6)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2)
loofah (~> 2.3)
railties (6.1.4.3)
actionpack (= 6.1.4.3)
activesupport (= 6.1.4.3)
railties (6.1.4.6)
actionpack (= 6.1.4.6)
activesupport (= 6.1.4.6)
method_source
rake (>= 0.13)
thor (~> 1.0)
@@ -447,7 +447,7 @@ GEM
rb-fsevent (0.11.0)
rb-inotify (0.10.1)
ffi (~> 1.0)
redis (4.4.0)
redis (4.5.1)
redis-namespace (1.8.1)
redis (>= 3.0.4)
regexp_parser (2.1.1)
@@ -546,7 +546,7 @@ GEM
sexp_processor (4.15.3)
shoulda-matchers (5.0.0)
activesupport (>= 5.2.0)
sidekiq (6.2.2)
sidekiq (6.4.0)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
@@ -583,7 +583,7 @@ GEM
squasher (0.6.2)
statsd-ruby (1.5.0)
telephone_number (1.4.12)
thor (1.1.0)
thor (1.2.1)
tilt (2.0.10)
time_diff (0.3.0)
activesupport
@@ -635,7 +635,7 @@ GEM
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.0)
zeitwerk (2.5.1)
zeitwerk (2.5.4)
PLATFORMS
arm64-darwin-20
@@ -726,7 +726,7 @@ DEPENDENCIES
sentry-ruby
sentry-sidekiq
shoulda-matchers
sidekiq
sidekiq (~> 6.4.0)
sidekiq-cron
simplecov (= 0.17.1)
slack-ruby-client

View File

@@ -2,7 +2,7 @@
class AccountBuilder
include CustomExceptions::Account
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password]
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin]
def perform
if @user.nil?
@@ -65,6 +65,7 @@ class AccountBuilder
password: user_password,
password_confirmation: user_password,
name: @user_full_name)
@user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed
@user.save!
end

View File

@@ -4,7 +4,7 @@ class ContactInboxBuilder
def perform
@contact = Contact.find(contact_id)
@inbox = @contact.account.inboxes.find(inbox_id)
return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
return unless ['Channel::TwilioSms', 'Channel::Sms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
source_id = @source_id || generate_source_id
create_contact_inbox(source_id) if source_id.present?
@@ -13,12 +13,18 @@ class ContactInboxBuilder
private
def generate_source_id
return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms'
return wa_source_id if @inbox.channel_type == 'Channel::Whatsapp'
return @contact.email if @inbox.channel_type == 'Channel::Email'
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api'
nil
case @inbox.channel_type
when 'Channel::TwilioSms'
twilio_source_id
when 'Channel::Whatsapp'
wa_source_id
when 'Channel::Email'
@contact.email
when 'Channel::Sms'
@contact.phone_number
when 'Channel::Api'
SecureRandom.uuid
end
end
def wa_source_id

View File

@@ -29,11 +29,12 @@ class Messages::MessageBuilder
return if @attachments.blank?
@attachments.each do |uploaded_attachment|
@message.attachments.build(
attachment = @message.attachments.build(
account_id: @message.account_id,
file_type: file_type(uploaded_attachment&.content_type),
file: uploaded_attachment
)
attachment.file_type = file_type(uploaded_attachment&.content_type) if uploaded_attachment.is_a?(ActionDispatch::Http::UploadedFile)
end
end

View File

@@ -2,6 +2,8 @@ class V2::ReportBuilder
include DateRangeHelper
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
def initialize(account, params)
@account = account
@params = params
@@ -64,26 +66,30 @@ class V2::ReportBuilder
def conversations_count
scope.conversations
.group_by_day(:created_at, range: range, default_value: 0)
.group_by_period(params[:group_by] || DEFAULT_GROUP_BY,
:created_at, range: range, default_value: 0, permit: %w[day week month year])
.count
end
def incoming_messages_count
scope.messages.incoming.unscope(:order)
.group_by_day(:created_at, range: range, default_value: 0)
.group_by_period(params[:group_by] || DEFAULT_GROUP_BY,
:created_at, range: range, default_value: 0, permit: %w[day week month year])
.count
end
def outgoing_messages_count
scope.messages.outgoing.unscope(:order)
.group_by_day(:created_at, range: range, default_value: 0)
.group_by_period(params[:group_by] || DEFAULT_GROUP_BY,
:created_at, range: range, default_value: 0, permit: %w[day week month year])
.count
end
def resolutions_count
scope.conversations
.resolved
.group_by_day(:created_at, range: range, default_value: 0)
.group_by_period(params[:group_by] || DEFAULT_GROUP_BY,
:created_at, range: range, default_value: 0, permit: %w[day week month year])
.count
end

View File

@@ -1,21 +1,44 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
def index
@automation_rules = Current.account.automation_rules
@automation_rules = Current.account.automation_rules.active
end
def create
@automation_rule = Current.account.automation_rules.create(automation_rules_permit)
end
def show; end
def update
@automation_rule.update(automation_rules_permit)
end
def destroy
@automation_rule.destroy!
head :ok
end
def clone
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
new_rule = automation_rule.dup
new_rule.save
@automation_rule = new_rule
end
private
def automation_rules_permit
params.permit(
:name, :description, :event_name, :account_id,
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
actions: [:action_name, { action_params: [:intiated_at] }]
actions: [:action_name, { action_params: [] }]
)
end
def fetch_automation_rule
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
end
end

View File

@@ -18,7 +18,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
def show; end
def update
@campaign.update(campaign_params)
@campaign.update!(campaign_params)
end
private

View File

@@ -61,6 +61,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
else
@status = @conversation.toggle_status
end
assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
end
def toggle_typing_status
@@ -93,6 +94,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
def assign_conversation
@agent = Current.account.users.find(current_user.id)
@conversation.update_assignee(@agent)
end
def trigger_typing_event(event, is_private)
user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)

View File

@@ -2,6 +2,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
@@ -91,20 +92,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create_channel
case permitted_params[:channel][:type]
when 'web_widget'
Current.account.web_widgets.create!(permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].except(:type))
when 'api'
Current.account.api_channels.create!(permitted_params(Channel::Api::EDITABLE_ATTRS)[:channel].except(:type))
when 'email'
Current.account.email_channels.create!(permitted_params(Channel::Email::EDITABLE_ATTRS)[:channel].except(:type))
when 'line'
Current.account.line_channels.create!(permitted_params(Channel::Line::EDITABLE_ATTRS)[:channel].except(:type))
when 'telegram'
Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type))
when 'whatsapp'
Current.account.whatsapp_channels.create!(permitted_params(Channel::Whatsapp::EDITABLE_ATTRS)[:channel].except(:type))
end
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
def update_channel_feature_flags
@@ -123,6 +113,30 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
)
end
def channel_type_from_params
{
'web_widget' => Channel::WebWidget,
'api' => Channel::Api,
'email' => Channel::Email,
'line' => Channel::Line,
'telegram' => Channel::Telegram,
'whatsapp' => Channel::Whatsapp,
'sms' => Channel::Sms
}[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
@@ -130,4 +144,10 @@ 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

View File

@@ -38,6 +38,7 @@ class Api::V1::ProfilesController < Api::BaseController
:name,
:display_name,
:avatar,
:message_signature,
ui_settings: {}
)
end

View File

@@ -29,11 +29,12 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
return if params[:message][:attachments].blank?
params[:message][:attachments].each do |uploaded_attachment|
@message.attachments.new(
attachment = @message.attachments.new(
account_id: @message.account_id,
file_type: helpers.file_type(uploaded_attachment&.content_type),
file: uploaded_attachment
)
attachment.file_type = helpers.file_type(uploaded_attachment&.content_type) if uploaded_attachment.is_a?(ActionDispatch::Http::UploadedFile)
end
end

View File

@@ -46,7 +46,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
type: params[:type].to_sym,
since: params[:since],
until: params[:until],
id: params[:id]
id: params[:id],
group_by: params[:group_by]
}
end
@@ -56,7 +57,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
type: params[:type].to_sym,
since: params[:since],
until: params[:until],
id: params[:id]
id: params[:id],
group_by: params[:group_by]
}
end

View File

@@ -25,7 +25,8 @@ class DashboardController < ActionController::Base
'API_CHANNEL_NAME',
'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN',
'ANALYTICS_HOST'
'ANALYTICS_HOST',
'DIRECT_UPLOADS_ENABLED'
).merge(app_config)
end

View File

@@ -10,6 +10,7 @@ class Installation::OnboardingController < ApplicationController
user_full_name: onboarding_params.dig(:user, :name),
email: onboarding_params.dig(:user, :email),
user_password: params.dig(:user, :password),
super_admin: true,
confirmed: true
).perform
rescue StandardError => e

View File

@@ -13,7 +13,8 @@ class Platform::Api::V1::UsersController < PlatformController
end
def login
render json: { url: "#{ENV['FRONTEND_URL']}/app/login?email=#{@resource.email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
encoded_email = ERB::Util.url_encode(@resource.email)
render json: { url: "#{ENV['FRONTEND_URL']}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
end
def show; end

View File

@@ -33,11 +33,11 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes).
# transform_values { |value| value == "" ? nil : value }
# end
def resource_params
permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params
end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information

View File

@@ -8,7 +8,7 @@ class SuperAdmin::Devise::SessionsController < Devise::SessionsController
def create
redirect_to(super_admin_session_path, flash: { error: @error_message }) && return unless valid_credentials?
sign_in(@super_admin, scope: :super_admin)
sign_in(:super_admin, @super_admin)
flash.discard
redirect_to super_admin_users_path
end

View File

@@ -1,44 +0,0 @@
class SuperAdmin::SuperAdminsController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
end

View File

@@ -33,12 +33,15 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes).
# transform_values { |value| value == "" ? nil : value }
# end
def resource_params
permitted_params = super
permitted_params.delete(:password) if permitted_params[:password].blank?
permitted_params
end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
def find_resource(param)
super.becomes(User)
end
end

View File

@@ -0,0 +1,6 @@
class Webhooks::SmsController < ActionController::API
def process_payload
Webhooks::SmsEventsJob.perform_later(params['_json']&.first&.to_unsafe_hash)
head :ok
end
end

View File

@@ -10,7 +10,7 @@ class WidgetsController < ActionController::Base
private
def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED')
end
def set_web_widget

View File

@@ -7,6 +7,8 @@ class AccountDashboard < Administrate::BaseDashboard
# Each different type represents an Administrate::Field object,
# which determines how the attribute is displayed
# on pages throughout the dashboard.
enterprise_attribute_types = ChatwootApp.enterprise? ? { limits: Enterprise::AccountLimitsField } : {}
ATTRIBUTE_TYPES = {
id: Field::Number,
name: Field::String,
@@ -16,7 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard
conversations: CountField,
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
account_users: Field::HasMany
}.freeze
}.merge(enterprise_attribute_types).freeze
# COLLECTION_ATTRIBUTES
# an array of attributes that will be displayed on the model's index page.
@@ -33,7 +35,8 @@ class AccountDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits] : []
SHOW_PAGE_ATTRIBUTES = (%i[
id
name
created_at
@@ -41,15 +44,16 @@ class AccountDashboard < Administrate::BaseDashboard
locale
conversations
account_users
].freeze
] + enterprise_show_page_attributes).freeze
# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
enterprise_form_attributes = ChatwootApp.enterprise? ? %i[limits] : []
FORM_ATTRIBUTES = (%i[
name
locale
].freeze
] + enterprise_form_attributes).freeze
# COLLECTION_FILTERS
# a hash that defines filters that can be used while searching via the search
@@ -69,4 +73,8 @@ class AccountDashboard < Administrate::BaseDashboard
def display_resource(account)
"##{account.id} #{account.name}"
end
def permitted_attributes
super + [limits: {}]
end
end

View File

@@ -1,75 +0,0 @@
require 'administrate/base_dashboard'
class SuperAdminDashboard < Administrate::BaseDashboard
# ATTRIBUTE_TYPES
# a hash that describes the type of each of the model's fields.
#
# Each different type represents an Administrate::Field object,
# which determines how the attribute is displayed
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
id: Field::Number,
email: Field::String,
password: Field::Password,
remember_created_at: Field::DateTime,
sign_in_count: Field::Number,
current_sign_in_at: Field::DateTime,
last_sign_in_at: Field::DateTime,
current_sign_in_ip: Field::String.with_options(searchable: false),
last_sign_in_ip: Field::String.with_options(searchable: false),
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze
# COLLECTION_ATTRIBUTES
# an array of attributes that will be displayed on the model's index page.
#
# By default, it's limited to four items to reduce clutter on index pages.
# Feel free to add, remove, or rearrange items.
COLLECTION_ATTRIBUTES = %i[
id
email
].freeze
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
email
remember_created_at
sign_in_count
current_sign_in_at
last_sign_in_at
current_sign_in_ip
last_sign_in_ip
created_at
updated_at
].freeze
# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
email
password
].freeze
# COLLECTION_FILTERS
# a hash that defines filters that can be used while searching via the search
# field of the dashboard.
#
# For example to add an option to search for open resources by typing "open:"
# in the search field:
#
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how super admins are displayed
# across all pages of the admin dashboard.
#
# def display_resource(super_admin)
# "SuperAdmin ##{super_admin.id}"
# end
end

View File

@@ -30,6 +30,7 @@ class UserDashboard < Administrate::BaseDashboard
created_at: Field::DateTime,
updated_at: Field::DateTime,
pubsub_token: Field::String,
type: Field::Select.with_options(collection: [nil, 'SuperAdmin']),
accounts: CountField
}.freeze
@@ -44,6 +45,7 @@ class UserDashboard < Administrate::BaseDashboard
name
email
accounts
type
].freeze
# SHOW_PAGE_ATTRIBUTES
@@ -53,10 +55,12 @@ class UserDashboard < Administrate::BaseDashboard
avatar_url
unconfirmed_email
name
type
display_name
email
created_at
updated_at
confirmed_at
account_users
].freeze
@@ -68,6 +72,8 @@ class UserDashboard < Administrate::BaseDashboard
display_name
email
password
confirmed_at
type
].freeze
# COLLECTION_FILTERS

View File

@@ -0,0 +1,7 @@
require 'administrate/field/base'
class Enterprise::AccountLimitsField < Administrate::Field::Base
def to_s
data.present? ? data.to_json : { agents: nil, inboxes: nil }.to_json
end
end

View File

@@ -55,7 +55,7 @@ class ConversationFinder
def set_inboxes
@inbox_ids = if params[:inbox_id]
current_account.inboxes.where(id: params[:inbox_id])
@current_user.assigned_inboxes.where(id: params[:inbox_id])
else
@current_user.assigned_inboxes.pluck(:id)
end

View File

@@ -26,8 +26,48 @@ module Api::V1::InboxesHelper
def validate_smtp(channel_data)
return unless channel_data.key?('smtp_enabled') && channel_data[:smtp_enabled]
smtp = Net::SMTP.start(channel_data[:smtp_address], channel_data[:smtp_port], channel_data[:smtp_domain], channel_data[:smtp_email],
channel_data[:smtp_password], :login)
smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port])
set_smtp_encryption(channel_data, smtp)
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_email], channel_data[:smtp_password], :login)
smtp.finish unless smtp&.nil?
end
def set_smtp_encryption(channel_data, smtp)
if channel_data[:smtp_enable_ssl_tls]
set_enable_tls(channel_data, smtp)
elsif channel_data[:smtp_enable_starttls_auto]
set_enable_starttls_auto(channel_data, smtp)
end
end
def set_enable_starttls_auto(channel_data, smtp)
return unless smtp.respond_to?(:enable_starttls_auto)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_starttls_auto(context)
else
smtp.enable_starttls_auto
end
end
def set_enable_tls(channel_data, smtp)
return unless smtp.respond_to?(:enable_tls)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_tls(context)
else
smtp.enable_tls
end
end
def enable_openssl_mode(smtp_openssl_verify_mode)
openssl_verify_mode = "OpenSSL::SSL::VERIFY_#{smtp_openssl_verify_mode.upcase}".constantize if smtp_openssl_verify_mode.is_a?(String)
context = Net::SMTP.default_ssl_context
context.verify_mode = openssl_verify_mode
context
end
end

View File

@@ -138,13 +138,14 @@ export default {
password,
password_confirmation,
displayName,
avatar,
...profileAttributes
}) {
const formData = new FormData();
Object.keys(profileAttributes).forEach(key => {
const value = profileAttributes[key];
if (value) {
formData.append(`profile[${key}]`, value);
const hasValue = profileAttributes[key] === undefined;
if (!hasValue) {
formData.append(`profile[${key}]`, profileAttributes[key]);
}
});
formData.append('profile[display_name]', displayName || '');
@@ -152,6 +153,9 @@ export default {
formData.append('profile[password]', password);
formData.append('profile[password_confirmation]', password_confirmation);
}
if (avatar) {
formData.append('profile[avatar]', avatar);
}
return axios.put(endPoints('profileUpdate').url, formData);
},

View File

@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class AutomationsAPI extends ApiClient {
constructor() {
super('automation_rules', { accountScoped: true });
}
clone(automationId) {
return axios.post(`${this.url}/${automationId}/clone`);
}
}
export default new AutomationsAPI();

View File

@@ -0,0 +1,18 @@
/* global axios */
import ApiClient from './ApiClient';
class CustomViewsAPI extends ApiClient {
constructor() {
super('custom_filters', { accountScoped: true });
}
getCustomViewsByFilterType(type) {
return axios.get(`${this.url}?filter_type=${type}`);
}
deleteCustomViews(id, type) {
return axios.delete(`${this.url}/${id}?filter_type=${type}`);
}
}
export default new CustomViewsAPI();

View File

@@ -7,17 +7,19 @@ export const buildCreatePayload = ({
isPrivate,
contentAttributes,
echoId,
file,
files,
ccEmails = '',
bccEmails = '',
}) => {
let payload;
if (file) {
if (files && files.length !== 0) {
payload = new FormData();
payload.append('attachments[]', file, file.name);
if (message) {
payload.append('content', message);
}
files.forEach(file => {
payload.append('attachments[]', file);
});
payload.append('private', isPrivate);
payload.append('echo_id', echoId);
payload.append('cc_emails', ccEmails);
@@ -46,7 +48,7 @@ class MessageApi extends ApiClient {
private: isPrivate,
contentAttributes,
echo_id: echoId,
file,
files,
ccEmails = '',
bccEmails = '',
}) {
@@ -58,7 +60,7 @@ class MessageApi extends ApiClient {
isPrivate,
contentAttributes,
echoId,
file,
files,
ccEmails,
bccEmails,
}),

View File

@@ -6,15 +6,15 @@ class ReportsAPI extends ApiClient {
super('reports', { accountScoped: true, apiVersion: 'v2' });
}
getReports(metric, since, until, type = 'account', id) {
getReports(metric, since, until, type = 'account', id, group_by) {
return axios.get(`${this.url}`, {
params: { metric, since, until, type, id },
params: { metric, since, until, type, id, group_by },
});
}
getSummary(since, until, type = 'account', id) {
getSummary(since, until, type = 'account', id, group_by) {
return axios.get(`${this.url}/summary`, {
params: { since, until, type, id },
params: { since, until, type, id, group_by },
});
}

View File

@@ -0,0 +1,15 @@
import automations from '../automation';
import ApiClient from '../ApiClient';
describe('#AutomationsAPI', () => {
it('creates correct instance', () => {
expect(automations).toBeInstanceOf(ApiClient);
expect(automations).toHaveProperty('get');
expect(automations).toHaveProperty('show');
expect(automations).toHaveProperty('create');
expect(automations).toHaveProperty('update');
expect(automations).toHaveProperty('delete');
expect(automations).toHaveProperty('clone');
expect(automations.url).toBe('/api/v1/automation_rules');
});
});

View File

@@ -36,7 +36,7 @@ describe('#ConversationAPI', () => {
echoId: 12,
isPrivate: true,
file: new Blob(['test-content'], { type: 'application/pdf' }),
files: [new Blob(['test-content'], { type: 'application/pdf' })],
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('content')).toEqual('test content');

View File

@@ -228,8 +228,14 @@
@include flex-align(right, null);
.wrap {
align-items: flex-end;
display: flex;
margin-right: $space-normal;
text-align: right;
.sender--info {
padding: var(--space-small) 0 var(--space-smaller) var(--space-small);
}
}
.bubble {

View File

@@ -15,7 +15,7 @@
@include shadow;
background-color: $woot-snackbar-bg;
border-radius: $space-smaller;
display: inline-block;
display: inline-flex;
margin-bottom: $space-small;
max-width: 40rem;
min-height: 3rem;

View File

@@ -1,26 +1,51 @@
l<template>
<template>
<div class="conversations-list-wrap">
<slot></slot>
<div class="chat-list__top" :class="{ filter__applied: hasAppliedFilters }">
<div
class="chat-list__top"
:class="{ filter__applied: hasAppliedFiltersOrActiveFolders }"
>
<h1 class="page-title text-truncate" :title="pageTitle">
{{ pageTitle }}
</h1>
<div class="filter--actions">
<chat-filter
v-if="!hasAppliedFilters"
v-if="!hasAppliedFiltersOrActiveFolders"
@statusFilterChange="updateStatusType"
/>
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="onClickOpenAddFoldersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="resetAndFetchData"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
class="delete-custom-view__button"
@click="onClickOpenDeleteFoldersModal"
/>
</div>
<woot-button
v-else
size="small"
variant="clear"
color-scheme="alert"
@click="resetAndFetchData"
>
{{ $t('FILTER.CLEAR_BUTTON_LABEL') }}
</woot-button>
<woot-button
v-tooltip.top-end="$t('FILTER.TOOLTIP_LABEL')"
variant="clear"
color-scheme="secondary"
@@ -33,8 +58,24 @@ l<template>
</div>
</div>
<add-custom-views
v-if="showAddFoldersModal"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
<delete-custom-views
v-if="showDeleteFoldersModal"
:show-delete-popup.sync="showDeleteFoldersModal"
:active-custom-view="activeFolder"
:custom-views-id="foldersId"
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
@close="onCloseDeleteFoldersModal"
/>
<chat-type-tabs
v-if="!hasAppliedFilters"
v-if="!hasAppliedFiltersOrActiveFolders"
:items="assigneeTabItems"
:active-tab="activeAssigneeTab"
class="tab--chat-type"
@@ -51,6 +92,7 @@ l<template>
:key="chat.id"
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
:chat="chat"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
@@ -87,7 +129,7 @@ l<template>
>
<conversation-advanced-filter
v-if="showAdvancedFilters"
:filter-types="advancedFilterTypes"
:initial-filter-types="advancedFilterTypes"
:on-close="onToggleAdvanceFiltersModal"
@applyFilter="onApplyFilter"
/>
@@ -108,6 +150,8 @@ import conversationMixin from '../mixins/conversations';
import wootConstants from '../constants';
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 {
hasPressedAltAndJKey,
@@ -116,10 +160,12 @@ import {
export default {
components: {
AddCustomViews,
ChatTypeTabs,
ConversationCard,
ChatFilter,
ConversationAdvancedFilter,
DeleteCustomViews,
},
mixins: [timeMixin, conversationMixin, eventListenerMixins],
props: {
@@ -139,6 +185,10 @@ export default {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
},
data() {
return {
@@ -149,6 +199,9 @@ export default {
...filter,
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
})),
foldersQuery: {},
showAddFoldersModal: false,
showDeleteFoldersModal: false,
};
},
computed: {
@@ -163,10 +216,24 @@ export default {
activeInbox: 'getSelectedInbox',
conversationStats: 'conversationStats/getStats',
appliedFilters: 'getAppliedConversationFilters',
folders: 'customViews/getCustomViews',
}),
hasAppliedFilters() {
return this.appliedFilters.length;
},
hasActiveFolders() {
return this.activeFolder && this.foldersId !== 0;
},
hasAppliedFiltersOrActiveFolders() {
return this.hasAppliedFilters || this.hasActiveFolders;
},
savedFoldersValue() {
if (this.hasActiveFolders) {
const payload = this.activeFolder.query;
this.fetchSavedFilteredConversations(payload);
}
return {};
},
assigneeTabItems() {
return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => {
const count = this.conversationStats[item.COUNT_KEY] || 0;
@@ -178,7 +245,10 @@ export default {
});
},
showAssigneeInConversationCard() {
return this.activeAssigneeTab === wootConstants.ASSIGNEE_TYPE.ALL;
return (
this.hasAppliedFiltersOrActiveFolders ||
this.activeAssigneeTab === wootConstants.ASSIGNEE_TYPE.ALL
);
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.activeInbox);
@@ -189,7 +259,9 @@ export default {
);
},
currentPageFilterKey() {
return this.hasAppliedFilters ? 'appliedFilters' : this.activeAssigneeTab;
return this.hasAppliedFiltersOrActiveFolders
? 'appliedFilters'
: this.activeAssigneeTab;
},
currentFiltersPage() {
return this.$store.getters['conversationPage/getCurrentPageFilter'](
@@ -212,6 +284,7 @@ export default {
conversationType: this.conversationType
? this.conversationType
: undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
};
},
pageTitle() {
@@ -227,11 +300,14 @@ export default {
if (this.conversationType === 'mention') {
return this.$t('CHAT_LIST.MENTION_HEADING');
}
if (this.hasActiveFolders) {
return this.activeFolder.name;
}
return this.$t('CHAT_LIST.TAB_HEADING');
},
conversationList() {
let conversationList = [];
if (!this.hasAppliedFilters) {
if (!this.hasAppliedFiltersOrActiveFolders) {
const filters = this.conversationFilters;
if (this.activeAssigneeTab === 'me') {
conversationList = [...this.mineChatsList(filters)];
@@ -246,6 +322,16 @@ export default {
return conversationList;
},
activeFolder() {
if (this.foldersId) {
const activeView = this.folders.filter(
view => view.id === Number(this.foldersId)
);
const [firstValue] = activeView;
return firstValue;
}
return undefined;
},
activeTeam() {
if (this.teamId) {
return this.$store.getters['teams/getTeam'](this.teamId);
@@ -266,6 +352,11 @@ export default {
conversationType() {
this.resetAndFetchData();
},
activeFolder() {
if (!this.hasAppliedFilters) {
this.resetAndFetchData();
}
},
},
mounted() {
this.$store.dispatch('setChatFilter', this.activeStatus);
@@ -280,10 +371,23 @@ export default {
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' });
}
this.foldersQuery = filterQueryGenerator(payload);
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
this.fetchFilteredConversations(payload);
},
onClickOpenAddFoldersModal() {
this.showAddFoldersModal = true;
},
onCloseAddFoldersModal() {
this.showAddFoldersModal = false;
},
onClickOpenDeleteFoldersModal() {
this.showDeleteFoldersModal = true;
},
onCloseDeleteFoldersModal() {
this.showDeleteFoldersModal = false;
},
onToggleAdvanceFiltersModal() {
this.showAdvancedFilters = !this.showAdvancedFilters;
},
@@ -335,6 +439,13 @@ export default {
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
this.$store.dispatch('clearConversationFilters');
if (this.hasActiveFolders) {
const payload = this.activeFolder.query;
this.fetchSavedFilteredConversations(payload);
}
if (this.foldersId) {
return;
}
this.fetchConversations();
},
fetchConversations() {
@@ -343,8 +454,12 @@ export default {
.then(() => this.$emit('conversation-load'));
},
loadMoreConversations() {
if (!this.hasAppliedFilters) {
if (!this.hasAppliedFiltersOrActiveFolders) {
this.fetchConversations();
}
if (this.hasActiveFolders) {
const payload = this.activeFolder.query;
this.fetchSavedFilteredConversations(payload);
} else {
this.fetchFilteredConversations(this.appliedFilters);
}
@@ -359,6 +474,15 @@ export default {
.then(() => this.$emit('conversation-load'));
this.showAdvancedFilters = false;
},
fetchSavedFilteredConversations(payload) {
let page = this.currentFiltersPage + 1;
this.$store
.dispatch('fetchFilteredConversations', {
queryData: payload,
page,
})
.then(() => this.$emit('conversation-load'));
},
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
bus.$emit('clearSearchInput');
@@ -374,6 +498,22 @@ export default {
this.resetAndFetchData();
}
},
openLastSavedItemInFolder() {
const lastItemOfFolder = this.folders[this.folders.length - 1];
const lastItemId = lastItemOfFolder.id;
this.$router.push({
name: 'folder_conversations',
params: { id: lastItemId },
});
},
openLastItemAfterDeleteInFolder() {
if (this.folders.length > 0) {
this.openLastSavedItemInFolder();
} else {
this.$router.push({ name: 'home' });
this.fetchConversations();
}
},
},
};
</script>
@@ -413,7 +553,11 @@ export default {
}
.filter__applied {
padding: var(--space-slab) 0 !important;
padding: 0 0 var(--space-slab) 0 !important;
border-bottom: 1px solid var(--color-border);
}
.delete-custom-view__button {
margin-right: var(--space-normal);
}
</style>

View File

@@ -63,7 +63,7 @@
rel="noopener noreferrer"
class="value"
>
{{ value || '---' }}
{{ urlValue }}
</a>
<p v-else class="value">
{{ displayValue || '---' }}
@@ -119,7 +119,7 @@ import format from 'date-fns/format';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import { isValidURL } from '../helper/URLHelper';
const DATE_FORMAT = 'yyyy-MM-dd';
export default {
@@ -141,16 +141,6 @@ export default {
editedValue: null,
};
},
validations() {
if (this.isAttributeTypeLink) {
return {
editedValue: { required, url },
};
}
return {
editedValue: { required },
};
},
computed: {
formattedValue() {
@@ -184,6 +174,9 @@ export default {
isAttributeTypeDate() {
return this.attributeType === 'date';
},
urlValue() {
return isValidURL(this.value) ? this.value : '---';
},
notAttributeTypeCheckboxAndList() {
return !this.isAttributeTypeCheckbox && !this.isAttributeTypeList;
},
@@ -206,6 +199,23 @@ export default {
return this.editedValue;
},
},
watch: {
value() {
this.isEditing = false;
this.editedValue = this.value;
},
},
validations() {
if (this.isAttributeTypeLink) {
return {
editedValue: { required, url },
};
}
return {
editedValue: { required },
};
},
mounted() {
this.editedValue = this.formattedValue;
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, focusAttributeKey => {

View File

@@ -4,7 +4,12 @@
<div class="ui-notification">
<fluent-icon icon="wifi-off" />
<p class="ui-notification-text">
{{ $t('NETWORK.NOTIFICATION.TEXT') }}
{{
useInstallationName(
$t('NETWORK.NOTIFICATION.TEXT'),
globalConfig.installationName
)
}}
</p>
<woot-button variant="clear" size="small" @click="refreshPage">
{{ $t('NETWORK.BUTTON.REFRESH') }}
@@ -23,13 +28,22 @@
</template>
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
export default {
mixins: [globalConfigMixin],
data() {
return {
showNotification: !navigator.onLine,
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
mounted() {
window.addEventListener('offline', this.updateOnlineStatus);
},

View File

@@ -4,6 +4,11 @@
<div class="ui-snackbar-text">
{{ message }}
</div>
<div v-if="action" class="ui-snackbar-action">
<router-link v-if="action.type == 'link'" :to="action.to">
{{ action.message }}
</router-link>
</div>
</div>
</div>
</template>
@@ -12,6 +17,10 @@
export default {
props: {
message: { type: String, default: '' },
action: {
type: Object,
default: () => {},
},
showButton: Boolean,
duration: {
type: [String, Number],

View File

@@ -4,6 +4,7 @@
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
</template>
@@ -35,8 +36,12 @@ export default {
bus.$off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage(message) {
this.snackMessages.push({ key: new Date().getTime(), message });
onNewToastMessage(message, action) {
this.snackMessages.push({
key: new Date().getTime(),
message,
action,
});
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, this.duration);

View File

@@ -14,6 +14,7 @@
:inboxes="inboxes"
:labels="labels"
:teams="teams"
:custom-views="customViews"
:menu-config="activeSecondaryMenu"
:current-role="currentRole"
@add-label="showAddLabelPopup"
@@ -92,6 +93,26 @@ export default {
labels: 'labels/getLabelsOnSidebar',
teams: 'teams/getMyTeams',
}),
activeCustomView() {
if (this.activePrimaryMenu.key === 'contacts') {
return 'contact';
}
if (this.activePrimaryMenu.key === 'conversations') {
return 'conversation';
}
return '';
},
customViews() {
return this.$store.getters['customViews/getCustomViewsByFilterType'](
this.activeCustomView
);
},
isConversationOrContactActive() {
return (
this.activePrimaryMenu.key === 'contacts' ||
this.activePrimaryMenu.key === 'conversations'
);
},
sideMenuConfig() {
return getSidebarItems(this.accountId);
},
@@ -119,15 +140,27 @@ export default {
return activePrimaryMenu;
},
},
watch: {
activeCustomView() {
this.fetchCustomViews();
},
},
mounted() {
this.$store.dispatch('labels/get');
this.$store.dispatch('inboxes/get');
this.$store.dispatch('notifications/unReadCount');
this.$store.dispatch('teams/get');
this.$store.dispatch('attributes/get');
this.fetchCustomViews();
},
methods: {
fetchCustomViews() {
if (this.isConversationOrContactActive) {
this.$store.dispatch('customViews/get', this.activeCustomView);
}
},
toggleKeyShortcutModal() {
this.showShortcutModal = true;
},

View File

@@ -5,6 +5,7 @@ const contacts = accountId => ({
routes: [
'contacts_dashboard',
'contact_profile_dashboard',
'contacts_segments_dashboard',
'contacts_labels_dashboard',
],
menuItems: [

View File

@@ -14,6 +14,8 @@ const conversations = accountId => ({
'conversations_through_team',
'conversation_mentions',
'conversation_through_mentions',
'folder_conversations',
'conversations_through_folders',
],
menuItems: [
{

View File

@@ -70,7 +70,7 @@ const settings = accountId => ({
toStateName: 'attributes_list',
},
{
icon: 'autocorrect',
icon: 'automation',
label: 'AUTOMATION',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),

View File

@@ -40,6 +40,10 @@ export default {
type: Array,
default: () => [],
},
customViews: {
type: Array,
default: () => [],
},
menuConfig: {
type: Object,
default: () => {},
@@ -53,6 +57,9 @@ export default {
hasSecondaryMenu() {
return this.menuConfig.menuItems && this.menuConfig.menuItems.length;
},
contactCustomViews() {
return this.customViews.filter(view => view.filter_type === 'contact');
},
accessibleMenuItems() {
if (!this.currentRole) {
return [];
@@ -150,14 +157,57 @@ export default {
})),
};
},
foldersSection() {
return {
icon: 'folder',
label: 'CUSTOM_VIEWS_FOLDER',
hasSubMenu: true,
key: 'custom_view',
children: this.customViews
.filter(view => view.filter_type === 'conversation')
.map(view => ({
id: view.id,
label: view.name,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/custom_view/${view.id}`
),
})),
};
},
contactSegmentsSection() {
return {
icon: 'folder',
label: 'CUSTOM_VIEWS_SEGMENTS',
hasSubMenu: true,
key: 'custom_view',
children: this.customViews
.filter(view => view.filter_type === 'contact')
.map(view => ({
id: view.id,
label: view.name,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/contacts/custom_view/${view.id}`
),
})),
};
},
additionalSecondaryMenuItems() {
let conversationMenuItems = [this.inboxSection, this.labelSection];
let contactMenuItems = [this.contactLabelSection];
if (this.teams.length) {
conversationMenuItems = [this.teamSection, ...conversationMenuItems];
}
if (this.customViews.length) {
conversationMenuItems = [this.foldersSection, ...conversationMenuItems];
}
if (this.contactCustomViews.length) {
contactMenuItems = [this.contactSegmentsSection, ...contactMenuItems];
}
return {
conversations: conversationMenuItems,
contacts: [this.contactLabelSection],
contacts: contactMenuItems,
};
},
},

View File

@@ -7,7 +7,7 @@
v-else
class="secondary-menu--title secondary-menu--link fs-small"
:class="computedClass"
:to="menuItem.toState"
:to="menuItem && menuItem.toState"
>
<fluent-icon
:icon="menuItem.icon"
@@ -15,6 +15,13 @@
size="14"
/>
{{ $t(`SIDEBAR.${menuItem.label}`) }}
<span
v-if="menuItem.label === 'AUTOMATION'"
data-view-component="true"
label="Beta"
class="beta"
>Beta
</span>
</router-link>
<ul v-if="hasSubMenu" class="nested vertical menu">
@@ -73,14 +80,48 @@ export default {
hasSubMenu() {
return !!this.menuItem.children;
},
isInboxConversation() {
return (
this.$store.state.route.name === 'inbox_conversation' &&
this.menuItem.toStateName === 'home'
);
},
isTeamsSettings() {
return (
this.$store.state.route.name === 'settings_teams_edit' &&
this.menuItem.toStateName === 'settings_teams_list'
);
},
isInboxsSettings() {
return (
this.$store.state.route.name === 'settings_inbox_show' &&
this.menuItem.toStateName === 'settings_inbox_list'
);
},
isIntegrationsSettings() {
return (
this.$store.state.route.name === 'settings_integrations_webhook' &&
this.menuItem.toStateName === 'settings_integrations'
);
},
isApplicationsSettings() {
return (
this.$store.state.route.name === 'settings_applications_integration' &&
this.menuItem.toStateName === 'settings_applications'
);
},
computedClass() {
// If active Inbox is present
// donot highlight conversations
if (this.activeInbox) return ' ';
if (
this.$store.state.route.name === 'inbox_conversation' &&
this.menuItem.toStateName === 'home'
this.isInboxConversation ||
this.isTeamsSettings ||
this.isInboxsSettings ||
this.isIntegrationsSettings ||
this.isApplicationsSettings
) {
return 'is-active';
}
@@ -187,4 +228,17 @@ export default {
color: var(--w-500);
}
}
.beta {
padding-right: var(--space-smaller) !important;
padding-left: var(--space-smaller) !important;
margin-left: var(--space-half) !important;
display: inline-block;
font-size: var(--font-size-mini);
font-weight: var(--font-weight-medium);
line-height: 18px;
border: 1px solid transparent;
border-radius: 2em;
color: var(--g-800);
border-color: var(--g-700);
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="banner" :class="bannerClasses">
<span>
{{ bannerMessage }}
<a
v-if="hrefLink"
:href="hrefLink"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ hrefLinkText }}
</a>
</span>
<woot-button
v-if="hasActionButton"
size="small"
variant="link"
icon="arrow-right"
color-scheme="primary"
class-names="banner-action__button"
@click="onClick"
>
{{ actionButtonLabel }}
</woot-button>
<woot-button
v-if="hasCloseButton"
size="small"
variant="link"
color-scheme="warning"
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
>
</woot-button>
</div>
</template>
<script>
export default {
props: {
bannerMessage: {
type: String,
default: '',
},
hrefLink: {
type: String,
default: '',
},
hrefLinkText: {
type: String,
default: '',
},
hasActionButton: {
type: Boolean,
default: false,
},
actionButtonLabel: {
type: String,
default: '',
},
colorScheme: {
type: String,
default: '',
},
hasCloseButton: {
type: Boolean,
default: false,
},
},
computed: {
bannerClasses() {
return [this.colorScheme];
},
},
methods: {
onClick(e) {
this.$emit('click', e);
},
onClickClose(e) {
this.$emit('close', e);
},
},
};
</script>
<style lang="scss" scoped>
.banner {
display: flex;
color: var(--white);
font-size: var(--font-size-mini);
padding: var(--space-slab) var(--space-normal);
justify-content: center;
position: sticky;
&.secondary {
background: var(--s-300);
}
&.alert {
background: var(--r-400);
}
&.warning {
background: var(--y-800);
color: var(--s-600);
a {
color: var(--s-600);
}
}
&.gray {
background: var(--b-500);
}
a {
text-decoration: underline;
color: var(--white);
font-size: var(--font-size-mini);
}
.banner-action__button {
margin: 0 var(--space-smaller);
::v-deep .button__content {
white-space: nowrap;
}
}
}
</style>

View File

@@ -30,7 +30,7 @@ export default {
},
value: {
type: Date,
default: () => [],
default: [],
},
},

View File

@@ -7,7 +7,7 @@
>
<div class="thumb-wrap">
<img
v-if="isTypeImage(attachment.resource.type)"
v-if="isTypeImage(attachment.resource)"
class="image-thumb"
:src="attachment.thumb"
/>
@@ -15,12 +15,12 @@
</div>
<div class="file-name-wrap">
<span class="item">
{{ attachment.resource.name }}
{{ fileName(attachment.resource) }}
</span>
</div>
<div class="file-size-wrap">
<span class="item">
{{ formatFileSize(attachment.resource.size) }}
{{ formatFileSize(attachment.resource) }}
</span>
</div>
<div class="remove-file-wrap">
@@ -50,12 +50,17 @@ export default {
onRemoveAttachment(index) {
this.removeAttachment(index);
},
formatFileSize(size) {
formatFileSize(file) {
const size = file.byte_size || file.size;
return formatBytes(size, 0);
},
isTypeImage(type) {
isTypeImage(file) {
const type = file.content_type || file.type;
return type.includes('image');
},
fileName(file) {
return file.filename || file.name;
},
},
};
</script>

View File

@@ -0,0 +1,182 @@
<template>
<div
class="filter"
:class="{ error: v.action_params.$dirty && v.action_params.$error }"
>
<div class="filter-inputs">
<select
v-model="action_name"
class="action__question"
@change="resetFilter()"
>
<option
v-for="attribute in actionTypes"
:key="attribute.key"
:value="attribute.key"
>
{{ attribute.label }}
</option>
</select>
<div class="filter__answer--wrap">
<div class="multiselect-wrap--small">
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="'Select'"
:multiple="true"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
/>
</div>
</div>
<woot-button
icon="dismiss"
variant="clear"
color-scheme="secondary"
@click="removeAction"
/>
</div>
<p
v-if="v.action_params.$dirty && v.action_params.$error"
class="filter-error"
>
{{ $t('FILTER.EMPTY_VALUE_ERROR') }}
</p>
</div>
</template>
<script>
export default {
props: {
value: {
type: Object,
default: () => null,
},
actionTypes: {
type: Array,
default: () => [],
},
dropdownValues: {
type: Array,
default: () => [],
},
v: {
type: Object,
default: () => null,
},
},
computed: {
action_name: {
get() {
if (!this.value) return null;
return this.value.action_name;
},
set(value) {
const payload = this.value || {};
this.$emit('input', { ...payload, action_name: value });
},
},
action_params: {
get() {
if (!this.value) return null;
return this.value.action_params;
},
set(value) {
const payload = this.value || {};
this.$emit('input', { ...payload, action_params: value });
},
},
},
methods: {
removeAction() {
this.$emit('removeAction');
},
resetFilter() {
this.$emit('resetFilter');
},
},
};
</script>
<style lang="scss" scoped>
.filter {
background: var(--color-background);
padding: var(--space-small);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
margin-bottom: var(--space-small);
}
.filter.error {
background: var(--r-50);
}
.filter-inputs {
display: flex;
}
.filter-error {
color: var(--r-500);
display: block;
margin: var(--space-smaller) 0;
}
.action__question,
.filter__operator {
margin-bottom: var(--space-zero);
margin-right: var(--space-smaller);
}
.action__question {
max-width: 50%;
}
.filter__answer--wrap {
margin-right: var(--space-smaller);
flex-grow: 1;
input {
margin-bottom: 0;
}
}
.filter__answer {
&.answer--text-input {
margin-bottom: var(--space-zero);
}
}
.filter__join-operator-wrap {
position: relative;
z-index: var(--z-index-twenty);
margin: var(--space-zero);
}
.filter__join-operator {
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: var(--space-one) var(--space-zero);
.operator__line {
position: absolute;
width: 100%;
border-bottom: 1px solid var(--color-border);
}
.operator__select {
position: relative;
width: auto;
margin-bottom: var(--space-zero) !important;
}
}
.multiselect {
margin-bottom: var(--space-zero);
}
</style>

View File

@@ -18,11 +18,11 @@ export default {
},
backgroundColor: {
type: String,
default: 'white',
default: '#c2e1ff',
},
color: {
type: String,
default: '',
default: '#1976cc',
},
customStyle: {
type: Object,

View File

@@ -0,0 +1,75 @@
export const OPERATOR_TYPES_1 = [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
];
export const OPERATOR_TYPES_2 = [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'is_present',
label: 'Is present',
},
{
value: 'is_not_present',
label: 'Is not present',
},
];
export const OPERATOR_TYPES_3 = [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'contains',
label: 'Contains',
},
{
value: 'does_not_contain',
label: 'Does not contain',
},
];
export const OPERATOR_TYPES_4 = [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'is_present',
label: 'Is present',
},
{
value: 'is_not_present',
label: 'Is not present',
},
{
value: 'is_greater_than',
label: 'Is greater than',
},
{
value: 'is_lesser_than',
label: 'Is lesser than',
},
];

View File

@@ -1,8 +1,29 @@
<template>
<div class="filters">
<div class="filter">
<div class="filter" :class="{ error: v.values.$dirty && v.values.$error }">
<div class="filter-inputs">
<select
v-if="groupedFilters"
v-model="attributeKey"
class="filter__question"
@change="resetFilter()"
>
<optgroup
v-for="(group, i) in filterGroups"
:key="i"
:label="group.name"
>
<option
v-for="attribute in group.attributes"
:key="attribute.key"
:value="attribute.key"
>
{{ attribute.name }}
</option>
</optgroup>
</select>
<select
v-else
v-model="attributeKey"
class="filter__question"
@change="resetFilter()"
@@ -63,6 +84,14 @@
:option-height="104"
/>
</div>
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
<input
v-model="values"
type="date"
:editable="false"
class="answer--text-input datepicker"
/>
</div>
<input
v-else
v-model="values"
@@ -132,6 +161,14 @@ export default {
type: Boolean,
default: true,
},
groupedFilters: {
type: Boolean,
default: false,
},
filterGroups: {
type: Array,
default: () => [],
},
},
computed: {
attributeKey: {
@@ -193,6 +230,10 @@ export default {
border-radius: var(--border-radius-medium);
}
.filter.error {
background: var(--r-50);
}
.filter-inputs {
display: flex;
}

View File

@@ -2,6 +2,7 @@
<div class="bottom-box" :class="wrapClass">
<div class="left-wrap">
<woot-button
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
:title="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="emoji"
emoji="😊"
@@ -14,10 +15,16 @@
<!-- ensure the same validations for attachment types are implemented in backend models as well -->
<file-upload
ref="upload"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
:size="4096 * 4096"
:accept="allowedFileTypes"
:multiple="enableMultipleFileUpload"
:drop="true"
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
@input-file="onFileUpload"
>
<woot-button
@@ -33,6 +40,7 @@
</file-upload>
<woot-button
v-if="enableRichEditor && !isOnPrivateNote"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="quote"
emoji="🖊️"
color-scheme="secondary"
@@ -41,6 +49,16 @@
:title="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
@click="toggleFormatMode"
/>
<woot-button
v-if="showMessageSignatureButton"
v-tooltip.top-end="signatureToggleTooltip"
icon="signature"
color-scheme="secondary"
variant="smooth"
size="small"
:title="signatureToggleTooltip"
@click="toggleMessageSignature"
/>
<transition name="modal-fade">
<div
v-show="$refs.upload && $refs.upload.dropActive"
@@ -79,18 +97,22 @@
<script>
import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage';
import {
hasPressedAltAndWKey,
hasPressedAltAndAKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import inboxMixin from 'shared/mixins/inboxMixin';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { REPLY_EDITOR_MODES } from './constants';
export default {
name: 'ReplyTopPanel',
components: { FileUpload },
mixins: [eventListenerMixins],
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin],
props: {
mode: {
type: String,
@@ -104,6 +126,10 @@ export default {
type: String,
default: '',
},
inbox: {
type: Object,
default: () => ({}),
},
showFileUpload: {
type: Boolean,
default: false,
@@ -144,6 +170,10 @@ export default {
type: Boolean,
default: true,
},
enableMultipleFileUpload: {
type: Boolean,
default: true,
},
},
computed: {
isNote() {
@@ -165,6 +195,21 @@ export default {
allowedFileTypes() {
return ALLOWED_FILE_TYPES;
},
showMessageSignatureButton() {
return !this.isPrivate && this.isAnEmailChannel;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled;
},
signatureToggleTooltip() {
return this.sendWithSignature
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
},
mounted() {
ActiveStorage.start();
},
methods: {
handleKeyEvents(e) {
@@ -181,6 +226,11 @@ export default {
toggleEnterToSend() {
this.$emit('toggleEnterToSend', !this.enterToSendEnabled);
},
toggleMessageSignature() {
this.updateUISettings({
send_with_signature: !this.sendWithSignature,
});
},
},
};
</script>

View File

@@ -9,12 +9,13 @@
v-for="(filter, i) in appliedFilters"
:key="i"
v-model="appliedFilters[i]"
:filter-attributes="filterAttributes"
:filter-groups="filterGroups"
:input-type="getInputType(appliedFilters[i].attribute_key)"
:operators="getOperators(appliedFilters[i].attribute_key)"
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
:show-query-operator="i !== appliedFilters.length - 1"
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
:grouped-filters="true"
:v="$v.appliedFilters.$each[i]"
@resetFilter="resetFilter(i, appliedFilters[i])"
@removeFilter="removeFilter(i)"
@@ -48,22 +49,24 @@
<script>
import alertMixin from 'shared/mixins/alertMixin';
import { required, requiredIf } from 'vuelidate/lib/validators';
import FilterInputBox from '../FilterInput.vue';
import FilterInputBox from '../FilterInput/Index.vue';
import languages from './advancedFilterItems/languages';
import countries from '/app/javascript/shared/constants/countries.js';
import countries from 'shared/constants/countries.js';
import { mapGetters } from 'vuex';
import { filterAttributeGroups } from './advancedFilterItems';
import filterMixin from 'shared/mixins/filterMixin';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
export default {
components: {
FilterInputBox,
},
mixins: [alertMixin],
mixins: [alertMixin, filterMixin],
props: {
onClose: {
type: Function,
default: () => {},
},
filterTypes: {
initialFilterTypes: {
type: Array,
default: () => [],
},
@@ -87,22 +90,21 @@ export default {
return {
show: true,
appliedFilters: [],
filterTypes: this.initialFilterTypes,
filterAttributeGroups,
filterGroups: [],
allCustomAttributes: [],
attributeModel: 'conversation_attribute',
filtersFori18n: 'FILTER',
};
},
computed: {
filterAttributes() {
return this.filterTypes.map(type => {
return {
key: type.attributeKey,
name: this.$t(`FILTER.ATTRIBUTES.${type.attributeI18nKey}`),
};
});
},
...mapGetters({
getAppliedConversationFilters: 'getAppliedConversationFilters',
}),
},
mounted() {
this.setFilterAttributes();
this.$store.dispatch('campaigns/get');
if (this.getAppliedConversationFilters.length) {
this.appliedFilters = [...this.getAppliedConversationFilters];
@@ -112,10 +114,41 @@ export default {
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
attribute_model: 'standard',
});
}
},
methods: {
getOperatorTypes(key) {
switch (key) {
case 'list':
return OPERATORS.OPERATOR_TYPES_1;
case 'text':
return OPERATORS.OPERATOR_TYPES_3;
case 'number':
return OPERATORS.OPERATOR_TYPES_1;
case 'link':
return OPERATORS.OPERATOR_TYPES_1;
case 'date':
return OPERATORS.OPERATOR_TYPES_4;
case 'checkbox':
return OPERATORS.OPERATOR_TYPES_1;
default:
return OPERATORS.OPERATOR_TYPES_1;
}
},
customAttributeInputType(key) {
switch (key) {
case 'date':
return 'date';
default:
return 'plain_text';
}
},
getAttributeModel(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.attributeModel;
},
getInputType(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.inputType;
@@ -192,7 +225,6 @@ export default {
'setConversationFilters',
JSON.parse(JSON.stringify(this.appliedFilters))
);
this.appliedFilters[this.appliedFilters.length - 1].query_operator = null;
this.$emit('applyFilter', this.appliedFilters);
},
resetFilter(index, currentFilter) {

View File

@@ -130,6 +130,10 @@ export default {
type: [String, Number],
default: 0,
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
@@ -248,8 +252,12 @@ export default {
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
});
if (this.isActiveChat) {
return;
}
router.push({ path: frontendURL(path) });
},
},

View File

@@ -57,22 +57,26 @@
/>
</div>
<spinner v-if="isPending" size="tiny" />
<a
v-if="isATweet && isIncoming && sender"
<div
v-if="showAvatar"
v-tooltip.top="tooltipForSender"
class="sender--info"
:href="twitterProfileLink"
target="_blank"
rel="noopener noreferrer nofollow"
>
<woot-thumbnail
:src="sender.thumbnail"
:username="sender.name"
:username="senderNameForAvatar"
size="16px"
/>
<div class="sender--available-name">
<a
v-if="isATweet && isIncoming"
class="sender--available-name"
:href="twitterProfileLink"
target="_blank"
rel="noopener noreferrer nofollow"
>
{{ sender.name }}
</div>
</a>
</a>
</div>
<div v-if="isFailed" class="message-failed--alert">
<woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
@@ -113,7 +117,6 @@ import BubbleActions from './bubble/Actions';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
import { isEmptyObject } from 'dashboard/helper/commons';
import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
@@ -242,6 +245,12 @@ export default {
isIncoming() {
return this.data.message_type === MESSAGE_TYPE.INCOMING;
},
isOutgoing() {
return this.data.message_type === MESSAGE_TYPE.OUTGOING;
},
isTemplate() {
return this.data.message_type === MESSAGE_TYPE.TEMPLATE;
},
emailHeadAttributes() {
return {
email: this.contentAttributes.email,
@@ -258,6 +267,19 @@ export default {
hasText() {
return !!this.data.content;
},
tooltipForSender() {
const name = this.senderNameForAvatar;
const { message_type: messageType } = this.data;
const showTooltip =
messageType === MESSAGE_TYPE.OUTGOING ||
messageType === MESSAGE_TYPE.TEMPLATE;
return showTooltip
? {
content: `${this.$t('CONVERSATION.SENT_BY')} ${name}`,
classes: 'top',
}
: false;
},
messageToolTip() {
if (this.isMessageDeleted) {
return false;
@@ -265,13 +287,7 @@ export default {
if (this.isFailed) {
return this.$t(`CONVERSATION.SEND_FAILED`);
}
const { sender } = this;
return this.data.message_type === 1 && !isEmptyObject(sender)
? {
content: `${this.$t('CONVERSATION.SENT_BY')} ${sender.name}`,
classes: 'top',
}
: false;
return false;
},
wrapClass() {
return {
@@ -313,6 +329,19 @@ export default {
const { meta } = this.data;
return meta ? meta.error : '';
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
return true;
}
return this.isATweet && this.isIncoming && this.sender;
},
senderNameForAvatar() {
if (this.isOutgoing || this.isTemplate) {
const { name = this.$t('CONVERSATION.BOT') } = this.sender || {};
return name;
}
return '';
},
},
watch: {
data() {
@@ -423,6 +452,9 @@ export default {
.message-text--metadata .time {
color: var(--v-50);
}
&.is-private .message-text--metadata .time {
color: var(--s-400);
}
}
&.is-failed {

View File

@@ -1,56 +1,29 @@
<template>
<div class="view-box fill-height">
<div
<banner
v-if="!currentChat.can_reply && !isAWhatsappChannel"
class="banner messenger-policy--banner"
>
<span>
{{ $t('CONVERSATION.CANNOT_REPLY') }}
<a
:href="facebookReplyPolicy"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('CONVERSATION.24_HOURS_WINDOW') }}
</a>
</span>
</div>
<div
v-if="!currentChat.can_reply && isAWhatsappChannel"
class="banner messenger-policy--banner"
>
<span>
{{ $t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY') }}
<a
:href="twilioWhatsAppReplyPolicy"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW') }}
</a>
</span>
</div>
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
v-if="isATweet"
color-scheme="gray"
:banner-message="tweetBannerText"
:has-close-button="hasSelectedTweetId"
@close="removeTweetSelection"
/>
<div v-if="isATweet" class="banner">
<span v-if="!selectedTweetId">
{{ $t('CONVERSATION.SELECT_A_TWEET_TO_REPLY') }}
</span>
<span v-else>
{{ $t('CONVERSATION.REPLYING_TO') }}
{{ selectedTweet.content || '' }}
</span>
<button
v-if="selectedTweetId"
class="banner-close-button"
@click="removeTweetSelection"
>
<fluent-icon
v-tooltip="$t('CONVERSATION.REMOVE_SELECTION')"
size="16"
icon="dismiss"
/>
</button>
</div>
<div class="sidebar-toggle__wrap">
<woot-button
variant="smooth"
@@ -126,6 +99,7 @@ import { mapGetters } from 'vuex';
import ReplyBox from './ReplyBox';
import Message from './Message';
import conversationMixin from '../../../mixins/conversations';
import Banner from 'dashboard/components/ui/Banner.vue';
import { getTypingUsersText } from '../../../helper/commons';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
@@ -139,6 +113,7 @@ export default {
components: {
Message,
ReplyBox,
Banner,
},
mixins: [conversationMixin, inboxMixin, eventListenerMixins, clickaway],
props: {
@@ -173,7 +148,17 @@ export default {
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
hasSelectedTweetId() {
return !!this.selectedTweetId;
},
tweetBannerText() {
return !this.selectedTweetId
? this.$t('CONVERSATION.SELECT_A_TWEET_TO_REPLY')
: `
${this.$t('CONVERSATION.REPLYING_TO')}
${this.selectedTweet.content}` || '';
},
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
@@ -375,31 +360,6 @@ export default {
</script>
<style scoped lang="scss">
.banner {
background: var(--b-500);
color: var(--white);
font-size: var(--font-size-mini);
padding: var(--space-slab) var(--space-normal);
text-align: center;
position: relative;
a {
text-decoration: underline;
color: var(--white);
font-size: var(--font-size-mini);
}
&.messenger-policy--banner {
background: var(--r-400);
}
.banner-close-button {
cursor: pointer;
margin-left: var(--space--two);
color: var(--white);
}
}
.spinner--container {
min-height: var(--space-jumbo);
}

View File

@@ -1,5 +1,13 @@
<template>
<div class="reply-box" :class="replyBoxClass">
<banner
v-if="showSelfAssignBanner"
color-scheme="secondary"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
:has-action-button="true"
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@click="onClickSelfAssign"
/>
<reply-top-panel
:mode="replyType"
:set-reply-mode="setReplyMode"
@@ -58,8 +66,16 @@
:remove-attachment="removeAttachment"
/>
</div>
<div
v-if="showMessageSignature"
v-tooltip="$t('CONVERSATION.FOOTER.MESSAGE_SIGN_TOOLTIP')"
class="message-signature-wrap"
>
<p class="message-signature" v-html="formatMessage(messageSignature)" />
</div>
<reply-bottom-panel
:mode="replyType"
:inbox="inbox"
:send-button-text="replyButtonLabel"
:on-file-upload="onFileUpload"
:show-file-upload="showFileUpload"
@@ -72,6 +88,7 @@
:is-format-mode="showRichContentEditor"
:enable-rich-editor="isRichEditorEnabled"
:enter-to-send-enabled="enterToSendEnabled"
:enable-multiple-file-upload="enableMultipleFileUpload"
@toggleEnterToSend="toggleEnterToSend"
/>
</div>
@@ -89,8 +106,10 @@ import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel';
import ReplyEmailHead from './ReplyEmailHead';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel';
import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import 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';
@@ -99,10 +118,12 @@ import {
isEscape,
isEnter,
hasPressedShift,
hasPressedCommandPlusKKey,
} from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { DirectUpload } from 'activestorage';
export default {
components: {
@@ -114,8 +135,15 @@ export default {
ReplyEmailHead,
ReplyBottomPanel,
WootMessageEditor,
Banner,
},
mixins: [clickaway, inboxMixin, uiSettingsMixin, alertMixin],
mixins: [
clickaway,
inboxMixin,
uiSettingsMixin,
alertMixin,
messageFormatterMixin,
],
props: {
selectedTweet: {
type: [Object, String],
@@ -147,6 +175,13 @@ export default {
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
messageSignature: 'getMessageSignature',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
}),
showRichContentEditor() {
if (this.isOnPrivateNote) {
return true;
@@ -161,7 +196,36 @@ export default {
}
return false;
},
...mapGetters({ currentChat: 'getSelectedChat' }),
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
this.$store.dispatch('setCurrentChatAssignee', agent);
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
})
.then(() => {
this.showAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
});
},
},
showSelfAssignBanner() {
if (this.message !== '' && !this.isOnPrivateNote) {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
return true;
}
}
return false;
},
enterToSendEnabled() {
return !!this.uiSettings.enter_to_send_enabled;
},
@@ -189,7 +253,7 @@ export default {
return this.maxLength - this.message.length;
},
isReplyButtonDisabled() {
if (this.isATweet && !this.inReplyTo) {
if (this.isATweet && !this.inReplyTo && !this.isOnPrivateNote) {
return true;
}
@@ -283,6 +347,16 @@ export default {
showReplyHead() {
return !this.isOnPrivateNote && this.isAnEmailChannel;
},
enableMultipleFileUpload() {
return this.isAnEmailChannel || this.isAWebWidgetInbox || this.isAPIInbox;
},
showMessageSignature() {
return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled;
},
},
watch: {
currentChat(conversation) {
@@ -355,17 +429,49 @@ export default {
e.preventDefault();
this.sendMessage();
}
} else if (hasPressedCommandPlusKKey(e)) {
this.openCommandBar();
}
},
openCommandBar() {
const ninja = document.querySelector('ninja-keys');
ninja.open();
},
toggleEnterToSend(enterToSendEnabled) {
this.updateUISettings({ enter_to_send_enabled: enterToSendEnabled });
},
onClickSelfAssign() {
const {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
avatar_url,
} = this.currentUser;
const selfAssign = {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail: avatar_url,
};
this.assignedAgent = selfAssign;
},
async sendMessage() {
if (this.isReplyButtonDisabled) {
return;
}
if (!this.showMentions) {
const newMessage = this.message;
let newMessage = this.message;
if (this.sendWithSignature && this.messageSignature) {
newMessage += '\n\n' + this.messageSignature;
}
const messagePayload = this.getMessagePayload(newMessage);
this.clearMessage();
try {
@@ -440,21 +546,30 @@ export default {
});
},
onFileUpload(file) {
if (this.globalConfig.directUploadsEnabled) {
this.onDirectFileUpload(file);
} else {
this.onIndirectFileUpload(file);
}
},
onDirectFileUpload(file) {
if (!file) {
return;
}
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.attachedFiles = [];
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
this.attachedFiles.push({
currentChatId: this.currentChat.id,
resource: file,
isPrivate: this.isPrivate,
thumb: reader.result,
});
};
const upload = new DirectUpload(
file.file,
'/rails/active_storage/direct_uploads',
null,
file.file.name
);
upload.create((error, blob) => {
if (error) {
this.showAlert(error);
} else {
this.attachFile({ file, blob });
}
});
} else {
this.showAlert(
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
@@ -463,13 +578,39 @@ export default {
);
}
},
onIndirectFileUpload(file) {
if (!file) {
return;
}
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.attachFile({ file });
} else {
this.showAlert(
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_FILE_UPLOAD_SIZE,
})
);
}
},
attachFile({ blob, file }) {
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
this.attachedFiles.push({
currentChatId: this.currentChat.id,
resource: blob || file,
isPrivate: this.isPrivate,
thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined,
});
};
},
removeAttachment(itemIndex) {
this.attachedFiles = this.attachedFiles.filter(
(item, index) => itemIndex !== index
);
},
getMessagePayload(message) {
const [attachment] = this.attachedFiles;
const messagePayload = {
conversationId: this.currentChat.id,
message,
@@ -480,8 +621,15 @@ export default {
messagePayload.contentAttributes = { in_reply_to: this.inReplyTo };
}
if (attachment) {
messagePayload.file = attachment.resource.file;
if (this.attachedFiles && this.attachedFiles.length) {
messagePayload.files = [];
this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) {
messagePayload.files.push(attachment.blobSignedId);
} else {
messagePayload.files.push(attachment.resource.file);
}
});
}
if (this.ccEmails) {
@@ -510,6 +658,25 @@ export default {
margin-bottom: 0;
}
.message-signature-wrap {
margin: 0 var(--space-normal);
padding: var(--space-small);
display: flex;
align-items: baseline;
justify-content: space-between;
border: 1px dashed var(--s-100);
border-radius: var(--border-radius-small);
&:hover {
background: var(--s-25);
}
}
.message-signature {
width: fit-content;
margin: 0;
}
.attachment-preview-box {
padding: 0 var(--space-normal);
background: transparent;

View File

@@ -1,229 +1,144 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_2,
OPERATOR_TYPES_3,
} from '../../FilterInput/FilterOperatorTypes';
const filterTypes = [
{
attributeKey: 'status',
attributeI18nKey: 'STATUS',
inputType: 'multi_select',
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
],
attribute_type: 'standard',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'assignee_id',
attributeI18nKey: 'ASSIGNEE_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'is_present',
label: 'Is present',
},
{
value: 'is_not_present',
label: 'Is not present',
},
],
attribute_type: 'standard',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'inbox_id',
attributeI18nKey: 'INBOX_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'is_present',
label: 'Is present',
},
{
value: 'is_not_present',
label: 'Is not present',
},
],
attribute_type: 'standard',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'team_id',
attributeI18nKey: 'TEAM_NAME',
inputType: 'search_select',
dataType: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'is_present',
label: 'Is present',
},
{
value: 'is_not_present',
label: 'Is not present',
},
],
attribute_type: 'standard',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
inputType: 'plain_text',
dataType: 'Number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'contains',
label: 'Contains',
},
{
value: 'does_not_contain',
label: 'Does not contain',
},
],
attribute_type: 'standard',
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'standard',
},
{
attributeKey: 'campaign_id',
attributeI18nKey: 'CAMPAIGN_NAME',
inputType: 'search_select',
dataType: 'Number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'is_present',
label: 'Is present',
},
{
value: 'is_not_present',
label: 'Is not present',
},
],
attribute_type: 'standard',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'labels',
attributeI18nKey: 'LABELS',
inputType: 'multi_select',
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'is_present',
label: 'Is present',
},
{
value: 'is_not_present',
label: 'Is not present',
},
],
attribute_type: 'standard',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'browser_language',
attributeI18nKey: 'BROWSER_LANGUAGE',
inputType: 'search_select',
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
],
attribute_type: 'additional_attributes',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'country_code',
attributeI18nKey: 'COUNTRY_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
],
attribute_type: 'additional_attributes',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
inputType: 'plain_text',
dataType: 'text',
filterOperators: [
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'additional',
},
];
export const filterAttributeGroups = [
{
name: 'Standard Filters',
i18nGroup: 'STANDARD_FILTERS',
attributes: [
{
value: 'equal_to',
label: 'Equal to',
key: 'status',
i18nKey: 'STATUS',
},
{
value: 'not_equal_to',
label: 'Not equal to',
key: 'assignee_id',
i18nKey: 'ASSIGNEE_NAME',
},
{
value: 'contains',
label: 'Contains',
key: 'inbox_id',
i18nKey: 'INBOX_NAME',
},
{
value: 'does_not_contain',
label: 'Does not contain',
key: 'team_id',
i18nKey: 'TEAM_NAME',
},
{
key: 'display_id',
i18nKey: 'CONVERSATION_IDENTIFIER',
},
{
key: 'campaign_id',
i18nKey: 'CAMPAIGN_NAME',
},
{
key: 'labels',
i18nKey: 'LABELS',
},
],
},
{
name: 'Additional Filters',
i18nGroup: 'ADDITIONAL_FILTERS',
attributes: [
{
key: 'browser_language',
i18nKey: 'BROWSER_LANGUAGE',
},
{
key: 'country_code',
i18nKey: 'COUNTRY_NAME',
},
{
key: 'referer',
i18nKey: 'REFERER_LINK',
},
],
attribute_type: 'additional_attributes',
},
];

View File

@@ -137,10 +137,10 @@ export default {
.time {
color: var(--w-100);
}
}
.action--icon {
color: var(--white);
.action--icon {
color: var(--white);
}
}
}

View File

@@ -4,6 +4,10 @@
class="message__mail-head"
:class="{ 'is-incoming': isIncoming }"
>
<div v-if="fromMail" class="meta-wrap">
<span class="message__content--type">{{ $t('EMAIL_HEADER.FROM') }}:</span>
<span>{{ fromMail }}</span>
</div>
<div v-if="toMails" class="meta-wrap">
<span class="message__content--type">{{ $t('EMAIL_HEADER.TO') }}:</span>
<span>{{ toMails }}</span>
@@ -46,6 +50,10 @@ export default {
},
},
computed: {
fromMail() {
const from = this.emailAttributes.from || [];
return from.join(', ');
},
toMails() {
const to = this.emailAttributes.to || [];
return to.join(', ');

View File

@@ -84,7 +84,8 @@ describe('MoveActions', () => {
expect(window.bus.$emit).toBeCalledWith(
'newToastMessage',
'This conversation is muted for 6 hours'
'This conversation is muted for 6 hours',
undefined
);
});
});
@@ -109,7 +110,8 @@ describe('MoveActions', () => {
expect(window.bus.$emit).toBeCalledWith(
'newToastMessage',
'This conversation is unmuted'
'This conversation is unmuted',
undefined
);
});
});

View File

@@ -12,6 +12,7 @@ export const conversationUrl = ({
label,
teamId,
conversationType = '',
foldersId,
}) => {
let url = `accounts/${accountId}/conversations/${id}`;
if (activeInbox) {
@@ -20,6 +21,8 @@ export const conversationUrl = ({
url = `accounts/${accountId}/label/${label}/conversations/${id}`;
} else if (teamId) {
url = `accounts/${accountId}/team/${teamId}/conversations/${id}`;
} else if (foldersId && foldersId !== 0) {
url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`;
} else if (conversationType === 'mention') {
url = `accounts/${accountId}/mentions/conversations/${id}`;
}
@@ -34,3 +37,9 @@ export const accountIdFromPathname = pathname => {
const accountId = isScoped ? Number(urlParam) : '';
return accountId;
};
export const isValidURL = value => {
/* eslint-disable no-useless-escape */
const URL_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/gm;
return URL_REGEX.test(value);
};

View File

@@ -0,0 +1,18 @@
const generatePayload = data => {
const actions = JSON.parse(JSON.stringify(data));
let payload = actions.map(item => {
if (Array.isArray(item.action_params)) {
item.action_params = item.action_params.map(val => val.id);
} else if (typeof item.values === 'object') {
item.action_params = [item.action_params.id];
} else if (!item.action_params) {
item.action_params = [];
} else {
item.action_params = [item.action_params];
}
return item;
});
return payload;
};
export default generatePayload;

View File

@@ -1,5 +1,19 @@
const lowerCaseValues = (operator, values) => {
if (operator === 'equal_to' || operator === 'not_equal_to') {
values = values.map(val => {
if (typeof val === 'string') {
return val.toLowerCase();
}
return val;
});
}
return values;
};
const generatePayload = data => {
let payload = data.map(item => {
// Make a copy of data to avoid vue data reactivity issues
const filters = JSON.parse(JSON.stringify(data));
let payload = filters.map(item => {
if (Array.isArray(item.values)) {
item.values = item.values.map(val => val.id);
} else if (typeof item.values === 'object') {
@@ -9,9 +23,14 @@ const generatePayload = data => {
} else {
item.values = [item.values];
}
// Convert all values to lowerCase if operator_type is 'equal_to' or 'not_equal_to'
item.values = lowerCaseValues(item.filter_operator, item.values);
return item;
});
// For every query added, the query_operator is set default to and so the
// last query will have an extra query_operator, this would break the api.
// Setting this to null for all query payload
payload[payload.length - 1].query_operator = undefined;
return { payload };
};

View File

@@ -2,6 +2,7 @@ import {
frontendURL,
conversationUrl,
accountIdFromPathname,
isValidURL,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -48,4 +49,13 @@ describe('#URL Helpers', () => {
expect(accountIdFromPathname('')).toBe('');
});
});
describe('isValidURL', () => {
it('should return true if valid url is passed', () => {
expect(isValidURL('https://chatwoot.com')).toBe(true);
});
it('should return false if invalid url is passed', () => {
expect(isValidURL('alert.window')).toBe(false);
});
});
});

View File

@@ -0,0 +1,41 @@
import actionQueryGenerator from '../actionQueryGenerator';
const testData = [
{
action_name: 'add_label',
action_params: [{ id: 'testlabel', name: 'testlabel' }],
},
{
action_name: 'assign_team',
action_params: [
{
id: 1,
name: 'sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
],
},
];
const finalResult = [
{
action_name: 'add_label',
action_params: ['testlabel'],
},
{
action_name: 'assign_team',
action_params: [1],
},
];
describe('#actionQueryGenerator', () => {
it('returns the correct format of filter query', () => {
expect(actionQueryGenerator(testData)).toEqual(finalResult);
expect(
actionQueryGenerator(testData).every(i => Array.isArray(i.action_params))
).toBe(true);
});
});

View File

@@ -5,7 +5,7 @@ const testData = [
attribute_key: 'status',
filter_operator: 'equal_to',
values: [
{ id: 'pending', name: 'Pending' },
{ id: 'PENDING', name: 'Pending' },
{ id: 'resolved', name: 'Resolved' },
],
query_operator: 'and',
@@ -31,7 +31,7 @@ const testData = [
attribute_key: 'id',
filter_operator: 'equal_to',
values: 'This is a test',
query_operator: null,
query_operator: 'or',
},
];
@@ -52,8 +52,7 @@ const finalResult = {
{
attribute_key: 'id',
filter_operator: 'equal_to',
values: ['This is a test'],
query_operator: null,
values: ['this is a test'],
},
],
};

View File

@@ -1,6 +1,6 @@
{
"FILTER": {
"TITLE": "تصفية المحادثة",
"TITLE": "تصفية المحادثات",
"SUBTITLE": "إضافة فلاتر أدناه واضغط على 'إرسال' لتصفية المحادثات.",
"ADD_NEW_FILTER": "إضافة فلتر",
"FILTER_DELETE_ERROR": "يجب ان يكون لديك فلتر واحد على الاقل",
@@ -19,7 +19,9 @@
"contains": "يحتوي",
"does_not_contain": "لا يحتوي",
"is_present": "موجود",
"is_not_present": "غير موجود"
"is_not_present": "غير موجود",
"is_greater_than": "هو أكبر من",
"is_lesser_than": "هو أقل من"
},
"ATTRIBUTES": {
"STATUS": "الحالة",
@@ -31,7 +33,54 @@
"LABELS": "الوسوم",
"BROWSER_LANGUAGE": "لغة المتصفح",
"COUNTRY_NAME": "اسم الدولة",
"REFERER_LINK": "رابط المرجع"
"REFERER_LINK": "رابط المرجع",
"CUSTOM_ATTRIBUTE_LIST": "القائمة",
"CUSTOM_ATTRIBUTE_TEXT": "النص",
"CUSTOM_ATTRIBUTE_NUMBER": "العدد",
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع"
},
"GROUPS": {
"STANDARD_FILTERS": "الفلاتر القياسية",
"ADDITIONAL_FILTERS": "فلاتر إضافية",
"CUSTOM_ATTRIBUTES": "سمات مخصصة"
},
"CUSTOM_VIEWS": {
"ADD": {
"TITLE": "هل تريد حفظ هذا الفلتر؟",
"LABEL": "تسمية هذا الفلتر",
"PLACEHOLDER": "أدخل اسم لهذا الفلتر",
"ERROR_MESSAGE": "الاسم مطلوب",
"SAVE_BUTTON": "حفظ الفلتر",
"CANCEL_BUTTON": "إلغاء",
"API_FOLDERS": {
"SUCCESS_MESSAGE": "تم إنشاء طريقة عرض مخصصة بنجاح",
"ERROR_MESSAGE": "خطأ أثناء إنشاء طريقة عرض مخصصة"
},
"API_SEGMENTS": {
"SUCCESS_MESSAGE": "تم إنشاء طريقة عرض مخصصة بنجاح",
"ERROR_MESSAGE": "خطأ أثناء إنشاء طريقة عرض مخصصة"
}
},
"DELETE": {
"DELETE_BUTTON": "حذف الفلتر",
"MODAL": {
"CONFIRM": {
"TITLE": "تأكيد الحذف",
"MESSAGE": "هل أنت متأكد من حذف الفلتر ",
"YES": "نعم، احذف",
"NO": "لا، احتفظ به"
}
},
"API_FOLDERS": {
"SUCCESS_MESSAGE": "تم حذف طريقة عرض مخصصة بنجاح",
"ERROR_MESSAGE": "حدث خطأ أثناء حذف المجلد"
},
"API_SEGMENTS": {
"SUCCESS_MESSAGE": "تم حذف العرض المخصص بنجاح",
"ERROR_MESSAGE": "حدث خطأ أثناء حذف طريقة عرض مخصصة"
}
}
}
}
}

View File

@@ -1,6 +1,89 @@
{
"AUTOMATION": {
"HEADER": "الأتمتة",
"HEADER_BTN_TXT": "إضافة قاعدة أتمتة"
"HEADER_BTN_TXT": "إضافة قاعدة أتمتة",
"LOADING": "جلب قواعد الأتمتة",
"SIDEBAR_TXT": "<p><b>قواعد الأتمتة الآليه</b> <p>يمكن للأتمتة استبدال وأتمتة العمليات القائمة التي تتطلب جهداً يدوياً. يمكنك القيام بالعديد من الأشياء مع التشغيل الآلي، بما في ذلك إضافة تسميات وتعيين المحادثة لأفضل وكيل. لذا يركز الفريق على ما يفعلونه على أفضل وجه ويقضي وقتاً قليلاً على المهام اليدوية.</p>",
"ADD": {
"TITLE": "إضافة قاعدة أتمتة",
"SUBMIT": "إنشاء",
"CANCEL_BUTTON_TEXT": "إلغاء",
"FORM": {
"NAME": {
"LABEL": "اسم القاعدة",
"PLACEHOLDER": "أدخل اسم القاعدة",
"ERROR": "الاسم مطلوب"
},
"DESC": {
"LABEL": "الوصف",
"PLACEHOLDER": "ادخل وصف القاعدة",
"ERROR": "الوصف مطلوب"
},
"EVENT": {
"LABEL": "الحدث",
"PLACEHOLDER": "الرجاء اختيار واحد",
"ERROR": "الحدث مطلوب"
},
"CONDITIONS": {
"LABEL": "الشروط"
},
"ACTIONS": {
"LABEL": "الإجراءات"
}
},
"CONDITION_BUTTON_LABEL": "إضافة شرط",
"ACTION_BUTTON_LABEL": "إضافة إجراء",
"API": {
"SUCCESS_MESSAGE": "تمت إضافة قاعدة الأتمتة بنجاح",
"ERROR_MESSAGE": "تعذر إنشاء قاعدة أتمتة ، يرجى المحاولة مرة أخرى لاحقاً"
}
},
"LIST": {
"TABLE_HEADER": [
"الاسم",
"الوصف",
"مفعل",
"تم إنشاؤها في"
],
"404": "لم يتم العثور على قواعد أتمتة"
},
"DELETE": {
"TITLE": "حذف قاعدة الأتمتة",
"SUBMIT": "حذف",
"CANCEL_BUTTON_TEXT": "إلغاء",
"CONFIRM": {
"TITLE": "تأكيد الحذف",
"MESSAGE": "هل أنت متأكد من الحذف ",
"YES": "نعم، احذف ",
"NO": "لا، احتفظ "
},
"API": {
"SUCCESS_MESSAGE": "تم حذف قاعدة الأتمتة بنجاح",
"ERROR_MESSAGE": "تعذر حذف قاعدة الأتمتة، يرجى المحاولة مرة أخرى لاحقاً"
}
},
"EDIT": {
"TITLE": "تعديل قاعدة الأتمتة",
"SUBMIT": "تعديل",
"CANCEL_BUTTON_TEXT": "إلغاء",
"API": {
"SUCCESS_MESSAGE": "تم تحديث قاعدة الأتمتة بنجاح",
"ERROR_MESSAGE": "تعذر تحديث قاعدة الأتمتة، الرجاء المحاولة مرة أخرى في وقت لاحق"
}
},
"CLONE": {
"TOOLTIP": "نسخ",
"API": {
"SUCCESS_MESSAGE": "تم نسخ الأتمتة بنجاح",
"ERROR_MESSAGE": "تعذر استنساخ قاعدة الأتمتة، الرجاء المحاولة مرة أخرى لاحقاً"
}
},
"FORM": {
"EDIT": "تعديل",
"CREATE": "إنشاء",
"DELETE": "حذف",
"CANCEL": "إلغاء",
"RESET_MESSAGE": "تغيير نوع الحدث سوف يعيد تعيين الشروط والأحداث التي أضفتها أدناه"
}
}
}

View File

@@ -17,7 +17,7 @@
},
"ADD": {
"TITLE": "إضافة رد جاهز",
"DESC": "الردود الجاهزة هي قوالب رسائل معدة مسبقاً يمكن استخدامها لتسريع كتابة الردود في المحادثات .",
"DESC": "الردود الجاهزة هي قوالب رسائل معدة مسبقاً يمكن استخدامها لتسريع كتابة الردود في المحادثات.",
"CANCEL_BUTTON_TEXT": "إلغاء",
"FORM": {
"SHORT_CODE": {

View File

@@ -7,7 +7,7 @@
"404": "لا توجد محادثات نشطة في هذه المجموعة."
},
"TAB_HEADING": "المحادثات",
"MENTION_HEADING": "Mentions",
"MENTION_HEADING": "الإشارات",
"SEARCH": {
"INPUT": "البحث عن جهات الاتصال، المحادثات، قوالب الردود الجاهزة .."
},

View File

@@ -111,7 +111,7 @@
"LABEL": "رقم الهاتف",
"HELP": "يجب ان يحتوى رقم الهاتف على كود دولتك تسبقها علامة +\nمثال: +20101243567",
"ERROR": "يجب ان تكون خانة رقم الهاتف إما فارغة او مكتملة مع رمز الدولة",
"DUPLICATE": "This phone number is in use for another contact."
"DUPLICATE": "رقم الهاتف هذا مستخدم لجهة اتصال أخرى."
},
"LOCATION": {
"PLACEHOLDER": "أدخل موقع جهة الاتصال",
@@ -169,6 +169,7 @@
"SUBMIT": "إرسال الرسالة",
"CANCEL": "إلغاء",
"SUCCESS_MESSAGE": "تم إرسال الرسالة!",
"GO_TO_CONVERSATION": "عرض",
"ERROR_MESSAGE": "تعذر الإرسال! حاول مرة أخرى"
}
},
@@ -177,7 +178,9 @@
"FIELDS": "تصنفيات جهات الاتصال",
"SEARCH_BUTTON": "بحث",
"SEARCH_INPUT_PLACEHOLDER": "بحث عن جهات الاتصال",
"FILTER_CONTACTS": "Filter",
"FILTER_CONTACTS": "فلترة",
"FILTER_CONTACTS_SAVE": "حفظ الفلتر",
"FILTER_CONTACTS_DELETE": "حذف الفلتر",
"LIST": {
"LOADING_MESSAGE": "جاري تحميل جهات الاتصال...",
"404": "لا توجد جهات اتصال تطابق بحثك 🔍",

View File

@@ -1,15 +1,15 @@
{
"CONTACTS_FILTER": {
"TITLE": "Filter Contacts",
"SUBTITLE": "Add filters below and hit 'Submit' to filter contacts.",
"TITLE": "تصفية جهات الاتصال",
"SUBTITLE": "إضافة فلاتر أدناه واضغط على 'إرسال' لتصفية جهات الاتصال.",
"ADD_NEW_FILTER": "إضافة فلتر",
"CLEAR_ALL_FILTERS": "Clear All Filters",
"CLEAR_ALL_FILTERS": "مسح جميع الفلاتر",
"FILTER_DELETE_ERROR": "يجب ان يكون لديك فلتر واحد على الاقل",
"SUBMIT_BUTTON_LABEL": "إرسال",
"CANCEL_BUTTON_LABEL": "إلغاء",
"CLEAR_BUTTON_LABEL": "مسح الفلاتر",
"EMPTY_VALUE_ERROR": "القيمة مطلوبة",
"TOOLTIP_LABEL": "Filter contacts",
"TOOLTIP_LABEL": "تصفية جهات الاتصال",
"QUERY_DROPDOWN_LABELS": {
"AND": "و",
"OR": "أو"
@@ -20,15 +20,27 @@
"contains": "يحتوي",
"does_not_contain": "لا يحتوي",
"is_present": "موجود",
"is_not_present": "غير موجود"
"is_not_present": "غير موجود",
"is_greater_than": "هو أكبر من",
"is_lesser_than": "هو أقل من"
},
"ATTRIBUTES": {
"NAME": "الاسم",
"EMAIL": "البريد الإلكتروني",
"PHONE_NUMBER": "رقم الهاتف",
"IDENTIFIER": "Identifier",
"IDENTIFIER": "المعرف",
"CITY": "المدينة",
"COUNTRY": "الدولة"
"COUNTRY": "الدولة",
"CUSTOM_ATTRIBUTE_LIST": "القائمة",
"CUSTOM_ATTRIBUTE_TEXT": "النص",
"CUSTOM_ATTRIBUTE_NUMBER": "العدد",
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع"
},
"GROUPS": {
"STANDARD_FILTERS": "الفلاتر القياسية",
"ADDITIONAL_FILTERS": "فلاتر إضافية",
"CUSTOM_ATTRIBUTES": "سمات مخصصة"
}
}
}

View File

@@ -22,6 +22,8 @@
"LOADING_CONVERSATIONS": "جاري تحميل المحادثات",
"CANNOT_REPLY": "لا يمكنك الرد بسبب",
"24_HOURS_WINDOW": "قيد نافذة الـ 24 ساعة",
"NOT_ASSIGNED_TO_YOU": "لم يتم تعيين هذه المحادثة لك. هل ترغب في تعيين هذه المحادثة لنفسك؟",
"ASSIGN_TO_ME": "إسناد لي",
"TWILIO_WHATSAPP_CAN_REPLY": "يمكنك فقط الرد على هذه المحادثة باستخدام رسالة قالب بسبب",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "قيد نافذة الـ 24 ساعة",
"SELECT_A_TWEET_TO_REPLY": "الرجاء تحديد تغريدة للرد عليها.",
@@ -55,6 +57,9 @@
}
},
"FOOTER": {
"MESSAGE_SIGN_TOOLTIP": "Message signature",
"ENABLE_SIGN_TOOLTIP": "Enable signature",
"DISABLE_SIGN_TOOLTIP": "Disable signature",
"MSG_INPUT": "زر Shift + Enter لإضافة سطر جديد. ابدأ بزر / للاختيار من الردود السريعة.",
"PRIVATE_MSG_INPUT": "زر Shift + Enter لإضافة سطر جديد. سيكون هذا مرئياً للموظفين فقط"
},
@@ -90,6 +95,9 @@
"FILE_SIZE_LIMIT": "حجم الملف يتجاوز حد الاقصى وهو {MAXIMUM_FILE_UPLOAD_SIZE}",
"MESSAGE_ERROR": "غير قادر على إرسال هذه الرسالة، الرجاء المحاولة مرة أخرى لاحقاً",
"SENT_BY": "أرسلت بواسطة:",
"BOT": "رد آلي",
"SEND_FAILED": "تعذر إرسال الرسالة! حاول مرة أخرى",
"TRY_AGAIN": "إعادة المحاولة",
"ASSIGNMENT": {
"SELECT_AGENT": "اختر وكيل",
"REMOVE": "حذف",
@@ -179,6 +187,7 @@
}
},
"EMAIL_HEADER": {
"FROM": "من",
"TO": "إلى",
"BCC": "Bcc",
"CC": "Cc",

View File

@@ -14,8 +14,8 @@
"NOTE": ""
},
"ACCOUNT_ID": {
"TITLE": "Account ID",
"NOTE": "This ID is required if you are building an API based integration"
"TITLE": "معرف الحساب",
"NOTE": "هذا المعرف مطلوب إذا كنت بصدد بناء تكامل على API"
},
"NAME": {
"LABEL": "اسم الحساب",
@@ -40,7 +40,7 @@
"AUTO_RESOLVE_DURATION": {
"LABEL": "عدد الأيام بعد التذكرة التي يجب أن يحل تلقائياً إذا لم يكن هناك أي نشاط",
"PLACEHOLDER": "30",
"ERROR": "الرجاء إدخال مدة الحل التلقائي صحيحة (يوم واحد على الأقل)"
"ERROR": "الرجاء إدخال مدة حل تلقائي صالحة (حد أدنى 1 يوم والحد الأقصى 999 يوما)"
},
"FEATURES": {
"INBOUND_EMAIL_ENABLED": "الاستمرار في المحادثة عبر رسائل البريد الإلكتروني مفعّل لحسابك.",

View File

@@ -49,7 +49,7 @@
"HELP": "لإضافة حساب تويتر الخاص بك كقناة تواصل، تحتاج إلى مصادقة حسابك على تويتر بك بالنقر على زر \"تسجيل الدخول باستخدام تويتر\" ",
"ERROR_MESSAGE": "حدث خطأ أثناء الاتصال بـ Twitter، الرجاء المحاولة مرة أخرى",
"TWEETS": {
"ENABLE": "Create conversations from mentioned Tweets"
"ENABLE": "إنشاء محادثات من التغريدات المشار إليها"
}
},
"WEBSITE_CHANNEL": {
@@ -136,8 +136,56 @@
}
},
"SMS": {
"TITLE": "قناة SMS عبر Twilio",
"DESC": "ابدأ في دعم عملائك عبر الرسائل القصيرة بإستخدام Twilio."
"TITLE": "قناة SMS",
"DESC": "ابدأ في دعم عملائك عبر الرسائل القصيرة.",
"PROVIDERS": {
"LABEL": "API Provider",
"TWILIO": "تويليو",
"BANDWIDTH": "سعة الإنترنت"
},
"API": {
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة الرسائل القصيرة"
},
"BANDWIDTH": {
"ACCOUNT_ID": {
"LABEL": "معرف الحساب",
"PLACEHOLDER": "الرجاء إدخال معرف حساب النطاق الترددي الخاص بك",
"ERROR": "هذا الحقل مطلوب"
},
"API_KEY": {
"LABEL": "مفتاح API",
"PLACEHOLDER": "الرجاء إدخال مفتاح API الخاص بك",
"ERROR": "هذا الحقل مطلوب"
},
"API_SECRET": {
"LABEL": "سرية API",
"PLACEHOLDER": "الرجاء إدخال مفتاح API الخاص بك",
"ERROR": "هذا الحقل مطلوب"
},
"APPLICATION_ID": {
"LABEL": "معرف التطبيق",
"PLACEHOLDER": "الرجاء إدخال معرف تطبيق النطاق الترددي الخاص بك",
"ERROR": "هذا الحقل مطلوب"
},
"INBOX_NAME": {
"LABEL": "اسم صندوق الوارد لقناة التواصل",
"PLACEHOLDER": "الرجاء إدخال اسم القناة",
"ERROR": "هذا الحقل مطلوب"
},
"PHONE_NUMBER": {
"LABEL": "رقم الهاتف",
"PLACEHOLDER": "الرجاء إدخال رقم الهاتف الذي سيتم إرسال الرسائل منه.",
"ERROR": "الرجاء إدخال قيمة صحيحة. يجب أن يبدأ رقم الهاتف بعلامة `+`."
},
"SUBMIT_BUTTON": "إنشاء قناة عرض التردد",
"API": {
"ERROR_MESSAGE": "تعذر تكوين المصادقة بواسطة بيانات الاعتماد الخاصة بحسابك على Twilio، يرجى المحاولة مرة أخرى"
},
"API_CALLBACK": {
"TITLE": "عنوان Callback URL",
"SUBTITLE": "يتوجب تهيئة عنوان callback URL في إعدادات Twilio بإدخال القيمة أدناه."
}
}
},
"WHATSAPP": {
"TITLE": "قناة واتساب",
@@ -305,6 +353,14 @@
"ENABLED": "مفعل",
"DISABLED": "معطّل"
},
"ALLOW_MESSAGES_AFTER_RESOLVED": {
"ENABLED": "مفعل",
"DISABLED": "معطّل"
},
"ENABLE_CONTINUITY_VIA_EMAIL": {
"ENABLED": "مفعل",
"DISABLED": "معطّل"
},
"ENABLE_HMAC": {
"LABEL": "تمكين"
}
@@ -351,6 +407,8 @@
"AUTO_ASSIGNMENT": "تفعيل الإسناد التلقائي",
"ENABLE_CSAT": "تمكين تقييم خدمة العملاء",
"ENABLE_CSAT_SUB_TEXT": "تمكين/تعطيل تقييم خدمة العملاء بعد إنتهاء المحادثة",
"ENABLE_CONTINUITY_VIA_EMAIL": "تمكين استمرارية المحادثة عبر البريد الإلكتروني",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "المحادثات ستستمر عبر البريد الإلكتروني إذا كان عنوان البريد الإلكتروني لجهة الاتصال متاحاً.",
"INBOX_UPDATE_TITLE": "إعدادات قناة التواصل",
"INBOX_UPDATE_SUB_TEXT": "تحديث إعدادات قناة التواصل",
"AUTO_ASSIGNMENT_SUB_TEXT": "تمكين أو تعطيل الإسناد التلقائي للمحادثات الجديدة إلى الموظفين المضافين إلى قناة التواصل هذه.",
@@ -361,7 +419,9 @@
"INBOX_IDENTIFIER": "معرف صندوق الوارد",
"INBOX_IDENTIFIER_SUB_TEXT": "استخدم رمز 'inbox_identifier' المعروض هنا للمصادقة على عملاء API الخاص بك.",
"FORWARD_EMAIL_TITLE": "إعادة التوجيه إلى البريد الإلكتروني",
"FORWARD_EMAIL_SUB_TEXT": "بدء إعادة توجيه رسائل البريد الإلكتروني الخاصة بك إلى عنوان البريد الإلكتروني التالي."
"FORWARD_EMAIL_SUB_TEXT": "بدء إعادة توجيه رسائل البريد الإلكتروني الخاصة بك إلى عنوان البريد الإلكتروني التالي.",
"ALLOW_MESSAGES_AFTER_RESOLVED": "السماح بالرسائل بعد حل المحادثة",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "السماح للمستخدمين النهائيين بإرسال رسائل حتى بعد تسوية المحادثة."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "إعادة التصريح",
@@ -461,7 +521,11 @@
"DOMAIN": {
"LABEL": "الدومين",
"PLACE_HOLDER": "الدومين"
}
},
"ENCRYPTION": "التشفير",
"SSL_TLS": "SSL/TLS",
"START_TLS": "STARTTLS",
"OPEN_SSL_VERIFY_MODE": "فتح وضع التحقق من SSL"
}
}
}

View File

@@ -1,9 +1,12 @@
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 _campaign } from './campaign.json';
import { default as _cannedMgmt } from './cannedMgmt.json';
import { default as _chatlist } from './chatlist.json';
import { default as _contact } from './contact.json';
import { default as _contactFilters } from './contactFilters.json';
import { default as _conversation } from './conversation.json';
import { default as _csatMgmtMgmt } from './csatMgmt.json';
import { default as _generalSettings } from './generalSettings.json';
@@ -20,12 +23,15 @@ import { default as _signup } from './signup.json';
import { default as _teamsSettings } from './teamsSettings.json';
export default {
..._advancedFilters,
..._agentMgmt,
..._attributesMgmt,
..._automation,
..._campaign,
..._cannedMgmt,
..._chatlist,
..._contact,
..._contactFilters,
..._conversation,
..._csatMgmtMgmt,
..._generalSettings,

View File

@@ -4,8 +4,8 @@
"TITLE": "إعدادات الملف الشخصي",
"BTN_TEXT": "تعديل الملف الشخصي",
"DELETE_AVATAR": "حذف الصورة الرمزية",
"AVATAR_DELETE_SUCCESS": "Avatar has been deleted successfully",
"AVATAR_DELETE_FAILED": "There is an error while deleting avatar, please try again",
"AVATAR_DELETE_SUCCESS": "تم حذف الصورة الرمزية بنجاح",
"AVATAR_DELETE_FAILED": "حدث خطأ أثناء حذف الصورة الرمزية، الرجاء المحاولة مرة أخرى",
"UPDATE_SUCCESS": "تم تحديث حسابك بنجاح",
"PASSWORD_UPDATE_SUCCESS": "تم تغيير كلمة المرور بنجاح",
"AFTER_EMAIL_CHANGED": "تم تحديث ملفك الشخصي بنجاح، الرجاء تسجيل الدخول مرة أخرى حيث أنه قد تم تغيير بيانات تسجيل الدخول الخاصة بك",
@@ -19,6 +19,18 @@
"TITLE": "الملف الشخصي",
"NOTE": "عنوان بريدك الإلكتروني هو المعرف الخاص بك الذي ستستخدمه لتسجيل الدخول."
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a personal message signature that would be added to all the messages you send from the platform. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",
"ERROR": "Message Signature cannot be empty",
"PLACEHOLDER": "Insert your personal message signature here."
},
"PASSWORD_SECTION": {
"TITLE": "كلمة المرور",
"NOTE": "تعديل كلمة المرور الخاصة بك سيعيد ضبط تسجيلات الدخول الخاصة بك في الأجهزة الأخرى.",
@@ -89,7 +101,7 @@
"PLACEHOLDER": "الرجاء إدخال كلمة مرور جديدة"
},
"PASSWORD": {
"LABEL": "كلمة المرور",
"LABEL": "كلمة مرور جديدة",
"ERROR": "الرجاء إدخال كلمة مرور بطول 6 أحرف أو أكثر",
"PLACEHOLDER": "الرجاء إدخال كلمة مرور جديدة"
},
@@ -103,7 +115,7 @@
"SIDEBAR_ITEMS": {
"CHANGE_AVAILABILITY_STATUS": "يتغيرون",
"CHANGE_ACCOUNTS": "تبديل الحساب",
"CONTACT_SUPPORT": "Contact Support",
"CONTACT_SUPPORT": "تواصل مع الدعم",
"SELECTOR_SUBTITLE": "اختر حساباً من القائمة التالية",
"PROFILE_SETTINGS": "إعدادات الملف الشخصي",
"KEYBOARD_SHORTCUTS": "اختصارات لوحة المفاتيح",
@@ -136,7 +148,7 @@
"SIDEBAR": {
"CONVERSATIONS": "المحادثات",
"ALL_CONVERSATIONS": "كل المحادثات",
"MENTIONED_CONVERSATIONS": "Mentions",
"MENTIONED_CONVERSATIONS": "الإشارات",
"REPORTS": "التقارير",
"SETTINGS": "الإعدادات",
"CONTACTS": "جهات الاتصال",
@@ -153,11 +165,13 @@
"CUSTOM_ATTRIBUTES": "سمات مخصصة",
"AUTOMATION": "الأتمتة",
"TEAMS": "الفرق",
"CUSTOM_VIEWS_FOLDER": "المجلدات",
"CUSTOM_VIEWS_SEGMENTS": "الأجزاء",
"ALL_CONTACTS": "جميع جهات الاتصال",
"TAGGED_WITH": "مشار إليه بواسطة",
"NEW_LABEL": "New label",
"NEW_TEAM": "New team",
"NEW_INBOX": "New inbox",
"NEW_LABEL": "علامة جديدة",
"NEW_TEAM": "فريق جديد",
"NEW_INBOX": "صندوق الوارد الجديد",
"REPORTS_OVERVIEW": "نظرة عامة",
"CSAT": "CSAT",
"CAMPAIGNS": "الحملات",
@@ -167,7 +181,7 @@
"REPORTS_LABEL": "الوسوم",
"REPORTS_INBOX": "صندوق الوارد",
"REPORTS_TEAM": "الفريق",
"SET_AVAILABILITY_TITLE": "Set yourself as"
"SET_AVAILABILITY_TITLE": "تعيين نفسك كـ"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "أوه! لم نتمكن من العثور على الحساب. الرجاء إنشاء حساب جديد للمتابعة.",

View File

@@ -19,7 +19,9 @@
"contains": "Съдържа",
"does_not_contain": "Не съдържа",
"is_present": "Присъства",
"is_not_present": "Не присъства"
"is_not_present": "Не присъства",
"is_greater_than": "Is greater than",
"is_lesser_than": "Is lesser than"
},
"ATTRIBUTES": {
"STATUS": "Статус",
@@ -31,7 +33,54 @@
"LABELS": "Етикети",
"BROWSER_LANGUAGE": "Език на браузъра",
"COUNTRY_NAME": "Име на държавата",
"REFERER_LINK": "Референтна връзка"
"REFERER_LINK": "Референтна връзка",
"CUSTOM_ATTRIBUTE_LIST": "List",
"CUSTOM_ATTRIBUTE_TEXT": "Text",
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
"ADDITIONAL_FILTERS": "Additional Filters",
"CUSTOM_ATTRIBUTES": "Персонализирани атрибути"
},
"CUSTOM_VIEWS": {
"ADD": {
"TITLE": "Do you want to save this filter?",
"LABEL": "Name this filter",
"PLACEHOLDER": "Enter a name for this filter",
"ERROR_MESSAGE": "Name is required",
"SAVE_BUTTON": "Save filter",
"CANCEL_BUTTON": "Отмени",
"API_FOLDERS": {
"SUCCESS_MESSAGE": "Folder created successfully",
"ERROR_MESSAGE": "Error while creating folder"
},
"API_SEGMENTS": {
"SUCCESS_MESSAGE": "Segment created successfully",
"ERROR_MESSAGE": "Error while creating segment"
}
},
"DELETE": {
"DELETE_BUTTON": "Delete filter",
"MODAL": {
"CONFIRM": {
"TITLE": "Потвърди изтриването",
"MESSAGE": "Are you sure to delete the filter ",
"YES": "Да, изтрий",
"NO": "No, Keep it"
}
},
"API_FOLDERS": {
"SUCCESS_MESSAGE": "Folder deleted successfully",
"ERROR_MESSAGE": "Error while deleting folder"
},
"API_SEGMENTS": {
"SUCCESS_MESSAGE": "Segment deleted successfully",
"ERROR_MESSAGE": "Error while deleting segment"
}
}
}
}
}

View File

@@ -1,6 +1,89 @@
{
"AUTOMATION": {
"HEADER": "Автоматизация",
"HEADER_BTN_TXT": "Добавяне правило за автоматизация"
"HEADER_BTN_TXT": "Добавяне правило за автоматизация",
"LOADING": "Fetching automation rules",
"SIDEBAR_TXT": "<p><b>Automation Rules</b> <p>Automation can replace and automate existing processes that require manual effort. You can do many things with automation, including adding labels and assigning conversation to the best agent. So the team focuses on what they do best and spends more little time on manual tasks.</p>",
"ADD": {
"TITLE": "Добавяне правило за автоматизация",
"SUBMIT": "Създаване",
"CANCEL_BUTTON_TEXT": "Отмени",
"FORM": {
"NAME": {
"LABEL": "Rule Name",
"PLACEHOLDER": "Enter rule name",
"ERROR": "Name is required"
},
"DESC": {
"LABEL": "Описание",
"PLACEHOLDER": "Enter rule description",
"ERROR": "Description is required"
},
"EVENT": {
"LABEL": "Event",
"PLACEHOLDER": "Please select one",
"ERROR": "Event is required"
},
"CONDITIONS": {
"LABEL": "Conditions"
},
"ACTIONS": {
"LABEL": "Действия"
}
},
"CONDITION_BUTTON_LABEL": "Add Condition",
"ACTION_BUTTON_LABEL": "Add Action",
"API": {
"SUCCESS_MESSAGE": "Automation rule added successfully",
"ERROR_MESSAGE": "Could not able to create a automation rule, Please try again later"
}
},
"LIST": {
"TABLE_HEADER": [
"Име",
"Описание",
"Активен",
"Created on"
],
"404": "No automation rules found"
},
"DELETE": {
"TITLE": "Delete Automation Rule",
"SUBMIT": "Изтрий",
"CANCEL_BUTTON_TEXT": "Отмени",
"CONFIRM": {
"TITLE": "Потвърди изтриването",
"MESSAGE": "Сигурни ли сте за изтриването ",
"YES": "Да, изтрий ",
"NO": "Не, запази "
},
"API": {
"SUCCESS_MESSAGE": "Automation rule deleted successfully",
"ERROR_MESSAGE": "Could not able to delete a automation rule, Please try again later"
}
},
"EDIT": {
"TITLE": "Edit Automation Rule",
"SUBMIT": "Редактирай",
"CANCEL_BUTTON_TEXT": "Отмени",
"API": {
"SUCCESS_MESSAGE": "Automation rule updated successfully",
"ERROR_MESSAGE": "Could not update automation rule, Please try again later"
}
},
"CLONE": {
"TOOLTIP": "Clone",
"API": {
"SUCCESS_MESSAGE": "Automation cloned successfully",
"ERROR_MESSAGE": "Could not clone automation rule, Please try again later"
}
},
"FORM": {
"EDIT": "Редактирай",
"CREATE": "Създаване",
"DELETE": "Изтрий",
"CANCEL": "Отмени",
"RESET_MESSAGE": "Changing event type will reset the conditions and events you have added below"
}
}
}

View File

@@ -4,7 +4,7 @@
"HEADER_BTN_TXT": "Добавяне на готов отговор",
"LOADING": "Извличане на готови отговори",
"SEARCH_404": "Няма резултати отговарящи на тази заявка",
"SIDEBAR_TXT": "<p><b>Готови отговори</b> </p><p> Готовите отговори са запазени шаблони за отговори, които могат да се използват за бързо изпращане на отговор в разговора. </p><p> За да създадете готов отговор, просто щракнете върху <b>Добавяне на готов отговор</b>. Можете също да редактирате или изтриете съществуващ готов отговор, като щракнете върху бутона Редактиране или Изтриване </p><p> Готовите отговори се използват с помощта на <b>Кратки кодове</b>. Агентите имат достъп до готовите отговори, докато са в чат, като напишат <b>'/'</b>, последвано от краткия код. </p>",
"SIDEBAR_TXT": "<p><b>Canned Responses</b> </p><p> Canned Responses are saved reply templates which can be used to quickly send out a reply to a conversation. </p><p> For creating a Canned Response, just click on the <b>Add Canned Response</b>. You can also edit or delete an existing Canned Response by clicking on the Edit or Delete button </p><p> Canned responses are used with the help of <b>Short Codes</b>. Agents can access canned responses while on a chat by typing <b>'/'</b> followed by the short code. </p>",
"LIST": {
"404": "Няма налични готови отговори в този акаунт.",
"TITLE": "Управлявайте готовите отговори",
@@ -17,12 +17,12 @@
},
"ADD": {
"TITLE": "Добавяне на готов отговор",
"DESC": "Готовите отговори са предварително дефинирани шаблони за отговор, които могат да се изпращат бързо в чата.",
"DESC": "Canned Responses are saved reply templates which can be used to quickly send out reply to conversation.",
"CANCEL_BUTTON_TEXT": "Отмени",
"FORM": {
"SHORT_CODE": {
"LABEL": "Кратък код",
"PLACEHOLDER": "Моля, въведете кратък код",
"PLACEHOLDER": "Please enter a short code",
"ERROR": "Краткия код е задължителен"
},
"CONTENT": {

View File

@@ -169,6 +169,7 @@
"SUBMIT": "Изпрати съобщение",
"CANCEL": "Отмени",
"SUCCESS_MESSAGE": "Съобщението е изпратено!",
"GO_TO_CONVERSATION": "View",
"ERROR_MESSAGE": "Не може да се изпрати! Опитайте пак"
}
},
@@ -178,6 +179,8 @@
"SEARCH_BUTTON": "Търсене",
"SEARCH_INPUT_PLACEHOLDER": "Търсене на контакти",
"FILTER_CONTACTS": "Филтър",
"FILTER_CONTACTS_SAVE": "Save filter",
"FILTER_CONTACTS_DELETE": "Delete filter",
"LIST": {
"LOADING_MESSAGE": "Зареждане на контактите...",
"404": "Няма контакти отговарящи на търсенети ви 🔍",

View File

@@ -20,7 +20,9 @@
"contains": "Съдържа",
"does_not_contain": "Не съдържа",
"is_present": "Присъства",
"is_not_present": "Не присъства"
"is_not_present": "Не присъства",
"is_greater_than": "Is greater than",
"is_lesser_than": "Is lesser than"
},
"ATTRIBUTES": {
"NAME": "Име",
@@ -28,7 +30,17 @@
"PHONE_NUMBER": "Телефон",
"IDENTIFIER": "Идентификатор",
"CITY": "Град",
"COUNTRY": "Държава"
"COUNTRY": "Държава",
"CUSTOM_ATTRIBUTE_LIST": "List",
"CUSTOM_ATTRIBUTE_TEXT": "Text",
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
"ADDITIONAL_FILTERS": "Additional Filters",
"CUSTOM_ATTRIBUTES": "Персонализирани атрибути"
}
}
}

View File

@@ -22,6 +22,8 @@
"LOADING_CONVERSATIONS": "Loading Conversations",
"CANNOT_REPLY": "You cannot reply due to",
"24_HOURS_WINDOW": "24 hour message window restriction",
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
"ASSIGN_TO_ME": "Assign to me",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"SELECT_A_TWEET_TO_REPLY": "Please select a tweet to reply to.",
@@ -55,6 +57,9 @@
}
},
"FOOTER": {
"MESSAGE_SIGN_TOOLTIP": "Message signature",
"ENABLE_SIGN_TOOLTIP": "Enable signature",
"DISABLE_SIGN_TOOLTIP": "Disable signature",
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents"
},
@@ -90,6 +95,9 @@
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
"BOT": "Бот",
"SEND_FAILED": "Couldn't send message! Try again",
"TRY_AGAIN": "retry",
"ASSIGNMENT": {
"SELECT_AGENT": "Select Agent",
"REMOVE": "Remove",
@@ -127,7 +135,7 @@
},
"TEAM_MEMBERS": {
"TITLE": "Invite your team members",
"DESCRIPTION": "Since you are getting ready to talk to your customer, bring in your teammates to assist you. You can invite your teammates by adding their email address to the agent list.",
"DESCRIPTION": "Since you are getting ready to talk to your customer, bring in your teammates to assist you. You can invite your teammates by adding their email addresses to the agent list.",
"NEW_LINK": "Click here to invite a team member"
},
"INBOXES": {
@@ -179,6 +187,7 @@
}
},
"EMAIL_HEADER": {
"FROM": "From",
"TO": "До",
"BCC": "Bcc",
"CC": "Cc",

View File

@@ -40,7 +40,7 @@
"AUTO_RESOLVE_DURATION": {
"LABEL": "Number of days after a ticket should auto resolve if there is no activity",
"PLACEHOLDER": "30",
"ERROR": "Please enter a valid auto resolve duration (minimum 1 day)"
"ERROR": "Please enter a valid auto resolve duration (minimum 1 day and maximum 999 days)"
},
"FEATURES": {
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",

View File

@@ -82,7 +82,7 @@
},
"CHANNEL_GREETING_TOGGLE": {
"LABEL": "Enable channel greeting",
"HELP_TEXT": "Send a greeting message to the user when he starts the conversation.",
"HELP_TEXT": "Send a greeting message to the users when they starts the conversation.",
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
@@ -136,8 +136,56 @@
}
},
"SMS": {
"TITLE": "SMS Channel via Twilio",
"DESC": "Start supporting your customers via SMS with Twilio integration."
"TITLE": "SMS Channel",
"DESC": "Start supporting your customers via SMS.",
"PROVIDERS": {
"LABEL": "API Provider",
"TWILIO": "Twilio",
"BANDWIDTH": "Bandwidth"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the SMS channel"
},
"BANDWIDTH": {
"ACCOUNT_ID": {
"LABEL": "Account ID",
"PLACEHOLDER": "Please enter your Bandwidth Account ID",
"ERROR": "This field is required"
},
"API_KEY": {
"LABEL": "API Key",
"PLACEHOLDER": "Please enter your Bandwith API Key",
"ERROR": "This field is required"
},
"API_SECRET": {
"LABEL": "API Secret",
"PLACEHOLDER": "Please enter your Bandwith API Secret",
"ERROR": "This field is required"
},
"APPLICATION_ID": {
"LABEL": "Application ID",
"PLACEHOLDER": "Please enter your Bandwidth Application ID",
"ERROR": "This field is required"
},
"INBOX_NAME": {
"LABEL": "Име на входящата кутия",
"PLACEHOLDER": "Please enter a inbox name",
"ERROR": "This field is required"
},
"PHONE_NUMBER": {
"LABEL": "Телефон",
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
"ERROR": "Please enter a valid value. Phone number should start with `+` sign."
},
"SUBMIT_BUTTON": "Create Bandwidth Channel",
"API": {
"ERROR_MESSAGE": "We were not able to authenticate Bandwidth credentials, please try again"
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "You have to configure the message callback URL in Bandwidth with the URL mentioned here."
}
}
},
"WHATSAPP": {
"TITLE": "WhatsApp Channel",
@@ -305,6 +353,14 @@
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
"ALLOW_MESSAGES_AFTER_RESOLVED": {
"ENABLED": "Включен",
"DISABLED": "Изключен"
},
"ENABLE_CONTINUITY_VIA_EMAIL": {
"ENABLED": "Включен",
"DISABLED": "Изключен"
},
"ENABLE_HMAC": {
"LABEL": "Enable"
}
@@ -351,6 +407,8 @@
"AUTO_ASSIGNMENT": "Enable auto assignment",
"ENABLE_CSAT": "Enable CSAT",
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
"INBOX_UPDATE_TITLE": "Inbox Settings",
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
@@ -361,7 +419,9 @@
"INBOX_IDENTIFIER": "Inbox Identifier",
"INBOX_IDENTIFIER_SUB_TEXT": "Use the `inbox_identifier` token shown here to authentication your API clients.",
"FORWARD_EMAIL_TITLE": "Forward to Email",
"FORWARD_EMAIL_SUB_TEXT": "Start forwarding your emails to the following email address."
"FORWARD_EMAIL_SUB_TEXT": "Start forwarding your emails to the following email address.",
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize",
@@ -461,7 +521,11 @@
"DOMAIN": {
"LABEL": "Domain",
"PLACE_HOLDER": "Domain"
}
},
"ENCRYPTION": "Encryption",
"SSL_TLS": "SSL/TLS",
"START_TLS": "STARTTLS",
"OPEN_SSL_VERIFY_MODE": "Open SSL Verify Mode"
}
}
}

View File

@@ -0,0 +1,49 @@
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 _campaign } from './campaign.json';
import { default as _cannedMgmt } from './cannedMgmt.json';
import { default as _chatlist } from './chatlist.json';
import { default as _contact } from './contact.json';
import { default as _contactFilters } from './contactFilters.json';
import { default as _conversation } from './conversation.json';
import { default as _csatMgmtMgmt } from './csatMgmt.json';
import { default as _generalSettings } from './generalSettings.json';
import { default as _inboxMgmt } from './inboxMgmt.json';
import { default as _integrationApps } from './integrationApps.json';
import { default as _integrations } from './integrations.json';
import { default as _labelsMgmt } from './labelsMgmt.json';
import { default as _login } from './login.json';
import { default as _report } from './report.json';
import { default as _resetPassword } from './resetPassword.json';
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';
export default {
..._advancedFilters,
..._agentMgmt,
..._attributesMgmt,
..._automation,
..._campaign,
..._cannedMgmt,
..._chatlist,
..._contact,
..._contactFilters,
..._conversation,
..._csatMgmtMgmt,
..._generalSettings,
..._inboxMgmt,
..._integrationApps,
..._integrations,
..._labelsMgmt,
..._login,
..._report,
..._resetPassword,
..._setNewPassword,
..._settings,
..._signup,
..._teamsSettings,
};

View File

@@ -19,6 +19,18 @@
"TITLE": "Profile",
"NOTE": "Your email address is your identity and is used to log in."
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a personal message signature that would be added to all the messages you send from the platform. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",
"ERROR": "Message Signature cannot be empty",
"PLACEHOLDER": "Insert your personal message signature here."
},
"PASSWORD_SECTION": {
"TITLE": "Password",
"NOTE": "Updating your password would reset your logins in multiple devices.",
@@ -89,14 +101,14 @@
"PLACEHOLDER": "Please enter the current password"
},
"PASSWORD": {
"LABEL": "Password",
"LABEL": "New password",
"ERROR": "Please enter a password of length 6 or more",
"PLACEHOLDER": "Please enter a new password"
},
"PASSWORD_CONFIRMATION": {
"LABEL": "Confirm new password",
"ERROR": "Confirm password should match the password",
"PLACEHOLDER": "Please re-enter your password"
"PLACEHOLDER": "Please re-enter your new password"
}
}
},
@@ -153,6 +165,8 @@
"CUSTOM_ATTRIBUTES": "Персонализирани атрибути",
"AUTOMATION": "Автоматизация",
"TEAMS": "Teams",
"CUSTOM_VIEWS_FOLDER": "Folders",
"CUSTOM_VIEWS_SEGMENTS": "Segments",
"ALL_CONTACTS": "All Contacts",
"TAGGED_WITH": "Tagged with",
"NEW_LABEL": "New label",

View File

@@ -2,7 +2,7 @@
"TEAMS_SETTINGS": {
"NEW_TEAM": "Create new team",
"HEADER": "Teams",
"SIDEBAR_TXT": "<p><b>Teams</b></p> <p>Teams let you organize your agents into groups based on their responsibilities. <br /> A user can be part of multiple teams. You can assign conversations to a team when you are working collaboratively. </p>",
"SIDEBAR_TXT": "<p><b>Teams</b></p> <p>Teams let you organize your agents into groups based on their responsibilities. <br /> An agent can be part of multiple teams. You can assign conversations to a team when you are working collaboratively. </p>",
"LIST": {
"404": "There are no teams created on this account.",
"EDIT_TEAM": "Edit team"

View File

@@ -19,7 +19,9 @@
"contains": "Contains",
"does_not_contain": "Does not contain",
"is_present": "Is present",
"is_not_present": "Is not present"
"is_not_present": "Is not present",
"is_greater_than": "Is greater than",
"is_lesser_than": "Is lesser than"
},
"ATTRIBUTES": {
"STATUS": "Estat",
@@ -31,7 +33,54 @@
"LABELS": "Etiquetes",
"BROWSER_LANGUAGE": "Browser Language",
"COUNTRY_NAME": "Country Name",
"REFERER_LINK": "Referer link"
"REFERER_LINK": "Referer link",
"CUSTOM_ATTRIBUTE_LIST": "List",
"CUSTOM_ATTRIBUTE_TEXT": "Text",
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
"ADDITIONAL_FILTERS": "Additional Filters",
"CUSTOM_ATTRIBUTES": "Atributs personalitzats"
},
"CUSTOM_VIEWS": {
"ADD": {
"TITLE": "Do you want to save this filter?",
"LABEL": "Name this filter",
"PLACEHOLDER": "Enter a name for this filter",
"ERROR_MESSAGE": "Name is required",
"SAVE_BUTTON": "Save filter",
"CANCEL_BUTTON": "Cancel·la",
"API_FOLDERS": {
"SUCCESS_MESSAGE": "Folder created successfully",
"ERROR_MESSAGE": "Error while creating folder"
},
"API_SEGMENTS": {
"SUCCESS_MESSAGE": "Segment created successfully",
"ERROR_MESSAGE": "Error while creating segment"
}
},
"DELETE": {
"DELETE_BUTTON": "Delete filter",
"MODAL": {
"CONFIRM": {
"TITLE": "Confirma l'esborrat",
"MESSAGE": "Are you sure to delete the filter ",
"YES": "Si, esborra",
"NO": "No, manten-la"
}
},
"API_FOLDERS": {
"SUCCESS_MESSAGE": "Folder deleted successfully",
"ERROR_MESSAGE": "Error while deleting folder"
},
"API_SEGMENTS": {
"SUCCESS_MESSAGE": "Segment deleted successfully",
"ERROR_MESSAGE": "Error while deleting segment"
}
}
}
}
}

View File

@@ -90,12 +90,12 @@
}
},
"SEARCH": {
"NO_RESULTS": "No results found."
"NO_RESULTS": "No s'ha trobat agents."
},
"MULTI_SELECTOR": {
"PLACEHOLDER": "None",
"PLACEHOLDER": "Ningú",
"TITLE": {
"AGENT": "Select agent",
"AGENT": "Seleccionar Agent",
"TEAM": "Select team"
},
"SEARCH": {

View File

@@ -1,6 +1,89 @@
{
"AUTOMATION": {
"HEADER": "Automation",
"HEADER_BTN_TXT": "Add Automation Rule"
"HEADER_BTN_TXT": "Add Automation Rule",
"LOADING": "Fetching automation rules",
"SIDEBAR_TXT": "<p><b>Automation Rules</b> <p>Automation can replace and automate existing processes that require manual effort. You can do many things with automation, including adding labels and assigning conversation to the best agent. So the team focuses on what they do best and spends more little time on manual tasks.</p>",
"ADD": {
"TITLE": "Add Automation Rule",
"SUBMIT": "Crear",
"CANCEL_BUTTON_TEXT": "Cancel·la",
"FORM": {
"NAME": {
"LABEL": "Rule Name",
"PLACEHOLDER": "Enter rule name",
"ERROR": "Name is required"
},
"DESC": {
"LABEL": "Descripció",
"PLACEHOLDER": "Enter rule description",
"ERROR": "Description is required"
},
"EVENT": {
"LABEL": "Event",
"PLACEHOLDER": "Please select one",
"ERROR": "Event is required"
},
"CONDITIONS": {
"LABEL": "Conditions"
},
"ACTIONS": {
"LABEL": "Accions"
}
},
"CONDITION_BUTTON_LABEL": "Add Condition",
"ACTION_BUTTON_LABEL": "Add Action",
"API": {
"SUCCESS_MESSAGE": "Automation rule added successfully",
"ERROR_MESSAGE": "Could not able to create a automation rule, Please try again later"
}
},
"LIST": {
"TABLE_HEADER": [
"Nom",
"Descripció",
"Active",
"Created on"
],
"404": "No automation rules found"
},
"DELETE": {
"TITLE": "Delete Automation Rule",
"SUBMIT": "Esborrar",
"CANCEL_BUTTON_TEXT": "Cancel·la",
"CONFIRM": {
"TITLE": "Confirma l'esborrat",
"MESSAGE": "N'estas segur? ",
"YES": "Si, esborra ",
"NO": "No, segueix "
},
"API": {
"SUCCESS_MESSAGE": "Automation rule deleted successfully",
"ERROR_MESSAGE": "Could not able to delete a automation rule, Please try again later"
}
},
"EDIT": {
"TITLE": "Edit Automation Rule",
"SUBMIT": "Edita",
"CANCEL_BUTTON_TEXT": "Cancel·la",
"API": {
"SUCCESS_MESSAGE": "Automation rule updated successfully",
"ERROR_MESSAGE": "Could not update automation rule, Please try again later"
}
},
"CLONE": {
"TOOLTIP": "Clone",
"API": {
"SUCCESS_MESSAGE": "Automation cloned successfully",
"ERROR_MESSAGE": "Could not clone automation rule, Please try again later"
}
},
"FORM": {
"EDIT": "Edita",
"CREATE": "Crear",
"DELETE": "Esborrar",
"CANCEL": "Cancel·la",
"RESET_MESSAGE": "Changing event type will reset the conditions and events you have added below"
}
}
}

View File

@@ -4,7 +4,7 @@
"HEADER_BTN_TXT": "Afegeix una resposta predeterminada",
"LOADING": "S'estan recollint les respostes predeterminades",
"SEARCH_404": "No hi ha cap resposta que coincideixi amb aquesta consulta",
"SIDEBAR_TXT": "<p><b>Respostes predeterminades</b> </p><p> Les respostes predeterminades són plantilles de resposta que es poden utilitzar per enviar ràpidament una resposta a una conversa .</p><p> Per crear una Resposta Predeterminada, clica en <b>Afegir Resposta Predeterminada</b>. També pots editar o suprimir una resposta predeterminada fent clic al botó Edita o Suprimeix </p><p> Les respostes predeterminades s'utilitzen amb l'ajuda dels <b>Codi curt</b>. Els agents poden accedir a les respostes predeterminades en un xat escrivint <b>'/'</b> seguit del codi curt. </p>",
"SIDEBAR_TXT": "<p><b>Canned Responses</b> </p><p> Canned Responses are saved reply templates which can be used to quickly send out a reply to a conversation. </p><p> For creating a Canned Response, just click on the <b>Add Canned Response</b>. You can also edit or delete an existing Canned Response by clicking on the Edit or Delete button </p><p> Canned responses are used with the help of <b>Short Codes</b>. Agents can access canned responses while on a chat by typing <b>'/'</b> followed by the short code. </p>",
"LIST": {
"404": "No hi ha respostes predeterminades disponibles en aquest compte.",
"TITLE": "Gestiona les respostes predeterminades",
@@ -17,12 +17,12 @@
},
"ADD": {
"TITLE": "Afegeix Resposta Predeterminada",
"DESC": "Les respostes predeterminades són plantilles de resposta que es poden utilitzar per enviar ràpidament les respostes a les converses.",
"DESC": "Canned Responses are saved reply templates which can be used to quickly send out reply to conversation.",
"CANCEL_BUTTON_TEXT": "Cancel·la",
"FORM": {
"SHORT_CODE": {
"LABEL": "Codi curt",
"PLACEHOLDER": "Introduïu un codi curt",
"PLACEHOLDER": "Please enter a short code",
"ERROR": "És necessari el codi curt"
},
"CONTENT": {

View File

@@ -169,6 +169,7 @@
"SUBMIT": "Send message",
"CANCEL": "Cancel·la",
"SUCCESS_MESSAGE": "Message sent!",
"GO_TO_CONVERSATION": "Veure",
"ERROR_MESSAGE": "Couldn't send! try again"
}
},
@@ -178,6 +179,8 @@
"SEARCH_BUTTON": "Cercar",
"SEARCH_INPUT_PLACEHOLDER": "Cerca de contactes",
"FILTER_CONTACTS": "Filter",
"FILTER_CONTACTS_SAVE": "Save filter",
"FILTER_CONTACTS_DELETE": "Delete filter",
"LIST": {
"LOADING_MESSAGE": "Carregant contactes...",
"404": "No hi ha cap contacte que coincideixi amb la vostra cerca 🔍",

View File

@@ -20,7 +20,9 @@
"contains": "Contains",
"does_not_contain": "Does not contain",
"is_present": "Is present",
"is_not_present": "Is not present"
"is_not_present": "Is not present",
"is_greater_than": "Is greater than",
"is_lesser_than": "Is lesser than"
},
"ATTRIBUTES": {
"NAME": "Nom",
@@ -28,7 +30,17 @@
"PHONE_NUMBER": "Número de telèfon",
"IDENTIFIER": "Identifier",
"CITY": "City",
"COUNTRY": "Country"
"COUNTRY": "Country",
"CUSTOM_ATTRIBUTE_LIST": "List",
"CUSTOM_ATTRIBUTE_TEXT": "Text",
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
"ADDITIONAL_FILTERS": "Additional Filters",
"CUSTOM_ATTRIBUTES": "Atributs personalitzats"
}
}
}

Some files were not shown because too many files have changed in this diff Show More