Merge branch 'release/1.19.0'

This commit is contained in:
Sojan
2021-08-17 01:50:25 +05:30
343 changed files with 5166 additions and 2049 deletions

View File

@@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build
docker:
# specify the version you desire here
- image: circleci/ruby:2.7.3-node-browsers
- image: circleci/ruby:3.0.2-node-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images

View File

@@ -1,5 +1,5 @@
# pre-build stage
ARG VARIANT=2.7
ARG VARIANT=3
FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.

View File

@@ -10,8 +10,8 @@ services:
context: ..
dockerfile: .devcontainer/Dockerfile
args:
# Update 'VARIANT' to pick a Ruby version: 2, 2.7, 2.6, 2.5
VARIANT: 2.7
# Update 'VARIANT' to pick a Ruby version: https://github.com/microsoft/vscode-dev-containers/tree/main/containers/ruby
VARIANT: 3
# [Choice] Install Node.js
INSTALL_NODE: "true"
NODE_VERSION: "lts/*"

View File

@@ -147,6 +147,10 @@ USE_INBOX_AVATAR_FOR_BOT=true
# maxmindb api key to use geoip2 service
# IP_LOOKUP_API_KEY=
## Rack Attack configuration
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=false
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
@@ -157,3 +161,7 @@ USE_INBOX_AVATAR_FOR_BOT=true
# LETTER_OPENER=true
# meant to be used in github codespaces
# WEBPACKER_DEV_SERVER_PUBLIC=
# If you want to use official mobile app,
# the notifications would be relayed via a Chatwoot server
ENABLE_PUSH_RELAY_SERVER=true

2
.nvmrc
View File

@@ -1 +1 @@
12.16.1
14.17.4

View File

@@ -13,6 +13,7 @@ Metrics/ClassLength:
- 'app/models/conversation.rb'
- 'app/mailers/conversation_reply_mailer.rb'
- 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
RSpec/ExampleLength:
Max: 25
Style/Documentation:
@@ -24,12 +25,13 @@ Style/FrozenStringLiteralComment:
Style/SymbolArray:
Enabled: false
Style/OptionalBooleanParameter:
Exclude:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
- 'app/dispatchers/dispatcher.rb'
Style/GlobalVars:
Exclude:
- 'config/initializers/redis.rb'
- 'config/initializers/01_redis.rb'
- 'config/initializers/rack_attack.rb'
- 'lib/redis/alfred.rb'
- 'lib/global_config.rb'
Style/ClassVars:
@@ -38,6 +40,14 @@ Style/ClassVars:
Lint/MissingSuper:
Exclude:
- 'app/drops/base_drop.rb'
Lint/SymbolConversion:
Enabled: false
Lint/EmptyBlock:
Exclude:
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
Lint/OrAssignmentToConstant:
Exclude:
- 'lib/redis/config.rb'
Metrics/BlockLength:
Exclude:
- spec/**/*
@@ -55,6 +65,11 @@ Rails/ApplicationController:
- 'app/controllers/widgets_controller.rb'
- 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb'
Rails/EnvironmentVariableAccess:
Enabled: false
Rails/TimeZoneAssignment:
Enabled: false
Style/ClassAndModuleChildren:
EnforcedStyle: compact
Exclude:
@@ -64,6 +79,10 @@ RSpec/NestedGroups:
Max: 4
RSpec/MessageSpies:
Enabled: false
RSpec/StubbedMock:
Enabled: false
Naming/VariableNumber:
Enabled: false
Metrics/MethodLength:
Exclude:
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
@@ -77,7 +96,7 @@ Style/GuardClause:
- 'app/models/message.rb'
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
Metrics/AbcSize:
Exclude:
Exclude:
- 'app/controllers/concerns/auth_helper.rb'
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
@@ -106,19 +125,24 @@ Rails/BulkChangeTable:
- 'db/migrate/20191027054756_create_contact_inboxes.rb'
- 'db/migrate/20191130164019_add_template_type_to_messages.rb'
- 'db/migrate/20210425093724_convert_integration_hook_settings_field.rb'
Rails/UniqueValidationWithoutIndex:
Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
- 'app/models/integrations/hook.rb'
Rails/RenderInline:
Exclude:
- 'app/controllers/swagger_controller.rb'
Performance/CollectionLiteralInLoop:
Exclude:
- 'db/migrate/20210315101919_enable_email_channel.rb'
RSpec/NamedSubject:
Enabled: false
# we should bring this down
RSpec/MultipleMemoizedHelpers:
Max: 12
AllCops:
NewCops: enable
Exclude:
@@ -133,4 +157,11 @@ AllCops:
- 'tmp/**/*'
- 'storage/**/*'
- 'db/migrate/20200225162150_init_schema.rb'
- 'config/initializers/azure_storage_service_patch.rb'
- 'db/migrate/20210611180222_create_active_storage_variant_records.active_storage.rb'
- 'db/migrate/20210611180221_add_service_name_to_active_storage_blobs.active_storage.rb'
- db/migrate/20200309213132_add_account_id_to_agent_bot_inboxes.rb
- db/migrate/20200331095710_add_identifier_to_contact.rb
- db/migrate/20200429082655_add_medium_to_twilio_sms.rb
- db/migrate/20200503151130_add_account_feature_flag.rb
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb

View File

@@ -1 +1 @@
2.7.3
3.0.2

22
Gemfile
View File

@@ -1,6 +1,6 @@
source 'https://rubygems.org'
ruby '2.7.3'
ruby '3.0.2'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
@@ -33,19 +33,22 @@ gem 'liquid'
gem 'commonmarker'
# Validate Data against JSON Schema
gem 'json_schemer'
# Rack middleware for blocking & throttling abusive requests
gem 'rack-attack'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down', '~> 5.0'
##-- for active storage --##
gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', require: false
gem 'google-cloud-storage', require: false
gem 'mini_magick'
gem 'image_processing'
##-- gems for database --#
gem 'groupdate'
gem 'pg'
gem 'redis'
gem 'redis-namespace'
gem 'redis-rack-cache'
# super fast record imports in bulk
gem 'activerecord-import'
@@ -90,8 +93,11 @@ gem 'google-cloud-dialogflow'
##--- gems for debugging and error reporting ---##
# static analysis
gem 'brakeman'
gem 'ddtrace'
gem 'scout_apm'
gem 'sentry-raven'
gem 'sentry-rails'
gem 'sentry-ruby'
gem 'sentry-sidekiq'
##-- background job processing --##
gem 'sidekiq'
@@ -120,7 +126,7 @@ group :development do
gem 'web-console'
# used in swagger build
gem 'json_refs', git: 'https://github.com/tzmfreedom/json_refs', ref: '131b11294fd6af9c428171f38516e6222a58c874'
gem 'json_refs'
# When we want to squash migrations
gem 'squasher'
@@ -134,19 +140,19 @@ group :test do
end
group :development, :test do
gem 'active_record_query_trace'
gem 'bundle-audit', require: false
gem 'byebug', platform: :mri
gem 'factory_bot_rails'
gem 'faker'
gem 'listen'
gem 'mock_redis', git: 'https://github.com/sds/mock_redis', ref: '16d00789f0341a3aac35126c0ffe97a596753ff9'
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec-rails', '~> 4.0.0.beta2'
gem 'rspec-rails', '~> 5.0.0'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false
gem 'scss_lint', require: false
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '0.17.1', require: false

View File

