Merge branch 'release/1.21.0'

This commit is contained in:
Sojan 2021-10-16 00:13:06 +05:30
commit c831bee0e3
703 changed files with 22272 additions and 2715 deletions

3
.bundler-audit.yml Normal file
View file

@ -0,0 +1,3 @@
---
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)

View file

@ -14,6 +14,10 @@ plugins:
checks:
similar-code:
enabled: false
method-count:
enabled: true
config:
threshold: 25
exclude_patterns:
- "spec/"
- "**/specs/"

View file

@ -100,6 +100,9 @@ FB_VERIFY_TOKEN=
FB_APP_SECRET=
FB_APP_ID=
# https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard
IG_VERIFY_TOKEN=
# Twitter
# documentation: https://www.chatwoot.com/docs/twitter-app-setup
TWITTER_APP_ID=
@ -113,7 +116,7 @@ SLACK_CLIENT_SECRET=
### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables
IOS_APP_ID=6C953F3RX2.com.chatwoot.app
IOS_APP_ID=L7YLMN4634.com.chatwoot.app
ANDROID_BUNDLE_ID=com.chatwoot.app
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
@ -166,7 +169,7 @@ USE_INBOX_AVATAR_FOR_BOT=true
## Rack Attack configuration
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=false
# ENABLE_RACK_ATTACK=true
## Running chatwoot as an API only server

11
Gemfile
View file

@ -56,7 +56,6 @@ gem 'activerecord-import'
gem 'dotenv-rails'
gem 'foreman'
gem 'puma'
gem 'rack-timeout'
gem 'webpacker', '~> 5.x'
# metrics on heroku
gem 'barnes'
@ -122,6 +121,11 @@ gem 'hairtrigger'
gem 'procore-sift'
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
end
group :development do
gem 'annotate'
gem 'bullet'
@ -143,6 +147,11 @@ group :test do
end
group :development, :test do
# TODO: is this needed ?
# errors thrown by devise password gem
gem 'flay'
gem 'rspec'
# for error thrown by devise password gem
gem 'active_record_query_trace'
gem 'bundle-audit', require: false
gem 'byebug', platform: :mri

View file

@ -90,21 +90,21 @@ GEM
rake (>= 10.4, < 14.0)
ast (2.4.2)
attr_extras (6.2.4)
aws-eventstream (1.1.1)
aws-partitions (1.482.0)
aws-sdk-core (3.119.0)
aws-eventstream (1.2.0)
aws-partitions (1.513.0)
aws-sdk-core (3.121.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.46.0)
aws-sdk-core (~> 3, >= 3.119.0)
aws-sdk-kms (1.49.0)
aws-sdk-core (~> 3, >= 3.120.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.98.0)
aws-sdk-core (~> 3, >= 3.119.0)
aws-sdk-s3 (1.103.0)
aws-sdk-core (~> 3, >= 3.120.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.4)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.4.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.1)
azure-storage-common (~> 2.0)
@ -119,28 +119,28 @@ GEM
statsd-ruby (~> 1.1)
bcrypt (3.1.16)
bindex (0.8.1)
bootsnap (1.7.7)
bootsnap (1.9.1)
msgpack (~> 1.0)
brakeman (5.1.1)
browser (5.3.1)
builder (3.2.4)
bullet (6.1.4)
bullet (6.1.5)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundle-audit (0.1.0)
bundler-audit
bundler-audit (0.8.0)
bundler-audit (0.9.0.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
coderay (1.1.3)
commonmarker (0.22.0)
commonmarker (0.23.2)
concurrent-ruby (1.1.9)
connection_pool (2.2.5)
crack (0.4.5)
rexml
crass (1.0.6)
cypress-on-rails (1.10.1)
cypress-on-rails (1.11.0)
rack
database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0)
@ -150,7 +150,7 @@ GEM
database_cleaner-core (2.0.1)
datetime_picker_rails (0.0.7)
momentjs-rails (>= 2.8.1)
ddtrace (0.51.1)
ddtrace (0.53.0)
ffi (~> 1.0)
msgpack
declarative (0.0.20)
@ -174,12 +174,13 @@ GEM
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
down (5.2.3)
down (5.2.4)
addressable (~> 2.8)
ecma-re-validator (0.3.0)
regexp_parser (~> 2.0)
erubi (1.10.0)
et-orbi (1.2.4)
erubis (2.7.0)
et-orbi (1.2.5)
tzinfo
execjs (2.8.1)
facebook-messenger (2.0.1)
@ -190,7 +191,7 @@ GEM
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
faker (2.18.0)
faker (2.19.0)
i18n (>= 1.6, < 2)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
@ -198,10 +199,15 @@ GEM
faraday (~> 1.0)
fcm (1.0.3)
faraday (~> 1)
ffi (1.15.3)
ffi (1.15.4)
flag_shih_tzu (0.3.23)
flay (2.12.1)
erubis (~> 2.7.0)
path_expander (~> 1.0)
ruby_parser (~> 3.0)
sexp_processor (~> 4.0)
foreman (0.87.2)
fugit (1.5.0)
fugit (1.5.2)
et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.4)
gapic-common (0.3.4)
@ -210,7 +216,7 @@ GEM
googleapis-common-protos-types (>= 1.0.4, < 2.0)
googleauth (~> 0.9)
grpc (~> 1.25)
geocoder (1.6.7)
geocoder (1.7.0)
gli (2.20.1)
globalid (0.5.2)
activesupport (>= 5.0)
@ -223,9 +229,9 @@ GEM
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.6.0)
google-apis-iamcredentials_v1 (0.7.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-storage_v1 (0.6.0)
google-apis-storage_v1 (0.8.0)
google-apis-core (>= 0.4, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
@ -238,7 +244,7 @@ GEM
google-cloud-errors (~> 1.0)
google-cloud-env (1.5.0)
faraday (>= 0.17.3, < 2.0)
google-cloud-errors (1.1.0)
google-cloud-errors (1.2.0)
google-cloud-storage (1.34.1)
addressable (~> 2.5)
digest-crc (~> 0.4)
@ -247,28 +253,32 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-protobuf (3.17.3-universal-darwin)
google-protobuf (3.17.3-x86_64-linux)
googleapis-common-protos (1.3.11)
google-protobuf (3.18.1)
google-protobuf (3.18.1-universal-darwin)
google-protobuf (3.18.1-x86_64-linux)
googleapis-common-protos (1.3.12)
google-protobuf (~> 3.14)
googleapis-common-protos-types (>= 1.0.6, < 2.0)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos-types (1.1.0)
googleapis-common-protos-types (1.2.0)
google-protobuf (~> 3.14)
googleauth (0.17.0)
googleauth (0.17.1)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (~> 0.14)
signet (~> 0.15)
groupdate (5.2.2)
activesupport (>= 5)
grpc (1.38.0-universal-darwin)
google-protobuf (~> 3.15)
grpc (1.41.0)
google-protobuf (~> 3.17)
googleapis-common-protos-types (~> 1.0)
grpc (1.38.0-x86_64-linux)
google-protobuf (~> 3.15)
grpc (1.41.0-universal-darwin)
google-protobuf (~> 3.17)
googleapis-common-protos-types (~> 1.0)
grpc (1.41.0-x86_64-linux)
google-protobuf (~> 3.17)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
hairtrigger (0.2.24)
@ -282,7 +292,7 @@ GEM
http-accept (1.7.0)
http-cookie (1.0.4)
domain_name (~> 0.5)
httparty (0.18.1)
httparty (0.20.0)
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
@ -306,7 +316,7 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
jwt (2.2.3)
jwt (2.3.0)
kaminari (1.2.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1)
@ -327,9 +337,9 @@ GEM
addressable (~> 2.7)
letter_opener (1.7.0)
launchy (~> 2.2)
line-bot-api (1.21.0)
liquid (5.0.1)
listen (3.6.0)
line-bot-api (1.22.0)
liquid (5.1.0)
listen (3.7.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.12.0)
@ -337,17 +347,18 @@ GEM
nokogiri (>= 1.5.9)
mail (2.7.1)
mini_mime (>= 0.1.1)
marcel (1.0.1)
marcel (1.0.2)
maxminddb (0.1.22)
memoist (0.16.2)
method_source (1.0.0)
mime-types (3.3.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2021.0704)
mime-types-data (3.2021.0901)
mini_magick (4.11.0)
mini_mime (1.1.1)
mini_mime (1.1.2)
mini_portile2 (2.5.3)
minitest (5.14.4)
mock_redis (0.28.0)
mock_redis (0.29.0)
ruby2_keywords
momentjs-rails (2.20.1)
railties (>= 3.1)
@ -358,8 +369,11 @@ GEM
net-http-persistent (4.0.1)
connection_pool (~> 2.2)
netrc (0.11.0)
newrelic_rpm (7.2.0)
newrelic_rpm (8.0.0)
nio4r (2.5.8)
nokogiri (1.11.7)
mini_portile2 (~> 2.5.0)
racc (~> 1.4)
nokogiri (1.11.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.11.7-x86_64-darwin)
@ -369,9 +383,10 @@ GEM
oauth (0.5.6)
orm_adapter (0.5.0)
os (1.1.1)
parallel (1.20.1)
parallel (1.21.0)
parser (3.0.2.0)
ast (~> 2.4.1)
path_expander (1.1.0)
pg (1.2.3)
procore-sift (0.16.0)
rails (> 4.2.0)
@ -381,9 +396,9 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.6)
puma (5.4.0)
puma (5.5.1)
nio4r (~> 2.0)
pundit (2.1.0)
pundit (2.1.1)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.5.2)
@ -415,7 +430,7 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.1)
rails-html-sanitizer (1.4.2)
loofah (~> 2.3)
railties (6.1.4.1)
actionpack (= 6.1.4.1)
@ -446,6 +461,10 @@ GEM
netrc (~> 0.8)
retriable (3.1.2)
rexml (3.2.5)
rspec (3.10.0)
rspec-core (~> 3.10.0)
rspec-expectations (~> 3.10.0)
rspec-mocks (~> 3.10.0)
rspec-core (3.10.1)
rspec-support (~> 3.10.0)
rspec-expectations (3.10.1)
@ -454,7 +473,7 @@ GEM
rspec-mocks (3.10.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0)
rspec-rails (5.0.1)
rspec-rails (5.0.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
railties (>= 5.2)
@ -463,35 +482,34 @@ GEM
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.10.2)
rubocop (1.18.4)
rubocop (1.22.1)
parallel (~> 1.10)
parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.8.0, < 2.0)
rubocop-ast (>= 1.12.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.8.0)
rubocop-ast (1.12.0)
parser (>= 3.0.1.1)
rubocop-performance (1.11.4)
rubocop-performance (1.11.5)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.11.3)
rubocop-rails (2.12.3)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (2.4.0)
rubocop (~> 1.0)
rubocop-ast (>= 1.1.0)
rubocop-rspec (2.5.0)
rubocop (~> 1.19)
ruby-progressbar (1.11.0)
ruby-vips (2.1.2)
ruby-vips (2.1.3)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
ruby2ruby (2.4.4)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_parser (3.16.0)
ruby_parser (3.17.0)
sexp_processor (~> 4.15, >= 4.15.1)
sassc (2.4.0)
ffi (~> 1.9)
@ -501,38 +519,38 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
scout_apm (4.1.1)
scout_apm (4.1.2)
parser
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (4.6.4)
sentry-rails (4.7.3)
railties (>= 5.0)
sentry-ruby-core (~> 4.6.0)
sentry-ruby (4.6.4)
sentry-ruby-core (~> 4.7.0)
sentry-ruby (4.7.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
faraday (>= 1.0)
sentry-ruby-core (= 4.6.4)
sentry-ruby-core (4.6.4)
sentry-ruby-core (= 4.7.3)
sentry-ruby-core (4.7.3)
concurrent-ruby
faraday
sentry-sidekiq (4.6.4)
sentry-ruby-core (~> 4.6.0)
sentry-sidekiq (4.7.3)
sentry-ruby-core (~> 4.7.0)
sidekiq (>= 3.0)
sexp_processor (4.15.3)
shoulda-matchers (5.0.0)
activesupport (>= 5.2.0)
sidekiq (6.2.1)
sidekiq (6.2.2)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
sidekiq-cron (1.2.0)
fugit (~> 1.1)
sidekiq (>= 4.2.1)
signet (0.15.0)
addressable (~> 2.3)
signet (0.16.0)
addressable (~> 2.8)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
@ -575,15 +593,15 @@ GEM
oauth
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.1)
tzinfo-data (1.2021.3)
tzinfo (>= 1.0.0)
uber (0.1.0)
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unicode-display_width (2.0.0)
unf_ext (0.0.8)
unicode-display_width (2.1.0)
uniform_notifier (1.14.2)
uri_template (0.7.0)
valid_email2 (4.0.0)
@ -596,11 +614,11 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.13.0)
addressable (>= 2.3.6)
webmock (3.14.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.4.0)
webpacker (5.4.3)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
@ -617,6 +635,7 @@ GEM
PLATFORMS
arm64-darwin-20
ruby
x86_64-darwin-18
x86_64-darwin-20
x86_64-darwin-21
@ -652,6 +671,7 @@ DEPENDENCIES
faker
fcm
flag_shih_tzu
flay
foreman
geocoder
google-cloud-dialogflow
@ -687,6 +707,7 @@ DEPENDENCIES
redis-namespace
responders
rest-client
rspec
rspec-rails (~> 5.0.0)
rubocop
rubocop-performance

View file

@ -42,7 +42,7 @@ class ContactMergeAction
end
def merge_and_remove_mergee_contact
mergable_attribute_keys = %w[identifier name email phone_number custom_attributes]
mergable_attribute_keys = %w[identifier name email phone_number additional_attributes custom_attributes]
base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank
mergee_contact_attributes = mergee_contact.attributes.slice(*mergable_attribute_keys).compact_blank

View file

@ -4,10 +4,11 @@
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Facebook::MessageBuilder
class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :response
def initialize(response, inbox, outgoing_echo: false)
super()
@response = response
@inbox = inbox
@outgoing_echo = outgoing_echo
@ -47,30 +48,12 @@ class Messages::Facebook::MessageBuilder
def build_message
@message = conversation.messages.create!(message_params)
@attachments.each do |attachment|
process_attachment(attachment)
end
end
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
def attach_file(attachment, file_url)
attachment_file = Down.download(
file_url
)
attachment.file.attach(
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
def ensure_contact_avatar
return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached?
@ -89,28 +72,6 @@ class Messages::Facebook::MessageBuilder
))
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
def location_params(attachment)
lat = attachment['payload']['coordinates']['lat']
long = attachment['payload']['coordinates']['long']
@ -167,7 +128,7 @@ class Messages::Facebook::MessageBuilder
result = {}
# OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user
# We don't need to capture this error as we don't care about contact params in case of echo messages
Sentry.capture_exception(e) unless outgoing_echo?
Sentry.capture_exception(e) unless @outgoing_echo
rescue StandardError => e
result = {}
Sentry.capture_exception(e)

View file

@ -0,0 +1,150 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
Sentry.capture_exception(e)
true
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def message_content
@messaging[:message][:text]
end
def content_attributes
{ message_id: @messaging[:message][:mid] }
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
@message = conversation.messages.create!(message_params)
attachments.each do |attachment|
process_attachment(attachment)
end
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
}
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_source_id,
content: message_content,
content_attributes: content_attributes,
sender: @outgoing_echo ? nil : contact
}
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: nil,
message_type: 'outgoing',
content: message_content,
private: false,
status: :sent
).first
cw_message.update(content_attributes: content_attributes) if cw_message.present?
cw_message.present?
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end

View file

@ -16,6 +16,7 @@ class Messages::MessageBuilder
def perform
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
@message.save!
@message
end
@ -34,6 +35,16 @@ class Messages::MessageBuilder
end
end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails]
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails]
@message.content_attributes[:cc_emails] = cc_emails
@message.content_attributes[:bcc_emails] = bcc_emails
end
def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'

View file

@ -0,0 +1,42 @@
class Messages::Messenger::MessageBuilder
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
def attach_file(attachment, file_url)
attachment_file = Down.download(
file_url
)
attachment.file.attach(
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
end

View file

@ -41,19 +41,25 @@ class V2::ReportBuilder
user
when :label
label
when :team
team
end
end
def inbox
@inbox ||= account.inboxes.where(id: params[:id]).first
@inbox ||= account.inboxes.find(params[:id])
end
def user
@user ||= account.users.where(id: params[:id]).first
@user ||= account.users.find(params[:id])
end
def label
@label ||= account.labels.where(id: params[:id]).first
@label ||= account.labels.find(params[:id])
end
def team
@team ||= account.teams.find(params[:id])
end
def conversations_count
@ -62,15 +68,14 @@ class V2::ReportBuilder
.count
end
# unscoped removes all scopes added to a model previously
def incoming_messages_count
scope.messages.unscoped.where(account_id: account.id).incoming
scope.messages.incoming.unscope(:order)
.group_by_day(:created_at, range: range, default_value: 0)
.count
end
def outgoing_messages_count
scope.messages.unscoped.where(account_id: account.id).outgoing
scope.messages.outgoing.unscope(:order)
.group_by_day(:created_at, range: range, default_value: 0)
.count
end

View file

@ -9,21 +9,18 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents = agents
end
def create; end
def update
@agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
end
def destroy
@agent.current_account_user.destroy
head :ok
end
def update
@agent.update!(agent_params.except(:role))
@agent.current_account_user.update!(role: agent_params[:role]) if agent_params[:role]
render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @agent }
end
def create
render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @user }
end
private
def check_authorization
@ -47,22 +44,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def save_account_user
AccountUser.create!(
AccountUser.create!({
account_id: Current.account.id,
user_id: @user.id,
role: new_agent_params[:role],
inviter_id: current_user.id
)
}.merge({
role: new_agent_params[:role],
availability: new_agent_params[:availability],
auto_offline: new_agent_params[:auto_offline]
}.compact))
end
def agent_params
params.require(:agent).permit(:email, :name, :role)
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
end
def new_agent_params
# intial string ensures the password requirements are met
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
params.require(:agent).permit(:email, :name, :role)
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
.merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user)
end

View file

@ -12,9 +12,10 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token: page_access_token
)
@facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel)
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
rescue StandardError => e
Rails.logger.info e
Sentry.capture_exception(e)
end
end
@ -22,6 +23,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
end
def set_instagram_id(page_access_token, facebook_channel)
fb_object = Koala::Facebook::API.new(page_access_token)
response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' })
return if response['instagram_business_account'].blank?
instagram_id = response['instagram_business_account']['id']
facebook_channel.update(instagram_id: instagram_id)
end
# get params[:inbox_id], current_account. params[:omniauth_token]
def reauthorize_page
if @inbox&.facebook?
@ -45,8 +55,13 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
def update_fb_page(fb_page_id, access_token)
fb_page = get_fb_page(fb_page_id)
fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token)
fb_page&.reauthorized!
ActiveRecord::Base.transaction do
fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token)
set_instagram_id(access_token, fb_page)
fb_page&.reauthorized!
rescue StandardError => e
Sentry.capture_exception(e)
end
end
def get_fb_page(fb_page_id)

View file

@ -28,7 +28,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
end
def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :inbox_id, :sender_id,
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {})
end
end

View file

@ -10,7 +10,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search]
before_action :fetch_contact, only: [:show, :update, :contactable_inboxes]
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes]
before_action :set_include_contact_inboxes, only: [:index, :search]
def index
@ -30,10 +30,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def import
render json: { error: I18n.t('errors.contacts.import.failed') }, status: :unprocessable_entity and return if params[:import_file].blank?
ActiveRecord::Base.transaction do
import = Current.account.data_imports.create!(data_type: 'contacts')
import.import_file.attach(params[:import_file])
end
head :ok
end
@ -70,6 +73,18 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
}, status: :unprocessable_entity
end
def destroy
if ::OnlineStatusTracker.get_presence(
@contact.account.id, 'Contact', @contact.id
)
return render_error({ message: I18n.t('contacts.online.delete', contact_name: @contact.name.capitalize) },
:unprocessable_entity)
end
@contact.destroy!
head :ok
end
private
# TODO: Move this to a finder class
@ -134,4 +149,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end
def render_error(error, error_status)
render json: error, status: error_status
end
end

View file

@ -69,6 +69,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def update_last_seen
@conversation.agent_last_seen_at = DateTime.now.utc
@conversation.assignee_last_seen_at = DateTime.now.utc if assignee?
@conversation.save!
end
def custom_attributes
@conversation.custom_attributes = params.permit(custom_attributes: {})[:custom_attributes]
@conversation.save!
end
@ -112,6 +118,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {}
custom_attributes = params[:custom_attributes]&.permit! || {}
status = params[:status].present? ? { status: params[:status] } : {}
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
@ -122,11 +129,18 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes,
snoozed_until: params[:snoozed_until]
custom_attributes: custom_attributes,
snoozed_until: params[:snoozed_until],
assignee_id: params[:assignee_id],
team_id: params[:team_id]
}.merge(status)
end
def conversation_finder
@conversation_finder ||= ConversationFinder.new(current_user, params)
end
def assignee?
@conversation.assignee_id? && current_user == @conversation.assignee
end
end

View file

@ -96,6 +96,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
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
end

View file

@ -1,9 +1,7 @@
class Api::V1::ProfilesController < Api::BaseController
before_action :set_user
def show
render partial: 'api/v1/models/user.json.jbuilder', locals: { resource: @user }
end
def show; end
def update
if password_params[:password].present?
@ -15,19 +13,26 @@ class Api::V1::ProfilesController < Api::BaseController
@user.update!(profile_params)
end
def availability
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
end
private
def set_user
@user = current_user
end
def availability_params
params.require(:profile).permit(:account_id, :availability)
end
def profile_params
params.require(:profile).permit(
:email,
:name,
:display_name,
:avatar,
:availability,
ui_settings: {}
)
end

View file

@ -29,6 +29,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render layout: false, template: 'api/v2/accounts/reports/labels.csv.erb', format: 'csv'
end
def teams
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=teams_report.csv'
render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv'
end
private
def check_authorization

View file

@ -0,0 +1,30 @@
class Webhooks::InstagramController < ApplicationController
skip_before_action :authenticate_user!, raise: false
skip_before_action :set_current_user
def verify
if valid_instagram_token?(params['hub.verify_token'])
Rails.logger.info('Instagram webhook verified')
render json: params['hub.challenge']
else
render json: { error: 'Error; wrong verify token', status: 403 }
end
end
def events
Rails.logger.info('Instagram webhook received events')
if params['object'].casecmp('instagram').zero?
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
render json: :ok
else
Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}")
head :unprocessable_entity
end
end
private
def valid_instagram_token?(token)
token == ENV['IG_VERIFY_TOKEN']
end
end

View file

@ -0,0 +1,6 @@
class Webhooks::WhatsappController < ActionController::API
def process_payload
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok
end
end

View file

@ -94,6 +94,8 @@ class ConversationFinder
end
def filter_by_status
return if params[:status] == 'all'
@conversations = @conversations.where(status: params[:status] || DEFAULT_STATUS)
end

View file