@@ -1,80 +1,70 @@
GIT
remote: https://github.com/sds/mock_redis
revision: 16d00789f0341a3aac35126c0ffe97a596753ff9
ref: 16d00789f0341a3aac35126c0ffe97a596753ff9
specs:
mock_redis (0.22.0)
GIT
remote: https://github.com/tzmfreedom/json_refs
revision: 131b11294fd6af9c428171f38516e6222a58c874
ref: 131b11294fd6af9c428171f38516e6222a58c874
specs:
json_refs (0.1.6)
hana
GEM
remote: https://rubygems.org/
specs:
actioncable (6.0.3.7)
actionpack (= 6.0.3.7)
actioncable (6.1.4)
actionpack (= 6.1.4)
activesupport (= 6.1.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.0.3.7)
actionpack (= 6.0.3.7)
activejob (= 6.0.3.7)
activerecord (= 6.0.3.7)
activestorage (= 6.0.3.7)
activesupport (= 6.0.3.7)
actionmailbox (6.1.4)
actionpack (= 6.1.4)
activejob (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
mail (>= 2.7.1)
actionmailer (6.0.3.7)
actionpack (= 6.0.3.7)
actionview (= 6.0.3.7)
activejob (= 6.0.3.7)
actionmailer (6.1.4)
actionpack (= 6.1.4)
actionview (= 6.1.4)
activejob (= 6.1.4)
activesupport (= 6.1.4)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.0.3.7)
actionview (= 6.0.3.7)
activesupport (= 6.0.3.7)
rack (~> 2.0, >= 2.0.8)
actionpack (6.1.4)
actionview (= 6.1.4)
activesupport (= 6.1.4)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.0.3.7)
actionpack (= 6.0.3.7)
activerecord (= 6.0.3.7)
activestorage (= 6.0.3.7)
activesupport (= 6.0.3.7)
actiontext (6.1.4)
actionpack (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
nokogiri (>= 1.8.5)
actionview (6.0.3.7)
activesupport (= 6.0.3.7)
actionview (6.1.4)
activesupport (= 6.1.4)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.0.3.7)
activesupport (= 6.0.3.7)
active_record_query_trace (1.8)
activejob (6.1.4)
activesupport (= 6.1.4)
globalid (>= 0.3.6)
activemodel (6.0.3.7)
activesupport (= 6.0.3.7)
activerecord (6.0.3.7)
activemodel (= 6.0.3.7)
activesupport (= 6.0.3.7)
activerecord-import (1.0.7)
activemodel (6.1.4)
activesupport (= 6.1.4)
activerecord (6.1.4)
activemodel (= 6.1.4)
activesupport (= 6.1.4)
activerecord-import (1.2.0)
activerecord (>= 3.2)
activestorage (6.0.3.7)
actionpack (= 6.0.3.7)
activejob (= 6.0.3.7)
activerecord (= 6.0.3.7)
activestorage (6.1.4)
actionpack (= 6.1.4)
activejob (= 6.1.4)
activerecord (= 6.1.4)
activesupport (= 6.1.4)
marcel (~> 1.0.0)
activesupport (6.0.3.7)
mini_mime (>= 1.1.0)
activesupport (6.1.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
zeitwerk (~> 2.2, >= 2.2.2)
acts-as-taggable-on (6.5.0)
activerecord (>= 5.0, < 6.1)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
acts-as-taggable-on (8.1.0)
activerecord (>= 5.0, < 6.2)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
administrate (0.16.0)
@@ -90,23 +80,23 @@ GEM
annotate (3.1.1)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0)
ast (2.4.1)
ast (2.4.2)
attr_extras (6.2.4)
aws-eventstream (1.1.0)
aws-partitions (1.360.0)
aws-sdk-core (3.105.0)
aws-eventstream (1.1.1)
aws-partitions (1.482.0)
aws-sdk-core (3.119.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.37.0)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sdk-kms (1.46.0)
aws-sdk-core (~> 3, >= 3.119.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.79.1)
aws-sdk-core (~> 3, >= 3.104.3)
aws-sdk-s3 (1.98.0)
aws-sdk-core (~> 3, >= 3.119.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.2)
aws-sigv4 (1.2.4)
aws-eventstream (~> 1, >= 1.0.2)
axiom-types (0.1.1)
descendants_tracker (~> 0.0.4)
@@ -120,42 +110,48 @@ GEM
faraday_middleware (~> 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1.11.0.rc2)
barnes (0.0.8)
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
bcrypt (3.1.16)
bindex (0.8.1)
bootsnap (1.4.8)
bootsnap (1.7.6)
msgpack (~> 1.0)
brakeman (4.9.0)
brakeman (5.1.1)
browser (5.3.1)
builder (3.2.4)
bullet (6.1.0)
bullet (6.1.4)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundle-audit (0.1.0)
bundler-audit
bundler-audit (0.7.0.1)
bundler-audit (0.8.0)
bundler (>= 1.2.0, < 3)
thor (>= 0.18, < 2)
thor (~> 1.0)
byebug (11.1.3)
coderay (1.1.3)
coercible (1.0.0)
descendants_tracker (~> 0.0.1)
commonmarker (0.21.1)
ruby-enum (~> 0.5)
concurrent-ruby (1.1.8)
connection_pool (2.2.3)
crack (0.4.3)
safe_yaml (~> 1.0.0)
commonmarker (0.22.0)
concurrent-ruby (1.1.9)
connection_pool (2.2.5)
crack (0.4.5)
rexml
crass (1.0.6)
cypress-on-rails (1.8.0)
cypress-on-rails (1.10.1)
rack
database_cleaner (1.8.5)
database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0)
database_cleaner-active_record (2.0.1)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datetime_picker_rails (0.0.7)
momentjs-rails (>= 2.8.1)
ddtrace (0.51.1)
ffi (~> 1.0)
msgpack
declarative (0.0.20)
declarative-option (0.1.0)
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
devise (4.8.0)
@@ -167,48 +163,49 @@ GEM
devise-secure_password (2.0.1)
devise (>= 4.0.0, < 5.0.0)
railties (>= 5.0.0, < 7.0.0)
devise_token_auth (1.1.4)
devise_token_auth (1.2.0)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 6.1)
sprockets (= 3.7.2)
rails (>= 4.2.0, < 6.2)
diff-lcs (1.4.4)
digest-crc (0.6.1)
rake (~> 13.0)
docile (1.3.2)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.7.6)
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
ecma-re-validator (0.2.1)
regexp_parser (~> 1.2)
equalizer (0.0.11)
down (5.2.3)
addressable (~> 2.8)
dry-inflector (0.2.1)
ecma-re-validator (0.3.0)
regexp_parser (~> 2.0)
erubi (1.10.0)
et-orbi (1.2.4)
tzinfo
execjs (2.7.0)
execjs (2.8.1)
facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5)
factory_bot (6.1.0)
factory_bot (6.2.0)
activesupport (>= 5.0.0)
factory_bot_rails (6.1.0)
factory_bot (~> 6.1.0)
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
faker (2.13.0)
faker (2.18.0)
i18n (>= 1.6, < 2)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
faraday_middleware (1.0.0)
faraday (~> 1.0)
fcm (1.0.2)
faraday (~> 1.0.0)
ffi (1.15.0)
fcm (1.0.3)
faraday (~> 1)
ffi (1.15.3)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.4.1)
fugit (1.5.0)
et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.4)
gapic-common (0.3.4)
@@ -217,18 +214,23 @@ GEM
googleapis-common-protos-types (>= 1.0.4, < 2.0)
googleauth (~> 0.9)
grpc (~> 1.25)
geocoder (1.6.3)
gli (2.19.2)
globalid (0.4.2)
activesupport (>= 4.2.0)
google-api-client (0.43.0)
geocoder (1.6.7)
gli (2.20.1)
globalid (0.5.1)
activesupport (>= 5.0)
google-apis-core (0.4.1)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 0.9)
httpclient (>= 2.8.1, < 3.0)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.0)
signet (~> 0.12)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.6.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-storage_v1 (0.6.0)
google-apis-core (>= 0.4, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
@@ -241,21 +243,23 @@ GEM
google-cloud-env (1.5.0)
faraday (>= 0.17.3, < 2.0)
google-cloud-errors (1.1.0)
google-cloud-storage (1.28.0)
google-cloud-storage (1.34.1)
addressable (~> 2.5)
digest-crc (~> 0.4)
google-api-client (~> 0.33)
google-cloud-core (~> 1.2)
googleauth (~> 0.9)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.1)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-protobuf (3.15.8)
google-protobuf (3.17.3-universal-darwin)
google-protobuf (3.17.3-x86_64-linux)
googleapis-common-protos (1.3.11)
google-protobuf (~> 3.14)
googleapis-common-protos-types (>= 1.0.6, < 2.0)
grpc (~> 1.27)
googleapis-common-protos-types (1.0.6)
googleapis-common-protos-types (1.1.0)
google-protobuf (~> 3.14)
googleauth (0.16.2)
googleauth (0.17.0)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
@@ -264,11 +268,14 @@ GEM
signet (~> 0.14)
groupdate (5.2.2)
activesupport (>= 5)
grpc (1.37.1)
grpc (1.38.0-universal-darwin)
google-protobuf (~> 3.15)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.0)
hairtrigger (0.2.23)
grpc (1.38.0-x86_64-linux)
google-protobuf (~> 3.15)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
hairtrigger (0.2.24)
activerecord (>= 5.0, < 7)
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
@@ -277,7 +284,7 @@ GEM
hashie (4.1.0)
hkdf (0.3.0)
http-accept (1.7.0)
http-cookie (1.0.3)
http-cookie (1.0.4)
domain_name (~> 0.5)
httparty (0.18.1)
mime-types (~> 3.0)
@@ -286,19 +293,23 @@ GEM
i18n (1.8.10)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
inflecto (0.0.2)
jbuilder (2.10.0)
image_processing (1.12.1)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
jbuilder (2.11.2)
activesupport (>= 5.0.0)
jmespath (1.4.0)
jquery-rails (4.4.0)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.3.1)
json_schemer (0.2.16)
ecma-re-validator (~> 0.2)
json (2.5.1)
json_refs (0.1.6)
hana
json_schemer (0.2.18)
ecma-re-validator (~> 0.3)
hana (~> 1.3)
regexp_parser (~> 1.5)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
jwt (2.2.3)
kaminari (1.2.1)
@@ -321,11 +332,11 @@ GEM
addressable (~> 2.7)
letter_opener (1.7.0)
launchy (~> 2.2)
liquid (4.0.3)
listen (3.3.3)
liquid (5.0.1)
listen (3.6.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.9.1)
loofah (2.11.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@@ -336,98 +347,97 @@ GEM
method_source (1.0.0)
mime-types (3.3.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2021.0225)
mini_magick (4.10.1)
mime-types-data (3.2021.0704)
mini_magick (4.11.0)
mini_mime (1.1.0)
mini_portile2 (2.5.1)
minitest (5.14.4)
mock_redis (0.28.0)
ruby2_keywords
momentjs-rails (2.20.1)
railties (>= 3.1)
msgpack (1.3.3)
msgpack (1.4.2)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.1.1)
net-http-persistent (4.0.0)
net-http-persistent (4.0.1)
connection_pool (~> 2.2)
netrc (0.11.0)
nio4r (2.5.7)
nokogiri (1.11.6)
mini_portile2 (~> 2.5.0)
nokogiri (1.11.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.11.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.11.7-x86_64-linux)
racc (~> 1.4)
oauth (0.5.6)
orm_adapter (0.5.0)
os (1.1.1)
parallel (1.19.2)
parser (2.7.1.4)
parallel (1.20.1)
parser (3.0.2.0)
ast (~> 2.4.1)
pg (1.2.3)
procore-sift (0.15.0)
procore-sift (0.16.0)
rails (> 4.2.0)
pry (0.13.1)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.6)
puma (4.3.8)
puma (5.4.0)
nio4r (~> 2.0)
pundit (2.1.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.5.2)
rack (2.2.3)
rack-cache (1.12.0)
rack (>= 0.4)
rack-attack (6.5.0)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-proxy (0.6.5)
rack-proxy (0.7.0)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.6.0)
rails (6.0.3.7)
actioncable (= 6.0.3.7)
actionmailbox (= 6.0.3.7)
actionmailer (= 6.0.3.7)
actionpack (= 6.0.3.7)
actiontext (= 6.0.3.7)
actionview (= 6.0.3.7)
activejob (= 6.0.3.7)
activemodel (= 6.0.3.7)
activerecord (= 6.0.3.7)
activestorage (= 6.0.3.7)
activesupport (= 6.0.3.7)
bundler (>= 1.3.0)
railties (= 6.0.3.7)
rails (6.1.4)
actioncable (= 6.1.4)
actionmailbox (= 6.1.4)
actionmailer (= 6.1.4)
actionpack (= 6.1.4)
actiontext (= 6.1.4)
actionview (= 6.1.4)
activejob (= 6.1.4)
activemodel (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
bundler (>= 1.15.0)
railties (= 6.1.4)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.3.0)
loofah (~> 2.3)
railties (6.0.3.7)
actionpack (= 6.0.3.7)
activesupport (= 6.0.3.7)
railties (6.1.4)
actionpack (= 6.1.4)
activesupport (= 6.1.4)
method_source
rake (>= 0.8.7)
thor (>= 0.20.3, < 2.0)
rake (>= 0.13)
thor (~> 1.0)
rainbow (3.0.0)
rake (13.0.3)
rb-fsevent (0.10.4)
rake (13.0.6)
rb-fsevent (0.11.0)
rb-inotify (0.10.1)
ffi (~> 1.0)
redis (4.2.1)
redis-namespace (1.8.0)
redis (4.4.0)
redis-namespace (1.8.1)
redis (>= 3.0.4)
redis-rack-cache (2.2.1)
rack-cache (>= 1.10, < 2)
redis-store (>= 1.6, < 2)
redis-store (1.9.0)
redis (>= 4, < 5)
regexp_parser (1.7.1)
representable (3.0.4)
regexp_parser (2.1.1)
representable (3.1.1)
declarative (< 0.1.0)
declarative-option (< 0.2.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
responders (3.0.1)
actionpack (>= 5.0)
@@ -439,57 +449,53 @@ GEM
netrc (~> 0.8)
retriable (3.1.2)
rexml (3.2.5)
rspec-core (3.9.2)
rspec-support (~> 3.9.3)
rspec-expectations (3.9.2)
rspec-core (3.10.1)
rspec-support (~> 3.10.0)
rspec-expectations (3.10.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0)
rspec-mocks (3.9.1)
rspec-support (~> 3.10.0)
rspec-mocks (3.10.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0)
rspec-rails (4.0.1)
actionpack (>= 4.2)
activesupport (>= 4.2)
railties (>= 4.2)
rspec-core (~> 3.9)
rspec-expectations (~> 3.9)
rspec-mocks (~> 3.9)
rspec-support (~> 3.9)
rspec-support (3.9.3)
rubocop (0.89.1)
rspec-support (~> 3.10.0)
rspec-rails (5.0.1)
actionpack (>= 5.2)
activesupport (>= 5.2)
railties (>= 5.2)
rspec-core (~> 3.10)
rspec-expectations (~> 3.10)
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.10.2)
rubocop (1.18.4)
parallel (~> 1.10)
parser (>= 2.7.1.1)
parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.7)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 0.3.0, < 1.0)
rubocop-ast (>= 1.8.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 2.0)
rubocop-ast (0.3.0)
parser (>= 2.7.1.4)
rubocop-performance (1.7.1)
rubocop (>= 0.82.0)
rubocop-rails (2.8.1)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.8.0)
parser (>= 3.0.1.1)
rubocop-performance (1.11.4)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.11.3)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 0.87.0)
rubocop-rspec (1.43.2)
rubocop (~> 0.87)
ruby-enum (0.9.0)
i18n
ruby-progressbar (1.10.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (2.4.0)
rubocop (~> 1.0)
rubocop-ast (>= 1.1.0)
ruby-progressbar (1.11.0)
ruby-vips (2.1.2)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
ruby2ruby (2.4.4)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_parser (3.15.0)
rubocop (>= 0.87.0)
sexp_processor (~> 4.9)
safe_yaml (1.0.5)
sass (3.7.4)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
ruby_parser (3.16.0)
sexp_processor (~> 4.15, >= 4.15.1)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
@@ -498,21 +504,30 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
scout_apm (2.6.9)
scout_apm (4.1.1)
parser
scss_lint (0.59.0)
sass (~> 3.5, >= 3.5.5)
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (2.3.0)
sentry-raven (3.0.3)
semantic_range (3.0.0)
sentry-rails (4.6.4)
railties (>= 5.0)
sentry-ruby-core (~> 4.6.0)
sentry-ruby (4.6.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
faraday (>= 1.0)
sexp_processor (4.15.1)
shoulda-matchers (4.4.1)
activesupport (>= 4.2.0)
sidekiq (6.1.1)
sentry-ruby-core (= 4.6.4)
sentry-ruby-core (4.6.4)
concurrent-ruby
faraday
sentry-sidekiq (4.6.4)
sentry-ruby-core (~> 4.6.0)
sidekiq (>= 3.0)
sexp_processor (4.15.3)
shoulda-matchers (5.0.0)
activesupport (>= 5.2.0)
sidekiq (6.2.1)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
@@ -529,7 +544,7 @@ GEM
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
slack-ruby-client (0.15.0)
slack-ruby-client (0.17.0)
faraday (>= 1.0)
faraday_middleware
gli
@@ -539,7 +554,7 @@ GEM
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
spring (>= 1.2, < 3.0)
sprockets (3.7.2)
sprockets (4.0.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.2.2)
@@ -547,27 +562,28 @@ GEM
activesupport (>= 4.0)
sprockets (>= 3.0.0)
squasher (0.6.2)
statsd-ruby (1.4.0)
telegram-bot-ruby (0.12.0)
statsd-ruby (1.5.0)
telegram-bot-ruby (0.16.0)
dry-inflector
faraday
inflecto
virtus
telephone_number (1.4.9)
virtus (~> 2.0)
telephone_number (1.4.12)
thor (1.1.0)
thread_safe (0.3.6)
tilt (2.0.10)
time_diff (0.3.0)
activesupport
i18n
trailblazer-option (0.1.1)
twilio-ruby (5.32.0)
faraday (~> 1.0.0)
jwt (>= 1.5, <= 2.5)
nokogiri (>= 1.6, < 2.0)
twitty (0.1.1)
twitty (0.1.4)
oauth
tzinfo (1.2.9)
thread_safe (~> 0.1)
tzinfo-data (1.2020.1)
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.1)
tzinfo (>= 1.0.0)
uber (0.1.0)
uglifier (4.2.0)
@@ -575,46 +591,49 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unicode-display_width (1.7.0)
uniform_notifier (1.13.0)
unicode-display_width (2.0.0)
uniform_notifier (1.14.2)
uri_template (0.7.0)
valid_email2 (3.3.1)
valid_email2 (4.0.0)
activemodel (>= 3.2)
mail (~> 2.5)
virtus (1.0.5)
virtus (2.0.0)
axiom-types (~> 0.1)
coercible (~> 1.0)
descendants_tracker (~> 0.0, >= 0.0.3)
equalizer (~> 0.0, >= 0.0.9)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.0.4)
web-console (4.1.0)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.8.3)
webmock (3.13.0)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.2.1)
webpacker (5.4.0)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webpush (1.0.0)
webpush (1.1.0)
hkdf (~> 0.2)
jwt (~> 2.0)
websocket-driver (0.7.3)
webrick (1.7.0)
websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.0)
zeitwerk (2.4.2)
PLATFORMS
ruby
arm64-darwin-20
x86_64-darwin-21
x86_64-linux
DEPENDENCIES
active_record_query_trace
activerecord-import
acts-as-taggable-on
administrate
@@ -632,10 +651,12 @@ DEPENDENCIES
commonmarker
cypress-on-rails (~> 1.0)
database_cleaner
ddtrace
devise
devise-secure_password (~> 2.0)
devise_token_auth
dotenv-rails
down (~> 5.0)
facebook-messenger
factory_bot_rails
faker
@@ -649,8 +670,9 @@ DEPENDENCIES
haikunator
hairtrigger
hashie
image_processing
jbuilder
json_refs!
json_refs
json_schemer
jwt
kaminari
@@ -659,30 +681,30 @@ DEPENDENCIES
liquid
listen
maxminddb
mini_magick
mock_redis!
mock_redis
pg
procore-sift
pry-rails
puma
pundit
rack-attack
rack-cors
rack-timeout
rails
redis
redis-namespace
redis-rack-cache
responders
rest-client
rspec-rails (~> 4.0.0.beta2)
rspec-rails (~> 5.0.0)
rubocop
rubocop-performance
rubocop-rails
rubocop-rspec
scout_apm
scss_lint
seed_dump
sentry-raven
sentry-rails
sentry-ruby
sentry-sidekiq
shoulda-matchers
sidekiq
sidekiq-cron
@@ -706,7 +728,7 @@ DEPENDENCIES
wisper (= 2.0.0)
RUBY VERSION
ruby 2.7.3p183
ruby 3.0.2p107
BUNDLED WITH
2.1.4
2.2.25

View File

@@ -29,9 +29,8 @@ ___
Chatwoot is an open-source omnichannel customer support software. The development of Chatwoot started in 2016. It failed to succeed as a business and eventually shut up shop in 2017. During 2019 #Hacktoberfest, the maintainers decided to make it open-source, instead of letting the code rust in a private repo. With a pleasant surprise, Chatwoot became a trending project on Hacker News and best of all, got lots of love from the community.
Now, a failed project is back on track and the prospects are looking great. The team is back to working on the project and this time, we are building it in the open. Thanks to the ideas and contributions from the community.
---
### Features
## Features
Chatwoot gives an integrated view of conversations happening in different communication channels.
@@ -57,28 +56,22 @@ Other features include:
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoots webhooks and APIs.
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
---
## Documentation
### Documentation
Detailed documentation is available at [chatwoot.com/help-center](https://www.chatwoot.com/help-center).
Detailed documentation is available at [www.chatwoot.com/help-center](https://www.chatwoot.com/help-center).
### Translation process
## Translation process
The translation process for Chatwoot web and mobile app is managed at [https://translate.chatwoot.com](https://translate.chatwoot.com) using Crowdin. Please read the [translation guide](https://www.chatwoot.com/docs/contributing/translating-chatwoot-to-your-language) for contributing to Chatwoot.
---
### Branching model
## Branching model
We use the [git-flow](https://nvie.com/posts/a-successful-git-branching-model/) branching model. The base branch is `develop`.
If you are looking for a stable version, please use the `master` or tags labelled as `v1.x.x`.
---
## Deployment
### Deployment
#### Heroku one-click deploy
### Heroku one-click deploy
Deploying Chatwoot to Heroku is a breeze. It's as simple as clicking this button:
@@ -86,13 +79,21 @@ Deploying Chatwoot to Heroku is a breeze. It's as simple as clicking this button
Follow this [link](https://www.chatwoot.com/docs/environment-variables) to understand setting the correct environment variables for the app to work with all the features. There might be breakages if you do not set the relevant environment variables.
#### Other deployment options
### Other deployment options
Please follow [deployment architecture guide](https://www.chatwoot.com/docs/deployment/architecture) to deploy with Docker or Caprover.
---
## Security
### Contributors ✨
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
## Community? Questions? Support ?
If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server.
## Contributors ✨
Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):

View File

@@ -1,8 +1,31 @@
# Security Policy
Chatwoot is looking forward to working with security researchers across the world to keep Chatwoot and our users safe. If you have found an issue in our systems/applications, please reach out to us.
## Reporting a Vulnerability
We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose).
This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
If you have any questions about the process, feel free to reach out to hello@chatwoot.com.
If you have any questions about the process, feel free to reach out to security@chatwoot.com.
## Out of scope
Please do not perform testing against Chatwoot production services. Use a self hosted instance to perform tests.
We consider the following to be out of scope, though there may be exceptions.
- Missing HTTP security headers
- Self XSS
- HTTP Host Header XSS without working proof-of-concept
- Incomplete/Missing SPF/DKIM
- Denial of Service attacks
- DNSSEC
- Social Engineering attacks
If you are not sure about the scope, please create a report.
## Thanks
Thank you for keeping Chatwoot and our users safe. 🙇

View File

@@ -28,6 +28,10 @@
"FRONTEND_URL": {
"description": "Public root URL of the Chatwoot installation. This will be used in the emails.",
"value": "https://CHANGE.herokuapp.com"
},
"INSTALLATION_ENV": {
"description": "Installation method used for Chatwoot.",
"value": "heroku"
}
},
"formation": {

View File

@@ -3,8 +3,9 @@ class ContactIdentifyAction
def perform
ActiveRecord::Base.transaction do
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
merge_if_existing_identified_contact
merge_if_existing_email_contact
merge_if_existing_phone_number_contact
update_contact
end
@contact
@@ -16,6 +17,18 @@ class ContactIdentifyAction
@account ||= @contact.account
end
def merge_if_existing_identified_contact
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
end
def merge_if_existing_email_contact
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
end
def merge_if_existing_phone_number_contact
@contact = merge_contact(existing_phone_number_contact, @contact) if merge_contacts?(existing_phone_number_contact, @contact)
end
def existing_identified_contact
return if params[:identifier].blank?
@@ -28,6 +41,12 @@ class ContactIdentifyAction
@existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email])
end
def existing_phone_number_contact
return if params[:phone_number].blank?
@existing_phone_number_contact ||= Contact.where(account_id: account.id).find_by(phone_number: params[:phone_number])
end
def merge_contacts?(existing_contact, _contact)
existing_contact && existing_contact.id != @contact.id
end
@@ -36,7 +55,9 @@ class ContactIdentifyAction
custom_attributes = params[:custom_attributes] ? @contact.custom_attributes.merge(params[:custom_attributes]) : @contact.custom_attributes
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.update!(params.slice(:name, :email, :identifier).reject { |_k, v| v.blank? }.merge({ custom_attributes: custom_attributes }))
@contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v|
v.blank?
end.merge({ custom_attributes: custom_attributes }))
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end

View File

@@ -38,17 +38,30 @@ class ContactBuilder
end
def find_contact
contact = nil
contact = account.contacts.find_by(identifier: contact_attributes[:identifier]) if contact_attributes[:identifier].present?
contact ||= account.contacts.find_by(email: contact_attributes[:email]) if contact_attributes[:email].present?
contact ||= account.contacts.find_by(phone_number: contact_attributes[:phone_number]) if contact_attributes[:phone_number].present?
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact
end
def find_contact_by_identifier(identifier)
return if identifier.blank?
account.contacts.find_by(identifier: identifier)
end
def find_contact_by_email(email)
return if email.blank?
account.contacts.find_by(email: email.downcase)
end
def find_contact_by_phone_number(phone_number)
return if phone_number.blank?
account.contacts.find_by(phone_number: phone_number)
end
def build_contact_inbox
ActiveRecord::Base.transaction do
contact = find_contact || create_contact
@@ -57,6 +70,7 @@ class ContactBuilder
contact_inbox
rescue StandardError => e
Rails.logger.info e
raise e
end
end
end

View File

@@ -17,12 +17,18 @@ class Messages::Facebook::MessageBuilder
end
def perform
# This channel might require reauthorization, may be owner might have changed the fb password
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_contact
build_message
end
ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError
Rails.logger.info "Facebook Authorization expired for Inbox #{@inbox.id}"
rescue StandardError => e
Raven.capture_exception(e)
Sentry.capture_exception(e)
true
end
@@ -36,7 +42,6 @@ class Messages::Facebook::MessageBuilder
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url]
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
end
@@ -56,10 +61,21 @@ class Messages::Facebook::MessageBuilder
end
def attach_file(attachment, file_url)
file_resource = LocalResource.new(file_url)
attachment.file.attach(io: file_resource.file, filename: file_resource.filename, content_type: file_resource.encoding)
rescue *ExceptionList::URI_EXCEPTIONS => e
Rails.logger.info "invalid url #{file_url} : #{e.message}"
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?
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url])
end
def conversation
@@ -136,9 +152,12 @@ class Messages::Facebook::MessageBuilder
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
result = {}
Raven.capture_exception(e)
Sentry.capture_exception(e)
end
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",