@ -8,6 +8,7 @@
:has-accounts="hasAccounts"
/>
<woot-snackbar-box />
<network-notification />
</div>
</template>
@ -15,6 +16,7 @@
import { mapGetters } from 'vuex';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
import WootSnackbarBox from './components/SnackbarContainer';
import NetworkNotification from './components/NetworkNotification';
import { accountIdFromPathname } from './helper/URLHelper';
export default {
@ -23,6 +25,7 @@ export default {
components: {
WootSnackbarBox,
AddAccountModal,
NetworkNotification,
},
data() {

View file

@ -0,0 +1,18 @@
/* global axios */
import ApiClient from './ApiClient';
class AccountActions extends ApiClient {
constructor() {
super('actions', { accountScoped: true });
}
merge(parentId, childId) {
return axios.post(`${this.url}/contact_merge`, {
base_contact_id: parentId,
mergee_contact_id: childId,
});
}
}
export default new AccountActions();

View file

@ -161,9 +161,9 @@ export default {
});
},
updateAvailability({ availability }) {
return axios.put(endPoints('profileUpdate').url, {
profile: { availability },
updateAvailability(availabilityData) {
return axios.post(endPoints('availabilityUpdate').url, {
profile: { ...availabilityData },
});
},
};

View file

@ -52,6 +52,14 @@ class ContactAPI extends ApiClient {
)}`;
return axios.get(requestURL);
}
importContacts(file) {
const formData = new FormData();
formData.append('import_file', file);
return axios.post(`${this.url}/import`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
}
export default new ContactAPI();

View file

@ -13,6 +13,9 @@ const endPoints = {
profileUpdate: {
url: '/api/v1/profile',
},
availabilityUpdate: {
url: '/api/v1/profile/availability',
},
logout: {
url: 'auth/sign_out',
},

View file

@ -8,6 +8,8 @@ export const buildCreatePayload = ({
contentAttributes,
echoId,
file,
ccEmails,
bccEmails,
}) => {
let payload;
if (file) {
@ -18,12 +20,16 @@ export const buildCreatePayload = ({
}
payload.append('private', isPrivate);
payload.append('echo_id', echoId);
payload.append('cc_emails', ccEmails);
payload.append('bcc_emails', bccEmails);
} else {
payload = {
content: message,
private: isPrivate,
echo_id: echoId,
content_attributes: contentAttributes,
cc_emails: ccEmails,
bcc_emails: bccEmails,
};
}
return payload;
@ -41,6 +47,8 @@ class MessageApi extends ApiClient {
contentAttributes,
echo_id: echoId,
file,
ccEmails,
bccEmails,
}) {
return axios({
method: 'post',
@ -51,6 +59,8 @@ class MessageApi extends ApiClient {
contentAttributes,
echoId,
file,
ccEmails,
bccEmails,
}),
});
}

View file

@ -6,15 +6,15 @@ class ReportsAPI extends ApiClient {
super('reports', { accountScoped: true, apiVersion: 'v2' });
}
getAccountReports(metric, since, until) {
getReports(metric, since, until, type = 'account', id) {
return axios.get(`${this.url}`, {
params: { metric, since, until, type: 'account' },
params: { metric, since, until, type, id },
});
}
getAccountSummary(since, until) {
getSummary(since, until, type = 'account', id) {
return axios.get(`${this.url}/summary`, {
params: { since, until, type: 'account' },
params: { since, until, type, id },
});
}
@ -23,6 +23,24 @@ class ReportsAPI extends ApiClient {
params: { since, until },
});
}
getLabelReports(since, until) {
return axios.get(`${this.url}/labels`, {
params: { since, until },
});
}
getInboxReports(since, until) {
return axios.get(`${this.url}/inboxes`, {
params: { since, until },
});
}
getTeamReports(since, until) {
return axios.get(`${this.url}/teams`, {
params: { since, until },
});
}
}
export default new ReportsAPI();

View file

@ -0,0 +1,23 @@
import accountActionsAPI from '../accountActions';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#ContactsAPI', () => {
it('creates correct instance', () => {
expect(accountActionsAPI).toBeInstanceOf(ApiClient);
expect(accountActionsAPI).toHaveProperty('merge');
});
describeWithAPIMock('API calls', context => {
it('#merge', () => {
accountActionsAPI.merge(1, 2);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/actions/contact_merge',
{
base_contact_id: 1,
mergee_contact_id: 2,
}
);
});
});
});

View file

@ -59,6 +59,18 @@ describe('#ContactsAPI', () => {
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
);
});
it('#importContacts', () => {
const file = 'file';
contactAPI.importContacts(file);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/import',
expect.any(FormData),
{
headers: { 'Content-Type': 'multipart/form-data' },
}
);
});
});
});

View file

@ -11,39 +11,35 @@ describe('#Reports API', () => {
expect(reportsAPI).toHaveProperty('create');
expect(reportsAPI).toHaveProperty('update');
expect(reportsAPI).toHaveProperty('delete');
expect(reportsAPI).toHaveProperty('getAccountReports');
expect(reportsAPI).toHaveProperty('getAccountSummary');
expect(reportsAPI).toHaveProperty('getReports');
expect(reportsAPI).toHaveProperty('getSummary');
expect(reportsAPI).toHaveProperty('getAgentReports');
expect(reportsAPI).toHaveProperty('getLabelReports');
expect(reportsAPI).toHaveProperty('getInboxReports');
expect(reportsAPI).toHaveProperty('getTeamReports');
});
describeWithAPIMock('API calls', context => {
it('#getAccountReports', () => {
reportsAPI.getAccountReports(
'conversations_count',
1621103400,
1621621800
);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports',
{
params: {
metric: 'conversations_count',
since: 1621103400,
until: 1621621800,
type: 'account'
},
}
);
reportsAPI.getReports('conversations_count', 1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
params: {
metric: 'conversations_count',
since: 1621103400,
until: 1621621800,
type: 'account',
},
});
});
it('#getAccountSummary', () => {
reportsAPI.getAccountSummary(1621103400, 1621621800);
reportsAPI.getSummary(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/summary',
{
params: {
since: 1621103400,
until: 1621621800,
type: 'account'
type: 'account',
},
}
);
@ -61,5 +57,44 @@ describe('#Reports API', () => {
}
);
});
it('#getLabelReports', () => {
reportsAPI.getLabelReports(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/labels',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
it('#getInboxReports', () => {
reportsAPI.getInboxReports(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/inboxes',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
it('#getTeamReports', () => {
reportsAPI.getTeamReports(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/teams',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

View file

@ -93,3 +93,17 @@
/* .slide-fade-leave-active for <2.1.8 */ {
opacity: 0;
}
.network-notification-fade-enter-active {
transition: all .1s $ease-in-sine;
}
.network-notification-fade-leave-active {
transition: all .1s $ease-out-sine;
}
.network-notification-fade-enter,
.network-notification-fade-leave-to {
transform: translateY(-$space-small);
opacity: 0;
}

View file

@ -0,0 +1,3 @@
.margin-right-small {
margin-right: var(--space-small);
}

View file

@ -14,6 +14,7 @@
@import 'helper-classes';
@import 'formulate';
@import 'date-picker';
@import 'utility-helpers';
@import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon';

View file

@ -15,6 +15,10 @@
.multiselect {
margin-bottom: var(--space-normal);
&.multiselect--disabled {
opacity: .8;
}
.multiselect--active {
>.multiselect__tags {
border-color: $color-woot;
@ -209,3 +213,53 @@
flex-shrink: 0;
}
}
.multiselect-wrap--medium {
$multiselect-height: 4.8rem;
.multiselect__tags,
.multiselect__input {
align-items: center;
display: flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
background: var(--white);
font-size: var(--font-size-small);
height: $multiselect-height;
min-height: $multiselect-height;
}
.multiselect__input {
height: $multiselect-height - $space-micro;
min-height: $multiselect-height - $space-micro;
}
.multiselect__single {
align-items: center;
display: flex;
font-size: var(--font-size-small);
margin: 0;
padding: var(--space-smaller) var(--space-micro);
}
.multiselect__placeholder {
margin: 0;
padding: var(--space-smaller) var(--space-micro);
}
.multiselect__select {
min-height: $multiselect-height;
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
background: transparent;
}
.multiselect__tags-wrap {
flex-shrink: 0;
}
}

View file

@ -42,14 +42,6 @@ $resolve-button-width: 13.2rem;
margin-right: var(--space-normal);
min-width: 0;
.user--name {
@include margin(0);
display: inline-block;
font-size: $font-size-medium;
line-height: 1.3;
text-transform: capitalize;
width: 100%;
}
.user--profile__meta {
align-items: flex-start;
@ -59,12 +51,6 @@ $resolve-button-width: 13.2rem;
margin-left: $space-slab;
min-width: 0;
}
.user--profile__button {
font-size: $font-size-mini;
margin-top: $space-micro;
padding: 0;
}
}
}

View file

@ -93,7 +93,7 @@
.conversation-panel {
@include flex;
@include flex-weight(1);
@include flex-weight(1 1 1px);
@include margin($zero);
flex-direction: column;
height: 100%;

View file

@ -71,7 +71,8 @@
@include padding($space-large);
}
form {
form,
.modal-content {
@include padding($space-large);
align-self: center;

View file

@ -32,7 +32,6 @@
}
}
.report-bar {
@include margin(-1px $zero);
@include background-white;

View file

@ -0,0 +1,30 @@
.date-picker {
margin-left: var(--space-smaller);
}
.margin-left-small {
margin-left: var(--space-smaller);
}
.reports-option__rounded--item {
border-radius: 100%;
height: var(--space-two);
width: var(--space-two);
}
.reports-option__item {
flex-shrink: 0;
margin-right: var(--space-small);
}
.reports-option__label--swatch {
border: 1px solid var(--color-border);
}
.margin-right-small {
margin-right: var(--space-small);
}
.display-flex {
display: flex;
}

View file

@ -26,6 +26,7 @@
:active-label="label"
:team-id="teamId"
:chat="chat"
:show-assignee="showAssigneeInConversationCard"
/>
<div v-if="chatListLoading" class="text-center">
@ -119,6 +120,9 @@ export default {
};
});
},
showAssigneeInConversationCard() {
return this.activeAssigneeTab === wootConstants.ASSIGNEE_TYPE.ALL;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.activeInbox);
},

View file

@ -0,0 +1,123 @@
<template>
<transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="ui-notification-container">
<div class="ui-notification">
<svg
class="ui-notification-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"
/>
</svg>
<p class="ui-notification-text">
{{ $t('NETWORK.NOTIFICATION.TEXT') }}
</p>
<button class="ui-refresh-button" @click="refreshPage">
{{ $t('NETWORK.BUTTON.REFRESH') }}
</button>
<button class="ui-close-button" @click="closeNotification">
<i class="ui-close-icon icon ion-ios-close-outline" />
</button>
</div>
</div>
</transition>
</template>
<script>
export default {
data() {
return {
showNotification: !navigator.onLine,
};
},
mounted() {
window.addEventListener('offline', this.updateOnlineStatus);
},
beforeDestroy() {
window.removeEventListener('offline', this.updateOnlineStatus);
},
methods: {
refreshPage() {
window.location.reload();
},
closeNotification() {
this.showNotification = false;
},
updateOnlineStatus(event) {
if (event.type === 'offline') {
this.showNotification = true;
}
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/mixins';
.ui-notification-container {
max-width: 40rem;
position: absolute;
right: var(--space-normal);
top: var(--space-normal);
width: 100%;
z-index: 9999;
}
.ui-notification {
@include shadow;
align-items: center;
background-color: var(--white);
border: 1px solid var(--color-border);
border-radius: var(--space-one);
display: flex;
justify-content: space-between;
max-width: 40rem;
min-height: 3rem;
min-width: 24rem;
padding: var(--space-normal) var(--space-two);
text-align: left;
}
.ui-notification-text {
margin: 0;
}
.ui-refresh-button {
color: var(--color-woot);
font-size: var(--font-size-small);
font-weight: bold;
&:hover {
cursor: pointer;
}
}
.ui-notification-icon {
color: var(--b-600);
width: var(--font-size-mega);
}
.ui-close-icon {
color: var(--b-600);
font-size: var(--font-size-large);
}
.ui-close-button {
&:hover {
cursor: pointer;
}
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<div class="status">
<div class="status-view">
<availability-status-badge :status="currentUserAvailabilityStatus" />
<availability-status-badge :status="currentUserAvailability" />
<div class="status-view--title">
{{ availabilityDisplayLabel }}
</div>
@ -26,7 +26,9 @@
color-scheme="secondary"
class-names="status-change--dropdown-button"
:is-disabled="status.disabled"
@click="changeAvailabilityStatus(status.value)"
@click="
changeAvailabilityStatus(status.value, currentAccountId)
"
>
<availability-status-badge :status="status.value" />
{{ status.label }}
@ -75,18 +77,22 @@ export default {
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
getCurrentUserAvailability: 'getCurrentUserAvailability',
getCurrentAccountId: 'getCurrentAccountId',
}),
availabilityDisplayLabel() {
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
key => key === this.currentUserAvailabilityStatus
key => key === this.currentUserAvailability
);
return this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUSES_LIST')[
availabilityIndex
];
},
currentUserAvailabilityStatus() {
return this.currentUser.availability_status;
currentAccountId() {
return this.getCurrentAccountId;
},
currentUserAvailability() {
return this.getCurrentUserAvailability;
},
availabilityStatuses() {
return this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUSES_LIST').map(
@ -94,7 +100,7 @@ export default {
label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index],
disabled:
this.currentUserAvailabilityStatus ===
this.currentUserAvailability ===
AVAILABILITY_STATUS_KEYS[index],
})
);
@ -108,16 +114,16 @@ export default {
closeStatusMenu() {
this.isStatusMenuOpened = false;
},
changeAvailabilityStatus(availability) {
changeAvailabilityStatus(availability, accountId) {
if (this.isUpdating) {
return;
}
this.isUpdating = true;
this.$store
.dispatch('updateAvailability', {
availability,
availability: availability,
account_id: accountId,
})
.finally(() => {
this.isUpdating = false;

View file

@ -17,7 +17,8 @@ const i18nConfig = new VueI18n({
});
describe('AvailabilityStatus', () => {
const currentUser = { availability_status: 'online' };
const currentAvailability = 'online';
const currentAccountId = '1';
let store = null;
let actions = null;
let modules = null;
@ -33,7 +34,8 @@ describe('AvailabilityStatus', () => {
modules = {
auth: {
getters: {
getCurrentUser: () => currentUser,
getCurrentUserAvailability: () => currentAvailability,
getCurrentAccountId: () => currentAccountId,
},
},
};
@ -77,7 +79,7 @@ describe('AvailabilityStatus', () => {
expect(actions.updateAvailability).toBeCalledWith(
expect.any(Object),
{ availability: 'offline' },
{ availability: 'offline', account_id: currentAccountId },
undefined
);
});

View file

@ -6,7 +6,7 @@
>
<img
v-if="channel.key === 'facebook'"
src="~dashboard/assets/images/channels/facebook.png"
src="~dashboard/assets/images/channels/messenger.png"
/>
<img
v-if="channel.key === 'twitter'"

View file

@ -0,0 +1,35 @@
<template>
<span class="inbox--name">
<i :class="computedInboxClass" />
{{ inbox.name }}
</span>
</template>
<script>
import { getInboxClassByType } from 'dashboard/helper/inbox';
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
},
computed: {
computedInboxClass() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
},
};
</script>
<style scoped>
.inbox--name {
padding: var(--space-micro) 0;
line-height: var(--space-slab);
font-weight: var(--font-weight-medium);
background: none;
color: var(--s-500);
font-size: var(--font-size-mini);
}
</style>

View file

@ -15,39 +15,60 @@
:size="avatarSize"
/>
<img
v-if="badge === 'Channel::FacebookPage'"
v-if="badge === 'instagram_direct_message'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/fb-badge.png"
src="/integrations/channels/badges/instagram-dm.png"
/>
<img
v-if="badge === 'Channel::TwitterProfile'"
v-else-if="badge === 'facebook'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/twitter-badge.png"
src="/integrations/channels/badges/messenger.png"
/>
<img
v-if="badge === 'Channel::TwilioSms'"
v-else-if="badge === 'twitter-tweet'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/channels/whatsapp.png"
src="/integrations/channels/badges/twitter-tweet.png"
/>
<img
v-if="badge === 'Channel::Line'"
v-else-if="badge === 'twitter-dm'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/channels/line.png"
src="/integrations/channels/badges/twitter-dm.png"
/>
<img
v-if="badge === 'Channel::Telegram'"
v-else-if="badge === 'whatsapp'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/channels/telegram.png"
src="/integrations/channels/badges/whatsapp.png"
/>
<img
v-else-if="badge === 'sms'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/sms.png"
/>
<img
v-else-if="badge === 'Channel::Line'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/line.png"
/>
<img
v-else-if="badge === 'Channel::Telegram'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/telegram.png"
/>
<div
v-if="showStatusIndicator"
@ -109,8 +130,10 @@ export default {
return Number(this.size.replace(/\D+/g, ''));
},
badgeStyle() {
const badgeSize = `${this.avatarSize / 3}px`;
return { width: badgeSize, height: badgeSize };
const size = Math.floor(this.avatarSize / 3);
const badgeSize = `${size + 2}px`;
const borderRadius = `${size / 2}px`;
return { width: badgeSize, height: badgeSize, borderRadius };
},
statusStyle() {
const statusSize = `${this.avatarSize / 4}px`;
@ -152,6 +175,7 @@ export default {
height: 100%;
width: 100%;
box-sizing: border-box;
object-fit: cover;
&.border {
border: 1px solid white;
@ -159,8 +183,12 @@ export default {
}
.source-badge {
background: white;
border-radius: var(--border-radius-small);
bottom: -$space-micro;
box-shadow: var(--shadow-small);
height: $space-slab;
padding: var(--space-micro);
position: absolute;
right: $zero;
width: $space-slab;

View file

@ -7,6 +7,9 @@
>
{{ item['TEXT'] }}
</option>
<option value="all">
{{ $t('CHAT_LIST.FILTER_ALL') }}
</option>
</select>
</template>
@ -30,6 +33,8 @@ export default {
} else if (this.activeStatus === wootConstants.STATUS_TYPE.PENDING) {
this.activeStatus = wootConstants.STATUS_TYPE.SNOOZED;
} else if (this.activeStatus === wootConstants.STATUS_TYPE.SNOOZED) {
this.activeStatus = wootConstants.STATUS_TYPE.ALL;
} else if (this.activeStatus === wootConstants.STATUS_TYPE.ALL) {
this.activeStatus = wootConstants.STATUS_TYPE.OPEN;
}
}

View file

@ -8,20 +8,26 @@
}"
@click="cardClick(chat)"
>
<Thumbnail
<thumbnail
v-if="!hideThumbnail"
:src="currentContact.thumbnail"
:badge="chatMetadata.channel"
:badge="inboxBadge"
class="columns"
:username="currentContact.name"
:status="currentContact.availability_status"
size="40px"
/>
<div class="conversation--details columns">
<span v-if="showInboxName" class="label">
<i :class="computedInboxClass" />
{{ inboxName }}
</span>
<div class="conversation--metadata">
<inbox-name v-if="showInboxName" :inbox="inbox" />
<span
v-if="showAssignee && assignee.name"
class="label assignee-label text-truncate"
>
<i class="ion-person" />
{{ assignee.name }}
</span>
</div>
<h4 class="conversation--user">
{{ currentContact.name }}
</h4>
@ -62,19 +68,21 @@
import { mapGetters } from 'vuex';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { getInboxClassByType } from 'dashboard/helper/inbox';
import Thumbnail from '../Thumbnail';
import conversationMixin from '../../../mixins/conversations';
import timeMixin from '../../../mixins/time';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName';
import inboxMixin from 'shared/mixins/inboxMixin';
export default {
components: {
InboxName,
Thumbnail,
},
mixins: [timeMixin, conversationMixin, messageFormatterMixin],
mixins: [inboxMixin, timeMixin, conversationMixin, messageFormatterMixin],
props: {
activeLabel: {
type: String,
@ -96,6 +104,10 @@ export default {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
},
computed: {
@ -108,7 +120,11 @@ export default {
}),
chatMetadata() {
return this.chat.meta;
return this.chat.meta || {};
},
assignee() {
return this.chatMetadata.assignee || {};
},
currentContact() {
@ -167,18 +183,12 @@ export default {
return this.getPlainText(subject || this.lastMessageInChat.content);
},
chatInbox() {
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
return stateInbox;
},
computedInboxClass() {
const { phone_number: phoneNumber, channel_type: type } = this.chatInbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
showInboxName() {
return (
!this.hideInboxName &&
@ -187,11 +197,10 @@ export default {
);
},
inboxName() {
const stateInbox = this.chatInbox;
const stateInbox = this.inbox;
return stateInbox.name || '';
},
},
methods: {
cardClick(chat) {
const { activeInbox } = this;
@ -226,15 +235,6 @@ export default {
}
}
.conversation--details .label {
padding: var(--space-micro) 0 var(--space-micro) 0;
line-height: var(--space-slab);
font-weight: var(--font-weight-medium);
background: none;
color: var(--s-500);
font-size: var(--font-size-mini);
}
.conversation--details {
.conversation--user {
padding-top: var(--space-micro);
@ -252,4 +252,23 @@ export default {
color: var(--s-600);
font-size: var(--font-size-mini);
}
.conversation--metadata {
display: flex;
justify-content: space-between;
padding-right: var(--space-normal);
.label {
padding: var(--space-micro) 0 var(--space-micro) 0;
line-height: var(--space-slab);
font-weight: var(--font-weight-medium);
background: none;
color: var(--s-500);
font-size: var(--font-size-mini);
}
.assignee-label {
max-width: 50%;
}
}
</style>

View file

@ -4,7 +4,7 @@
<Thumbnail
:src="currentContact.thumbnail"
size="40px"
:badge="chatMetadata.channel"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
/>
@ -12,20 +12,23 @@
<h3 class="user--name text-truncate">
{{ currentContact.name }}
</h3>
<woot-button
class="user--profile__button"
size="small"
variant="link"
@click="$emit('contact-panel-toggle')"
>
{{
`${
isContactPanelOpen
? $t('CONVERSATION.HEADER.CLOSE')
: $t('CONVERSATION.HEADER.OPEN')
} ${$t('CONVERSATION.HEADER.DETAILS')}`
}}
</woot-button>
<div class="conversation--header--actions">
<inbox-name :inbox="inbox" class="margin-right-small" />
<span
v-if="isSnoozed"
class="snoozed--display-text margin-right-small"
>
{{ snoozedDisplayText }}
</span>
<woot-button
class="user--profile__button margin-right-small"
size="small"
variant="link"
@click="$emit('contact-panel-toggle')"
>
{{ contactPanelToggleText }}
</woot-button>
</div>
</div>
</div>
<div
@ -42,14 +45,19 @@ import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import agentMixin from '../../../mixins/agentMixin.js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
import wootConstants from '../../../constants';
import differenceInHours from 'date-fns/differenceInHours';
import InboxName from '../InboxName';
export default {
components: {
InboxName,
MoreActions,
Thumbnail,
},
mixins: [agentMixin, eventListenerMixins],
mixins: [inboxMixin, agentMixin, eventListenerMixins],
props: {
chat: {
type: Object,
@ -60,14 +68,6 @@ export default {
default: false,
},
},
data() {
return {
currentChatAssignee: null,
inboxId: null,
};
},
computed: {
...mapGetters({
uiFlags: 'inboxAssignableAgents/getUIFlags',
@ -83,10 +83,37 @@ export default {
this.chat.meta.sender.id
);
},
},
mounted() {
const { inbox_id: inboxId } = this.chat;
this.inboxId = inboxId;
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
snoozedDisplayText() {
const { snoozed_until: snoozedUntil } = this.currentChat;
if (snoozedUntil) {
// When the snooze is applied, it schedules the unsnooze event to next day/week 9AM.
// By that logic if the time difference is less than or equal to 24 + 9 hours we can consider it tomorrow.
const MAX_TIME_DIFFERENCE = 33;
const isSnoozedUntilTomorrow =
differenceInHours(new Date(snoozedUntil), new Date()) <=
MAX_TIME_DIFFERENCE;
return this.$t(
isSnoozedUntilTomorrow
? 'CONVERSATION.HEADER.SNOOZED_UNTIL_TOMORROW'
: 'CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_WEEK'
);
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
contactPanelToggleText() {
return `${
this.isContactPanelOpen
? this.$t('CONVERSATION.HEADER.CLOSE')
: this.$t('CONVERSATION.HEADER.OPEN')
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
},
},
methods: {
@ -122,4 +149,28 @@ export default {
flex-shrink: 0;
}
}
.user--name {
display: inline-block;
font-size: var(--font-size-medium);
line-height: 1.3;
margin: 0;
text-transform: capitalize;
width: 100%;
}
.conversation--header--actions {
align-items: center;
display: flex;
font-size: var(--font-size-mini);
.user--profile__button {
padding: 0;
}
.snoozed--display-text {
font-weight: var(--font-weight-medium);
color: var(--y-900);
}
}
</style>

View file

@ -3,8 +3,9 @@
<div :class="wrapClass">
<div v-tooltip.top-start="sentByMessage" :class="bubbleClass">
<bubble-mail-head
v-if="isEmailContentType"
:email-attributes="contentAttributes.email"
:cc="emailHeadAttributes.cc"
:bcc="emailHeadAttributes.bcc"
:is-incoming="isIncoming"
/>
<bubble-text
@ -222,6 +223,13 @@ export default {
isIncoming() {
return this.data.message_type === MESSAGE_TYPE.INCOMING;
},
emailHeadAttributes() {
return {
email: this.contentAttributes.email,
cc: this.contentAttributes.cc_emails,
bcc: this.contentAttributes.bcc_emails
}
},
hasAttachments() {
return !!(this.data.attachments && this.data.attachments.length > 0);
},

View file

@ -1,7 +1,7 @@
<template>
<div class="view-box fill-height">
<div
v-if="!currentChat.can_reply && !isATwilioWhatsappChannel"
v-if="!currentChat.can_reply && !isAWhatsappChannel"
class="banner messenger-policy--banner"
>
<span>
@ -16,7 +16,7 @@
</span>
</div>
<div
v-if="!currentChat.can_reply && isATwilioWhatsappChannel"
v-if="!currentChat.can_reply && isAWhatsappChannel"
class="banner messenger-policy--banner"
>
<span>

View file

@ -1,41 +1,29 @@
<template>
<div class="flex-container actions--container">
<woot-button
v-if="!currentChat.muted"
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
class="hollow secondary actions--button"
icon="ion-volume-mute"
@click="mute"
/>
<woot-button
v-else
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
class="hollow secondary actions--button"
icon="ion-volume-medium"
@click="unmute"
/>
<woot-button
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
class="hollow secondary actions--button"
icon="ion-share"
@click="toggleEmailActionsModal"
/>
<resolve-action
:conversation-id="currentChat.id"
:status="currentChat.status"
/>
<woot-button
class="more--button"
variant="clear"
size="large"
color-scheme="secondary"
icon="ion-android-more-vertical"
@click="toggleConversationActions"
/>
<div
v-if="showConversationActions"
v-on-clickaway="hideConversationActions"
class="dropdown-pane dropdowm--bottom"
:class="{ 'dropdown-pane--open': showConversationActions }"
>
<woot-dropdown-menu>
<woot-dropdown-item v-if="!currentChat.muted">
<button class="button clear alert " @click="mute">
<span>{{ $t('CONTACT_PANEL.MUTE_CONTACT') }}</span>
</button>
</woot-dropdown-item>
<woot-dropdown-item v-else>
<button class="button clear alert" @click="unmute">
<span>{{ $t('CONTACT_PANEL.UNMUTE_CONTACT') }}</span>
</button>
</woot-dropdown-item>
<woot-dropdown-item>
<button class="button clear" @click="toggleEmailActionsModal">
{{ $t('CONTACT_PANEL.SEND_TRANSCRIPT') }}
</button>
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
<email-transcript-modal
v-if="showEmailActionsModal"
:show="showEmailActionsModal"
@ -50,13 +38,9 @@ import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import EmailTranscriptModal from './EmailTranscriptModal';
import ResolveAction from '../../buttons/ResolveAction';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
export default {
components: {
WootDropdownMenu,
WootDropdownItem,
EmailTranscriptModal,
ResolveAction,
},
@ -97,7 +81,16 @@ export default {
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/mixins';
.actions--container {
align-items: center;
.button {
font-size: var(--font-size-large);
margin-right: var(--space-small);
border-color: var(--color-border);
color: var(--s-400);
}
}
.more--button {
align-items: center;

View file

@ -20,6 +20,11 @@
v-on-clickaway="hideEmojiPicker"
:on-click="emojiOnClick"
/>
<reply-email-head
v-if="showReplyHead"
:clear-mails="clearMails"
@set-emails="setCcEmails"
/>
<resizable-text-area
v-if="!showRichContentEditor"
ref="messageInput"
@ -82,6 +87,7 @@ import CannedResponse from './CannedResponse';
import ResizableTextArea from 'shared/components/ResizableTextArea';
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 { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
@ -104,6 +110,7 @@ export default {
ResizableTextArea,
AttachmentPreview,
ReplyTopPanel,
ReplyEmailHead,
ReplyBottomPanel,
WootMessageEditor,
},
@ -134,6 +141,7 @@ export default {
mentionSearchKey: '',
hasUserMention: false,
hasSlashCommand: false,
clearMails: false,
};
},
computed: {
@ -156,7 +164,7 @@ export default {
return !!this.uiSettings.enter_to_send_enabled;
},
isPrivate() {
if (this.currentChat.can_reply || this.isATwilioWhatsappChannel) {
if (this.currentChat.can_reply || this.isAWhatsappChannel) {
return this.isOnPrivateNote;
}
return true;
@ -203,7 +211,7 @@ export default {
if (this.isAFacebookInbox) {
return MESSAGE_MAX_LENGTH.FACEBOOK;
}
if (this.isATwilioWhatsappChannel) {
if (this.isAWhatsappChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}
if (this.isATwilioSMSChannel) {
@ -223,7 +231,8 @@ export default {
this.isATwilioWhatsappChannel ||
this.isAPIInbox ||
this.isAnEmailChannel ||
this.isATwilioSMSChannel
this.isATwilioSMSChannel ||
this.isATelegramChannel
);
},
replyButtonLabel() {
@ -269,6 +278,9 @@ export default {
}
return !this.message.trim().replace(/\n/g, '').length;
},
showReplyHead() {
return !this.isOnPrivateNote && this.isAnEmailChannel;
},
},
watch: {
currentChat(conversation) {
@ -277,7 +289,7 @@ export default {
return;
}
if (canReply || this.isATwilioWhatsappChannel) {
if (canReply || this.isAWhatsappChannel) {
this.replyType = REPLY_EDITOR_MODES.REPLY;
} else {
this.replyType = REPLY_EDITOR_MODES.NOTE;
@ -347,9 +359,13 @@ export default {
await this.$store.dispatch('sendMessage', messagePayload);
this.$emit('scrollToMessage');
} catch (error) {
// Error
const errorMessage =
error?.response?.data?.error ||
this.$t('CONVERSATION.MESSAGE_ERROR');
this.showAlert(errorMessage);
}
this.hideEmojiPicker();
this.clearMails = false;
}
},
replaceText(message) {
@ -360,7 +376,7 @@ export default {
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
const { can_reply: canReply } = this.currentChat;
if (canReply || this.isATwilioWhatsappChannel) this.replyType = mode;
if (canReply || this.isAWhatsappChannel) this.replyType = mode;
if (this.showRichContentEditor) {
return;
}
@ -372,6 +388,7 @@ export default {
clearMessage() {
this.message = '';
this.attachedFiles = [];
this.clearMails = true;
},
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
@ -448,11 +465,23 @@ export default {
messagePayload.file = attachment.resource.file;
}
if (this.ccEmails) {
messagePayload.ccEmails = this.ccEmails;
}
if (this.bccEmails) {
messagePayload.bccEmails = this.bccEmails;
}
return messagePayload;
},
setFormatMode(value) {
this.updateUISettings({ display_rich_content_editor: value });
},
setCcEmails(value) {
this.bccEmails = value.bccEmails;
this.ccEmails = value.ccEmails;
},
},
};
</script>

View file

@ -1,17 +1,17 @@
<template>
<div>
<div class="input-group-wrap">
<div class="input-group small" :class="{ error: $v.ccEmails.$error }">
<div class="input-group small" :class="{ error: $v.ccEmailsVal.$error }">
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.LABEL') }}
</label>
<div class="input-group-field">
<woot-input
v-model.trim="ccEmails"
v-model.trim="$v.ccEmailsVal.$model"
type="email"
:class="{ error: $v.ccEmails.$error }"
:class="{ error: $v.ccEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="$v.ccEmails.$touch"
@blur="onBlur"
/>
</div>
<woot-button
@ -23,28 +23,28 @@
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.ADD_BCC') }}
</woot-button>
</div>
<span v-if="$v.ccEmails.$error" class="message">
<span v-if="$v.ccEmailsVal.$error" class="message">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.ERROR') }}
</span>
</div>
<div v-if="showBcc" class="input-group-wrap">
<div class="input-group small" :class="{ error: $v.bccEmails.$error }">
<div class="input-group small" :class="{ error: $v.bccEmailsVal.$error }">
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.LABEL') }}
</label>
<div class="input-group-field">
<woot-input
v-model.trim="bccEmails"
v-model.trim="$v.bccEmailsVal.$model"
type="email"
:class="{ error: $v.bccEmails.$error }"
:class="{ error: $v.bccEmailsVal.$error }"
:placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
"
@blur="$v.bccEmails.$touch"
@blur="onBlur"
/>
</div>
</div>
<span v-if="$v.bccEmails.$error" class="message">
<span v-if="$v.bccEmailsVal.$error" class="message">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.ERROR') }}
</span>
</div>
@ -55,27 +55,25 @@
import { validEmailsByComma } from './helpers/emailHeadHelper';
export default {
props: {
ccEmails: {
type: String,
default: '',
},
bccEmails: {
type: String,
default: '',
clearMails: {
type: Boolean,
default: false,
},
},
data() {
return {
showBcc: false,
ccEmailsVal: '',
bccEmailsVal: '',
};
},
validations: {
ccEmails: {
ccEmailsVal: {
hasValidEmails(value) {
return validEmailsByComma(value);
},
},
bccEmails: {
bccEmailsVal: {
hasValidEmails(value) {
return validEmailsByComma(value);
},
@ -85,7 +83,20 @@ export default {
handleAddBcc() {
this.showBcc = true;
},
onBlur() {
this.$v.$touch();
this.$emit("set-emails", { bccEmails: this.bccEmailsVal, ccEmails: this.ccEmailsVal });
},
},
watch: {
clearMails: function(value){
if(value) {
this.ccEmailsVal = '';
this.bccEmailsVal = '';
this.clearMails = false;
}
}
}
};
</script>
<style lang="scss" scoped>

View file

@ -1,6 +1,12 @@
<template>
<div class="message-text--metadata">
<span class="time">{{ readableTime }}</span>
<span v-if="showSentIndicator" class="time">
<i
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
class="icon ion-checkmark"
/>
</span>
<i
v-if="isEmail"
v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')"
@ -36,8 +42,10 @@
<script>
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import inboxMixin from 'shared/mixins/inboxMixin';
export default {
mixins: [inboxMixin],
props: {
sender: {
type: Object,
@ -99,6 +107,9 @@ export default {
return `https://twitter.com/${screenName ||
this.inbox.name}/status/${sourceId}`;
},
showSentIndicator() {
return this.isOutgoing && this.sourceId && this.isAnEmailChannel;
},
},
methods: {
onTweetReply() {
@ -117,6 +128,10 @@ export default {
color: var(--w-100);
}
}
.icon {
color: var(--white);
}
}
.left {
@ -127,13 +142,6 @@ export default {
}
}
.right {
.ion-reply,
.ion-android-open {
color: var(--white);
}
}
.message-text--metadata {
align-items: flex-end;
display: flex;
@ -192,6 +200,10 @@ export default {
.time {
color: var(--s-400);
}
.icon {
color: var(--s-400);
}
}
&.is-image {
@ -201,4 +213,8 @@ export default {
}
}
}
.delivered-icon {
margin-left: -var(--space-normal);
}
</style>

View file

@ -36,6 +36,14 @@ export default {
type: Boolean,
default: true,
},
cc: {
type: Array,
default: () => [],
},
bcc: {
type: Array,
default: () => [],
},
},
computed: {
toMails() {
@ -43,11 +51,11 @@ export default {
return to.join(', ');
},
ccMails() {
const cc = this.emailAttributes.cc || [];
const cc = this.emailAttributes.cc || this.cc || [];
return cc.join(', ');
},
bccMails() {
const bcc = this.emailAttributes.bcc || [];
const bcc = this.emailAttributes.bcc || this.bcc || [];
return bcc.join(', ');
},
subject() {

View file

@ -60,11 +60,9 @@ export default {
.text-content {
overflow: auto;
&::v-deep {
ul,
ol {
margin-left: var(--space-normal);
}
ul,
ol {
padding-left: var(--space-two);
}
table {
all: revert;

View file

@ -1,6 +1,7 @@
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import VTooltip from 'v-tooltip';
import Button from 'dashboard/components/buttons/Button';
import i18n from 'dashboard/i18n';
@ -10,6 +11,7 @@ import MoreActions from '../MoreActions';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.use(VTooltip);
localVue.component('woot-button', Button);
@ -63,21 +65,9 @@ describe('MoveActions', () => {
moreActions = mount(MoreActions, { store, localVue, i18n: i18nConfig });
});
it('opens the menu when user clicks "more"', async () => {
expect(moreActions.find('.dropdown-pane').exists()).toBe(false);
await moreActions.find('.more--button').trigger('click');
expect(moreActions.find('.dropdown-pane').exists()).toBe(true);
});
describe('muting discussion', () => {
it('triggers "muteConversation"', async () => {
await moreActions.find('.more--button').trigger('click');
await moreActions
.find('.dropdown-pane button:first-child')
.trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(muteConversation).toBeCalledWith(
expect.any(Object),
@ -87,11 +77,7 @@ describe('MoveActions', () => {
});
it('shows alert', async () => {
await moreActions.find('.more--button').trigger('click');
await moreActions
.find('.dropdown-pane button:first-child')
.trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(window.bus.$emit).toBeCalledWith(
'newToastMessage',
@ -106,11 +92,7 @@ describe('MoveActions', () => {
});
it('triggers "unmuteConversation"', async () => {
await moreActions.find('.more--button').trigger('click');
await moreActions
.find('.dropdown-pane button:first-child')
.trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(unmuteConversation).toBeCalledWith(
expect.any(Object),
@ -120,11 +102,7 @@ describe('MoveActions', () => {
});
it('shows alert', async () => {
await moreActions.find('.more--button').trigger('click');
await moreActions
.find('.dropdown-pane button:first-child')
.trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(window.bus.$emit).toBeCalledWith(
'newToastMessage',

View file

@ -5,6 +5,7 @@
:value="value"
:type="type"
:placeholder="placeholder"
:readonly="readonly"
@input="onChange"
@blur="onBlur"
/>
@ -42,6 +43,10 @@ export default {
type: String,
default: '',
},
readonly: {
type: Boolean,
deafaut: false,
},
},
methods: {
onChange(e) {

View file

@ -10,6 +10,7 @@ export default {
RESOLVED: 'resolved',
PENDING: 'pending',
SNOOZED: 'snoozed',
ALL: 'all',
},
};
export const DEFAULT_REDIRECT_URL = '/app/';

View file

@ -19,6 +19,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.typing_off': this.onTypingOff,
'conversation.contact_changed': this.onConversationContactChange,
'presence.update': this.onPresenceUpdate,
'contact.deleted': this.onContactDelete,
};
}
@ -33,7 +34,7 @@ class ActionCableConnector extends BaseActionCableConnector {
onPresenceUpdate = data => {
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
this.app.$store.dispatch('agents/updatePresence', data.users);
this.app.$store.dispatch('setCurrentUserAvailabilityStatus', data.users);
this.app.$store.dispatch('setCurrentUserAvailability', data.users);
};
onConversationContactChange = payload => {
@ -115,6 +116,14 @@ class ActionCableConnector extends BaseActionCableConnector {
fetchConversationStats = () => {
bus.$emit('fetch_conversation_stats');
};
onContactDelete = data => {
this.app.$store.dispatch(
'contacts/deleteContactThroughConversations',
data.id
);
this.fetchConversationStats();
};
}
export default {

View file

@ -0,0 +1,6 @@
export const downloadCsvFile = (fileName, fileContent) => {
const link = document.createElement('a');
link.download = fileName;
link.href = `data:text/csv;charset=utf-8,` + encodeURI(fileContent);
link.click();
};

View file

@ -16,6 +16,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
? 'ion-social-whatsapp-outline'
: 'ion-android-textsms';
case INBOX_TYPES.WHATSAPP:
return 'ion-social-whatsapp-outline';
case INBOX_TYPES.API:
return 'ion-cloud';

View file

@ -0,0 +1,21 @@
import { downloadCsvFile } from '../downloadCsvFile';
const fileName = 'test.csv';
const fileData = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
Pranav,36,114,28411`;
describe('#downloadCsvFile', () => {
it('should download the csv file', () => {
const link = {
click: jest.fn(),
};
jest.spyOn(document, 'createElement').mockImplementation(() => link);
downloadCsvFile(fileName, fileData);
expect(link.download).toEqual(fileName);
expect(link.href).toEqual(
`data:text/csv;charset=utf-8,${encodeURI(fileData)}`
);
expect(link.click).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,245 +1,13 @@
import { frontendURL } from '../helper/URLHelper';
import common from './sidebarItems/common';
import contacts from './sidebarItems/contacts';
import reports from './sidebarItems/reports';
import campaigns from './sidebarItems/campaigns';
import settings from './sidebarItems/settings';
export const getSidebarItems = accountId => ({
common: {
routes: [
'home',
'inbox_dashboard',
'inbox_conversation',
'conversation_through_inbox',
'notifications_dashboard',
'profile_settings',
'profile_settings_index',
'label_conversations',
'conversations_through_label',
'team_conversations',
'conversations_through_team',
'notifications_index',
],
menuItems: {
assignedToMe: {
icon: 'ion-chatbox-working',
label: 'CONVERSATIONS',
hasSubMenu: false,
key: '',
toState: frontendURL(`accounts/${accountId}/dashboard`),
toolTip: 'Conversation from all subscribed inboxes',
toStateName: 'home',
},
contacts: {
icon: 'ion-person',
label: 'CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
},
notifications: {
icon: 'ion-ios-bell',
label: 'NOTIFICATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/notifications`),
toStateName: 'notifications_dashboard',
},
report: {
icon: 'ion-arrow-graph-up-right',
label: 'REPORTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'settings_account_reports',
},
campaigns: {
icon: 'ion-speakerphone',
label: 'CAMPAIGNS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'settings_account_campaigns',
},
settings: {
icon: 'ion-settings',
label: 'SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings`),
toStateName: 'settings_home',
},
},
},
contacts: {
routes: [
'contacts_dashboard',
'contacts_dashboard_manage',
'contacts_labels_dashboard',
],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
contacts: {
icon: 'ion-person',
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
},
},
},
reports: {
routes: ['settings_account_reports', 'csat_reports'],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
reportOverview: {
icon: 'ion-arrow-graph-up-right',
label: 'REPORTS_OVERVIEW',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`),
toStateName: 'settings_account_reports',
},
csatReports: {
icon: 'ion-happy',
label: 'CSAT',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/csat`),
toStateName: 'csat_reports',
},
},
},
campaigns: {
routes: ['settings_account_campaigns', 'one_off'],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
ongoingCampaigns: {
icon: 'ion-arrow-swap',
label: 'ONGOING',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/ongoing`),
toStateName: 'settings_account_campaigns',
},
onOffCampaigns: {
icon: 'ion-radio-waves',
label: 'ONE_OFF',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/one_off`),
toStateName: 'one_off',
},
},
},
settings: {
routes: [
'agent_list',
'canned_list',
'labels_list',
'settings_inbox',
'attributes_list',
'settings_inbox_new',
'settings_inbox_list',
'settings_inbox_show',
'settings_inboxes_page_channel',
'settings_inboxes_add_agents',
'settings_inbox_finish',
'settings_integrations',
'settings_integrations_webhook',
'settings_integrations_integration',
'settings_applications',
'settings_applications_webhook',
'settings_applications_integration',
'general_settings',
'general_settings_index',
'settings_teams_list',
'settings_teams_new',
'settings_teams_add_agents',
'settings_teams_finish',
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
agents: {
icon: 'ion-person-stalker',
label: 'AGENTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list',
},
teams: {
icon: 'ion-ios-people',
label: 'TEAMS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list',
},
inboxes: {
icon: 'ion-archive',
label: 'INBOXES',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
},
labels: {
icon: 'ion-pricetags',
label: 'LABELS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
},
attributes: {
icon: 'ion-code',
label: 'ATTRIBUTES',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/attributes/list`),
toStateName: 'attributes_list',
},
cannedResponses: {
icon: 'ion-chatbox-working',
label: 'CANNED_RESPONSES',
hasSubMenu: false,
toState: frontendURL(
`accounts/${accountId}/settings/canned-response/list`
),
toStateName: 'canned_list',
},
settings_integrations: {
icon: 'ion-flash',
label: 'INTEGRATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations',
},
settings_applications: {
icon: 'ion-asterisk',
label: 'APPLICATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
},
general_settings_index: {
icon: 'ion-gear-a',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
},
},
common: common(accountId),
contacts: contacts(accountId),
reports: reports(accountId),
campaigns: campaigns(accountId),
settings: settings(accountId),
});

View file

@ -54,6 +54,7 @@
"ERROR": "الوقت على الصفحة مطلوب"
},
"ENABLED": "تفعيل الحملة",
"TRIGGER_ONLY_BUSINESS_HOURS": "Trigger only during business hours",
"SUBMIT": "إضافة حملة"
},
"API": {

View file

@ -10,6 +10,7 @@
"SEARCH": {
"INPUT": "البحث عن جهات الاتصال، المحادثات، قوالب الردود الجاهزة .."
},
"FILTER_ALL": "الكل",
"STATUS_TABS": [
{
"NAME": "فتح",
@ -48,11 +49,11 @@
},
{
"TEXT": "معلق",
"VALUE": "pending"
"VALUE": "معلق"
},
{
"TEXT": "غفوة",
"VALUE": "snoozed"
"VALUE": "غفوة"
}
],
"ATTACHMENTS": {
@ -85,6 +86,8 @@
"VIEW_TWEET_IN_TWITTER": "عرض التغريدة في تويتر",
"REPLY_TO_TWEET": "الرد على هذه التغريدة",
"NO_MESSAGES": "لا توجد رسائل",
"NO_CONTENT": "لم يتم العثور على محتوى"
"NO_CONTENT": "لم يتم العثور على محتوى",
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text"
}
}

View file

@ -32,6 +32,8 @@
"NO_RESULT": "لم يتم العثور على تصنيفات"
}
},
"MERGE_CONTACT": "Merge contact",
"CONTACT_ACTIONS": "Contact actions",
"MUTE_CONTACT": "كتم المحادثة",
"UNMUTE_CONTACT": "إلغاء كتم المحادثة",
"MUTED_SUCCESS": "تم كتم هذه المحادثة لمدة 6 ساعات",
@ -54,6 +56,35 @@
"TITLE": "إنشاء جهة اتصال جديدة",
"DESC": "إضافة معلومات أساسية حول جهة الاتصال."
},
"IMPORT_CONTACTS": {
"BUTTON_LABEL": "Import",
"TITLE": "Import Contacts",
"DESC": "Import contacts through a CSV file.",
"DOWNLOAD_LABEL": "Download a sample csv.",
"FORM": {
"LABEL": "CSV File",
"SUBMIT": "Import",
"CANCEL": "إلغاء"
},
"SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى"
},
"DELETE_CONTACT": {
"BUTTON_LABEL": "Delete Contact",
"TITLE": "Delete contact",
"DESC": "Delete contact details",
"CONFIRM": {
"TITLE": "تأكيد الحذف",
"MESSAGE": "هل أنت متأكد من الحذف ",
"PLACE_HOLDER": "Please type {contactName} to confirm",
"YES": "نعم، احذف ",
"NO": "لا، احتفظ "
},
"API": {
"SUCCESS_MESSAGE": "Contact deleted successfully",
"ERROR_MESSAGE": "Could not delete contact. Please try again later."
}
},
"CONTACT_FORM": {
"FORM": {
"SUBMIT": "إرسال",
@ -213,17 +244,19 @@
},
"MERGE_CONTACTS": {
"TITLE": "دمج جهة الاتصال",
"DESCRIPTION": "دمج جهة الاتصال مفيد عندما يكون لديك مدخلات مكررة لنفس جهة الاتصال. عملية الدمج تأخذ جهة اتصال رئيسية وتدمجها بجهة الاتصال المكررة. بعد الدمج، ستبقى جميع التفاصيل في جهة الاتصال الرئيسية كما هي. إذا لم يكن لدى جهة الاتصال الرئيسية حقل ، فسيتم استخدام القيمة من جهة الاتصال المكررة بعد الدمج. إذا حدث تضارب بالبيانات، ستبقى الحقول في جهة الاتصال الأساسية غير متأثرة، ولكن الحقول من جهة الاتصال الثانوية سيتم نسخها إلى السمات المخصصة في جهة الاتصال الرئيسية.",
"DESCRIPTION": "Merge contacts to combine two profiles into one, including all attributes and conversations. In case of conflict, the Primary contact s attributes will take precedence.",
"PRIMARY": {
"TITLE": "جهة الاتصال الرئيسية"
"TITLE": "جهة الاتصال الرئيسية",
"HELP_LABEL": "To be kept"
},
"CHILD": {
"TITLE": "دمج جهة الإتصال",
"PLACEHOLDER": "اختر جهة اتصال"
"PLACEHOLDER": "Search for a contact",
"HELP_LABEL": "To be deleted"
},
"SUMMARY": {
"TITLE": "ملخص",
"DELETE_WARNING": "الاتصال بـ <strong>%{childContactName}</strong>سيتم حذفه.",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong> will be deleted.",
"ATTRIBUTE_WARNING": "سيتم نسخ تفاصيل الاتصال بـ <strong>%{childContactName}</strong> إلى <strong>%{primaryContactName}</strong>."
},
"SEARCH": {
@ -236,7 +269,7 @@
"ERROR": "حدد جهة اتصال فرعية للدمج"
},
"SUCCESS_MESSAGE": "تم دمج جهة الاتصال بنجاح",
"ERROR_MESSAGE": "تعذر دمج جهات الاتصال ، حاول مرة أخرى!"
"ERROR_MESSAGE": "Could not merge contacts, try again!"
}
}
}

View file

@ -39,7 +39,10 @@
"OPEN_ACTION": "فتح",
"OPEN": "المزيد",
"CLOSE": "أغلق",
"DETAILS": "التفاصيل"
"DETAILS": "التفاصيل",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply"
},
"RESOLVE_DROPDOWN": {
"MARK_PENDING": "تحديد كمعلق",
@ -84,6 +87,7 @@
"CHANGE_AGENT": "تم تغيير الموظف الذي تم إحالة المحادثة إليه",
"CHANGE_TEAM": "تم تغيير فريق المحادثة",
"FILE_SIZE_LIMIT": "حجم الملف يتجاوز حد الاقصى وهو {MAXIMUM_FILE_UPLOAD_SIZE}",
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "أرسلت بواسطة:",
"ASSIGNMENT": {
"SELECT_AGENT": "اختر وكيل",

View file

@ -71,5 +71,13 @@
"assigned_conversation_new_message": "رسالة جديدة",
"conversation_mention": "إشارة"
}
},
"NETWORK": {
"NOTIFICATION": {
"TEXT": "Disconnected from Chatwoot"
},
"BUTTON": {
"REFRESH": "Refresh"
}
}
}

View file

@ -56,6 +56,11 @@
"CHANNEL_AVATAR": {
"LABEL": "الصورة الرمزية للقناة"
},
"CHANNEL_WEBHOOK_URL": {
"LABEL": "رابط Webhook",
"PLACEHOLDER": "Enter your Webhook URL",
"ERROR": "الرجاء إدخال عنوان URL صالح"
},
"CHANNEL_DOMAIN": {
"LABEL": "نطاق الموقع",
"PLACEHOLDER": "أدخل نطاق موقعك الإلكتروني (مثال: acme.com)"
@ -92,8 +97,8 @@
"SUBMIT_BUTTON": "إنشاء قناة تواصل"
},
"TWILIO": {
"TITLE": "قناة Twilio SMS/WhatsApp",
"DESC": "قم بإضافة قناة Twilio لتمكن عملائك من التواصل معك عبر الرسائل القصيرة SMS أو عبر واتساب.",
"TITLE": "Twilio SMS/WhatsApp Channel",
"DESC": "Integrate Twilio and start supporting your customers via SMS or WhatsApp.",
"ACCOUNT_SID": {
"LABEL": "معرف حساب Twilio (يعرف أيضاً بـ Account SID)",
"PLACEHOLDER": "الرجاء إدخال معرف حساب Twilio الخاص بك (يعرف أيضاً بـ Account SID)",
@ -109,8 +114,8 @@
"ERROR": "هذا الحقل مطلوب"
},
"CHANNEL_NAME": {
"LABEL": "اسم القناة",
"PLACEHOLDER": "الرجاء إدخال اسم القناة",
"LABEL": "اسم صندوق الوارد لقناة التواصل",
"PLACEHOLDER": "Please enter a inbox name",
"ERROR": "هذا الحقل مطلوب"
},
"PHONE_NUMBER": {
@ -132,8 +137,34 @@
"DESC": "ابدأ في دعم عملائك عبر الرسائل القصيرة بإستخدام Twilio."
},
"WHATSAPP": {
"TITLE": "قناة Whatsapp عبر Twilio",
"DESC": "ابدأ في دعم عملائك عبر الواتساب بإستخدام Twilio."
"TITLE": "WhatsApp Channel",
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
"LABEL": "API Provider",
"TWILIO": "Twilio",
"360_DIALOG": "360Dialog"
},
"INBOX_NAME": {
"LABEL": "اسم صندوق الوارد لقناة التواصل",
"PLACEHOLDER": "Please enter an inbox name",
"ERROR": "هذا الحقل مطلوب"
},
"PHONE_NUMBER": {
"LABEL": "رقم الهاتف",
"PLACEHOLDER": "الرجاء إدخال رقم الهاتف الذي سيتم إرسال الرسائل منه.",
"ERROR": "الرجاء إدخال قيمة صحيحة. يجب أن يبدأ رقم الهاتف بعلامة `+`."
},
"API_KEY": {
"LABEL": "API key",
"SUBTITLE": "Configure the WhatsApp API key.",
"PLACEHOLDER": "API key",
"APPLY_FOR_ACCESS": "Don't have any API key? Apply for access here",
"ERROR": "Please enter a valid value."
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"API_CHANNEL": {
"TITLE": "قناة API",
@ -195,6 +226,10 @@
"SUBMIT_BUTTON": "Create LINE Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the LINE channel"
},
"API_CALLBACK": {
"TITLE": "عنوان Callback URL",
"SUBTITLE": "You have to configure the webhook URL in LINE application with the URL mentioned here."
}
},
"TELEGRAM_CHANNEL": {
@ -212,7 +247,7 @@
},
"AUTH": {
"TITLE": "اختر قناة",
"DESC": "شاتوت يدعم أداة الدردشة المباشرة، صفحة الفيسبوك، ملف تويتر الشخصي، واتسب، البريد الإلكتروني وما إلى ذلك، كقنوات. إذا كنت ترغب في إنشاء قناة مخصصة، يمكنك إنشاءها باستخدام قناة API. حدد قناة واحدة من الخيارات أدناه للمتابعة."
"DESC": "Chatwoot supports live-chat widget, Facebook page, Twitter profile, WhatsApp, Email etc., as channels. If you want to build a custom channel, you can create it using the API channel. Select one channel from the options below to proceed."
},
"AGENTS": {
"TITLE": "موظف الدعم",
@ -266,6 +301,9 @@
"ENABLE_CSAT": {
"ENABLED": "مفعل",
"DISABLED": "معطّل"
},
"ENABLE_HMAC": {
"LABEL": "Enable"
}
},
"DELETE": {
@ -315,6 +353,8 @@
"AUTO_ASSIGNMENT_SUB_TEXT": "تمكين أو تعطيل الإسناد التلقائي للمحادثات الجديدة إلى الموظفين المضافين إلى قناة التواصل هذه.",
"HMAC_VERIFICATION": "التحقق من هوية المستخدم",
"HMAC_DESCRIPTION": "Inorder to validate the user's identity, the SDK allows you to pass an `identifier_hash` for each user. You can generate HMAC using 'sha256' with the key shown here.",
"HMAC_MANDATORY_VERIFICATION": "Enforce User Identity Validation",
"HMAC_MANDATORY_DESCRIPTION": "If enabled, Chatwoot SDKs setUser method will not work unless the `identifier_hash` is provided for each user.",
"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",
@ -350,7 +390,7 @@
"TIMEZONE_LABEL": "اختر المنطقة الزمنية",
"UPDATE": "تحديث إعدادات ساعات العمل",
"TOGGLE_AVAILABILITY": "تمكين توافر العمل لهذا البريد الوارد",
"UNAVAILABLE_MESSAGE_LABEL": "رسالة غير متاح للزائرين",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "نحن غير متوفرين في هذه اللحظة. اترك رسالة سنرد عليها بمجرد عودتنا.",
"TOGGLE_HELP": "تمكين توفر العمل سيظهر الساعات المتاحة على أداة الدردشة المباشرة حتى لو كان جميع الوكلاء غير متصلين بالإنترنت. خارج الساعات المتاحة يمكن تحذير الزوار برسالة ونموذج ما قبل الدردشة.",
"DAY": {

View file

@ -61,6 +61,258 @@
"PLACEHOLDER": "اختر نطاق المدة"
}
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",
"LOADING_CHART": "تحميل بيانات الرسم البياني...",
"NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.",
"DOWNLOAD_AGENT_REPORTS": "تنزيل تقارير الوكيل",
"FILTER_DROPDOWN_LABEL": "اختر وكيل",
"METRICS": {
"CONVERSATIONS": {
"NAME": "المحادثات",
"DESC": "(الإجمالي)"
},
"INCOMING_MESSAGES": {
"NAME": "الرسائل الواردة",
"DESC": "(الإجمالي)"
},
"OUTGOING_MESSAGES": {
"NAME": "الرسائل الصادرة",
"DESC": "(الإجمالي)"
},
"FIRST_RESPONSE_TIME": {
"NAME": "وقت الاستجابة الأولى",
"DESC": "(متوسط)"
},
"RESOLUTION_TIME": {
"NAME": "وقت إغلاق المحادثات",
"DESC": "(متوسط)"
},
"RESOLUTION_COUNT": {
"NAME": "عدد مرات الإغلاق",
"DESC": "(الإجمالي)"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "آخر 7 أيام"
},
{
"id": 1,
"name": "آخر 30 يوماً"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "العام الماضي"
},
{
"id": 5,
"name": "تحديد نطاق المدة"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "تطبيق",
"PLACEHOLDER": "اختر نطاق المدة"
}
},
"LABEL_REPORTS": {
"HEADER": "Labels Overview",
"LOADING_CHART": "تحميل بيانات الرسم البياني...",
"NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
"FILTER_DROPDOWN_LABEL": "Select Label",
"METRICS": {
"CONVERSATIONS": {
"NAME": "المحادثات",
"DESC": "(الإجمالي)"
},
"INCOMING_MESSAGES": {
"NAME": "الرسائل الواردة",
"DESC": "(الإجمالي)"
},
"OUTGOING_MESSAGES": {
"NAME": "الرسائل الصادرة",
"DESC": "(الإجمالي)"
},
"FIRST_RESPONSE_TIME": {
"NAME": "وقت الاستجابة الأولى",
"DESC": "(متوسط)"
},
"RESOLUTION_TIME": {
"NAME": "وقت إغلاق المحادثات",
"DESC": "(متوسط)"
},
"RESOLUTION_COUNT": {
"NAME": "عدد مرات الإغلاق",
"DESC": "(الإجمالي)"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "آخر 7 أيام"
},
{
"id": 1,
"name": "آخر 30 يوماً"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "العام الماضي"
},
{
"id": 5,
"name": "تحديد نطاق المدة"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "تطبيق",
"PLACEHOLDER": "اختر نطاق المدة"
}
},
"INBOX_REPORTS": {
"HEADER": "Inbox Overview",
"LOADING_CHART": "تحميل بيانات الرسم البياني...",
"NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
"FILTER_DROPDOWN_LABEL": "اختر صندوق الوارد",
"METRICS": {
"CONVERSATIONS": {
"NAME": "المحادثات",
"DESC": "(الإجمالي)"
},
"INCOMING_MESSAGES": {
"NAME": "الرسائل الواردة",
"DESC": "(الإجمالي)"
},
"OUTGOING_MESSAGES": {
"NAME": "الرسائل الصادرة",
"DESC": "(الإجمالي)"
},
"FIRST_RESPONSE_TIME": {
"NAME": "وقت الاستجابة الأولى",
"DESC": "(متوسط)"
},
"RESOLUTION_TIME": {
"NAME": "وقت إغلاق المحادثات",
"DESC": "(متوسط)"
},
"RESOLUTION_COUNT": {
"NAME": "عدد مرات الإغلاق",
"DESC": "(الإجمالي)"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "آخر 7 أيام"
},
{
"id": 1,
"name": "آخر 30 يوماً"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "العام الماضي"
},
{
"id": 5,
"name": "تحديد نطاق المدة"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "تطبيق",
"PLACEHOLDER": "اختر نطاق المدة"
}
},
"TEAM_REPORTS": {
"HEADER": "Team Overview",
"LOADING_CHART": "تحميل بيانات الرسم البياني...",
"NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.",
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
"FILTER_DROPDOWN_LABEL": "Select Team",
"METRICS": {
"CONVERSATIONS": {
"NAME": "المحادثات",
"DESC": "(الإجمالي)"
},
"INCOMING_MESSAGES": {
"NAME": "الرسائل الواردة",
"DESC": "(الإجمالي)"
},
"OUTGOING_MESSAGES": {
"NAME": "الرسائل الصادرة",
"DESC": "(الإجمالي)"
},
"FIRST_RESPONSE_TIME": {
"NAME": "وقت الاستجابة الأولى",
"DESC": "(متوسط)"
},
"RESOLUTION_TIME": {
"NAME": "وقت إغلاق المحادثات",
"DESC": "(متوسط)"
},
"RESOLUTION_COUNT": {
"NAME": "عدد مرات الإغلاق",
"DESC": "(الإجمالي)"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "آخر 7 أيام"
},
{
"id": 1,
"name": "آخر 30 يوماً"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "العام الماضي"
},
{
"id": 5,
"name": "تحديد نطاق المدة"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "تطبيق",
"PLACEHOLDER": "اختر نطاق المدة"
}
},
"CSAT_REPORTS": {
"HEADER": "تقارير CSAT",
"NO_RECORDS": "لا توجد ردود متوفرة على الدراسة الاستقصائية CSAT.",
@ -87,4 +339,4 @@
}
}
}
}
}

View file

@ -150,7 +150,11 @@
"CSAT": "CSAT",
"CAMPAIGNS": "الحملات",
"ONGOING": "جارية",
"ONE_OFF": "إيقاف واحد"
"ONE_OFF": "إيقاف واحد",
"REPORTS_AGENT": "موظف الدعم",
"REPORTS_LABEL": "الوسوم",
"REPORTS_INBOX": "صندوق الوارد",
"REPORTS_TEAM": "Team"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "أوه! لم نتمكن من العثور على الحساب. الرجاء إنشاء حساب جديد للمتابعة.",

View file

@ -54,6 +54,7 @@
"ERROR": "Time on page is required"
},
"ENABLED": "Enable campaign",
"TRIGGER_ONLY_BUSINESS_HOURS": "Trigger only during business hours",
"SUBMIT": "Add Campaign"
},
"API": {

View file

@ -10,6 +10,7 @@
"SEARCH": {
"INPUT": "Cerca persones, xats, respostes desades .."
},
"FILTER_ALL": "Totes",
"STATUS_TABS": [
{
"NAME": "Obrir",
@ -85,6 +86,8 @@
"VIEW_TWEET_IN_TWITTER": "Veure el tuit a Twitter",
"REPLY_TO_TWEET": "Respon a aquest tuit",
"NO_MESSAGES": "Cap Missatge",
"NO_CONTENT": "No content available"
"NO_CONTENT": "No content available",
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text"
}
}

View file

@ -32,6 +32,8 @@
"NO_RESULT": "No labels found"
}
},
"MERGE_CONTACT": "Merge contact",
"CONTACT_ACTIONS": "Contact actions",
"MUTE_CONTACT": "Silencia la conversa",
"UNMUTE_CONTACT": "Desactiva el silenci de la conversa",
"MUTED_SUCCESS": "Aquesta conversa s'ha silenciat durant 6 hores",
@ -54,6 +56,35 @@
"TITLE": "Crear un nou contacte",
"DESC": "Afegir informació bàsica sobre el contacte."
},
"IMPORT_CONTACTS": {
"BUTTON_LABEL": "Import",
"TITLE": "Import Contacts",
"DESC": "Import contacts through a CSV file.",
"DOWNLOAD_LABEL": "Download a sample csv.",
"FORM": {
"LABEL": "CSV File",
"SUBMIT": "Import",
"CANCEL": "Cancel·la"
},
"SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "S'ha produït un error; tornau-ho a provar"
},
"DELETE_CONTACT": {
"BUTTON_LABEL": "Delete Contact",
"TITLE": "Delete contact",
"DESC": "Delete contact details",
"CONFIRM": {
"TITLE": "Confirma l'esborrat",
"MESSAGE": "N'estas segur? ",
"PLACE_HOLDER": "Please type {contactName} to confirm",
"YES": "Si, esborra ",
"NO": "No, segueix "
},
"API": {
"SUCCESS_MESSAGE": "Contact deleted successfully",
"ERROR_MESSAGE": "Could not delete contact. Please try again later."
}
},
"CONTACT_FORM": {
"FORM": {
"SUBMIT": "Envia",
@ -213,17 +244,19 @@
},
"MERGE_CONTACTS": {
"TITLE": "Merge contacts",
"DESCRIPTION": "Merge contact is helpful when you have duplicated entries of the same contact. Merging action takes a primary contact and a child contact. After merging, all details in the primary contact will remain the same. If the primary contact doesn't have a field, then the value from the child contact will be used after merging. If a conflict happens, fields in primary contact will remain unaffected, but fields from secondary will be copied to the custom attributes in the primary contact.",
"DESCRIPTION": "Merge contacts to combine two profiles into one, including all attributes and conversations. In case of conflict, the Primary contact s attributes will take precedence.",
"PRIMARY": {
"TITLE": "Primary contact"
"TITLE": "Primary contact",
"HELP_LABEL": "To be kept"
},
"CHILD": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Choose a contact"
"PLACEHOLDER": "Search for a contact",
"HELP_LABEL": "To be deleted"
},
"SUMMARY": {
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong>will be deleted.",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong> will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{childContactName}</strong> will be copied to <strong>%{primaryContactName}</strong>."
},
"SEARCH": {
@ -236,7 +269,7 @@
"ERROR": "Select a child contact to merge"
},
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contcts, try again!"
"ERROR_MESSAGE": "Could not merge contacts, try again!"
}
}
}

View file

@ -39,7 +39,10 @@
"OPEN_ACTION": "Obrir",
"OPEN": "Més",
"CLOSE": "Tanca",
"DETAILS": "detalls"
"DETAILS": "detalls",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply"
},
"RESOLVE_DROPDOWN": {
"MARK_PENDING": "Mark as pending",
@ -84,6 +87,7 @@
"CHANGE_AGENT": "Assignació de la conversa canviat",
"CHANGE_TEAM": "Conversation team changed",
"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": "Enviat per:",
"ASSIGNMENT": {
"SELECT_AGENT": "Seleccionar Agent",

View file

@ -71,5 +71,13 @@
"assigned_conversation_new_message": "Missatge Nou",
"conversation_mention": "Menció"
}
},
"NETWORK": {
"NOTIFICATION": {
"TEXT": "Disconnected from Chatwoot"
},
"BUTTON": {
"REFRESH": "Refresh"
}
}
}

View file

@ -56,6 +56,11 @@
"CHANNEL_AVATAR": {
"LABEL": "Avatar del canal"
},
"CHANNEL_WEBHOOK_URL": {
"LABEL": "URL del webhook",
"PLACEHOLDER": "Enter your Webhook URL",
"ERROR": "Introduïu una URL vàlid"
},
"CHANNEL_DOMAIN": {
"LABEL": "Domini del lloc web",
"PLACEHOLDER": "Introduïu el vostre domini de lloc web (pe: acme.com)"
@ -92,8 +97,8 @@
"SUBMIT_BUTTON": "Crea la safata entrada"
},
"TWILIO": {
"TITLE": "Canal Twilio SMS",
"DESC": "Integra Twilio i comença a donar suport als teus clients mitjançant SMS.",
"TITLE": "Twilio SMS/WhatsApp Channel",
"DESC": "Integrate Twilio and start supporting your customers via SMS or WhatsApp.",
"ACCOUNT_SID": {
"LABEL": "Compte SID",
"PLACEHOLDER": "Introduïu el vostre compte Twilio SID",
@ -109,8 +114,8 @@
"ERROR": "Aquest camp és obligatori"
},
"CHANNEL_NAME": {
"LABEL": "Nom del canal",
"PLACEHOLDER": "Introduïu el nom del canal",
"LABEL": "Nom de la safata d'entrada",
"PLACEHOLDER": "Please enter a inbox name",
"ERROR": "Aquest camp és obligatori"
},
"PHONE_NUMBER": {
@ -132,8 +137,34 @@
"DESC": "Start supporting your customers via SMS with Twilio integration."
},
"WHATSAPP": {
"TITLE": "Whatsapp Channel via Twilio",
"DESC": "Start supporting your customers via Whatsapp with Twilio integration."
"TITLE": "WhatsApp Channel",
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
"LABEL": "API Provider",
"TWILIO": "Twilio",
"360_DIALOG": "360Dialog"
},
"INBOX_NAME": {
"LABEL": "Nom de la safata d'entrada",
"PLACEHOLDER": "Please enter an inbox name",
"ERROR": "Aquest camp és obligatori"
},
"PHONE_NUMBER": {
"LABEL": "Número de telèfon",
"PLACEHOLDER": "Introduïu el número de telèfon des del qual serà enviat el missatge.",
"ERROR": "Introduïu un valor vàlid. El número de telèfon hauria de començar amb el signe `+`."
},
"API_KEY": {
"LABEL": "API key",
"SUBTITLE": "Configure the WhatsApp API key.",
"PLACEHOLDER": "API key",
"APPLY_FOR_ACCESS": "Don't have any API key? Apply for access here",
"ERROR": "Please enter a valid value."
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"API_CHANNEL": {
"TITLE": "Canal de l'API",
@ -195,6 +226,10 @@
"SUBMIT_BUTTON": "Create LINE Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the LINE channel"
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "You have to configure the webhook URL in LINE application with the URL mentioned here."
}
},
"TELEGRAM_CHANNEL": {
@ -212,7 +247,7 @@
},
"AUTH": {
"TITLE": "Choose a channel",
"DESC": "Chatwoot supports live-chat widget, Facebook page, Twitter profile, Whatsapp, Email etc., as channels. If you want to build a custom channel, you can create it using the API channel. Select one channel from the options below to proceed."
"DESC": "Chatwoot supports live-chat widget, Facebook page, Twitter profile, WhatsApp, Email etc., as channels. If you want to build a custom channel, you can create it using the API channel. Select one channel from the options below to proceed."
},
"AGENTS": {
"TITLE": "Agents",
@ -266,6 +301,9 @@
"ENABLE_CSAT": {
"ENABLED": "Habilita",
"DISABLED": "Inhabilita"
},
"ENABLE_HMAC": {
"LABEL": "Enable"
}
},
"DELETE": {
@ -315,6 +353,8 @@
"AUTO_ASSIGNMENT_SUB_TEXT": "Activa o desactiva l'assignació automàtica d'agents disponibles a les noves converses",
"HMAC_VERIFICATION": "Validació de la Identitat del Usuari",
"HMAC_DESCRIPTION": "Inorder to validate the user's identity, the SDK allows you to pass an `identifier_hash` for each user. You can generate HMAC using 'sha256' with the key shown here.",
"HMAC_MANDATORY_VERIFICATION": "Enforce User Identity Validation",
"HMAC_MANDATORY_DESCRIPTION": "If enabled, Chatwoot SDKs setUser method will not work unless the `identifier_hash` is provided for each user.",
"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",
@ -350,7 +390,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -61,6 +61,258 @@
"PLACEHOLDER": "Select date range"
}
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",
"LOADING_CHART": "S'estan carregant dades del gràfic...",
"NO_ENOUGH_DATA": "No hem rebut suficients punts de dades per generar l'informe. Torneu-ho a provar més endavant.",
"DOWNLOAD_AGENT_REPORTS": "Descarregar Informes d'Agent",
"FILTER_DROPDOWN_LABEL": "Seleccionar Agent",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Converses",
"DESC": "( Total )"
},
"INCOMING_MESSAGES": {
"NAME": "Missatges d'entrada",
"DESC": "( Total )"
},
"OUTGOING_MESSAGES": {
"NAME": "Missatges de sortida",
"DESC": "( Total )"
},
"FIRST_RESPONSE_TIME": {
"NAME": "Primer temps de resposta",
"DESC": "( Promig )"
},
"RESOLUTION_TIME": {
"NAME": "Temps de resolució",
"DESC": "( Promig )"
},
"RESOLUTION_COUNT": {
"NAME": "Total de resolucions",
"DESC": "( Total )"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Últims 7 dies"
},
{
"id": 1,
"name": "Últims 30 dies"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"LABEL_REPORTS": {
"HEADER": "Labels Overview",
"LOADING_CHART": "S'estan carregant dades del gràfic...",
"NO_ENOUGH_DATA": "No hem rebut suficients punts de dades per generar l'informe. Torneu-ho a provar més endavant.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
"FILTER_DROPDOWN_LABEL": "Select Label",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Converses",
"DESC": "( Total )"
},
"INCOMING_MESSAGES": {
"NAME": "Missatges d'entrada",
"DESC": "( Total )"
},
"OUTGOING_MESSAGES": {
"NAME": "Missatges de sortida",
"DESC": "( Total )"
},
"FIRST_RESPONSE_TIME": {
"NAME": "Primer temps de resposta",
"DESC": "( Promig )"
},
"RESOLUTION_TIME": {
"NAME": "Temps de resolució",
"DESC": "( Promig )"
},
"RESOLUTION_COUNT": {
"NAME": "Total de resolucions",
"DESC": "( Total )"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Últims 7 dies"
},
{
"id": 1,
"name": "Últims 30 dies"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"INBOX_REPORTS": {
"HEADER": "Inbox Overview",
"LOADING_CHART": "S'estan carregant dades del gràfic...",
"NO_ENOUGH_DATA": "No hem rebut suficients punts de dades per generar l'informe. Torneu-ho a provar més endavant.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Converses",
"DESC": "( Total )"
},
"INCOMING_MESSAGES": {
"NAME": "Missatges d'entrada",
"DESC": "( Total )"
},
"OUTGOING_MESSAGES": {
"NAME": "Missatges de sortida",
"DESC": "( Total )"
},
"FIRST_RESPONSE_TIME": {
"NAME": "Primer temps de resposta",
"DESC": "( Promig )"
},
"RESOLUTION_TIME": {
"NAME": "Temps de resolució",
"DESC": "( Promig )"
},
"RESOLUTION_COUNT": {
"NAME": "Total de resolucions",
"DESC": "( Total )"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Últims 7 dies"
},
{
"id": 1,
"name": "Últims 30 dies"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"TEAM_REPORTS": {
"HEADER": "Team Overview",
"LOADING_CHART": "S'estan carregant dades del gràfic...",
"NO_ENOUGH_DATA": "No hem rebut suficients punts de dades per generar l'informe. Torneu-ho a provar més endavant.",
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
"FILTER_DROPDOWN_LABEL": "Select Team",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Converses",
"DESC": "( Total )"
},
"INCOMING_MESSAGES": {
"NAME": "Missatges d'entrada",
"DESC": "( Total )"
},
"OUTGOING_MESSAGES": {
"NAME": "Missatges de sortida",
"DESC": "( Total )"
},
"FIRST_RESPONSE_TIME": {
"NAME": "Primer temps de resposta",
"DESC": "( Promig )"
},
"RESOLUTION_TIME": {
"NAME": "Temps de resolució",
"DESC": "( Promig )"
},
"RESOLUTION_COUNT": {
"NAME": "Total de resolucions",
"DESC": "( Total )"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Últims 7 dies"
},
{
"id": 1,
"name": "Últims 30 dies"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
@ -87,4 +339,4 @@
}
}
}
}
}

View file

@ -150,7 +150,11 @@
"CSAT": "CSAT",
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
"ONE_OFF": "One off"
"ONE_OFF": "One off",
"REPORTS_AGENT": "Agents",
"REPORTS_LABEL": "Etiquetes",
"REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",

View file

@ -54,6 +54,7 @@
"ERROR": "Time on page is required"
},
"ENABLED": "Enable campaign",
"TRIGGER_ONLY_BUSINESS_HOURS": "Trigger only during business hours",
"SUBMIT": "Add Campaign"
},
"API": {

View file

@ -10,6 +10,7 @@
"SEARCH": {
"INPUT": "Hledat lidi, chaty, Uložené odpovědi .."
},
"FILTER_ALL": "Vše",
"STATUS_TABS": [
{
"NAME": "Otevřít",
@ -48,11 +49,11 @@
},
{
"TEXT": "Čekající",
"VALUE": "pending"
"VALUE": "čekající"
},
{
"TEXT": "Odložené",
"VALUE": "snoozed"
"VALUE": "odložené"
}
],
"ATTACHMENTS": {
@ -85,6 +86,8 @@
"VIEW_TWEET_IN_TWITTER": "Zobrazit tweet na Twitteru",
"REPLY_TO_TWEET": "Odpovědět na tento tweet",
"NO_MESSAGES": "Žádné zprávy",
"NO_CONTENT": "Žádný obsah k dispozici"
"NO_CONTENT": "Žádný obsah k dispozici",
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text"
}
}

View file

@ -32,6 +32,8 @@
"NO_RESULT": "No labels found"
}
},
"MERGE_CONTACT": "Merge contact",
"CONTACT_ACTIONS": "Contact actions",
"MUTE_CONTACT": "Ztlumit konverzaci",
"UNMUTE_CONTACT": "Zrušit ztlumení konverzace",
"MUTED_SUCCESS": "Tato konverzace je ztlumena na 6 hodin",
@ -54,6 +56,35 @@
"TITLE": "Vytvořit nový kontakt",
"DESC": "Přidat základní informace o kontaktu."
},
"IMPORT_CONTACTS": {
"BUTTON_LABEL": "Import",
"TITLE": "Import Contacts",
"DESC": "Import contacts through a CSV file.",
"DOWNLOAD_LABEL": "Download a sample csv.",
"FORM": {
"LABEL": "CSV File",
"SUBMIT": "Import",
"CANCEL": "Zrušit"
},
"SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "Došlo k chybě, zkuste to prosím znovu"
},
"DELETE_CONTACT": {
"BUTTON_LABEL": "Delete Contact",
"TITLE": "Delete contact",
"DESC": "Delete contact details",
"CONFIRM": {
"TITLE": "Potvrdit odstranění",
"MESSAGE": "Opravdu chcete odstranit ",
"PLACE_HOLDER": "Please type {contactName} to confirm",
"YES": "Ano, odstranit ",
"NO": "Ne, zachovat "
},
"API": {
"SUCCESS_MESSAGE": "Contact deleted successfully",
"ERROR_MESSAGE": "Could not delete contact. Please try again later."
}
},
"CONTACT_FORM": {
"FORM": {
"SUBMIT": "Odeslat",
@ -213,17 +244,19 @@
},
"MERGE_CONTACTS": {
"TITLE": "Merge contacts",
"DESCRIPTION": "Merge contact is helpful when you have duplicated entries of the same contact. Merging action takes a primary contact and a child contact. After merging, all details in the primary contact will remain the same. If the primary contact doesn't have a field, then the value from the child contact will be used after merging. If a conflict happens, fields in primary contact will remain unaffected, but fields from secondary will be copied to the custom attributes in the primary contact.",
"DESCRIPTION": "Merge contacts to combine two profiles into one, including all attributes and conversations. In case of conflict, the Primary contact s attributes will take precedence.",
"PRIMARY": {
"TITLE": "Primary contact"
"TITLE": "Primary contact",
"HELP_LABEL": "To be kept"
},
"CHILD": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Choose a contact"
"PLACEHOLDER": "Search for a contact",
"HELP_LABEL": "To be deleted"
},
"SUMMARY": {
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong>will be deleted.",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong> will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{childContactName}</strong> will be copied to <strong>%{primaryContactName}</strong>."
},
"SEARCH": {
@ -236,7 +269,7 @@
"ERROR": "Select a child contact to merge"
},
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contcts, try again!"
"ERROR_MESSAGE": "Could not merge contacts, try again!"
}
}
}

View file

@ -39,7 +39,10 @@
"OPEN_ACTION": "Otevřít",
"OPEN": "Více",
"CLOSE": "Zavřít",
"DETAILS": "Podrobnosti"
"DETAILS": "Podrobnosti",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply"
},
"RESOLVE_DROPDOWN": {
"MARK_PENDING": "Mark as pending",
@ -84,6 +87,7 @@
"CHANGE_AGENT": "Konverzace pověřená osoba změněna",
"CHANGE_TEAM": "Tým konverzace se změnil",
"FILE_SIZE_LIMIT": "Soubor překračuje limit {MAXIMUM_FILE_UPLOAD_SIZE} přílohy",
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Odeslal:",
"ASSIGNMENT": {
"SELECT_AGENT": "Vybrat agenta",

View file

@ -71,5 +71,13 @@
"assigned_conversation_new_message": "Nová zpráva",
"conversation_mention": "Zmínka"
}
},
"NETWORK": {
"NOTIFICATION": {
"TEXT": "Disconnected from Chatwoot"
},
"BUTTON": {
"REFRESH": "Refresh"
}
}
}

View file

@ -56,6 +56,11 @@
"CHANNEL_AVATAR": {
"LABEL": "Avatar kanálu"
},
"CHANNEL_WEBHOOK_URL": {
"LABEL": "URL webového háčku",
"PLACEHOLDER": "Enter your Webhook URL",
"ERROR": "Zadejte prosím platnou URL"
},
"CHANNEL_DOMAIN": {
"LABEL": "Doména webových stránek",
"PLACEHOLDER": "Zadejte doménu webu (např. acme.com)"
@ -92,8 +97,8 @@
"SUBMIT_BUTTON": "Vytvořit doručenou poštu"
},
"TWILIO": {
"TITLE": "Twilio SMS/Whatsapp Channel",
"DESC": "Integrate Twilio and start supporting your customers via SMS or Whatsapp.",
"TITLE": "Twilio SMS/WhatsApp Channel",
"DESC": "Integrate Twilio and start supporting your customers via SMS or WhatsApp.",
"ACCOUNT_SID": {
"LABEL": "SID účtu",
"PLACEHOLDER": "Zadejte SID vašeho Twilio účtu",
@ -109,8 +114,8 @@
"ERROR": "Toto pole je povinné"
},
"CHANNEL_NAME": {
"LABEL": "Název kanálu",
"PLACEHOLDER": "Zadejte název kanálu",
"LABEL": "Název schránky",
"PLACEHOLDER": "Please enter a inbox name",
"ERROR": "Toto pole je povinné"
},
"PHONE_NUMBER": {
@ -132,8 +137,34 @@
"DESC": "Start supporting your customers via SMS with Twilio integration."
},
"WHATSAPP": {
"TITLE": "Whatsapp Channel via Twilio",
"DESC": "Start supporting your customers via Whatsapp with Twilio integration."
"TITLE": "WhatsApp Channel",
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
"LABEL": "API Provider",
"TWILIO": "Twilio",
"360_DIALOG": "360Dialog"
},
"INBOX_NAME": {
"LABEL": "Název schránky",
"PLACEHOLDER": "Please enter an inbox name",
"ERROR": "Toto pole je povinné"
},
"PHONE_NUMBER": {
"LABEL": "Telefonní číslo",
"PLACEHOLDER": "Zadejte prosím telefonní číslo, ze kterého bude zpráva odeslána.",
"ERROR": "Zadejte platnou hodnotu. Telefonní číslo by mělo začínat znakem `+`."
},
"API_KEY": {
"LABEL": "API key",
"SUBTITLE": "Configure the WhatsApp API key.",
"PLACEHOLDER": "API key",
"APPLY_FOR_ACCESS": "Don't have any API key? Apply for access here",
"ERROR": "Please enter a valid value."
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
@ -195,6 +226,10 @@
"SUBMIT_BUTTON": "Create LINE Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the LINE channel"
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "You have to configure the webhook URL in LINE application with the URL mentioned here."
}
},
"TELEGRAM_CHANNEL": {
@ -212,7 +247,7 @@
},
"AUTH": {
"TITLE": "Choose a channel",
"DESC": "Chatwoot supports live-chat widget, Facebook page, Twitter profile, Whatsapp, Email etc., as channels. If you want to build a custom channel, you can create it using the API channel. Select one channel from the options below to proceed."
"DESC": "Chatwoot supports live-chat widget, Facebook page, Twitter profile, WhatsApp, Email etc., as channels. If you want to build a custom channel, you can create it using the API channel. Select one channel from the options below to proceed."
},
"AGENTS": {
"TITLE": "Agenti",
@ -266,6 +301,9 @@
"ENABLE_CSAT": {
"ENABLED": "Povoleno",
"DISABLED": "Zakázáno"
},
"ENABLE_HMAC": {
"LABEL": "Enable"
}
},
"DELETE": {
@ -315,6 +353,8 @@
"AUTO_ASSIGNMENT_SUB_TEXT": "Povolit nebo zakázat automatické přiřazování nových konverzací agentům přidaným do této schránky.",
"HMAC_VERIFICATION": "User Identity Validation",
"HMAC_DESCRIPTION": "Inorder to validate the user's identity, the SDK allows you to pass an `identifier_hash` for each user. You can generate HMAC using 'sha256' with the key shown here.",
"HMAC_MANDATORY_VERIFICATION": "Enforce User Identity Validation",
"HMAC_MANDATORY_DESCRIPTION": "If enabled, Chatwoot SDKs setUser method will not work unless the `identifier_hash` is provided for each user.",
"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",
@ -350,7 +390,7 @@
"TIMEZONE_LABEL": "Vyberte časové pásmo",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -61,6 +61,258 @@
"PLACEHOLDER": "Select date range"
}
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",
"LOADING_CHART": "Načítání dat mapy...",
"NO_ENOUGH_DATA": "Pro vytvoření hlášení jsme neobdrželi dostatek dat, zkuste to prosím později.",
"DOWNLOAD_AGENT_REPORTS": "Stáhnout reporty agentů",
"FILTER_DROPDOWN_LABEL": "Vybrat agenta",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Konverzace",
"DESC": "( celkem)"
},
"INCOMING_MESSAGES": {
"NAME": "Příchozí zprávy",
"DESC": "( celkem)"
},
"OUTGOING_MESSAGES": {
"NAME": "Odchozí zprávy",
"DESC": "( celkem)"
},
"FIRST_RESPONSE_TIME": {
"NAME": "Čas první odpovědi",
"DESC": "(Průměrný)"
},
"RESOLUTION_TIME": {
"NAME": "Čas rozlišení",
"DESC": "(Průměrný)"
},
"RESOLUTION_COUNT": {
"NAME": "Počet rozlišení",
"DESC": "( celkem)"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Posledních 7 dní"
},
{
"id": 1,
"name": "Posledních 30 dní"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"LABEL_REPORTS": {
"HEADER": "Labels Overview",
"LOADING_CHART": "Načítání dat mapy...",
"NO_ENOUGH_DATA": "Pro vytvoření hlášení jsme neobdrželi dostatek dat, zkuste to prosím později.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
"FILTER_DROPDOWN_LABEL": "Select Label",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Konverzace",
"DESC": "( celkem)"
},
"INCOMING_MESSAGES": {
"NAME": "Příchozí zprávy",
"DESC": "( celkem)"
},
"OUTGOING_MESSAGES": {
"NAME": "Odchozí zprávy",
"DESC": "( celkem)"
},
"FIRST_RESPONSE_TIME": {
"NAME": "Čas první odpovědi",
"DESC": "(Průměrný)"
},
"RESOLUTION_TIME": {
"NAME": "Čas rozlišení",
"DESC": "(Průměrný)"
},
"RESOLUTION_COUNT": {
"NAME": "Počet rozlišení",
"DESC": "( celkem)"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Posledních 7 dní"
},
{
"id": 1,
"name": "Posledních 30 dní"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"INBOX_REPORTS": {
"HEADER": "Inbox Overview",
"LOADING_CHART": "Načítání dat mapy...",
"NO_ENOUGH_DATA": "Pro vytvoření hlášení jsme neobdrželi dostatek dat, zkuste to prosím později.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Konverzace",
"DESC": "( celkem)"
},
"INCOMING_MESSAGES": {
"NAME": "Příchozí zprávy",
"DESC": "( celkem)"
},
"OUTGOING_MESSAGES": {
"NAME": "Odchozí zprávy",
"DESC": "( celkem)"
},
"FIRST_RESPONSE_TIME": {
"NAME": "Čas první odpovědi",
"DESC": "(Průměrný)"
},
"RESOLUTION_TIME": {
"NAME": "Čas rozlišení",
"DESC": "(Průměrný)"
},
"RESOLUTION_COUNT": {
"NAME": "Počet rozlišení",
"DESC": "( celkem)"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Posledních 7 dní"
},
{
"id": 1,
"name": "Posledních 30 dní"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"TEAM_REPORTS": {
"HEADER": "Team Overview",
"LOADING_CHART": "Načítání dat mapy...",
"NO_ENOUGH_DATA": "Pro vytvoření hlášení jsme neobdrželi dostatek dat, zkuste to prosím později.",
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
"FILTER_DROPDOWN_LABEL": "Select Team",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Konverzace",
"DESC": "( celkem)"
},
"INCOMING_MESSAGES": {
"NAME": "Příchozí zprávy",
"DESC": "( celkem)"
},
"OUTGOING_MESSAGES": {
"NAME": "Odchozí zprávy",
"DESC": "( celkem)"
},
"FIRST_RESPONSE_TIME": {
"NAME": "Čas první odpovědi",
"DESC": "(Průměrný)"
},
"RESOLUTION_TIME": {
"NAME": "Čas rozlišení",
"DESC": "(Průměrný)"
},
"RESOLUTION_COUNT": {
"NAME": "Počet rozlišení",
"DESC": "( celkem)"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Posledních 7 dní"
},
{
"id": 1,
"name": "Posledních 30 dní"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
@ -87,4 +339,4 @@
}
}
}
}
}

View file

@ -150,7 +150,11 @@
"CSAT": "CSAT",
"CAMPAIGNS": "Kampaně",
"ONGOING": "Ongoing",
"ONE_OFF": "One off"
"ONE_OFF": "One off",
"REPORTS_AGENT": "Agenti",
"REPORTS_LABEL": "Štítky",
"REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",

View file

@ -54,6 +54,7 @@
"ERROR": "Time on page is required"
},
"ENABLED": "Enable campaign",
"TRIGGER_ONLY_BUSINESS_HOURS": "Trigger only during business hours",
"SUBMIT": "Add Campaign"
},
"API": {

View file

@ -10,6 +10,7 @@
"SEARCH": {
"INPUT": "Søg efter Mennesker, Chats, Gemte svar .."
},
"FILTER_ALL": "Alle",
"STATUS_TABS": [
{
"NAME": "Åbn",
@ -85,6 +86,8 @@
"VIEW_TWEET_IN_TWITTER": "Se tweet på Twitter",
"REPLY_TO_TWEET": "Svar på dette tweet",
"NO_MESSAGES": "No Messages",
"NO_CONTENT": "No content available"
"NO_CONTENT": "No content available",
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text"
}
}

View file

@ -32,6 +32,8 @@
"NO_RESULT": "No labels found"
}
},
"MERGE_CONTACT": "Merge contact",
"CONTACT_ACTIONS": "Contact actions",
"MUTE_CONTACT": "Gør Samtale Lydløs",
"UNMUTE_CONTACT": "Fjern Lydløs",
"MUTED_SUCCESS": "Denne samtale er gjort tavs i 6 timer",
@ -54,6 +56,35 @@
"TITLE": "Create new contact",
"DESC": "Add basic information details about the contact."
},
"IMPORT_CONTACTS": {
"BUTTON_LABEL": "Import",
"TITLE": "Import Contacts",
"DESC": "Import contacts through a CSV file.",
"DOWNLOAD_LABEL": "Download a sample csv.",
"FORM": {
"LABEL": "CSV File",
"SUBMIT": "Import",
"CANCEL": "Annuller"
},
"SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "Der opstod en fejl. Prøv venligst igen"
},
"DELETE_CONTACT": {
"BUTTON_LABEL": "Delete Contact",
"TITLE": "Delete contact",
"DESC": "Delete contact details",
"CONFIRM": {
"TITLE": "Bekræft Sletning",
"MESSAGE": "Er du sikker på du vil slette ",
"PLACE_HOLDER": "Please type {contactName} to confirm",
"YES": "Ja, Slet ",
"NO": "Nej, Behold "
},
"API": {
"SUCCESS_MESSAGE": "Contact deleted successfully",
"ERROR_MESSAGE": "Could not delete contact. Please try again later."
}
},
"CONTACT_FORM": {
"FORM": {
"SUBMIT": "Send",
@ -213,17 +244,19 @@
},
"MERGE_CONTACTS": {
"TITLE": "Merge contacts",
"DESCRIPTION": "Merge contact is helpful when you have duplicated entries of the same contact. Merging action takes a primary contact and a child contact. After merging, all details in the primary contact will remain the same. If the primary contact doesn't have a field, then the value from the child contact will be used after merging. If a conflict happens, fields in primary contact will remain unaffected, but fields from secondary will be copied to the custom attributes in the primary contact.",
"DESCRIPTION": "Merge contacts to combine two profiles into one, including all attributes and conversations. In case of conflict, the Primary contact s attributes will take precedence.",
"PRIMARY": {
"TITLE": "Primary contact"
"TITLE": "Primary contact",
"HELP_LABEL": "To be kept"
},
"CHILD": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Choose a contact"
"PLACEHOLDER": "Search for a contact",
"HELP_LABEL": "To be deleted"
},
"SUMMARY": {
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong>will be deleted.",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong> will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{childContactName}</strong> will be copied to <strong>%{primaryContactName}</strong>."
},
"SEARCH": {
@ -236,7 +269,7 @@
"ERROR": "Select a child contact to merge"
},
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contcts, try again!"
"ERROR_MESSAGE": "Could not merge contacts, try again!"
}
}
}

View file

@ -39,7 +39,10 @@
"OPEN_ACTION": "Åbn",
"OPEN": "Mere",
"CLOSE": "Luk",
"DETAILS": "detaljer"
"DETAILS": "detaljer",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply"
},
"RESOLVE_DROPDOWN": {
"MARK_PENDING": "Mark as pending",
@ -84,6 +87,7 @@
"CHANGE_AGENT": "Samtaleansvarlig ændret",
"CHANGE_TEAM": "Conversation team changed",
"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:",
"ASSIGNMENT": {
"SELECT_AGENT": "Select Agent",

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