View File

@@ -14,7 +14,7 @@ class Api::V1::Accounts::BaseController < Api::BaseController
account = Account.find(params[:account_id])
if current_user
account_accessible_for_user?(account)
elsif @resource&.is_a?(AgentBot)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
end
account

View File

@@ -69,39 +69,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
return [] if data.empty?
data.inject([]) do |result, page_detail|
page_detail[:exists] = Current.account.facebook_pages.exists?(page_id: page_detail['id']) ? true : false
page_detail[:exists] = Current.account.facebook_pages.exists?(page_id: page_detail['id'])
result << page_detail
end
end
def set_avatar(facebook_inbox, page_id)
uri = get_avatar_url(page_id)
return unless uri
avatar_resource = LocalResource.new(uri)
facebook_inbox.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
rescue *ExceptionList::URI_EXCEPTIONS => e
Rails.logger.info "invalid url #{file_url} : #{e.message}"
end
def get_avatar_url(page_id)
begin
url = 'http://graph.facebook.com/' << page_id << '/picture?type=large'
uri = URI.parse(url)
tries = 3
begin
response = uri.open(redirect: false)
rescue OpenURI::HTTPRedirect => e
uri = e.uri # assigned from the "Location" response header
retry if (tries -= 1).positive?
raise
end
pic_url = response.base_uri.to_s
rescue StandardError => e
Rails.logger.debug "Rescued: #{e.inspect}"
pic_url = nil
end
pic_url
avatar_file = Down.download(
"http://graph.facebook.com/#{page_id}/picture?type=large"
)
facebook_inbox.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
end
end

View File

@@ -6,9 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
authenticate_twilio
build_inbox
setup_webhooks if @twilio_channel.sms?
rescue Twilio::REST::TwilioError => e
render_could_not_create_error(e.message)
rescue StandardError => e
Sentry.capture_exception(e)
render_could_not_create_error(e.message)
end
end

View File

@@ -11,6 +11,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 :set_include_contact_inboxes, only: [:index, :search]
def index
@contacts_count = resolved_contacts.count
@@ -21,7 +22,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search',
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q]}%"
)
@contacts_count = contacts.count
@@ -87,11 +88,15 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contacts_with_conversation_count(contacts)
filtrate(contacts).left_outer_joins(:conversations)
.select('contacts.*, COUNT(conversations.id) as conversations_count')
.group('contacts.id')
.includes([{ avatar_attachment: [:blob] }, { contact_inboxes: [:inbox] }])
.page(@current_page).per(RESULTS_PER_PAGE)
contacts_with_conversation_count = filtrate(contacts).left_outer_joins(:conversations)
.select('contacts.*, COUNT(conversations.id) as conversations_count')
.group('contacts.id')
.includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE)
return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
contacts_with_conversation_count
end
def build_contact_inbox
@@ -103,7 +108,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def contact_params
params.require(:contact).permit(:name, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
params.require(:contact).permit(:name, :identifier, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
end
def contact_custom_attributes
@@ -117,6 +122,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes })
end
def set_include_contact_inboxes
@include_contact_inboxes = if params[:include_contact_inboxes].present?
params[:include_contact_inboxes] == 'true'
else
true
end
end
def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end

View File

@@ -1,5 +1,6 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create]
before_action :contact_inbox, only: [:create]
@@ -49,8 +50,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def toggle_status
if params[:status]
@conversation.status = params[:status]
@status = @conversation.save
set_conversation_status
@status = @conversation.save!
else
@status = @conversation.toggle_status
end
@@ -73,6 +74,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
private
def set_conversation_status
status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = status
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
def trigger_typing_event(event)
user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user)
@@ -106,12 +113,16 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def conversation_params
additional_attributes = params[:additional_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
status = { status: 'pending' } if status[:status] == 'bot'
{
account_id: Current.account.id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes
additional_attributes: additional_attributes,
snoozed_until: params[:snoozed_until]
}.merge(status)
end

View File

@@ -0,0 +1,50 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end
def show; end
def create
@custom_attribute_definition = Current.account.custom_attribute_definitions.create!(
permitted_payload
)
end
def update
@custom_attribute_definition.update!(permitted_payload)
end
def destroy
@custom_attribute_definition.destroy
head :no_content
end
private
def fetch_custom_attributes_definitions
@custom_attribute_definitions = Current.account.custom_attribute_definitions.where(
attribute_model: permitted_params[:attribute_model] || DEFAULT_ATTRIBUTE_MODEL
)
end
def fetch_custom_attribute_definition
@custom_attribute_definition = @custom_attribute_definitions.find(permitted_params[:id])
end
def permitted_payload
params.require(:custom_attribute_definition).permit(
:attribute_display_name,
:attribute_display_type,
:attribute_key,
:attribute_model,
:default_value
)
end
def permitted_params
params.permit(:id, :filter_type)
end
end

View File

@@ -9,7 +9,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
update_agents_list
head :ok
rescue StandardError => e
Rails.logger.debug "Rescued: #{e.inspect}"
Rails.logger.debug { "Rescued: #{e.inspect}" }
render_could_not_create_error('Could not add agents to inbox')
end
end

View File

@@ -10,7 +10,7 @@ class Api::V1::WebhooksController < ApplicationController
twitter_consumer.consume
head :ok
rescue StandardError => e
Raven.capture_exception(e)
Sentry.capture_exception(e)
head :ok
end

View File

@@ -94,6 +94,10 @@ class Api::V1::Widget::BaseController < ApplicationController
{ timestamp: permitted_params[:message][:timestamp] }
end
def permitted_params
params.permit(:website_token)
end
def message_params
{
account_id: conversation.account_id,

View File

@@ -4,10 +4,4 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
def index
@campaigns = @web_widget.inbox.campaigns.where(enabled: true)
end
private
def permitted_params
params.permit(:website_token)
end
end

View File

@@ -0,0 +1,41 @@
class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
before_action :set_global_config
def create
build_contact
set_token
end
private
def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox&.contact
end
def build_contact
return if @contact.present?
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
@contact = @contact_inbox.contact
end
def set_token
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
@token = ::Widget::TokenService.new(payload: payload).generate_token
end
def additional_attributes
if @web_widget.inbox.account.feature_enabled?('ip_lookup')
{ created_at_ip: request.remote_ip }
else
{}
end
end
end

View File

@@ -29,6 +29,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def permitted_params
params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {})
params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
end
end

View File

@@ -4,10 +4,4 @@ class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController
def index
@inbox_members = @web_widget.inbox.inbox_members.includes(:user)
end
private
def permitted_params
params.permit(:website_token)
end
end

View File

@@ -16,7 +16,7 @@ class ApplicationController < ActionController::Base
def handle_with_exception
yield
rescue ActiveRecord::RecordNotFound => e
Raven.capture_exception(e)
Sentry.capture_exception(e)
render_not_found_error('Resource could not be found')
rescue Pundit::NotAuthorizedError
render_unauthorized('You are not authorized to do this action')

View File

@@ -1,8 +1,8 @@
module AuthHelper
def send_auth_headers(user)
data = user.create_new_auth_token
response.headers[DeviseTokenAuth.headers_names[:"access-token"]] = data['access-token']
response.headers[DeviseTokenAuth.headers_names[:"token-type"]] = 'Bearer'
response.headers[DeviseTokenAuth.headers_names[:'access-token']] = data['access-token']
response.headers[DeviseTokenAuth.headers_names[:'token-type']] = 'Bearer'
response.headers[DeviseTokenAuth.headers_names[:client]] = data['client']
response.headers[DeviseTokenAuth.headers_names[:expiry]] = data['expiry']
response.headers[DeviseTokenAuth.headers_names[:uid]] = data['uid']

View File

@@ -0,0 +1,32 @@
class Public::Api::V1::CsatSurveyController < PublicController
before_action :set_conversation
before_action :set_message
def show; end
def update
render json: { error: 'You cannot update the CSAT survey after 14 days' }, status: :unprocessable_entity and return if check_csat_locked
@message.update!(message_update_params[:message])
end
private
def set_conversation
return if params[:id].blank?
@conversation = Conversation.find_by!(uuid: params[:id])
end
def set_message
@message = @conversation.messages.find_by!(content_type: 'input_csat')
end
def message_update_params
params.permit(message: [{ submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback_message, :rating] }] }])
end
def check_csat_locked
(Time.zone.now.to_date - @message.created_at.to_date).to_i > 14
end
end

View File

@@ -14,7 +14,7 @@ class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesCon
def update
@message.update!(message_update_params)
rescue StandardError => e
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
render json: { error: @contact.errors, message: e.message }.to_json, status: :internal_server_error
end
private

View File

@@ -0,0 +1,10 @@
class Survey::ResponsesController < ActionController::Base
before_action :set_global_config
def show; end
private
def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
end
end

View File

@@ -3,7 +3,7 @@ class SwaggerController < ApplicationController
if Rails.env.development? || Rails.env.test?
render inline: File.read(Rails.root.join('swagger', derived_path))
else
head 404
head :not_found
end
end

View File

@@ -1,3 +1,4 @@
# TODO : Delete this and associated spec once 'api/widget/config' end point is merged
class WidgetsController < ActionController::Base
before_action :set_global_config
before_action :set_web_widget

View File

@@ -114,13 +114,13 @@ class ConversationFinder
end
def current_page
params[:page]
params[:page] || 1
end
def conversations
@conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team
)
current_page ? @conversations.latest.page(current_page) : @conversations.latest
@conversations.latest.page(current_page)
end
end

View File

@@ -3,12 +3,17 @@
<transition name="fade" mode="out-in">
<router-view></router-view>
</transition>
<add-account-modal
:show="showAddAccountModal"
:has-accounts="hasAccounts"
/>
<woot-snackbar-box />
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
import WootSnackbarBox from './components/SnackbarContainer';
import { accountIdFromPathname } from './helper/URLHelper';
@@ -17,14 +22,36 @@ export default {
components: {
WootSnackbarBox,
AddAccountModal,
},
data() {
return {
showAddAccountModal: false,
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
currentUser: 'getCurrentUser',
}),
hasAccounts() {
return (
this.currentUser &&
this.currentUser.accounts &&
this.currentUser.accounts.length !== 0
);
},
},
watch: {
currentUser() {
if (!this.hasAccounts) {
this.showAddAccountModal = true;
}
},
},
mounted() {
this.$store.dispatch('setUser');
this.setLocale(window.chatwootConfig.selectedLocale);

View File

@@ -2,7 +2,7 @@
import ApiClient from './ApiClient';
export const buildContactParams = (page, sortAttr, label, search) => {
let params = `page=${page}&sort=${sortAttr}`;
let params = `include_contact_inboxes=false&page=${page}&sort=${sortAttr}`;
if (search) {
params = `${params}&q=${search}`;
}

View File

@@ -28,9 +28,10 @@ class ConversationApi extends ApiClient {
});
}
toggleStatus({ conversationId, status }) {
toggleStatus({ conversationId, status, snoozedUntil = null }) {
return axios.post(`${this.url}/${conversationId}/toggle_status`, {
status,
snoozed_until: snoozedUntil,
});
}

View File

@@ -2,6 +2,33 @@
/* global axios */
import ApiClient from '../ApiClient';
export const buildCreatePayload = ({
message,
isPrivate,
contentAttributes,
echoId,
file,
}) => {
let payload;
if (file) {
payload = new FormData();
payload.append('attachments[]', file, file.name);
if (message) {
payload.append('content', message);
}
payload.append('private', isPrivate);
payload.append('echo_id', echoId);
} else {
payload = {
content: message,
private: isPrivate,
echo_id: echoId,
content_attributes: contentAttributes,
};
}
return payload;
};
class MessageApi extends ApiClient {
constructor() {
super('conversations', { accountScoped: true });
@@ -15,18 +42,16 @@ class MessageApi extends ApiClient {
echo_id: echoId,
file,
}) {
const formData = new FormData();
if (file) formData.append('attachments[]', file, file.name);
if (message) formData.append('content', message);
if (contentAttributes)
formData.append('content_attributes', JSON.stringify(contentAttributes));
formData.append('private', isPrivate);
formData.append('echo_id', echoId);
return axios({
method: 'post',
url: `${this.url}/${conversationId}/messages`,
data: formData,
data: buildCreatePayload({
message,
isPrivate,
contentAttributes,
echoId,
file,
}),
});
}

View File

@@ -17,7 +17,7 @@ describe('#ContactsAPI', () => {
it('#get', () => {
contactAPI.get(1, 'name', 'customer-support');
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts?page=1&sort=name&labels[]=customer-support'
'/api/v1/contacts?include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support'
);
});
@@ -56,7 +56,7 @@ describe('#ContactsAPI', () => {
it('#search', () => {
contactAPI.search('leads', 1, 'date', 'customer-support');
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?page=1&sort=date&q=leads&labels[]=customer-support'
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
);
});
});
@@ -64,12 +64,16 @@ describe('#ContactsAPI', () => {
describe('#buildContactParams', () => {
it('returns correct string', () => {
expect(buildContactParams(1, 'name', '', '')).toBe('page=1&sort=name');
expect(buildContactParams(1, 'name', '', '')).toBe(
'include_contact_inboxes=false&page=1&sort=name'
);
expect(buildContactParams(1, 'name', 'customer-support', '')).toBe(
'page=1&sort=name&labels[]=customer-support'
'include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support'
);
expect(
buildContactParams(1, 'name', 'customer-support', 'message-content')
).toBe('page=1&sort=name&q=message-content&labels[]=customer-support');
).toBe(
'include_contact_inboxes=false&page=1&sort=name&q=message-content&labels[]=customer-support'
);
});
});

View File

@@ -69,6 +69,7 @@ describe('#ConversationAPI', () => {
`/api/v1/conversations/12/toggle_status`,
{
status: 'online',
snoozed_until: null,
}
);
});

View File

@@ -1,4 +1,4 @@
import messageAPI from '../../inbox/message';
import messageAPI, { buildCreatePayload } from '../../inbox/message';
import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../apiSpecHelper';
@@ -29,4 +29,34 @@ describe('#ConversationAPI', () => {
);
});
});
describe('#buildCreatePayload', () => {
it('builds form payload if file is available', () => {
const formPayload = buildCreatePayload({
message: 'test content',
echoId: 12,
isPrivate: true,
file: new Blob(['test-content'], { type: 'application/pdf' }),
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('content')).toEqual('test content');
expect(formPayload.get('echo_id')).toEqual('12');
expect(formPayload.get('private')).toEqual('true');
});
it('builds object payload if file is not available', () => {
expect(
buildCreatePayload({
message: 'test content',
isPrivate: false,
echoId: 12,
contentAttributes: { in_reply_to: 12 },
})
).toEqual({
content: 'test content',
private: false,
echo_id: 12,
content_attributes: { in_reply_to: 12 },
});
});
});
});

View File

@@ -204,4 +204,8 @@
.multiselect--disabled .multiselect__select {
background: transparent;
}
.multiselect__tags-wrap {
flex-shrink: 0;
}
}

View File

@@ -8,6 +8,22 @@
font-weight: var(--font-weight-light);
margin-top: var(--space-large);
}
.update-subscription--checkbox {
display: flex;
input {
line-height: 1.5;
margin-right: var(--space-one);
margin-top: var(--space-smaller);
}
label {
font-size: var(--font-size-small);
line-height: 1.5;
margin-bottom: var(--space-normal);
}
}
}
.alert-box {
@@ -20,17 +36,4 @@
text-align: center;
}
.update-subscription--checkbox {
display: flex;
input {
line-height: 1.5;
margin-right: var(--space-one);
}
div {
font-size: var(--font-size-small);
line-height: 1.5;
margin-bottom: var(--space-normal);
}
}

View File

@@ -90,11 +90,6 @@
font-size: $font-size-mini;
vertical-align: top;
}
.message-from-agent {
color: $color-gray;
font-size: $font-size-mini;
}
}
.conversation--meta {

View File

@@ -19,7 +19,7 @@
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<div class="conversations-list">
<div ref="activeConversation" class="conversations-list">
<conversation-card
v-for="chat in conversationList"
:key="chat.id"
@@ -62,8 +62,13 @@ import ChatFilter from './widgets/conversation/ChatFilter';
import ChatTypeTabs from './widgets/ChatTypeTabs';
import ConversationCard from './widgets/conversation/ConversationCard';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from '../constants';
import {
hasPressedAltAndJKey,
hasPressedAltAndKKey,
} from 'shared/helpers/KeyboardHelpers';
export default {
components: {
@@ -71,7 +76,7 @@ export default {
ConversationCard,
ChatFilter,
},
mixins: [timeMixin, conversationMixin],
mixins: [timeMixin, conversationMixin, eventListenerMixins],
props: {
conversationInbox: {
type: [String, Number],
@@ -94,6 +99,7 @@ export default {
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
chatLists: 'getAllConversations',
mineChatsList: 'getMineChats',
allChatList: 'getAllStatusChats',
@@ -188,6 +194,33 @@ export default {
});
},
methods: {
handleKeyEvents(e) {
const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation'
);
const activeConversation = this.$refs.activeConversation.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
if (hasPressedAltAndJKey(e)) {
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
}
if (hasPressedAltAndKKey(e)) {
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
}
},
resetAndFetchData() {
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');

View File

@@ -24,7 +24,7 @@
{{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }}
</woot-button>
<woot-button
v-else-if="isBot"
v-else-if="showOpenButton"
class-names="resolve"
color-scheme="primary"
icon="ion-person"
@@ -34,7 +34,8 @@
{{ this.$t('CONVERSATION.HEADER.OPEN_ACTION') }}
</woot-button>
<woot-button
v-if="showDropDown"
v-if="showAdditionalActions"
ref="arrowDownButton"
:color-scheme="buttonClass"
:disabled="isLoading"
icon="ion-arrow-down-b"
@@ -43,19 +44,53 @@
/>
</div>
<div
v-if="showDropdown"
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open"
>
<woot-dropdown-menu>
<woot-dropdown-item v-if="!isBot">
<woot-dropdown-item v-if="!isPending">
<woot-button
variant="clear"
@click="() => toggleStatus(STATUS_TYPE.BOT)"
@click="() => toggleStatus(STATUS_TYPE.PENDING)"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.OPEN_BOT') }}
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-sub-menu
v-if="isOpen"
:title="this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.TITLE')"
>
<woot-dropdown-item>
<woot-button
variant="clear"
@click="() => toggleStatus(STATUS_TYPE.SNOOZED, null)"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.NEXT_REPLY') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
@click="
() => toggleStatus(STATUS_TYPE.SNOOZED, snoozeTimes.tomorrow)
"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.TOMORROW') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
@click="
() => toggleStatus(STATUS_TYPE.SNOOZED, snoozeTimes.nextWeek)
"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.NEXT_WEEK') }}
</woot-button>
</woot-dropdown-item>
</woot-dropdown-sub-menu>
</woot-dropdown-menu>
</div>
</div>
@@ -65,22 +100,37 @@
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedAltAndEKey,
hasPressedCommandPlusAltAndEKey,
hasPressedAltAndMKey,
} from 'shared/helpers/KeyboardHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from '../../constants';
import {
getUnixTime,
addHours,
addWeeks,
startOfTomorrow,
startOfWeek,
} from 'date-fns';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
WootDropdownSubMenu,
},
mixins: [clickaway, alertMixin],
mixins: [clickaway, alertMixin, eventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
isLoading: false,
showDropdown: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
};
},
@@ -91,36 +141,83 @@ export default {
isOpen() {
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
},
isBot() {
return this.currentChat.status === wootConstants.STATUS_TYPE.BOT;
isPending() {
return this.currentChat.status === wootConstants.STATUS_TYPE.PENDING;
},
isResolved() {
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
buttonClass() {
if (this.isBot) return 'primary';
if (this.isPending) return 'primary';
if (this.isOpen) return 'success';
if (this.isResolved) return 'warning';
return '';
},
showDropDown() {
return !this.isBot;
showAdditionalActions() {
return !this.isPending && !this.isSnoozed;
},
snoozeTimes() {
return {
// tomorrow = 9AM next day
tomorrow: getUnixTime(addHours(startOfTomorrow(), 9)),
// next week = 9AM Monday, next week
nextWeek: getUnixTime(
addHours(startOfWeek(addWeeks(new Date(), 1), { weekStartsOn: 1 }), 9)
),
};
},
},
methods: {
async handleKeyEvents(e) {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
if (hasPressedAltAndMKey(e)) {
this.$refs.arrowDownButton.$el.click();
}
if (hasPressedAltAndEKey(e)) {
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
try {
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
// error
}
if (hasPressedCommandPlusAltAndEKey(e)) {
if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
} else if (allConversations.length > 1) {
allConversations[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
}
}
},
showOpenButton() {
return this.isResolved || this.isSnoozed;
},
closeDropdown() {
this.showDropdown = false;
this.showActionsDropdown = false;
},
openDropdown() {
this.showDropdown = true;
this.showActionsDropdown = true;
},
toggleStatus(status) {
toggleStatus(status, snoozedUntil) {
this.closeDropdown();
this.isLoading = true;
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat.id,
status,
snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));

View File

@@ -50,10 +50,17 @@
:show="showOptionsMenu"
@toggle-accounts="toggleAccountModal"
@show-support-chat-window="toggleSupportChatWindow"
@key-shortcut-modal="toggleKeyShortcutModal"
@close="toggleOptions"
/>
</div>
<woot-key-shortcut-modal
v-if="showShortcutModal"
@close="closeKeyShortcutModal"
@clickaway="closeKeyShortcutModal"
/>
<account-selector
:show-account-modal="showAccountModal"
@close-account-modal="toggleAccountModal"
@@ -86,6 +93,9 @@ import OptionsMenu from './sidebarComponents/OptionsMenu.vue';
import AccountSelector from './sidebarComponents/AccountSelector.vue';
import AddAccountModal from './sidebarComponents/AddAccountModal.vue';
import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
import WootKeyShortcutModal from 'components/widgets/modal/WootKeyShortcutModal';
import { hasPressedCommandAndForwardSlash } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
export default {
components: {
@@ -97,14 +107,16 @@ export default {
AccountSelector,
AddAccountModal,
AddLabelModal,
WootKeyShortcutModal,
},
mixins: [adminMixin, alertMixin],
mixins: [adminMixin, alertMixin, eventListenerMixins],
data() {
return {
showOptionsMenu: false,
showAccountModal: false,
showCreateAccountModal: false,
showAddLabelModal: false,
showShortcutModal: false,
};
},
@@ -254,7 +266,19 @@ export default {
this.$store.dispatch('teams/get');
this.setChatwootUser();
},
methods: {
toggleKeyShortcutModal() {
this.showShortcutModal = true;
},
closeKeyShortcutModal() {
this.showShortcutModal = false;
},
handleKeyEvents(e) {
if (hasPressedCommandAndForwardSlash(e)) {
this.toggleKeyShortcutModal();
}
},
toggleSupportChatWindow() {
window.$chatwoot.toggle();
},

View File

@@ -59,10 +59,17 @@
import { mapGetters } from 'vuex';
import router from '../../routes';
import {
hasPressedAltAndCKey,
hasPressedAltAndVKey,
hasPressedAltAndRKey,
hasPressedAltAndSKey,
} from 'shared/helpers/KeyboardHelpers';
import adminMixin from '../../mixins/isAdmin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { getInboxClassByType } from 'dashboard/helper/inbox';
export default {
mixins: [adminMixin],
mixins: [adminMixin, eventListenerMixins],
props: {
menuItem: {
type: Object,
@@ -117,6 +124,20 @@ export default {
}
}
},
handleKeyEvents(e) {
if (hasPressedAltAndCKey(e)) {
router.push({ name: 'home' });
}
if (hasPressedAltAndVKey(e)) {
router.push({ name: 'contacts_dashboard' });
}
if (hasPressedAltAndRKey(e)) {
router.push({ name: 'settings_account_reports' });
}
if (hasPressedAltAndSKey(e)) {
router.push({ name: 'settings_home' });
}
},
showItem(item) {
return this.isAdmin && item.newLink !== undefined;
},

View File

@@ -9,6 +9,14 @@
:header-title="$t('CREATE_ACCOUNT.NEW_ACCOUNT')"
:header-content="$t('CREATE_ACCOUNT.SELECTOR_SUBTITLE')"
/>
<div v-if="!hasAccounts" class="alert-wrap">
<div class="callout alert">
<div class="icon-wrap">
<i class="ion-alert-circled"></i>
</div>
{{ $t('CREATE_ACCOUNT.NO_ACCOUNT_WARNING') }}
</div>
</div>
<form class="row" @submit.prevent="addAccount">
<div class="medium-12 columns">
@@ -53,6 +61,10 @@ export default {
type: Boolean,
default: false,
},
hasAccounts: {
type: Boolean,
default: true,
},
},
data() {
return {
@@ -90,3 +102,19 @@ export default {
},
};
</script>
<style lang="scss">
.alert-wrap {
margin: var(--space-zero) var(--space-large);
margin-top: var(--space-medium);
.callout {
display: flex;
justify-content: center;
}
}
.icon-wrap {
font-size: var(--font-size-big);
margin-left: var(--space-smaller);
margin-right: var(--space-slab);
}
</style>

View File

@@ -26,6 +26,16 @@
Contact Support
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
size="small"
class=" change-accounts--button"
@click="$emit('key-shortcut-modal')"
>
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<router-link
:to="`/app/accounts/${accountId}/profile/settings`"

View File

@@ -10,8 +10,11 @@
</template>
<script>
import wootConstants from '../../constants';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
export default {
mixins: [eventListenerMixins],
props: {
items: {
type: Array,
@@ -28,6 +31,15 @@ export default {
},
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndNKey(e)) {
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(0);
} else {
this.onTabChange(this.activeTabIndex + 1);
}
}
},
onTabChange(selectedTabIndex) {
if (this.items[selectedTabIndex].key !== this.activeTab) {
this.$emit('chatTabChange', this.items[selectedTabIndex].key);

View File

@@ -0,0 +1,38 @@
<template>
<div class="inbox">
<i :class="icon" />
<span>{{ inbox.name }}</span>
</div>
</template>
<script>
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
},
computed: {
icon() {
if (this.inbox.channel_type === INBOX_TYPES.WEB) {
return 'icon ion-earth';
}
return 'icon ion-android-textsms';
},
},
};
</script>
<style scoped lang="scss">
.inbox {
display: flex;
align-items: center;
.icon {
margin-right: var(--space-micro);
min-width: var(--space-normal);
position: relative;
top: -1px;
}
}
</style>

View File

@@ -57,6 +57,7 @@ export default {
value: { type: String, default: '' },
placeholder: { type: String, default: '' },
isPrivate: { type: Boolean, default: false },
isFormatMode: { type: Boolean, default: false },
},
data() {
return {
@@ -139,8 +140,17 @@ export default {
value(newValue = '') {
if (newValue !== this.lastValue) {
const { tr } = this.state;
tr.insertText(newValue, 0, tr.doc.content.size);
this.state = this.view.state.apply(tr);
if (this.isFormatMode) {
this.state = createState(
newValue,
this.placeholder,
this.plugins,
this.isFormatMode
);
} else {
tr.insertText(newValue, 0, tr.doc.content.size);
this.state = this.view.state.apply(tr);
}
this.view.updateState(this.state);
}
},
@@ -271,4 +281,25 @@ export default {
padding: 0 var(--space-smaller);
}
}
.editor-wrap {
margin-bottom: var(--space-normal);
}
.message-editor {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-normal);
padding: 0 var(--space-slab);
margin-bottom: 0;
}
.editor_warning {
border: 1px solid var(--r-400);
}
.editor-warning__message {
color: var(--r-400);
font-weight: var(--font-weight-normal);
padding: var(--space-smaller) 0 0 0;
}
</style>

View File

@@ -78,11 +78,17 @@
<script>
import FileUpload from 'vue-upload-component';
import {
hasPressedAltAndWKey,
hasPressedAltAndAKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { REPLY_EDITOR_MODES } from './constants';
export default {
name: 'ReplyTopPanel',
components: { FileUpload },
mixins: [eventListenerMixins],
props: {
mode: {
type: String,
@@ -156,6 +162,14 @@ export default {
},
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndWKey(e)) {
this.toggleFormatMode();
}
if (hasPressedAltAndAKey(e)) {
this.$refs.upload.$children[1].$el.click();
}
},
toggleFormatMode() {
this.setFormatMode(!this.isFormatMode);
},

View File

@@ -32,11 +32,17 @@
<script>
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
import {
hasPressedAltAndPKey,
hasPressedAltAndLKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
export default {
name: 'ReplyTopPanel',
components: {
EmojiOrIcon,
},
mixins: [eventListenerMixins],
props: {
mode: {
type: String,
@@ -76,6 +82,14 @@ export default {
},
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndPKey(e)) {
this.handleNoteClick();
}
if (hasPressedAltAndLKey(e)) {
this.handleReplyClick();
}
},
handleReplyClick() {
this.setReplyMode(REPLY_EDITOR_MODES.REPLY);
},

View File

@@ -12,12 +12,29 @@
<script>
import wootConstants from '../../../constants';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndBKey } from 'shared/helpers/KeyboardHelpers';
export default {
mixins: [eventListenerMixins],
data: () => ({
activeStatus: wootConstants.STATUS_TYPE.OPEN,
}),
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndBKey(e)) {
if (this.activeStatus === wootConstants.STATUS_TYPE.OPEN) {
this.activeStatus = wootConstants.STATUS_TYPE.RESOLVED;
} else if (this.activeStatus === wootConstants.STATUS_TYPE.RESOLVED) {
this.activeStatus = wootConstants.STATUS_TYPE.PENDING;
} 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.OPEN;
}
}
this.onTabChange();
},
onTabChange() {
this.$store.dispatch('setChatFilter', this.activeStatus);
this.$emit('statusFilterChange', this.activeStatus);

View File

@@ -49,13 +49,11 @@ export default {
},
isContactPanelOpen: {
type: Boolean,
default: false,
default: true,
},
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
}),
...mapGetters({ currentChat: 'getSelectedChat' }),
showContactPanel() {
return this.isContactPanelOpen && this.currentChat.id;
},

View File

@@ -26,7 +26,12 @@
{{ currentContact.name }}
</h4>
<p v-if="lastMessageInChat" class="conversation--message">
<i v-if="messageByAgent" class="ion-ios-undo message-from-agent"></i>
<i v-if="isMessagePrivate" class="ion-locked last-message-icon" />
<i v-else-if="messageByAgent" class="ion-ios-undo last-message-icon" />
<i
v-else-if="isMessageAnActivity"
class="ion-information-circled last-message-icon"
/>
<span v-if="lastMessageInChat.content">
{{ parsedLastMessage }}
</span>
@@ -144,6 +149,18 @@ export default {
return messageType === MESSAGE_TYPE.OUTGOING;
},
isMessageAnActivity() {
const lastMessage = this.lastMessageInChat;
const { message_type: messageType } = lastMessage;
return messageType === MESSAGE_TYPE.ACTIVITY;
},
isMessagePrivate() {
const lastMessage = this.lastMessageInChat;
const { private: isPrivate } = lastMessage;
return isPrivate;
},
parsedLastMessage() {
const { content_attributes: contentAttributes } = this.lastMessageInChat;
const { email: { subject } = {} } = contentAttributes || {};
@@ -230,4 +247,9 @@ export default {
font-size: var(--font-size-mini);
}
}
.last-message-icon {
color: var(--s-600);
font-size: var(--font-size-mini);
}
</style>

View File

@@ -32,33 +32,6 @@
class="header-actions-wrap"
:class="{ 'has-open-sidebar': isContactPanelOpen }"
>
<div class="multiselect-box multiselect-wrap--small">
<i class="icon ion-headphone" />
<multiselect
v-model="currentChat.meta.assignee"
:loading="uiFlags.isFetching"
:allow-empty="true"
deselect-label=""
:options="agentsList"
:placeholder="$t('CONVERSATION.ASSIGNMENT.SELECT_AGENT')"
select-label=""
label="name"
selected-label
track-by="id"
@select="assignAgent"
@remove="removeAgent"
>
<template slot="option" slot-scope="props">
<div class="option__desc">
<availability-status-badge
:status="props.option.availability_status"
/>
<span class="option__title">{{ props.option.name }}</span>
</div>
</template>
<span slot="noResult">{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}</span>
</multiselect>
</div>
<more-actions :conversation-id="currentChat.id" />
</div>
</div>
@@ -68,15 +41,15 @@ import { mapGetters } from 'vuex';
import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import agentMixin from '../../../mixins/agentMixin.js';
import AvailabilityStatusBadge from '../conversation/AvailabilityStatusBadge';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
export default {
components: {
MoreActions,
Thumbnail,
AvailabilityStatusBadge,
},
mixins: [agentMixin],
mixins: [agentMixin, eventListenerMixins],
props: {
chat: {
type: Object,
@@ -117,17 +90,11 @@ export default {
},
methods: {
assignAgent(agent) {
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId: agent.id,
})
.then(() => {
bus.$emit('newToastMessage', this.$t('CONVERSATION.CHANGE_AGENT'));
});
handleKeyEvents(e) {
if (hasPressedAltAndOKey(e)) {
this.$emit('contact-panel-toggle');
}
},
removeAgent() {},
},
};
</script>

View File

@@ -2,6 +2,11 @@
<li v-if="hasAttachments || data.content" :class="alignBubble">
<div :class="wrapClass">
<div v-tooltip.top-start="sentByMessage" :class="bubbleClass">
<bubble-mail-head
v-if="isEmailContentType"
:email-attributes="contentAttributes.email"
:is-incoming="isIncoming"
/>
<bubble-text
v-if="data.content"
:message="message"
@@ -41,6 +46,7 @@
:message-type="data.message_type"
:readable-time="readableTime"
:source-id="data.source_id"
:inbox-id="data.inbox_id"
/>
</div>
<spinner v-if="isPending" size="tiny" />
@@ -79,16 +85,19 @@ import copy from 'copy-text-to-clipboard';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import timeMixin from '../../../mixins/time';
import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text';
import BubbleImage from './bubble/Image';
import BubbleFile from './bubble/File';
import BubbleActions from './bubble/Actions';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
import { isEmptyObject } from 'dashboard/helper/commons';
import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import BubbleActions from './bubble/Actions';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
@@ -98,6 +107,7 @@ export default {
BubbleText,
BubbleImage,
BubbleFile,
BubbleMailHead,
ContextMenu,
Spinner,
},
@@ -133,11 +143,11 @@ export default {
const {
email: {
content_type: contentType = '',
html_content: { full: fullHTMLContent, reply: replyHTMLContent } = {},
text_content: { full: fullTextContent, reply: replyTextContent } = {},
} = {},
} = this.contentAttributes;
let contentToBeParsed =
replyHTMLContent ||
replyTextContent ||
@@ -147,7 +157,12 @@ export default {
if (contentToBeParsed && this.isIncoming) {
const parsedContent = this.stripStyleCharacters(contentToBeParsed);
if (parsedContent) {
return parsedContent;
// This is a temporary fix for line-breaks in text/plain emails
// Now, It is not rendered properly in the email preview.
// FIXME: Remove this once we have a better solution for rendering text/plain emails
return contentType.includes('text/plain')
? parsedContent.replace(/\n/g, '<br />')
: parsedContent;
}
}
return (

View File

@@ -33,11 +33,11 @@
<div v-if="isATweet" class="banner">
<span v-if="!selectedTweetId">
{{ $t('CONVERSATION.LAST_INCOMING_TWEET') }}
{{ $t('CONVERSATION.SELECT_A_TWEET_TO_REPLY') }}
</span>
<span v-else>
{{ $t('CONVERSATION.REPLYING_TO') }}
{{ selectedTweet }}
{{ selectedTweet.content || '' }}
</span>
<button
v-if="selectedTweetId"
@@ -89,9 +89,10 @@
/>
</div>
</div>
<ReplyBox
<reply-box
:conversation-id="currentChat.id"
:in-reply-to="selectedTweetId"
:is-a-tweet="isATweet"
:selected-tweet="selectedTweet"
@scrollToMessage="scrollToBottom"
/>
</div>
@@ -207,10 +208,10 @@ export default {
selectedTweet() {
if (this.selectedTweetId) {
const { messages = [] } = this.getMessages;
const [selectedMessage = {}] = messages.filter(
const [selectedMessage] = messages.filter(
message => message.id === this.selectedTweetId
);
return selectedMessage.content || '';
return selectedMessage || {};
}
return '';
},
@@ -233,7 +234,7 @@ export default {
created() {
bus.$on('scrollToMessage', () => {
setTimeout(() => this.scrollToBottom(), 0);
this.$nextTick(() => this.scrollToBottom());
this.makeMessagesRead();
});
@@ -255,7 +256,7 @@ export default {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.setScrollParams();
this.conversationPanel.addEventListener('scroll', this.handleScroll);
this.scrollToBottom();
this.$nextTick(() => this.scrollToBottom());
this.isLoadingPrevious = false;
},
removeScrollListener() {

View File

@@ -74,6 +74,7 @@
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import EmojiInput from 'shared/components/emoji/EmojiInput';
import CannedResponse from './CannedResponse';
@@ -105,11 +106,21 @@ export default {
ReplyBottomPanel,
WootMessageEditor,
},
mixins: [clickaway, inboxMixin, uiSettingsMixin, alertMixin],
mixins: [
clickaway,
inboxMixin,
uiSettingsMixin,
alertMixin,
eventListenerMixins,
],
props: {
inReplyTo: {
type: [String, Number],
default: '',
selectedTweet: {
type: [Object, String],
default: () => ({}),
},
isATweet: {
type: Boolean,
default: false,
},
},
data() {
@@ -169,11 +180,14 @@ export default {
return this.maxLength - this.message.length;
},
isReplyButtonDisabled() {
const isMessageEmpty = !this.message.trim().replace(/\n/g, '').length;
if (this.isATweet && !this.inReplyTo) {
return true;
}
if (this.hasAttachments) return false;
return (
isMessageEmpty ||
this.isMessageEmpty ||
this.message.length === 0 ||
this.message.length > this.maxLength
);
@@ -198,7 +212,7 @@ export default {
}
if (this.isATwitterInbox) {
if (this.conversationType === 'tweet') {
return MESSAGE_MAX_LENGTH.TWEET;
return MESSAGE_MAX_LENGTH.TWEET - this.replyToUserLength - 2;
}
}
return MESSAGE_MAX_LENGTH.GENERAL;
@@ -235,6 +249,25 @@ export default {
isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE;
},
inReplyTo() {
const selectedTweet = this.selectedTweet || {};
return selectedTweet.id;
},
replyToUserLength() {
const selectedTweet = this.selectedTweet || {};
const {
sender: {
additional_attributes: { screen_name: screenName = '' } = {},
} = {},
} = selectedTweet;
return screenName ? screenName.length : 0;
},
isMessageEmpty() {
if (!this.message) {
return true;
}
return !this.message.trim().replace(/\n/g, '').length;
},
},
watch: {
currentChat(conversation) {
@@ -263,12 +296,6 @@ export default {
}
},
},
mounted() {
document.addEventListener('keydown', this.handleKeyEvents);
},
destroyed() {
document.removeEventListener('keydown', this.handleKeyEvents);
},
methods: {
toggleUserMention(currentMentionState) {
this.hasUserMention = currentMentionState;
@@ -327,6 +354,9 @@ export default {
if (this.showRichContentEditor) {
return;
}
if (this.$refs.messageInput === undefined) {
return;
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
emojiOnClick(emoji) {

View File

@@ -0,0 +1,119 @@
<template>
<div>
<div class="input-group-wrap">
<div class="input-group small" :class="{ error: $v.ccEmails.$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"
type="email"
:class="{ error: $v.ccEmails.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="$v.ccEmails.$touch"
/>
</div>
<woot-button
v-if="!showBcc"
variant="clear"
size="small"
@click="handleAddBcc"
>
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.ADD_BCC') }}
</woot-button>
</div>
<span v-if="$v.ccEmails.$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 }">
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.LABEL') }}
</label>
<div class="input-group-field">
<woot-input
v-model.trim="bccEmails"
type="email"
:class="{ error: $v.bccEmails.$error }"
:placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
"
@blur="$v.bccEmails.$touch"
/>
</div>
</div>
<span v-if="$v.bccEmails.$error" class="message">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.ERROR') }}
</span>
</div>
</div>
</template>
<script>
import { validEmailsByComma } from './helpers/emailHeadHelper';
export default {
props: {
ccEmails: {
type: String,
default: '',
},
bccEmails: {
type: String,
default: '',
},
},
data() {
return {
showBcc: false,
};
},
validations: {
ccEmails: {
hasValidEmails(value) {
return validEmailsByComma(value);
},
},
bccEmails: {
hasValidEmails(value) {
return validEmailsByComma(value);
},
},
},
methods: {
handleAddBcc() {
this.showBcc = true;
},
},
};
</script>
<style lang="scss" scoped>
.input-group-wrap .message {
font-size: var(--font-size-small);
color: var(--r-500);
}
.input-group {
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-smaller);
margin-top: var(--space-smaller);
.input-group-label {
border-color: transparent;
background: transparent;
font-size: var(--font-size-mini);
font-weight: var(--font-weight-bold);
}
.input-group-field::v-deep input {
margin-bottom: 0;
border-color: transparent;
}
}
.input-group.error {
border-bottom-color: var(--r-500);
.input-group-label {
color: var(--r-500);
}
}
</style>

View File

@@ -14,13 +14,13 @@
@mouseleave="isHovered = false"
/>
<i
v-if="isATweet && isIncoming"
v-if="isATweet && (isIncoming || isOutgoing) && sourceId"
v-tooltip.top-start="$t('CHAT_LIST.REPLY_TO_TWEET')"
class="icon ion-reply cursor-pointer"
@click="onTweetReply"
/>
<a
v-if="isATweet && isIncoming"
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
:href="linkToTweet"
target="_blank"
rel="noopener noreferrer nofollow"
@@ -71,19 +71,33 @@ export default {
type: [String, Number],
default: '',
},
inboxId: {
type: [String, Number],
default: 0,
},
},
computed: {
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
isIncoming() {
return MESSAGE_TYPE.INCOMING === this.messageType;
},
isOutgoing() {
return MESSAGE_TYPE.OUTGOING === this.messageType;
},
screenName() {
const { additional_attributes: additionalAttributes = {} } =
this.sender || {};
return additionalAttributes?.screen_name || '';
},
linkToTweet() {
if (!this.sourceId || !this.inbox.name) {
return '';
}
const { screenName, sourceId } = this;
return `https://twitter.com/${screenName}/status/${sourceId}`;
return `https://twitter.com/${screenName ||
this.inbox.name}/status/${sourceId}`;
},
},
methods: {
@@ -113,6 +127,13 @@ export default {
}
}
.right {
.ion-reply,
.ion-android-open {
color: var(--white);
}
}
.message-text--metadata {
align-items: flex-end;
display: flex;

View File

@@ -36,7 +36,7 @@ export default {
methods: {
openLink() {
const win = window.open(this.url, '_blank', 'noopener');
win.focus();
if (win) win.focus();
},
},
};

View File

@@ -0,0 +1,82 @@
<template>
<div
v-if="showHead"
class="message__mail-head"
:class="{ 'is-incoming': isIncoming }"
>
<div v-if="toMails" class="meta-wrap">
<span class="message__content--type">{{ $t('EMAIL_HEADER.TO') }}:</span>
<span>{{ toMails }}</span>
</div>
<div v-if="ccMails" class="meta-wrap">
<span class="message__content--type">{{ $t('EMAIL_HEADER.CC') }}:</span>
<span>{{ ccMails }}</span>
</div>
<div v-if="bccMails" class="meta-wrap">
<span class="message__content--type">{{ $t('EMAIL_HEADER.BCC') }}:</span>
<span>{{ bccMails }}</span>
</div>
<div v-if="subject" class="meta-wrap">
<span class="message__content--type">
{{ $t('EMAIL_HEADER.SUBJECT') }}:
</span>
<span>{{ subject }}</span>
</div>
</div>
</template>
<script>
export default {
props: {
emailAttributes: {
type: Object,
default: () => ({}),
},
isIncoming: {
type: Boolean,
default: true,
},
},
computed: {
toMails() {
const to = this.emailAttributes.to || [];
return to.join(', ');
},
ccMails() {
const cc = this.emailAttributes.cc || [];
return cc.join(', ');
},
bccMails() {
const bcc = this.emailAttributes.bcc || [];
return bcc.join(', ');
},
subject() {
return this.emailAttributes.subject || '';
},
showHead() {
return this.toMails || this.ccMails || this.bccMails;
},
},
};
</script>
<style lang="scss" scoped>
.message__mail-head {
padding-bottom: var(--space-small);
margin-bottom: var(--space-small);
border-bottom: 1px solid var(--w-300);
&.is-incoming {
border-bottom: 1px solid var(--color-border-light);
}
}
.meta-wrap {
.message__content--type {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-mini);
}
span {
font-size: var(--font-size-mini);
}
}
</style>

View File

@@ -0,0 +1,7 @@
import emailValidator from 'vuelidate/lib/validators/email';
export const validEmailsByComma = value => {
if (!value.length) return true;
const emails = value.split(',');
return emails.every(email => emailValidator(email));
};

View File

@@ -0,0 +1,13 @@
import { validEmailsByComma } from '../emailHeadHelper';
describe('#validEmailsByComma', () => {
it('returns true when empty string is passed', () => {
expect(validEmailsByComma('')).toEqual(true);
});
it('returns true when valid emails separated by comma is passed', () => {
expect(validEmailsByComma('ni@njan.com,po@va.da')).toEqual(true);
});
it('returns false when one of the email passed is invalid', () => {
expect(validEmailsByComma('ni@njan.com,pova.da')).toEqual(false);
});
});

View File

@@ -0,0 +1,27 @@
import ReplyEmailHead from '../ReplyEmailHead';
export default {
title: 'Components/ReplyBox/EmailHead',
component: ReplyEmailHead,
argTypes: {
ccEmails: {
control: {
type: 'string',
},
},
bccEmails: {
control: {
type: 'string',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ReplyEmailHead },
template:
'<reply-email-head v-bind="$props" @add="onAdd" @click="onClick"></reply-email-head>',
});
export const Add = Template.bind({});

View File

@@ -0,0 +1,17 @@
import WootKeyboardShortcutModal from './WootKeyShortcutModal.vue';
export default {
title: 'Components/Shortcuts/Keyboard Shortcut',
component: WootKeyboardShortcutModal,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { WootKeyboardShortcutModal },
template:
'<woot-keyboard-shortcut-modal v-bind="$props"></woot-keyboard-shortcut-modal>',
});
export const KeyboardShortcut = Template.bind({});
KeyboardShortcut.args = {};

View File

@@ -0,0 +1,182 @@
<template>
<transition name="slide-up">
<div class="modal-mask">
<div v-on-clickaway="() => $emit('clickaway')" class="modal-container">
<div class="header-wrap">
<div class="title-shortcut-key__wrap">
<h2 class="page-title">
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
</h2>
<div class="shortcut-key__wrap">
<p class="shortcut-key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.COMMAND_KEY') }}
</p>
<p class="shortcut-key key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.FORWARD_SLASH_KEY') }}
</p>
</div>
</div>
<i class="ion-android-close modal--close" @click="$emit('close')"></i>
</div>
<div class="shortcut__wrap">
<div class="title-key__wrap">
<span class="sub-block-title">
{{ $t('KEYBOARD_SHORTCUTS.TITLE.OPEN_CONVERSATION') }}
</span>
<div class="shortcut-key__wrap">
<div class="open-conversation__key">
<span class="shortcut-key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</span>
<span class="shortcut-key">
J
</span>
<span class="forward-slash sub-block-title">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.FORWARD_SLASH_KEY') }}
</span>
</div>
<span class="shortcut-key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</span>
<span class="shortcut-key key">
K
</span>
</div>
</div>
<div class="title-key__wrap">
<span class="sub-block-title">
{{ $t('KEYBOARD_SHORTCUTS.TITLE.RESOLVE_AND_NEXT') }}
</span>
<div class="shortcut-key__wrap">
<span class="shortcut-key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.COMMAND_KEY') }}
</span>
<span class="shortcut-key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</span>
<span class="shortcut-key key">
E
</span>
</div>
</div>
<div
v-for="shortcutKey in shortcutKeys"
:key="shortcutKey.id"
class="title-key__wrap"
>
<span class="sub-block-title">
{{ title(shortcutKey) }}
</span>
<div class="shortcut-key__wrap">
<span class="shortcut-key">
{{ shortcutKey.firstkey }}
</span>
<span class="shortcut-key key">
{{ shortcutKey.secondKey }}
</span>
</div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { SHORTCUT_KEYS } from './constants';
export default {
mixins: [clickaway],
data() {
return {
shortcutKeys: SHORTCUT_KEYS,
};
},
methods: {
title(item) {
return this.$t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`);
},
},
};
</script>
<style lang="scss" scoped>
.modal-container {
padding: var(--space-medium) var(--space-large) var(--space-large)
var(--space-large);
width: fit-content;
}
.header-wrap {
display: flex;
justify-content: space-between;
align-items: center;
}
.title-shortcut-key__wrap {
display: flex;
margin-bottom: var(--space-small);
}
.page-title {
font-size: var(--font-size-big);
font-weight: var(--font-weight-bold);
}
.shortcut-key__wrap {
display: flex;
align-items: center;
margin-bottom: var(--space-smaller);
margin-left: var(--space-small);
}
.shortcut__wrap {
display: grid;
grid-template-columns: repeat(2, 0.5fr);
gap: var(--space-smaller) var(--space-large);
margin-top: var(--space-small);
}
.title-key__wrap {
display: flex;
justify-content: space-between;
align-items: center;
min-width: 40rem;
}
.sub-block-title {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
.forward-slash {
display: flex;
align-items: center;
font-weight: var(--font-weight-bold);
}
.shortcut-key {
background: var(--color-background);
padding: var(--space-small) var(--space-one);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-mini);
align-items: center;
border-radius: var(--border-radius-normal);
margin-right: var(--space-small);
}
.key {
display: flex;
justify-content: center;
min-width: var(--space-large);
margin-right: 0;
}
.open-conversation__key {
display: flex;
margin-right: var(--space-small);
}
</style>

View File

@@ -0,0 +1,86 @@
export const SHORTCUT_KEYS = [
{
id: 1,
label: 'NAVIGATE_DROPDOWN',
firstkey: 'Up',
secondKey: 'Down',
},
{
id: 2,
label: 'RESOLVE_CONVERSATION',
firstkey: 'Alt / ⌥',
secondKey: 'E',
},
{
id: 3,
label: 'GO_TO_CONVERSATION_DASHBOARD',
firstkey: 'Alt / ⌥',
secondKey: 'C',
},
{
id: 4,
label: 'ADD_ATTACHMENT',
firstkey: 'Alt / ⌥',
secondKey: 'A',
},
{
id: 5,
label: 'GO_TO_CONTACTS_DASHBOARD',
firstkey: 'Alt / ⌥',
secondKey: 'V',
},
{
id: 6,
label: 'TOGGLE_SIDEBAR',
firstkey: 'Alt / ⌥',
secondKey: 'O',
},
{
id: 7,
label: 'GO_TO_REPORTS_SIDEBAR',
firstkey: 'Alt / ⌥',
secondKey: 'R',
},
{
id: 8,
label: 'MOVE_TO_NEXT_TAB',
firstkey: 'Alt / ⌥',
secondKey: 'N',
},
{
id: 9,
label: 'GO_TO_SETTINGS',
firstkey: 'Alt / ⌥',
secondKey: 'S',
},
{
id: 10,
label: 'SWITCH_CONVERSATION_STATUS',
firstkey: 'Alt / ⌥',
secondKey: 'B',
},
{
id: 11,
label: 'SWITCH_TO_PRIVATE_NOTE',
firstkey: 'Alt / ⌥',
secondKey: 'P',
},
{
id: 12,
label: 'TOGGLE_RICH_CONTENT_EDITOR',
firstkey: 'Alt / ⌥',
secondKey: 'W',
},
{
id: 13,
label: 'SWITCH_TO_REPLY',
firstkey: 'Alt / ⌥',
secondKey: 'L',
},
{
id: 14,
label: 'TOGGLE_SNOOZE_DROPDOWN',
firstkey: 'Alt / ⌥',
secondKey: 'M',
},
];

View File

@@ -8,7 +8,8 @@ export default {
STATUS_TYPE: {
OPEN: 'open',
RESOLVED: 'resolved',
BOT: 'bot',
PENDING: 'pending',
SNOOZED: 'snoozed',
},
};
export const DEFAULT_REDIRECT_URL = '/app/';

View File

@@ -47,6 +47,13 @@ export const getSidebarItems = accountId => ({
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',
@@ -105,6 +112,32 @@ export const getSidebarItems = accountId => ({
},
},
},
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',

View File

@@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "تحديث إعدادات قناة التواصل",
"AUTO_ASSIGNMENT_SUB_TEXT": "تمكين أو تعطيل الإسناد التلقائي للمحادثات الجديدة إلى الموظفين المضافين إلى قناة التواصل هذه.",
"HMAC_VERIFICATION": "التحقق من هوية المستخدم",
"HMAC_DESCRIPTION": "للتحقق من هوية المستخدمين، يسمح لك SDK بتمرير 'identity_hash' لكل مستخدم. يمكنك إنشاء HMAC باستخدام 'sha256' مع المفتاح المعروض هنا."
"HMAC_DESCRIPTION": "للتحقق من هوية المستخدمين، يسمح لك SDK بتمرير 'identifier_hash' لكل مستخدم. يمكنك إنشاء HMAC باستخدام 'sha256' مع المفتاح المعروض هنا."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "إعادة التصريح",

View File

@@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Actualitza la configuració de la safata d'entrada",
"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 validate the users identity, the SDK allows you to pass an `identity_hash` for each user. You can generate HMAC using 'sha256' with the key shown here."
"HMAC_DESCRIPTION": "Inorder validate the users identity, the SDK allows you to pass an `identifier_hash` for each user. You can generate HMAC using 'sha256' with the key shown here."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reautoritza",

View File

@@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Aktualizujte nastavení doručené pošty",
"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 validate the users identity, the SDK allows you to pass an `identity_hash` for each user. You can generate HMAC using 'sha256' with the key shown here."
"HMAC_DESCRIPTION": "Inorder validate the users identity, the SDK allows you to pass an `identifier_hash` for each user. You can generate HMAC using 'sha256' with the key shown here."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Znovu autorizovat",

View File

@@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Opdater dine indbakkeindstillinger",
"AUTO_ASSIGNMENT_SUB_TEXT": "Aktiver eller deaktiver automatisk tildeling af nye samtaler til agenter tilføjet til denne indbakke.",
"HMAC_VERIFICATION": "User Identity Validation",
"HMAC_DESCRIPTION": "Inorder validate the users identity, the SDK allows you to pass an `identity_hash` for each user. You can generate HMAC using 'sha256' with the key shown here."
"HMAC_DESCRIPTION": "Inorder validate the users identity, the SDK allows you to pass an `identifier_hash` for each user. You can generate HMAC using 'sha256' with the key shown here."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Genautorisér",

View File

@@ -9,6 +9,7 @@
"SEARCH_MESSAGES": "Nachrichten durchsuchen",
"SEARCH": {
"TITLE": "Nachrichten durchsuchen",
"RESULT_TITLE": "Suchergebnisse",
"LOADING_MESSAGE": "Daten werden geladen...",
"PLACEHOLDER": "Geben Sie einen Text ein, um danach zu suchen",
"NO_MATCHING_RESULTS": "Keine Ergebnisse gefunden."

View File

@@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Posteingangseinstellungen aktualisieren",
"AUTO_ASSIGNMENT_SUB_TEXT": "Aktivieren oder deaktivieren Sie die automatische Zuweisung verfügbarer Agenten für neue Konversationen",
"HMAC_VERIFICATION": "Benutzeridentitätsüberprüfung",
"HMAC_DESCRIPTION": "Um die Benutzer-Identität zu validieren, kannst du einen `identity_hash` für jeden Benutzer übergeben. Du kannst den Hash mithilfe des 'sha256' Verfahrens generieren, der notwendige Schlüssel wird hier angezeigt."
"HMAC_DESCRIPTION": "Um die Benutzer-Identität zu validieren, kannst du einen `identifier_hash` für jeden Benutzer übergeben. Du kannst den Hash mithilfe des 'sha256' Verfahrens generieren, der notwendige Schlüssel wird hier angezeigt."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Neu autorisieren",

View File

@@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Ενημερώστε τις ρυθμίσεις του κιβωτίου σας",
"AUTO_ASSIGNMENT_SUB_TEXT": "Ενεργοποιήστε ή απενεργοποιήστε την αυτόματη αντιστοίχιση των νέων συζητήσεων στους πράκτορες αυτού του κιβωτίου.",
"HMAC_VERIFICATION": "Επικύρωση Ταυτότητας Χρήστη",
"HMAC_DESCRIPTION": "Για λόγους επικύρωσης της ταυτότητας των χρηστών, το SDK σας επιτρέπει να περάσετε ένα `identity_hash` για κάθε χρήστη. Μπορείτε να δημιουργήσετε HMAC χρησιμοποιώντας το 'sha256' με το κλειδί που εμφανίζεται εδώ."
"HMAC_DESCRIPTION": "Για λόγους επικύρωσης της ταυτότητας των χρηστών, το SDK σας επιτρέπει να περάσετε ένα `identifier_hash` για κάθε χρήστη. Μπορείτε να δημιουργήσετε HMAC χρησιμοποιώντας το 'sha256' με το κλειδί που εμφανίζεται εδώ."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Εκ νέου εξουσιοδότηση",

View File

@@ -91,6 +91,23 @@
},
"SEARCH": {
"NO_RESULTS": "No results found."
},
"MULTI_SELECTOR": {
"PLACEHOLDER": "None",
"TITLE": {
"AGENT": "Select agent",
"TEAM": "Select team"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
"TEAM": "No teams found"
},
"PLACEHOLDER": {
"AGENT": "Search agents",
"TEAM": "Search teams"
}
}
}
}
}

View File

@@ -2,7 +2,10 @@
"CAMPAIGN": {
"HEADER": "Campaigns",
"SIDEBAR_TXT": "Proactive messages allow the customer to send outbound messages to their contacts which would trigger more conversations. Click on <b>Add Campaign</b> to create a new campaign. You can also edit or delete an existing campaign by clicking on the Edit or Delete button.",
"HEADER_BTN_TXT": "Create a campaign",
"HEADER_BTN_TXT": {
"ONE_OFF": "Create a one off campaign",
"ONGOING": "Create a ongoing campaign"
},
"ADD": {
"TITLE": "Create a campaign",
"DESC": "Proactive messages allow the customer to send outbound messages to their contacts which would trigger more conversations.",
@@ -25,6 +28,11 @@
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
},
"INBOX": {
"LABEL": "Select Inbox",
"PLACEHOLDER": "Select Inbox",
"ERROR": "Inbox is required"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter the message of campaign",
@@ -80,6 +88,7 @@
"TABLE_HEADER": {
"TITLE": "Title",
"MESSAGE": "Message",
"INBOX": "Inbox",
"STATUS": "Status",
"SENDER": "Sender",
"URL": "URL",
@@ -101,6 +110,16 @@
"SENDER": {
"BOT": "Bot"
}
},
"ONE_OFF": {
"HEADER": "One off campaigns",
"404": "There are no one off campaigns created",
"INBOXES_NOT_FOUND": "Please create an sms inbox and start adding campaigns"
},
"ONGOING": {
"HEADER": "Ongoing campaigns",
"404": "There are no ongoing campaigns created",
"INBOXES_NOT_FOUND": "Please create an website inbox and start adding campaigns"
}
}
}

View File

@@ -47,8 +47,12 @@
"VALUE": "resolved"
},
{
"TEXT": "Bot",
"VALUE": "bot"
"TEXT": "Pending",
"VALUE": "pending"
},
{
"TEXT": "Snoozed",
"VALUE": "snoozed"
}
],
"ATTACHMENTS": {

View File

@@ -9,6 +9,7 @@
"SEARCH_MESSAGES": "Search for messages in conversations",
"SEARCH": {
"TITLE": "Search messages",
"RESULT_TITLE": "Search Results",
"LOADING_MESSAGE": "Crunching data...",
"PLACEHOLDER": "Type any text to search messages",
"NO_MATCHING_RESULTS": "No results found."
@@ -22,7 +23,7 @@
"24_HOURS_WINDOW": "24 hour message window restriction",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"LAST_INCOMING_TWEET": "You are replying to the last incoming tweet",
"SELECT_A_TWEET_TO_REPLY": "Please select a tweet to reply to.",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -41,7 +42,13 @@
"DETAILS": "details"
},
"RESOLVE_DROPDOWN": {
"OPEN_BOT": "Open with bot"
"MARK_PENDING": "Mark as pending",
"SNOOZE": {
"TITLE": "Snooze until",
"NEXT_REPLY": "Next reply",
"TOMORROW": "Tomorrow",
"NEXT_WEEK": "Next week"
}
},
"FOOTER": {
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
@@ -57,7 +64,20 @@
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"ENTER_TO_SEND": "Enter to send",
"DRAG_DROP": "Drag and drop here to attach"
"DRAG_DROP": "Drag and drop here to attach",
"EMAIL_HEAD": {
"ADD_BCC": "Add bcc",
"CC": {
"LABEL": "CC",
"PLACEHOLDER": "Emails separated by commas",
"ERROR": "Please enter valid email addresses"
},
"BCC": {
"LABEL": "BCC",
"PLACEHOLDER": "Emails separated by commas",
"ERROR": "Please enter valid email addresses"
}
}
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
"CHANGE_STATUS": "Conversation status changed",
@@ -123,5 +143,11 @@
"SELECT": {
"PLACEHOLDER": "None"
}
},
"EMAIL_HEADER": {
"TO": "To",
"BCC": "Bcc",
"CC": "Cc",
"SUBJECT": "Subject"
}
}

View File

@@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
"HMAC_VERIFICATION": "User Identity Validation",
"HMAC_DESCRIPTION": "Inorder validate the users identity, the SDK allows you to pass an `identity_hash` for each user. You can generate HMAC using 'sha256' with the key shown here."
"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."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize",

View File

@@ -98,6 +98,7 @@
"CHANGE_ACCOUNTS": "Switch Account",
"SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile Settings",
"KEYBOARD_SHORTCUTS": "Keyboard Shortcuts",
"LOGOUT": "Logout"
},
"APP_GLOBAL": {
@@ -126,8 +127,8 @@
"SIDEBAR": {
"CONVERSATIONS": "Conversations",
"REPORTS": "Reports",
"CONTACTS": "Contacts",
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
"HOME": "Home",
"AGENTS": "Agents",
"INBOXES": "Inboxes",
@@ -141,9 +142,13 @@
"ALL_CONTACTS": "All Contacts",
"TAGGED_WITH": "Tagged with",
"REPORTS_OVERVIEW": "Overview",
"CSAT": "CSAT"
"CSAT": "CSAT",
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
"ONE_OFF": "One off"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",
"SELECTOR_SUBTITLE": "Create a new account",
"API": {
@@ -158,5 +163,30 @@
},
"SUBMIT": "Submit"
}
},
"KEYBOARD_SHORTCUTS": {
"TITLE": {
"OPEN_CONVERSATION": "Open conversation",
"RESOLVE_AND_NEXT": "Resolve and move to next",
"NAVIGATE_DROPDOWN": "Navigate dropdown items",
"RESOLVE_CONVERSATION": "Resolve Conversation",
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
"ADD_ATTACHMENT": "Add Attachment",
"GO_TO_CONTACTS_DASHBOARD": "Go to Contacts Dashboard",
"TOGGLE_SIDEBAR": "Toggle Sidebar",
"GO_TO_REPORTS_SIDEBAR": "Go to Reports sidebar",
"MOVE_TO_NEXT_TAB": "Move to next tab in conversation list",
"GO_TO_SETTINGS": "Go to Settings",
"SWITCH_CONVERSATION_STATUS": "Switch Conversation status",
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"TOGGLE_RICH_CONTENT_EDITOR": "Toggle Rich Content editor",
"SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
},
"KEYS": {
"COMMAND_KEY": "⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
}
}
}

View File

@@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Actualizar la configuración de tu bandeja de entrada",
"AUTO_ASSIGNMENT_SUB_TEXT": "Activar o desactivar la asignación automática de nuevas conversaciones a los agentes añadidos a esta bandeja de entrada.",
"HMAC_VERIFICATION": "Validación de identidad de usuario",
"HMAC_DESCRIPTION": "Con el fin de validar la identidad de los usuarios, el SDK le permite pasar un `identity_hash` por cada usuario. Puede generar HMAC usando 'sha256' con la clave que se muestra aquí."
"HMAC_DESCRIPTION": "Con el fin de validar la identidad de los usuarios, el SDK le permite pasar un `identifier_hash` por cada usuario. Puede generar HMAC usando 'sha256' con la clave que se muestra aquí."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reautorizar",

View File

@@ -3,7 +3,7 @@
"HEADER": "Edustajat",
"HEADER_BTN_TXT": "Lisää edustaja",
"LOADING": "Haetaan Edustajalistaa",
"SIDEBAR_TXT": "<p><b>Edustajat</b></p> <p> An <b>Edustaja</b> on jäsenenä asiakastukitiimissäsi. </p><p> Edustajat voivat katsella ja vastata viesteihin asiakkailtasi. Luettelo näyttää kaikki edustajat, jotka ovat tällä hetkellä tililläsi. </p><p> Klikkaa <b>Lisää edustaja</b> lisätäksesi uuden edustajan. Edustaja, jonka lisäät, saa sähköpostiviestin, jossa on vahvistuslinkki tilin aktivointiin, jonka jälkeen he voivat käyttää Chatwoot -sovellusta ja vastata viesteihin. </p><p> Pääsy Chatwoot'n ominaisuuksiin perustuu seuraaviin rooleihin. </p><p> <b>Edustaja</b> - Tällä roolilla toimivat edustajat voivat käyttää vain saapuneita, raportteja ja keskusteluja. He voivat määrittää keskusteluja muille edustajille tai itse ratkaista keskusteluja.</p><p> <b>Ylläpitäjä</b> - Ylläpitäjällä on pääsy kaikkiin Chatwoot ominaisuuksiin, jotka ovat käytössä tililläsi, mukaan lukien asetukset sekä kaikki normaalien asiamiesten oikeudet.</p>",
"SIDEBAR_TXT": "<p><b>Edustajat</b></p> <p> <b>Edustaja</b> on jäsenenä asiakastukitiimissäsi. </p><p> Edustajat voivat katsella ja vastata viesteihin asiakkailtasi. Luettelo näyttää kaikki edustajat, jotka ovat tällä hetkellä tililläsi. </p><p> Klikkaa <b>Lisää edustaja</b> lisätäksesi uuden edustajan. Edustaja, jonka lisäät, saa sähköpostiviestin, jossa on vahvistuslinkki tilin aktivointiin, jonka jälkeen he voivat käyttää Chatwoot -sovellusta ja vastata viesteihin. </p><p> Pääsy Chatwoot'n ominaisuuksiin perustuu seuraaviin rooleihin. </p><p> <b>Edustaja</b> - Tällä roolilla toimivat edustajat voivat käyttää vain saapuneita, raportteja ja keskusteluja. He voivat määrittää keskusteluja muille edustajille tai itse ratkaista keskusteluja.</p><p> <b>Ylläpitäjä</b> - Ylläpitäjällä on pääsy kaikkiin Chatwoot ominaisuuksiin, jotka ovat käytössä tililläsi, mukaan lukien asetukset sekä kaikki normaalien asiamiesten oikeudet.</p>",
"AGENT_TYPES": {
"ADMINISTRATOR": "Ylläpitäjä",
"AGENT": "Edustajat"
@@ -29,9 +29,9 @@
"PLACEHOLDER": "Ole hyvä ja kirjoita edustajan nimi"
},
"AGENT_TYPE": {
"LABEL": "Agent Type",
"PLACEHOLDER": "Please select a type",
"ERROR": "Agent type is required"
"LABEL": "Edustajan tyyppi",
"PLACEHOLDER": "Valitse tyyppi",
"ERROR": "Edustajan tyyppi on pakollinen"
},
"EMAIL": {
"LABEL": "Sähköpostiosoite",
@@ -66,9 +66,9 @@
"PLACEHOLDER": "Ole hyvä ja kirjoita edustajan nimi"
},
"AGENT_TYPE": {
"LABEL": "Agent Type",
"PLACEHOLDER": "Please select a type",
"ERROR": "Agent type is required"
"LABEL": "Edustajan tyyppi",
"PLACEHOLDER": "Valitse edustajan tyyppi",
"ERROR": "Edustajan tyyppi on pakollinen"
},
"EMAIL": {
"LABEL": "Sähköpostiosoite",
@@ -90,7 +90,7 @@
}
},
"SEARCH": {
"NO_RESULTS": "No results found."
"NO_RESULTS": "Ei hakutuloksia."
}
}
}

View File

@@ -1,56 +1,56 @@
{
"CAMPAIGN": {
"HEADER": "Campaigns",
"SIDEBAR_TXT": "Proactive messages allow the customer to send outbound messages to their contacts which would trigger more conversations. Click on <b>Add Campaign</b> to create a new campaign. You can also edit or delete an existing campaign by clicking on the Edit or Delete button.",
"HEADER_BTN_TXT": "Create a campaign",
"HEADER": "Kampanja",
"SIDEBAR_TXT": "Proactive messages allow the customer to send outbound messages to their contacts which would trigger more conversations. Click on <b>Add Campaign</b> to create a new kampanja. You can also edit or delete an existing kampanja by clicking on the Edit or Delete button.",
"HEADER_BTN_TXT": "Luo kampanja",
"ADD": {
"TITLE": "Create a campaign",
"TITLE": "Luo kampanja",
"DESC": "Proactive messages allow the customer to send outbound messages to their contacts which would trigger more conversations.",
"CANCEL_BUTTON_TEXT": "Peruuta",
"CREATE_BUTTON_TEXT": "Luo",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
"LABEL": "Otsikko",
"PLACEHOLDER": "Syötä kampanjan otsikko",
"ERROR": "Otsikko on pakollinen"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Please select the time",
"CONFIRM": "Confirm",
"ERROR": "Scheduled time is required"
"LABEL": "Kampanjan alkamisaika",
"PLACEHOLDER": "Syötä kampanja alkamisaika",
"CONFIRM": "Varmista",
"ERROR": "Alkamisaika on pakollinen"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
"LABEL": "Kohdeyleisö",
"PLACEHOLDER": "Valitse tunnisteet",
"ERROR": "Kohdeyleisö on pakollinen"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter the message of campaign",
"ERROR": "Message is required"
"LABEL": "Viesti",
"PLACEHOLDER": "Syötä kampanjan viesti",
"ERROR": "Viesti on pakollinen"
},
"SENT_BY": {
"LABEL": "Lähettäjä",
"PLACEHOLDER": "Please select the the content of campaign",
"ERROR": "Sender is required"
"PLACEHOLDER": "Syötä kampanjan lähettäjä",
"ERROR": "Lähettäjä on pakollinen"
},
"END_POINT": {
"LABEL": "URL",
"PLACEHOLDER": "Please enter the URL",
"PLACEHOLDER": "Syötä kampanjan URL",
"ERROR": "Anna kelvollinen URL-osoite"
},
"TIME_ON_PAGE": {
"LABEL": "Time on page(Seconds)",
"PLACEHOLDER": "Please enter the time",
"ERROR": "Time on page is required"
"LABEL": "Aika sivulla (sekunttia)",
"PLACEHOLDER": "Syötä aika",
"ERROR": "Aika sivulla on pakollinen"
},
"ENABLED": "Enable campaign",
"SUBMIT": "Add Campaign"
"ENABLED": "Aktivoi kampanja",
"SUBMIT": "Lisää kampanja"
},
"API": {
"SUCCESS_MESSAGE": "Campaign created successfully",
"ERROR_MESSAGE": "There was an error. Please try again."
"SUCCESS_MESSAGE": "Kampanja luotu onnistuneesti",
"ERROR_MESSAGE": "Hö! Kampanjaa luodessa tapahtui virhe"
}
},
"DELETE": {
@@ -62,41 +62,41 @@
"NO": "Ei, säilytä "
},
"API": {
"SUCCESS_MESSAGE": "Campaign deleted successfully",
"ERROR_MESSAGE": "Could not delete the campaign. Please try again later."
"SUCCESS_MESSAGE": "Kampanja poistettu onnistuneesti",
"ERROR_MESSAGE": "Hö! Kampanjaa poistaessa tapahtui virhe. Kokeile myöhemmin uudelleen"
}
},
"EDIT": {
"TITLE": "Edit campaign",
"TITLE": "Muokkaa kampanjaa",
"UPDATE_BUTTON_TEXT": "Päivitä",
"API": {
"SUCCESS_MESSAGE": "Campaign updated successfully",
"ERROR_MESSAGE": "Tapahtui virhe, yritä uudelleen"
"SUCCESS_MESSAGE": "Kampanjaa muokattu onnistuneesti",
"ERROR_MESSAGE": "Hö! Kampanjaa muokatessa tapahtui virhe"
}
},
"LIST": {
"LOADING_MESSAGE": "Loading campaigns...",
"404": "There are no campaigns created for this inbox.",
"LOADING_MESSAGE": "Ladataan kampanjoita...",
"404": "Tälle kansiolle ei ole luotu kampanjoita.",
"TABLE_HEADER": {
"TITLE": "Title",
"MESSAGE": "Message",
"TITLE": "Otsikko",
"MESSAGE": "Viesti",
"STATUS": "Tila",
"SENDER": "Sender",
"SENDER": "Lähettäjä",
"URL": "URL",
"SCHEDULED_AT": "Scheduled time",
"TIME_ON_PAGE": "Time(Seconds)",
"CREATED_AT": "Created at"
"SCHEDULED_AT": "Aloitusaika",
"TIME_ON_PAGE": "Aika sivulla (sekunttia)",
"CREATED_AT": "Luotu"
},
"BUTTONS": {
"ADD": "Add",
"ADD": "Luo uusi",
"EDIT": "Muokkaa",
"DELETE": "Poista"
},
"STATUS": {
"ENABLED": "Käytössä",
"DISABLED": "Pois käytöstä",
"COMPLETED": "Completed",
"ACTIVE": "Active"
"COMPLETED": "Suoritettu",
"ACTIVE": "Käynnissä"
},
"SENDER": {
"BOT": "Botti"

View File

@@ -81,6 +81,6 @@
"VIEW_TWEET_IN_TWITTER": "Näytä twiitti Twitterissä",
"REPLY_TO_TWEET": "Vastaa tähän twiittiin",
"NO_MESSAGES": "Ei Viestejä",
"NO_CONTENT": "No content available"
"NO_CONTENT": "Sisältöä ei saatavilla"
}
}

View File

@@ -19,17 +19,17 @@
},
"LABELS": {
"CONTACT": {
"TITLE": "Contact Labels",
"ERROR": "Couldn't update labels"
"TITLE": "Yhteystietotunnisteet",
"ERROR": "Tunnisteita ei voitu päivittää"
},
"CONVERSATION": {
"TITLE": "Keskustelutunnisteet",
"ADD_BUTTON": "Add Labels"
"ADD_BUTTON": "Lisää tunnisteita"
},
"LABEL_SELECT": {
"TITLE": "Add Labels",
"PLACEHOLDER": "Search labels",
"NO_RESULT": "No labels found"
"TITLE": "Lisää tunnisteita",
"PLACEHOLDER": "Hae tunnisteita",
"NO_RESULT": "Tunnisteita ei löytynyt"
}
},
"MUTE_CONTACT": "Mykistä Keskustelu",
@@ -71,8 +71,8 @@
"PHONE_NUMBER": {
"PLACEHOLDER": "Anna yhteystiedon puhelinnumero",
"LABEL": "Puhelinnumero",
"HELP": "Phone number should be of E.164 format eg: +1415555555 [+][country code][area code][local phone number]",
"ERROR": "Phone number should be either empty or of E.164 format"
"HELP": "Puhelinnumeron tulee olla E.164 formaattia, esim: +1415555555 [+][maakoodi][aluekoodi][puhelinnumero]",
"ERROR": "Puhelinnumeron tulee olla tyhjä tai E.164 formaattia"
},
"LOCATION": {
"PLACEHOLDER": "Anna yhteystiedon sijainti",
@@ -109,129 +109,129 @@
"BUTTON_LABEL": "Aloita keskustelu",
"TITLE": "Uusi keskustelu",
"DESC": "Aloita uusi keskustelu lähettämällä uusi viesti.",
"NO_INBOX": "Couldn't find an inbox to initiate a new conversation with this contact.",
"NO_INBOX": "Kansiota jolla tälle kontaktille olisi voinut luoda keskustelun ei löytynyt.",
"FORM": {
"TO": {
"LABEL": "To"
"LABEL": "Kohde"
},
"INBOX": {
"LABEL": "Inbox",
"ERROR": "Select an inbox"
"LABEL": "Kansio",
"ERROR": "Valitse kansio"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Write your message here",
"ERROR": "Message can't be empty"
"LABEL": "Viesti",
"PLACEHOLDER": "Kirjoita viestisi tähän",
"ERROR": "Viesti ei voi olla tyhjä"
},
"SUBMIT": "Send message",
"SUBMIT": "Lähetä viesti",
"CANCEL": "Peruuta",
"SUCCESS_MESSAGE": "Message sent!",
"ERROR_MESSAGE": "Couldn't send! try again"
"SUCCESS_MESSAGE": "Viesti lähetetty!",
"ERROR_MESSAGE": "Hö! Virhe, yritä uudelleen"
}
},
"CONTACTS_PAGE": {
"HEADER": "Yhteystiedot",
"FIELDS": "Contact fields",
"FIELDS": "Yhteystietokentät",
"SEARCH_BUTTON": "Etsi",
"SEARCH_INPUT_PLACEHOLDER": "Etsi yhteystietoja",
"LIST": {
"LOADING_MESSAGE": "Ladataan yhteystietoja...",
"404": "Ei hakua vastaavia yhteystietoja 🔍",
"NO_CONTACTS": "There are no available contacts",
"NO_CONTACTS": "Yhtään kontaktia ei löytynyt.",
"TABLE_HEADER": {
"NAME": "Nimi",
"PHONE_NUMBER": "Puhelinnumero",
"CONVERSATIONS": "Keskustelut",
"LAST_ACTIVITY": "Last Activity",
"COUNTRY": "Country",
"CITY": "City",
"SOCIAL_PROFILES": "Social Profiles",
"LAST_ACTIVITY": "Viimeksi aktiivinen",
"COUNTRY": "Maa",
"CITY": "Kaupunki",
"SOCIAL_PROFILES": "Sosiaalisen median tilit",
"COMPANY": "Yritys",
"EMAIL_ADDRESS": "Sähköpostiosoite"
},
"VIEW_DETAILS": "View details"
"VIEW_DETAILS": "Katso lisätietoja"
}
},
"REMINDER": {
"ADD_BUTTON": {
"BUTTON": "Add",
"TITLE": "Shift + Enter to create a task"
"BUTTON": "Lisää",
"TITLE": "Shift + Enter luodaksesi tehtävän"
},
"FOOTER": {
"DUE_DATE": "Due date",
"LABEL_TITLE": "Set type"
"DUE_DATE": "Eräpäivä",
"LABEL_TITLE": "Aseta tyyppi"
}
},
"NOTES": {
"HEADER": {
"TITLE": "Notes"
"TITLE": "Muistiinpanot"
},
"ADD": {
"BUTTON": "Add",
"PLACEHOLDER": "Add a note",
"TITLE": "Shift + Enter to create a note"
"BUTTON": "Lisää",
"PLACEHOLDER": "Lisää muistiinpano",
"TITLE": "Shift + Enter luodaksesi muistiinpano"
},
"FOOTER": {
"BUTTON": "View all notes"
"BUTTON": "Näe kaikki muistiinpanot"
}
},
"EVENTS": {
"HEADER": {
"TITLE": "Activities"
"TITLE": "Aktiviteetit"
},
"BUTTON": {
"PILL_BUTTON_NOTES": "notes",
"PILL_BUTTON_EVENTS": "events",
"PILL_BUTTON_NOTES": "muistiinpanot",
"PILL_BUTTON_EVENTS": "tapahtumat",
"PILL_BUTTON_CONVO": "keskustelut"
}
},
"CUSTOM_ATTRIBUTES": {
"TITLE": "Mukautetut attribuutit",
"BUTTON": "Add custom attribute",
"BUTTON": "Lisää kustomoitu attribuutti",
"ADD": {
"TITLE": "Create custom attribute",
"DESC": "Add custom information to this contact."
"TITLE": "Lisää kustomoitu attribuutti",
"DESC": "Lisää lisätietoja tähän kontaktiin."
},
"FORM": {
"CREATE": "Add attribute",
"CREATE": "Lisää atribuutti",
"CANCEL": "Peruuta",
"NAME": {
"LABEL": "Custom attribute name",
"PLACEHOLDER": "Eg: shopify id",
"ERROR": "Invalid custom attribute name"
"LABEL": "Attribuutin nimi",
"PLACEHOLDER": "Esim: shopify id",
"ERROR": "Virheellinen nimi"
},
"VALUE": {
"LABEL": "Attribute value",
"PLACEHOLDER": "Eg: 11901 "
"LABEL": "Attribuutin arvo",
"PLACEHOLDER": "Esim: 11901 "
}
}
},
"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.",
"TITLE": "Yhdistä kontakteja",
"DESCRIPTION": "Yhdistää kaksi kontakia. Ensimmäinen kontaktin tiedot pysyvät koskemattomina, mutta toisen attribuutit kopioidaan ensimmäiseen kustomoituina attribuutteina.",
"PRIMARY": {
"TITLE": "Primary contact"
"TITLE": "Ensimmäinen kontakti"
},
"CHILD": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Choose a contact"
"TITLE": "Yhdistettävä kontakti",
"PLACEHOLDER": "Valitse kontakti"
},
"SUMMARY": {
"TITLE": "Summary",
"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>."
"TITLE": "Yhteenveto",
"DELETE_WARNING": "Kontakti <strong>%{childContactName}</strong> poistetaan.",
"ATTRIBUTE_WARNING": "Kontaktin <strong>%{childContactName}</strong> tiedot kopioidaan kontaktiin <strong>%{primaryContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
},
"FORM": {
"SUBMIT": " Merge contacts",
"SUBMIT": " Yhdistä",
"CANCEL": "Peruuta",
"CHILD_CONTACT": {
"ERROR": "Select a child contact to merge"
"ERROR": "Valitse yhdistettävä tili"
},
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contcts, try again!"
"SUCCESS_MESSAGE": "Kontaktien yhdistäminen onnistui",
"ERROR_MESSAGE": "Virhe, yritä uudelleen!"
}
}
}

View File

@@ -27,11 +27,11 @@
"REMOVE_SELECTION": "Poista valinnat",
"DOWNLOAD": "Lataa",
"UPLOADING_ATTACHMENTS": "Ladataan liitteitä...",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"SUCCESS_DELETE_MESSAGE": "Viesti poistettu onnistuneesti",
"FAIL_DELETE_MESSSAGE": "Viestin poistaminen epäonnistui.",
"NO_RESPONSE": "Ei vastausta",
"RATING_TITLE": "Arvostelut",
"FEEDBACK_TITLE": "Palaute",
"HEADER": {
"RESOLVE_ACTION": "Ratkaise",
"REOPEN_ACTION": "Uudelleenavaa",
@@ -41,7 +41,7 @@
"DETAILS": "tiedot"
},
"RESOLVE_DROPDOWN": {
"OPEN_BOT": "Open with bot"
"OPEN_BOT": "Avaa botilla"
},
"FOOTER": {
"MSG_INPUT": "Vaihto + enter siirtyäksesi uudelle riville. Aloita '/' valitaksesi tallennettu vastaus.",
@@ -53,17 +53,17 @@
"SEND": "Lähetä",
"CREATE": "Lisää muistiinpano",
"TWEET": "Twiittaa",
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"ENTER_TO_SEND": "Enter to send",
"DRAG_DROP": "Drag and drop here to attach"
"TIP_FORMAT_ICON": "Näytä rikas tekstieditori",
"TIP_EMOJI_ICON": "Näytä emojivalitsin",
"TIP_ATTACH_ICON": "Liitä tiedosto",
"ENTER_TO_SEND": "Paina Enter lähettääksesi",
"DRAG_DROP": "Pudota tiedosto tähän liittääksesi"
},
"VISIBLE_TO_AGENTS": "Yksityinen huomautus: Näkyy vain sinulle ja tiimillesi",
"CHANGE_STATUS": "Keskustelun tila muutettu",
"CHANGE_AGENT": "Keskustelun vastaanottaja vaihdettu",
"CHANGE_TEAM": "Conversation team changed",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"CHANGE_TEAM": "Keskustelun tiimi vaihdettu",
"FILE_SIZE_LIMIT": "Tiedoston koko ylittää maksimin: {MAXIMUM_FILE_UPLOAD_SIZE} ",
"SENT_BY": "Lähettäjä:",
"ASSIGNMENT": {
"SELECT_AGENT": "Valitse edustaja",
@@ -93,35 +93,35 @@
}
},
"ONBOARDING": {
"TITLE": "Hey 👋, Welcome to %{installationName}!",
"TITLE": "Hei 👋, tervetuloa %{installationName}!",
"DESCRIPTION": "Thanks for signing up. We want you to get the most out of %{installationName}. Here are a few things you can do in %{installationName} to make the experience delightful.",
"READ_LATEST_UPDATES": "Read our latest updates",
"ALL_CONVERSATION": {
"TITLE": "All your conversations in one place",
"DESCRIPTION": "View all the conversations from your customers in one single dashboard. You can filter the conversations by the incoming channel, label and status."
"TITLE": "Kaikki keskustelut yhdessä paikassa",
"DESCRIPTION": "Kaikki asiakkaiden viestit yhdessä ohjauspaneelissa. Voit filtteröidä viestejä tunnisteen, kansion ja tiimin perusteella."
},
"TEAM_MEMBERS": {
"TITLE": "Invite your team members",
"DESCRIPTION": "Since you are getting ready to talk to your customer, bring in your teammates to assist you. You can invite your teammates by adding their email address to the agent list.",
"NEW_LINK": "Click here to invite a team member"
"TITLE": "Kutsu tiimijäseniä",
"DESCRIPTION": "Koska olet valmis palvelemaan asiakkaita, kutsu muutama tiimijäsentä auttamaan sinua!",
"NEW_LINK": "Paina tästä kutsuaksesi tiimijäsen"
},
"INBOXES": {
"TITLE": "Connect Inboxes",
"DESCRIPTION": "Connect various channels through which your customers would be talking to you. It can be a website live-chat, your Facebook or Twitter page or even your WhatsApp number.",
"NEW_LINK": "Click here to create an inbox"
"TITLE": "Yhdistä kansioita",
"DESCRIPTION": "Yhdistä monta eri viestikanavaa jolla asiakkaat voivat ottaa sinun yhteyden. Esimerkiksi widgetti omille verkkosivuille, Twitterissä tai Whatsapissa.",
"NEW_LINK": "Paina tästä luodaksesi kansio"
},
"LABELS": {
"TITLE": "Organize conversations with labels",
"DESCRIPTION": "Labels provide an easier way to categorize your conversation. Create some labels like #support-enquiry, #billing-question etc., so that you can use them in a conversation later.",
"NEW_LINK": "Click here to create tags"
"TITLE": "Järjestä keskusteluja tunnisteilla",
"DESCRIPTION": "Tunnisteet auttavat sinua järjestelemään eri keskusteluja.",
"NEW_LINK": "Paina tästä luodaksesi uusi tunniste"
}
},
"CONVERSATION_SIDEBAR": {
"ASSIGNEE_LABEL": "Assigned Agent",
"SELF_ASSIGN": "Assign to me",
"TEAM_LABEL": "Assigned Team",
"ASSIGNEE_LABEL": "Määrätty edustaja",
"SELF_ASSIGN": "Määrätty minulle",
"TEAM_LABEL": "Määrätty tiimi",
"SELECT": {
"PLACEHOLDER": "None"
"PLACEHOLDER": "Ei mikään"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"CSAT": {
"TITLE": "Rate your conversation",
"PLACEHOLDER": "Tell us more..."
"TITLE": "Arvostele keskustelu",
"PLACEHOLDER": "Kerro meille lisää"
}
}

View File

@@ -43,7 +43,7 @@
"CUSTOM_EMAIL_DOMAIN_ENABLED": "Voit nyt vastaanottaa sähköposteja mukautetulla verkkotunnuksellasi."
}
},
"UPDATE_CHATWOOT": "An update %{latestChatwootVersion} for Chatwoot is available. Please update your instance."
"UPDATE_CHATWOOT": "Uusi ChatWoot-päivitys on saatavilla. Uusin versio on %{latestChatwootVersion}"
},
"FORMS": {
"MULTISELECT": {
@@ -54,10 +54,10 @@
},
"NOTIFICATIONS_PAGE": {
"HEADER": "Ilmoitukset",
"MARK_ALL_DONE": "Mark All Done",
"MARK_ALL_DONE": "Merkkaa kaikki luetuksi",
"LIST": {
"LOADING_MESSAGE": "Loading notifications...",
"404": "No Notifications",
"LOADING_MESSAGE": "Ladataan ilmoituksia...",
"404": "Ei ilmoituksia",
"TABLE_HEADER": [
"Nimi",
"Puhelinnumero",
@@ -66,10 +66,10 @@
]
},
"TYPE_LABEL": {
"conversation_creation": "New conversation",
"conversation_assignment": "Conversation Assigned",
"assigned_conversation_new_message": "New Message",
"conversation_mention": "Mention"
"conversation_creation": "Uusi keskustelu",
"conversation_assignment": "Keskustelu määrätty",
"assigned_conversation_new_message": "Uusi viesti",
"conversation_mention": "Mainitse"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"INBOX_MGMT": {
"HEADER": "Saapuneet-kansiot",
"SIDEBAR_TXT": "<p><b>Postilaatikko</b></p> <p> Kun yhdistät sivuston tai facebook-sivun Chatwotiin, sitä kutsutaan <b>postilaatikoksi</b>. Sinulla voi olla rajoittamaton määrä postilaatikoita Chatwoot tililläsi. </p><p> Klikkaa <b>Lisää postilaatikko</b> yhdistääksesi verkkosivuston tai Facebook-sivun. </p><p> Kojelaudalla näet kaikki keskustelut kaikista saapuneet-kansiostasi yhdessä paikassa ja vastaat niihin `Keskustelut`-välilehdessä. </p><p> Voit myös nähdä postilaatikkoon liittyviä keskusteluja klikkaamalla postilaatikon nimeä kojelaudan vasemmassa paneelissa. </p>",
"SIDEBAR_TXT": "<p><b>Postilaatikko</b></p> <p> Kun yhdistät sivuston tai Facebook-sivun Chatwotiin, sitä kutsutaan <b>postilaatikoksi</b>. Sinulla voi olla rajoittamaton määrä postilaatikoita Chatwoot tililläsi. </p><p> Klikkaa <b>Lisää postilaatikko</b> yhdistääksesi verkkosivuston tai Facebook-sivun. </p><p> Kojelaudalla näet kaikki keskustelut kaikista saapuneet-kansiostasi yhdessä paikassa ja vastaat niihin `Keskustelut`-välilehdessä. </p><p> Voit myös nähdä postilaatikkoon liittyviä keskusteluja klikkaamalla postilaatikon nimeä kojelaudan vasemmassa paneelissa. </p>",
"LIST": {
"404": "Tähän tiliin ei ole liitetty saapuneet-kansiota."
},
@@ -30,7 +30,7 @@
"ADD": {
"CHANNEL_NAME": {
"LABEL": "Kansion nimi",
"PLACEHOLDER": "Enter your inbox name (eg: Acme Inc)"
"PLACEHOLDER": "Valitse kansion nimi (esim: Acme Oy)"
},
"WEBSITE_NAME": {
"LABEL": "Sivuston nimi",
@@ -47,7 +47,7 @@
},
"TWITTER": {
"HELP": "Lisätäksesi twitter-profiilin kanavaksesi, sinun tulee autentikoida twitter-tilisi klikkaamalla \"Kirjaudu sisään Twitterillä\" ",
"ERROR_MESSAGE": "There was an error connecting to Twitter, please try again"
"ERROR_MESSAGE": "Twitteriin yhdistäessä tapahtui virhe"
},
"WEBSITE_CHANNEL": {
"TITLE": "Sivuston chat",
@@ -83,7 +83,7 @@
"IN_A_FEW_MINUTES": "Muutamassa minuutissa",
"IN_A_FEW_HOURS": "Muutamassa tunnissa",
"IN_A_DAY": "Päivän kuluessa",
"HELP_TEXT": "Vastausaika näytetään chat -widgetissä"
"HELP_TEXT": "Vastausaika näytetään chat-widgetissä"
},
"WIDGET_COLOR": {
"LABEL": "Widgetin väri",
@@ -128,12 +128,12 @@
}
},
"SMS": {
"TITLE": "SMS Channel via Twilio",
"DESC": "Start supporting your customers via SMS with Twilio integration."
"TITLE": "SMS Twilio:n kautta",
"DESC": "Ala palvelemaan asiakkaitasi tekstiviestien avulla käyttäen Twilio-alustaa"
},
"WHATSAPP": {
"TITLE": "Whatsapp Channel via Twilio",
"DESC": "Start supporting your customers via Whatsapp with Twilio integration."
"TITLE": "Whatsapp Twilio:n kautta",
"DESC": "Ala palvelemaan asiakkaitasi Whatsapp-viestien avulla käyttäen Twilio-alustaa"
},
"API_CHANNEL": {
"TITLE": "API-rajapinta",
@@ -173,8 +173,8 @@
"FINISH_MESSAGE": "Aloita välittämällä sähköpostit seuraavaan osoitteeseen."
},
"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."
"TITLE": "Valitse kanava",
"DESC": "Chatwoot tukee live-chat widgettiä, Facebook-sivua, Twitter-profiilia, Whatsappia, Sähköpostia jne., as channels. Voit luoda oman kanavan API-rajapinnalla."
},
"AGENTS": {
"TITLE": "Edustajat",
@@ -264,16 +264,16 @@
"INBOX_AGENTS": "Edustajat",
"INBOX_AGENTS_SUB_TEXT": "Lisää tai poista edustajia tästä saapuneet-kansiosta",
"UPDATE": "Päivitä",
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
"ENABLE_EMAIL_COLLECT_BOX": "Aktivoi sähköpostilaatikko",
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Aktivoi tai disabloi sähköpostilaatikko uusissa keskusteluissa",
"AUTO_ASSIGNMENT": "Ota automaattinen delegointi käyttöön",
"ENABLE_CSAT": "Enable CSAT",
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
"ENABLE_CSAT": "Aktivoi ASTK",
"ENABLE_CSAT_SUB_TEXT": "Aktivoi/Disabloi ASTK(ASiakasTyytyväisyysKysely) keskustelun ratkaisun jälkeen",
"INBOX_UPDATE_TITLE": "Postilaatikon tiedot",
"INBOX_UPDATE_SUB_TEXT": "Päivitä postilaatikon asetukset",
"AUTO_ASSIGNMENT_SUB_TEXT": "Ota käyttöön tai poista käytöstä automaattinen keskusteluiden delegointi edustajille.",
"HMAC_VERIFICATION": "User Identity Validation",
"HMAC_DESCRIPTION": "Inorder validate the users identity, the SDK allows you to pass an `identity_hash` for each user. You can generate HMAC using 'sha256' with the key shown here."
"HMAC_VERIFICATION": "Käyttäjän identiteettivarmistus",
"HMAC_DESCRIPTION": "Varmistaaksemme käyttäjän identiteetin, SDK sallii sinun antaa `identifier_hash` jokaiselle käyttäjälle. Voit generoida HMAC käyttäen 'sha256' avaimella joka on alla."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Uudelleenvaltuuta",
@@ -282,38 +282,38 @@
"MESSAGE_ERROR": "Tapahtui virhe, yritä uudelleen"
},
"PRE_CHAT_FORM": {
"DESCRIPTION": "Pre chat forms enable you to capture user information before they start conversation with you.",
"DESCRIPTION": "Pikakysely kysyy käyttäjän sähköpostin ja nimen ennen keskustelua",
"ENABLE": {
"LABEL": "Enable pre chat form",
"LABEL": "Aktivoi pikakysely",
"OPTIONS": {
"ENABLED": "Yes",
"DISABLED": "No"
"ENABLED": "Kyllä",
"DISABLED": "Ei"
}
},
"PRE_CHAT_MESSAGE": {
"LABEL": "Pre Chat Message",
"PLACEHOLDER": "This message would be visible to the users along with the form"
"LABEL": "Pikakyselyviesti",
"PLACEHOLDER": "Tämä viesti näytetään käyttäjille joille pikakysely näytetään"
},
"REQUIRE_EMAIL": {
"LABEL": "Visitors should provide their name and email address before starting the chat"
"LABEL": "Käyttäjien tulee antaa sähköpostiosoite ja nimi ennen keskustelun aloitusta"
}
},
"BUSINESS_HOURS": {
"TITLE": "Set your availability",
"SUBTITLE": "Set your availability on your livechat widget",
"WEEKLY_TITLE": "Set your weekly hours",
"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_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.",
"TITLE": "Aseta tilasi",
"SUBTITLE": "Aseta tilasi livechat widgetissä",
"WEEKLY_TITLE": "Aseta viikkotyöaikasi",
"TIMEZONE_LABEL": "Aseta aikavyöhyke",
"UPDATE": "Aseta työaika",
"TOGGLE_AVAILABILITY": "Aktivoi työaikasaatavuus tälle kansiolle",
"UNAVAILABLE_MESSAGE_LABEL": "Ei saatavilla-viesti käyttäjille",
"UNAVAILABLE_MESSAGE_DEFAULT": "Emme ole saatavilla juuri nyt. Jätä viesti ja vastaamme heti kun pystymme.",
"TOGGLE_HELP": "Aktivoimalla työaikasaatavuus livechat widgetissä näytetään olevanne saatavilla, vaikka kukaan edustaja ei olisi paikalla. Näiden aikojen ulkopuolella käyttäjille näytetään varoitus ja pikakysely.",
"DAY": {
"ENABLE": "Enable availability for this day",
"UNAVAILABLE": "Unavailable",
"HOURS": "hours",
"VALIDATION_ERROR": "Starting time should be before closing time.",
"CHOOSE": "Choose"
"ENABLE": "Aktivoi saatavuus tälle päivälle",
"UNAVAILABLE": "Ei saatavilla",
"HOURS": "tuntia",
"VALIDATION_ERROR": "Avaamisajan tulisi olla ennen sulkemisaikaa.",
"CHOOSE": "Valitse"
}
}
}

View File

@@ -1,36 +1,36 @@
{
"INTEGRATION_APPS": {
"FETCHING": "Fetching Integrations",
"NO_HOOK_CONFIGURED": "There are no %{integrationId} integrations configured in this account.",
"HEADER": "Applications",
"FETCHING": "Haetaan integraatioita",
"NO_HOOK_CONFIGURED": "Integraatiota IDllä %{integrationId} ei löytynyt tai ei ole konfiguroitu.",
"HEADER": "Applikaatiot",
"STATUS": {
"ENABLED": "Käytössä",
"DISABLED": "Pois käytöstä"
},
"CONFIGURE": "Määrittele",
"ADD_BUTTON": "Add a new hook",
"ADD_BUTTON": "Lisää uusi webhook",
"DELETE": {
"TITLE": {
"INBOX": "Confirm deletion",
"ACCOUNT": "Disconnect"
"INBOX": "Varmista poisto",
"ACCOUNT": "Katkaise"
},
"MESSAGE": {
"INBOX": "Oletko varma että haluat poistaa?",
"ACCOUNT": "Are you sure to disconnect?"
"ACCOUNT": "Oletko varma että haluat katkaista?"
},
"CONFIRM_BUTTON_TEXT": {
"INBOX": "Kyllä, poista",
"ACCOUNT": "Yes, Disconnect"
"ACCOUNT": "Kyllä, katkaise"
},
"CANCEL_BUTTON_TEXT": "Peruuta",
"API": {
"SUCCESS_MESSAGE": "Hook deleted successfully",
"SUCCESS_MESSAGE": "Webhook poistettu onnistuneesti",
"ERROR_MESSAGE": "Yhteyden muodostaminen Woot-palvelimelle ei onnistunut, yritä myöhemmin uudelleen"
}
},
"LIST": {
"FETCHING": "Fetching integration hooks",
"INBOX": "Inbox",
"FETCHING": "Haetaan integraatiowebhookkeja",
"INBOX": "Kansio",
"DELETE": {
"BUTTON_TEXT": "Poista"
}
@@ -38,14 +38,14 @@
"ADD": {
"FORM": {
"INBOX": {
"LABEL": "Select Inbox",
"PLACEHOLDER": "Select Inbox"
"LABEL": "Valitse kansio",
"PLACEHOLDER": "Valitse kansio"
},
"SUBMIT": "Luo",
"CANCEL": "Peruuta"
},
"API": {
"SUCCESS_MESSAGE": "Integration hook added successfully",
"SUCCESS_MESSAGE": "Integraatio webhook lisätty",
"ERROR_MESSAGE": "Yhteyden muodostaminen Woot-palvelimelle ei onnistunut, yritä myöhemmin uudelleen"
}
},
@@ -53,7 +53,7 @@
"BUTTON_TEXT": "Yhdistä"
},
"DISCONNECT": {
"BUTTON_TEXT": "Disconnect"
"BUTTON_TEXT": "Katkaise"
},
"SIDEBAR_DESCRIPTION": {
"DIALOGFLOW": "Dialogflow is a natural language understanding platform that makes it easy to design and integrate a conversational user interface into your mobile app, web application, device, bot, interactive voice response system, and so on. <br /> <br /> Dialogflow integration with %{installationName} allows you to configure a Dialogflow bot with your inboxes which lets the bot handle the queries initially and hand them over to an agent when needed. Dialogflow can be used to qualifying the leads, reduce the workload of agents by providing frequently asked questions etc. <br /> <br /> To add Dialogflow, you need to create a Service Account in your Google project console and share the credentials. Please refer to the Dialogflow docs for more information."

View File

@@ -10,7 +10,7 @@
"PLACEHOLDER": "Salasana"
},
"API": {
"SUCCESS_MESSAGE": "Kirjautuminen Onnistui",
"SUCCESS_MESSAGE": "Kirjautuminen onnistui",
"ERROR_MESSAGE": "Yhteyden muodostaminen Woot-palvelimelle ei onnistunut, yritä myöhemmin uudelleen",
"UNAUTH": "Käyttäjätunnus / salasana virheellinen. Yritä uudelleen"
},

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