Merge branch 'release/1.19.0'

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

View File

@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build working_directory: ~/build
docker: docker:
# specify the version you desire here # 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 # Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images # CircleCI maintains a library of pre-built images

View File

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

View File

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

View File

@ -147,6 +147,10 @@ USE_INBOX_AVATAR_FOR_BOT=true
# maxmindb api key to use geoip2 service # maxmindb api key to use geoip2 service
# IP_LOOKUP_API_KEY= # IP_LOOKUP_API_KEY=
## Rack Attack configuration
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=false
## Running chatwoot as an API only server ## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints ## setting this value to true will disable the frontend dashboard endpoints
@ -157,3 +161,7 @@ USE_INBOX_AVATAR_FOR_BOT=true
# LETTER_OPENER=true # LETTER_OPENER=true
# meant to be used in github codespaces # meant to be used in github codespaces
# WEBPACKER_DEV_SERVER_PUBLIC= # 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/models/conversation.rb'
- 'app/mailers/conversation_reply_mailer.rb' - 'app/mailers/conversation_reply_mailer.rb'
- 'app/models/message.rb' - 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:
@ -29,7 +30,8 @@ Style/OptionalBooleanParameter:
- 'app/dispatchers/dispatcher.rb' - 'app/dispatchers/dispatcher.rb'
Style/GlobalVars: Style/GlobalVars:
Exclude: Exclude:
- 'config/initializers/redis.rb' - 'config/initializers/01_redis.rb'
- 'config/initializers/rack_attack.rb'
- 'lib/redis/alfred.rb' - 'lib/redis/alfred.rb'
- 'lib/global_config.rb' - 'lib/global_config.rb'
Style/ClassVars: Style/ClassVars:
@ -38,6 +40,14 @@ Style/ClassVars:
Lint/MissingSuper: Lint/MissingSuper:
Exclude: Exclude:
- 'app/drops/base_drop.rb' - '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: Metrics/BlockLength:
Exclude: Exclude:
- spec/**/* - spec/**/*
@ -55,6 +65,11 @@ Rails/ApplicationController:
- 'app/controllers/widgets_controller.rb' - 'app/controllers/widgets_controller.rb'
- 'app/controllers/platform_controller.rb' - 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb' - 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb'
Rails/EnvironmentVariableAccess:
Enabled: false
Rails/TimeZoneAssignment:
Enabled: false
Style/ClassAndModuleChildren: Style/ClassAndModuleChildren:
EnforcedStyle: compact EnforcedStyle: compact
Exclude: Exclude:
@ -64,6 +79,10 @@ RSpec/NestedGroups:
Max: 4 Max: 4
RSpec/MessageSpies: RSpec/MessageSpies:
Enabled: false Enabled: false
RSpec/StubbedMock:
Enabled: false
Naming/VariableNumber:
Enabled: false
Metrics/MethodLength: Metrics/MethodLength:
Exclude: Exclude:
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb' - 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
@ -111,14 +130,19 @@ Rails/UniqueValidationWithoutIndex:
- 'app/models/channel/twitter_profile.rb' - 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb' - 'app/models/webhook.rb'
- 'app/models/contact.rb' - 'app/models/contact.rb'
- 'app/models/integrations/hook.rb'
Rails/RenderInline: Rails/RenderInline:
Exclude: Exclude:
- 'app/controllers/swagger_controller.rb' - 'app/controllers/swagger_controller.rb'
Performance/CollectionLiteralInLoop:
Exclude:
- 'db/migrate/20210315101919_enable_email_channel.rb'
RSpec/NamedSubject: RSpec/NamedSubject:
Enabled: false Enabled: false
# we should bring this down # we should bring this down
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 12 Max: 12
AllCops: AllCops:
NewCops: enable NewCops: enable
Exclude: Exclude:
@ -133,4 +157,11 @@ AllCops:
- 'tmp/**/*' - 'tmp/**/*'
- 'storage/**/*' - 'storage/**/*'
- 'db/migrate/20200225162150_init_schema.rb' - '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' source 'https://rubygems.org'
ruby '2.7.3' ruby '3.0.2'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'
@ -33,19 +33,22 @@ gem 'liquid'
gem 'commonmarker' gem 'commonmarker'
# Validate Data against JSON Schema # Validate Data against JSON Schema
gem 'json_schemer' 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 --## ##-- for active storage --##
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', require: false gem 'azure-storage-blob', require: false
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'mini_magick' gem 'image_processing'
##-- gems for database --# ##-- gems for database --#
gem 'groupdate' gem 'groupdate'
gem 'pg' gem 'pg'
gem 'redis' gem 'redis'
gem 'redis-namespace' gem 'redis-namespace'
gem 'redis-rack-cache'
# super fast record imports in bulk # super fast record imports in bulk
gem 'activerecord-import' gem 'activerecord-import'
@ -90,8 +93,11 @@ gem 'google-cloud-dialogflow'
##--- gems for debugging and error reporting ---## ##--- gems for debugging and error reporting ---##
# static analysis # static analysis
gem 'brakeman' gem 'brakeman'
gem 'ddtrace'
gem 'scout_apm' gem 'scout_apm'
gem 'sentry-raven' gem 'sentry-rails'
gem 'sentry-ruby'
gem 'sentry-sidekiq'
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq' gem 'sidekiq'
@ -120,7 +126,7 @@ group :development do
gem 'web-console' gem 'web-console'
# used in swagger build # 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 # When we want to squash migrations
gem 'squasher' gem 'squasher'
@ -134,19 +140,19 @@ group :test do
end end
group :development, :test do group :development, :test do
gem 'active_record_query_trace'
gem 'bundle-audit', require: false gem 'bundle-audit', require: false
gem 'byebug', platform: :mri gem 'byebug', platform: :mri
gem 'factory_bot_rails' gem 'factory_bot_rails'
gem 'faker' gem 'faker'
gem 'listen' gem 'listen'
gem 'mock_redis', git: 'https://github.com/sds/mock_redis', ref: '16d00789f0341a3aac35126c0ffe97a596753ff9' gem 'mock_redis'
gem 'pry-rails' gem 'pry-rails'
gem 'rspec-rails', '~> 4.0.0.beta2' gem 'rspec-rails', '~> 5.0.0'
gem 'rubocop', require: false gem 'rubocop', require: false
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false gem 'rubocop-rspec', require: false
gem 'scss_lint', require: false
gem 'seed_dump' gem 'seed_dump'
gem 'shoulda-matchers' gem 'shoulda-matchers'
gem 'simplecov', '0.17.1', require: false 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 GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (6.0.3.7) actioncable (6.1.4)
actionpack (= 6.0.3.7) actionpack (= 6.1.4)
activesupport (= 6.1.4)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (6.0.3.7) actionmailbox (6.1.4)
actionpack (= 6.0.3.7) actionpack (= 6.1.4)
activejob (= 6.0.3.7) activejob (= 6.1.4)
activerecord (= 6.0.3.7) activerecord (= 6.1.4)
activestorage (= 6.0.3.7) activestorage (= 6.1.4)
activesupport (= 6.0.3.7) activesupport (= 6.1.4)
mail (>= 2.7.1) mail (>= 2.7.1)
actionmailer (6.0.3.7) actionmailer (6.1.4)
actionpack (= 6.0.3.7) actionpack (= 6.1.4)
actionview (= 6.0.3.7) actionview (= 6.1.4)
activejob (= 6.0.3.7) activejob (= 6.1.4)
activesupport (= 6.1.4)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (6.0.3.7) actionpack (6.1.4)
actionview (= 6.0.3.7) actionview (= 6.1.4)
activesupport (= 6.0.3.7) activesupport (= 6.1.4)
rack (~> 2.0, >= 2.0.8) rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.0.3.7) actiontext (6.1.4)
actionpack (= 6.0.3.7) actionpack (= 6.1.4)
activerecord (= 6.0.3.7) activerecord (= 6.1.4)
activestorage (= 6.0.3.7) activestorage (= 6.1.4)
activesupport (= 6.0.3.7) activesupport (= 6.1.4)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (6.0.3.7) actionview (6.1.4)
activesupport (= 6.0.3.7) activesupport (= 6.1.4)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.0.3.7) active_record_query_trace (1.8)
activesupport (= 6.0.3.7) activejob (6.1.4)
activesupport (= 6.1.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (6.0.3.7) activemodel (6.1.4)
activesupport (= 6.0.3.7) activesupport (= 6.1.4)
activerecord (6.0.3.7) activerecord (6.1.4)
activemodel (= 6.0.3.7) activemodel (= 6.1.4)
activesupport (= 6.0.3.7) activesupport (= 6.1.4)
activerecord-import (1.0.7) activerecord-import (1.2.0)
activerecord (>= 3.2) activerecord (>= 3.2)
activestorage (6.0.3.7) activestorage (6.1.4)
actionpack (= 6.0.3.7) actionpack (= 6.1.4)
activejob (= 6.0.3.7) activejob (= 6.1.4)
activerecord (= 6.0.3.7) activerecord (= 6.1.4)
activesupport (= 6.1.4)
marcel (~> 1.0.0) 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) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2) i18n (>= 1.6, < 2)
minitest (~> 5.1) minitest (>= 5.1)
tzinfo (~> 1.1) tzinfo (~> 2.0)
zeitwerk (~> 2.2, >= 2.2.2) zeitwerk (~> 2.3)
acts-as-taggable-on (6.5.0) acts-as-taggable-on (8.1.0)
activerecord (>= 5.0, < 6.1) activerecord (>= 5.0, < 6.2)
addressable (2.8.0) addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0) public_suffix (>= 2.0.2, < 5.0)
administrate (0.16.0) administrate (0.16.0)
@ -90,23 +80,23 @@ GEM
annotate (3.1.1) annotate (3.1.1)
activerecord (>= 3.2, < 7.0) activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.1) ast (2.4.2)
attr_extras (6.2.4) attr_extras (6.2.4)
aws-eventstream (1.1.0) aws-eventstream (1.1.1)
aws-partitions (1.360.0) aws-partitions (1.482.0)
aws-sdk-core (3.105.0) aws-sdk-core (3.119.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0) aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-kms (1.37.0) aws-sdk-kms (1.46.0)
aws-sdk-core (~> 3, >= 3.99.0) aws-sdk-core (~> 3, >= 3.119.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.79.1) aws-sdk-s3 (1.98.0)
aws-sdk-core (~> 3, >= 3.104.3) aws-sdk-core (~> 3, >= 3.119.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.2) aws-sigv4 (1.2.4)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
axiom-types (0.1.1) axiom-types (0.1.1)
descendants_tracker (~> 0.0.4) descendants_tracker (~> 0.0.4)
@ -120,42 +110,48 @@ GEM
faraday_middleware (~> 1.0.0.rc1) faraday_middleware (~> 1.0.0.rc1)
net-http-persistent (~> 4.0) net-http-persistent (~> 4.0)
nokogiri (~> 1.11.0.rc2) nokogiri (~> 1.11.0.rc2)
barnes (0.0.8) barnes (0.0.9)
multi_json (~> 1) multi_json (~> 1)
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
bcrypt (3.1.16) bcrypt (3.1.16)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.4.8) bootsnap (1.7.6)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.9.0) brakeman (5.1.1)
browser (5.3.1) browser (5.3.1)
builder (3.2.4) builder (3.2.4)
bullet (6.1.0) bullet (6.1.4)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
bundle-audit (0.1.0) bundle-audit (0.1.0)
bundler-audit bundler-audit
bundler-audit (0.7.0.1) bundler-audit (0.8.0)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (>= 0.18, < 2) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
coderay (1.1.3) coderay (1.1.3)
coercible (1.0.0) coercible (1.0.0)
descendants_tracker (~> 0.0.1) descendants_tracker (~> 0.0.1)
commonmarker (0.21.1) commonmarker (0.22.0)
ruby-enum (~> 0.5) concurrent-ruby (1.1.9)
concurrent-ruby (1.1.8) connection_pool (2.2.5)
connection_pool (2.2.3) crack (0.4.5)
crack (0.4.3) rexml
safe_yaml (~> 1.0.0)
crass (1.0.6) crass (1.0.6)
cypress-on-rails (1.8.0) cypress-on-rails (1.10.1)
rack 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) datetime_picker_rails (0.0.7)
momentjs-rails (>= 2.8.1) momentjs-rails (>= 2.8.1)
ddtrace (0.51.1)
ffi (~> 1.0)
msgpack
declarative (0.0.20) declarative (0.0.20)
declarative-option (0.1.0)
descendants_tracker (0.0.4) descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1) thread_safe (~> 0.3, >= 0.3.1)
devise (4.8.0) devise (4.8.0)
@ -167,48 +163,49 @@ GEM
devise-secure_password (2.0.1) devise-secure_password (2.0.1)
devise (>= 4.0.0, < 5.0.0) devise (>= 4.0.0, < 5.0.0)
railties (>= 5.0.0, < 7.0.0) railties (>= 5.0.0, < 7.0.0)
devise_token_auth (1.1.4) devise_token_auth (1.2.0)
bcrypt (~> 3.0) bcrypt (~> 3.0)
devise (> 3.5.2, < 5) devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 6.1) rails (>= 4.2.0, < 6.2)
sprockets (= 3.7.2)
diff-lcs (1.4.4) diff-lcs (1.4.4)
digest-crc (0.6.1) digest-crc (0.6.4)
rake (~> 13.0) rake (>= 12.0.0, < 14.0.0)
docile (1.3.2) docile (1.4.0)
domain_name (0.5.20190701) domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
dotenv (2.7.6) dotenv (2.7.6)
dotenv-rails (2.7.6) dotenv-rails (2.7.6)
dotenv (= 2.7.6) dotenv (= 2.7.6)
railties (>= 3.2) railties (>= 3.2)
ecma-re-validator (0.2.1) down (5.2.3)
regexp_parser (~> 1.2) addressable (~> 2.8)
equalizer (0.0.11) dry-inflector (0.2.1)
ecma-re-validator (0.3.0)
regexp_parser (~> 2.0)
erubi (1.10.0) erubi (1.10.0)
et-orbi (1.2.4) et-orbi (1.2.4)
tzinfo tzinfo
execjs (2.7.0) execjs (2.8.1)
facebook-messenger (2.0.1) facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7) httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5) rack (>= 1.4.5)
factory_bot (6.1.0) factory_bot (6.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
factory_bot_rails (6.1.0) factory_bot_rails (6.2.0)
factory_bot (~> 6.1.0) factory_bot (~> 6.2.0)
railties (>= 5.0.0) railties (>= 5.0.0)
faker (2.13.0) faker (2.18.0)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
faraday (1.0.1) faraday (1.0.1)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
faraday_middleware (1.0.0) faraday_middleware (1.0.0)
faraday (~> 1.0) faraday (~> 1.0)
fcm (1.0.2) fcm (1.0.3)
faraday (~> 1.0.0) faraday (~> 1)
ffi (1.15.0) ffi (1.15.3)
flag_shih_tzu (0.3.23) flag_shih_tzu (0.3.23)
foreman (0.87.2) foreman (0.87.2)
fugit (1.4.1) fugit (1.5.0)
et-orbi (~> 1.1, >= 1.1.8) et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.4) raabro (~> 1.4)
gapic-common (0.3.4) gapic-common (0.3.4)
@ -217,18 +214,23 @@ GEM
googleapis-common-protos-types (>= 1.0.4, < 2.0) googleapis-common-protos-types (>= 1.0.4, < 2.0)
googleauth (~> 0.9) googleauth (~> 0.9)
grpc (~> 1.25) grpc (~> 1.25)
geocoder (1.6.3) geocoder (1.6.7)
gli (2.19.2) gli (2.20.1)
globalid (0.4.2) globalid (0.5.1)
activesupport (>= 4.2.0) activesupport (>= 5.0)
google-api-client (0.43.0) google-apis-core (0.4.1)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (~> 0.9) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.0) httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.0) retriable (>= 2.0, < 4.a)
signet (~> 0.12) 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-core (1.6.0)
google-cloud-env (~> 1.0) google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
@ -241,21 +243,23 @@ GEM
google-cloud-env (1.5.0) google-cloud-env (1.5.0)
faraday (>= 0.17.3, < 2.0) faraday (>= 0.17.3, < 2.0)
google-cloud-errors (1.1.0) google-cloud-errors (1.1.0)
google-cloud-storage (1.28.0) google-cloud-storage (1.34.1)
addressable (~> 2.5) addressable (~> 2.5)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-api-client (~> 0.33) google-apis-iamcredentials_v1 (~> 0.1)
google-cloud-core (~> 1.2) google-apis-storage_v1 (~> 0.1)
googleauth (~> 0.9) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) 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) googleapis-common-protos (1.3.11)
google-protobuf (~> 3.14) google-protobuf (~> 3.14)
googleapis-common-protos-types (>= 1.0.6, < 2.0) googleapis-common-protos-types (>= 1.0.6, < 2.0)
grpc (~> 1.27) grpc (~> 1.27)
googleapis-common-protos-types (1.0.6) googleapis-common-protos-types (1.1.0)
google-protobuf (~> 3.14) google-protobuf (~> 3.14)
googleauth (0.16.2) googleauth (0.17.0)
faraday (>= 0.17.3, < 2.0) faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
memoist (~> 0.16) memoist (~> 0.16)
@ -264,11 +268,14 @@ GEM
signet (~> 0.14) signet (~> 0.14)
groupdate (5.2.2) groupdate (5.2.2)
activesupport (>= 5) activesupport (>= 5)
grpc (1.37.1) grpc (1.38.0-universal-darwin)
google-protobuf (~> 3.15) google-protobuf (~> 3.15)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
haikunator (1.1.0) grpc (1.38.0-x86_64-linux)
hairtrigger (0.2.23) google-protobuf (~> 3.15)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
hairtrigger (0.2.24)
activerecord (>= 5.0, < 7) activerecord (>= 5.0, < 7)
ruby2ruby (~> 2.4) ruby2ruby (~> 2.4)
ruby_parser (~> 3.10) ruby_parser (~> 3.10)
@ -277,7 +284,7 @@ GEM
hashie (4.1.0) hashie (4.1.0)
hkdf (0.3.0) hkdf (0.3.0)
http-accept (1.7.0) http-accept (1.7.0)
http-cookie (1.0.3) http-cookie (1.0.4)
domain_name (~> 0.5) domain_name (~> 0.5)
httparty (0.18.1) httparty (0.18.1)
mime-types (~> 3.0) mime-types (~> 3.0)
@ -286,19 +293,23 @@ GEM
i18n (1.8.10) i18n (1.8.10)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
ice_nine (0.11.2) ice_nine (0.11.2)
inflecto (0.0.2) image_processing (1.12.1)
jbuilder (2.10.0) mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
jbuilder (2.11.2)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
jmespath (1.4.0) jmespath (1.4.0)
jquery-rails (4.4.0) jquery-rails (4.4.0)
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.3.1) json (2.5.1)
json_schemer (0.2.16) json_refs (0.1.6)
ecma-re-validator (~> 0.2) hana
json_schemer (0.2.18)
ecma-re-validator (~> 0.3)
hana (~> 1.3) hana (~> 1.3)
regexp_parser (~> 1.5) regexp_parser (~> 2.0)
uri_template (~> 0.7) uri_template (~> 0.7)
jwt (2.2.3) jwt (2.2.3)
kaminari (1.2.1) kaminari (1.2.1)
@ -321,11 +332,11 @@ GEM
addressable (~> 2.7) addressable (~> 2.7)
letter_opener (1.7.0) letter_opener (1.7.0)
launchy (~> 2.2) launchy (~> 2.2)
liquid (4.0.3) liquid (5.0.1)
listen (3.3.3) listen (3.6.0)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.9.1) loofah (2.11.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.1) mail (2.7.1)
@ -336,98 +347,97 @@ GEM
method_source (1.0.0) method_source (1.0.0)
mime-types (3.3.1) mime-types (3.3.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2021.0225) mime-types-data (3.2021.0704)
mini_magick (4.10.1) mini_magick (4.11.0)
mini_mime (1.1.0) mini_mime (1.1.0)
mini_portile2 (2.5.1)
minitest (5.14.4) minitest (5.14.4)
mock_redis (0.28.0)
ruby2_keywords
momentjs-rails (2.20.1) momentjs-rails (2.20.1)
railties (>= 3.1) railties (>= 3.1)
msgpack (1.3.3) msgpack (1.4.2)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0) multi_xml (0.6.0)
multipart-post (2.1.1) multipart-post (2.1.1)
net-http-persistent (4.0.0) net-http-persistent (4.0.1)
connection_pool (~> 2.2) connection_pool (~> 2.2)
netrc (0.11.0) netrc (0.11.0)
nio4r (2.5.7) nio4r (2.5.7)
nokogiri (1.11.6) nokogiri (1.11.7-arm64-darwin)
mini_portile2 (~> 2.5.0) racc (~> 1.4)
nokogiri (1.11.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.11.7-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (0.5.6) oauth (0.5.6)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.1) os (1.1.1)
parallel (1.19.2) parallel (1.20.1)
parser (2.7.1.4) parser (3.0.2.0)
ast (~> 2.4.1) ast (~> 2.4.1)
pg (1.2.3) pg (1.2.3)
procore-sift (0.15.0) procore-sift (0.16.0)
rails (> 4.2.0) rails (> 4.2.0)
pry (0.13.1) pry (0.14.1)
coderay (~> 1.1) coderay (~> 1.1)
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.6)
puma (4.3.8) puma (5.4.0)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.1.0) pundit (2.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.5.2) racc (1.5.2)
rack (2.2.3) rack (2.2.3)
rack-cache (1.12.0) rack-attack (6.5.0)
rack (>= 0.4) rack (>= 1.0, < 3)
rack-cors (1.1.1) rack-cors (1.1.1)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-proxy (0.6.5) rack-proxy (0.7.0)
rack rack
rack-test (1.1.0) rack-test (1.1.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-timeout (0.6.0) rack-timeout (0.6.0)
rails (6.0.3.7) rails (6.1.4)
actioncable (= 6.0.3.7) actioncable (= 6.1.4)
actionmailbox (= 6.0.3.7) actionmailbox (= 6.1.4)
actionmailer (= 6.0.3.7) actionmailer (= 6.1.4)
actionpack (= 6.0.3.7) actionpack (= 6.1.4)
actiontext (= 6.0.3.7) actiontext (= 6.1.4)
actionview (= 6.0.3.7) actionview (= 6.1.4)
activejob (= 6.0.3.7) activejob (= 6.1.4)
activemodel (= 6.0.3.7) activemodel (= 6.1.4)
activerecord (= 6.0.3.7) activerecord (= 6.1.4)
activestorage (= 6.0.3.7) activestorage (= 6.1.4)
activesupport (= 6.0.3.7) activesupport (= 6.1.4)
bundler (>= 1.3.0) bundler (>= 1.15.0)
railties (= 6.0.3.7) railties (= 6.1.4)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.3.0) rails-html-sanitizer (1.3.0)
loofah (~> 2.3) loofah (~> 2.3)
railties (6.0.3.7) railties (6.1.4)
actionpack (= 6.0.3.7) actionpack (= 6.1.4)
activesupport (= 6.0.3.7) activesupport (= 6.1.4)
method_source method_source
rake (>= 0.8.7) rake (>= 0.13)
thor (>= 0.20.3, < 2.0) thor (~> 1.0)
rainbow (3.0.0) rainbow (3.0.0)
rake (13.0.3) rake (13.0.6)
rb-fsevent (0.10.4) rb-fsevent (0.11.0)
rb-inotify (0.10.1) rb-inotify (0.10.1)
ffi (~> 1.0) ffi (~> 1.0)
redis (4.2.1) redis (4.4.0)
redis-namespace (1.8.0) redis-namespace (1.8.1)
redis (>= 3.0.4) redis (>= 3.0.4)
redis-rack-cache (2.2.1) regexp_parser (2.1.1)
rack-cache (>= 1.10, < 2) representable (3.1.1)
redis-store (>= 1.6, < 2)
redis-store (1.9.0)
redis (>= 4, < 5)
regexp_parser (1.7.1)
representable (3.0.4)
declarative (< 0.1.0) declarative (< 0.1.0)
declarative-option (< 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
responders (3.0.1) responders (3.0.1)
actionpack (>= 5.0) actionpack (>= 5.0)
@ -439,57 +449,53 @@ GEM
netrc (~> 0.8) netrc (~> 0.8)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.5) rexml (3.2.5)
rspec-core (3.9.2) rspec-core (3.10.1)
rspec-support (~> 3.9.3) rspec-support (~> 3.10.0)
rspec-expectations (3.9.2) rspec-expectations (3.10.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0) rspec-support (~> 3.10.0)
rspec-mocks (3.9.1) rspec-mocks (3.10.2)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0) rspec-support (~> 3.10.0)
rspec-rails (4.0.1) rspec-rails (5.0.1)
actionpack (>= 4.2) actionpack (>= 5.2)
activesupport (>= 4.2) activesupport (>= 5.2)
railties (>= 4.2) railties (>= 5.2)
rspec-core (~> 3.9) rspec-core (~> 3.10)
rspec-expectations (~> 3.9) rspec-expectations (~> 3.10)
rspec-mocks (~> 3.9) rspec-mocks (~> 3.10)
rspec-support (~> 3.9) rspec-support (~> 3.10)
rspec-support (3.9.3) rspec-support (3.10.2)
rubocop (0.89.1) rubocop (1.18.4)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.7.1.1) parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.7) regexp_parser (>= 1.8, < 3.0)
rexml rexml
rubocop-ast (>= 0.3.0, < 1.0) rubocop-ast (>= 1.8.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 2.0) unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (0.3.0) rubocop-ast (1.8.0)
parser (>= 2.7.1.4) parser (>= 3.0.1.1)
rubocop-performance (1.7.1) rubocop-performance (1.11.4)
rubocop (>= 0.82.0) rubocop (>= 1.7.0, < 2.0)
rubocop-rails (2.8.1) rubocop-ast (>= 0.4.0)
rubocop-rails (2.11.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 0.87.0) rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (1.43.2) rubocop-rspec (2.4.0)
rubocop (~> 0.87) rubocop (~> 1.0)
ruby-enum (0.9.0) rubocop-ast (>= 1.1.0)
i18n ruby-progressbar (1.11.0)
ruby-progressbar (1.10.1) ruby-vips (2.1.2)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
ruby2ruby (2.4.4) ruby2ruby (2.4.4)
ruby_parser (~> 3.1) ruby_parser (~> 3.1)
sexp_processor (~> 4.6) sexp_processor (~> 4.6)
ruby_parser (3.15.0) ruby_parser (3.16.0)
rubocop (>= 0.87.0) sexp_processor (~> 4.15, >= 4.15.1)
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)
sassc (2.4.0) sassc (2.4.0)
ffi (~> 1.9) ffi (~> 1.9)
sassc-rails (2.1.2) sassc-rails (2.1.2)
@ -498,21 +504,30 @@ GEM
sprockets (> 3.0) sprockets (> 3.0)
sprockets-rails sprockets-rails
tilt tilt
scout_apm (2.6.9) scout_apm (4.1.1)
parser parser
scss_lint (0.59.0)
sass (~> 3.5, >= 3.5.5)
seed_dump (3.3.1) seed_dump (3.3.1)
activerecord (>= 4) activerecord (>= 4)
activesupport (>= 4) activesupport (>= 4)
selectize-rails (0.12.6) selectize-rails (0.12.6)
semantic_range (2.3.0) semantic_range (3.0.0)
sentry-raven (3.0.3) 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) faraday (>= 1.0)
sexp_processor (4.15.1) sentry-ruby-core (= 4.6.4)
shoulda-matchers (4.4.1) sentry-ruby-core (4.6.4)
activesupport (>= 4.2.0) concurrent-ruby
sidekiq (6.1.1) 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) connection_pool (>= 2.2.2)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.2.0) redis (>= 4.2.0)
@ -529,7 +544,7 @@ GEM
json (>= 1.8, < 3) json (>= 1.8, < 3)
simplecov-html (~> 0.10.0) simplecov-html (~> 0.10.0)
simplecov-html (0.10.2) simplecov-html (0.10.2)
slack-ruby-client (0.15.0) slack-ruby-client (0.17.0)
faraday (>= 1.0) faraday (>= 1.0)
faraday_middleware faraday_middleware
gli gli
@ -539,7 +554,7 @@ GEM
spring-watcher-listen (2.0.1) spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0) listen (>= 2.7, < 4.0)
spring (>= 1.2, < 3.0) spring (>= 1.2, < 3.0)
sprockets (3.7.2) sprockets (4.0.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
sprockets-rails (3.2.2) sprockets-rails (3.2.2)
@ -547,27 +562,28 @@ GEM
activesupport (>= 4.0) activesupport (>= 4.0)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
squasher (0.6.2) squasher (0.6.2)
statsd-ruby (1.4.0) statsd-ruby (1.5.0)
telegram-bot-ruby (0.12.0) telegram-bot-ruby (0.16.0)
dry-inflector
faraday faraday
inflecto virtus (~> 2.0)
virtus telephone_number (1.4.12)
telephone_number (1.4.9)
thor (1.1.0) thor (1.1.0)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.0.10) tilt (2.0.10)
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
trailblazer-option (0.1.1)
twilio-ruby (5.32.0) twilio-ruby (5.32.0)
faraday (~> 1.0.0) faraday (~> 1.0.0)
jwt (>= 1.5, <= 2.5) jwt (>= 1.5, <= 2.5)
nokogiri (>= 1.6, < 2.0) nokogiri (>= 1.6, < 2.0)
twitty (0.1.1) twitty (0.1.4)
oauth oauth
tzinfo (1.2.9) tzinfo (2.0.4)
thread_safe (~> 0.1) concurrent-ruby (~> 1.0)
tzinfo-data (1.2020.1) tzinfo-data (1.2021.1)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uber (0.1.0) uber (0.1.0)
uglifier (4.2.0) uglifier (4.2.0)
@ -575,46 +591,49 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.7.7) unf_ext (0.0.7.7)
unicode-display_width (1.7.0) unicode-display_width (2.0.0)
uniform_notifier (1.13.0) uniform_notifier (1.14.2)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (3.3.1) valid_email2 (4.0.0)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
virtus (1.0.5) virtus (2.0.0)
axiom-types (~> 0.1) axiom-types (~> 0.1)
coercible (~> 1.0) coercible (~> 1.0)
descendants_tracker (~> 0.0, >= 0.0.3) descendants_tracker (~> 0.0, >= 0.0.3)
equalizer (~> 0.0, >= 0.0.9)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.0.4) web-console (4.1.0)
actionview (>= 6.0.0) actionview (>= 6.0.0)
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
webmock (3.8.3) webmock (3.13.0)
addressable (>= 2.3.6) addressable (>= 2.3.6)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.2.1) webpacker (5.4.0)
activesupport (>= 5.2) activesupport (>= 5.2)
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
webpush (1.0.0) webpush (1.1.0)
hkdf (~> 0.2) hkdf (~> 0.2)
jwt (~> 2.0) 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.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.0) wisper (2.0.0)
zeitwerk (2.4.2) zeitwerk (2.4.2)
PLATFORMS PLATFORMS
ruby arm64-darwin-20
x86_64-darwin-21
x86_64-linux
DEPENDENCIES DEPENDENCIES
active_record_query_trace
activerecord-import activerecord-import
acts-as-taggable-on acts-as-taggable-on
administrate administrate
@ -632,10 +651,12 @@ DEPENDENCIES
commonmarker commonmarker
cypress-on-rails (~> 1.0) cypress-on-rails (~> 1.0)
database_cleaner database_cleaner
ddtrace
devise devise
devise-secure_password (~> 2.0) devise-secure_password (~> 2.0)
devise_token_auth devise_token_auth
dotenv-rails dotenv-rails
down (~> 5.0)
facebook-messenger facebook-messenger
factory_bot_rails factory_bot_rails
faker faker
@ -649,8 +670,9 @@ DEPENDENCIES
haikunator haikunator
hairtrigger hairtrigger
hashie hashie
image_processing
jbuilder jbuilder
json_refs! json_refs
json_schemer json_schemer
jwt jwt
kaminari kaminari
@ -659,30 +681,30 @@ DEPENDENCIES
liquid liquid
listen listen
maxminddb maxminddb
mini_magick mock_redis
mock_redis!
pg pg
procore-sift procore-sift
pry-rails pry-rails
puma puma
pundit pundit
rack-attack
rack-cors rack-cors
rack-timeout rack-timeout
rails rails
redis redis
redis-namespace redis-namespace
redis-rack-cache
responders responders
rest-client rest-client
rspec-rails (~> 4.0.0.beta2) rspec-rails (~> 5.0.0)
rubocop rubocop
rubocop-performance rubocop-performance
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
scout_apm scout_apm
scss_lint
seed_dump seed_dump
sentry-raven sentry-rails
sentry-ruby
sentry-sidekiq
shoulda-matchers shoulda-matchers
sidekiq sidekiq
sidekiq-cron sidekiq-cron
@ -706,7 +728,7 @@ DEPENDENCIES
wisper (= 2.0.0) wisper (= 2.0.0)
RUBY VERSION RUBY VERSION
ruby 2.7.3p183 ruby 3.0.2p107
BUNDLED WITH 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. 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. 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. 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. - **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. - **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. 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`. 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`. 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: 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. 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. 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): Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):

View File

@ -1,8 +1,31 @@
# Security Policy # 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 ## 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). 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. 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": { "FRONTEND_URL": {
"description": "Public root URL of the Chatwoot installation. This will be used in the emails.", "description": "Public root URL of the Chatwoot installation. This will be used in the emails.",
"value": "https://CHANGE.herokuapp.com" "value": "https://CHANGE.herokuapp.com"
},
"INSTALLATION_ENV": {
"description": "Installation method used for Chatwoot.",
"value": "heroku"
} }
}, },
"formation": { "formation": {

View File

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

View File

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

View File

@ -17,12 +17,18 @@ class Messages::Facebook::MessageBuilder
end end
def perform 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 ActiveRecord::Base.transaction do
build_contact build_contact
build_message build_message
end end
ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError
Rails.logger.info "Facebook Authorization expired for Inbox #{@inbox.id}"
rescue StandardError => e rescue StandardError => e
Raven.capture_exception(e) Sentry.capture_exception(e)
true true
end end
@ -36,7 +42,6 @@ class Messages::Facebook::MessageBuilder
return if contact.present? return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url)) @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) @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
end end
@ -56,10 +61,21 @@ class Messages::Facebook::MessageBuilder
end end
def attach_file(attachment, file_url) def attach_file(attachment, file_url)
file_resource = LocalResource.new(file_url) attachment_file = Down.download(
attachment.file.attach(io: file_resource.file, filename: file_resource.filename, content_type: file_resource.encoding) file_url
rescue *ExceptionList::URI_EXCEPTIONS => e )
Rails.logger.info "invalid url #{file_url} : #{e.message}" 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 end
def conversation def conversation
@ -136,9 +152,12 @@ class Messages::Facebook::MessageBuilder
begin begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {} result = k.get_object(@sender_id) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e rescue StandardError => e
result = {} result = {}
Raven.capture_exception(e) Sentry.capture_exception(e)
end end
{ {
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}", 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]) account = Account.find(params[:account_id])
if current_user if current_user
account_accessible_for_user?(account) account_accessible_for_user?(account)
elsif @resource&.is_a?(AgentBot) elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account) account_accessible_for_bot?(account)
end end
account account

View File

@ -69,39 +69,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
return [] if data.empty? return [] if data.empty?
data.inject([]) do |result, page_detail| 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 result << page_detail
end end
end end
def set_avatar(facebook_inbox, page_id) def set_avatar(facebook_inbox, page_id)
uri = get_avatar_url(page_id) avatar_file = Down.download(
"http://graph.facebook.com/#{page_id}/picture?type=large"
return unless uri )
facebook_inbox.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
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
end end
end end

View File

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

View File

@ -11,6 +11,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search] before_action :set_current_page, only: [:index, :active, :search]
before_action :fetch_contact, only: [:show, :update, :contactable_inboxes] before_action :fetch_contact, only: [:show, :update, :contactable_inboxes]
before_action :set_include_contact_inboxes, only: [:index, :search]
def index def index
@contacts_count = resolved_contacts.count @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 render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = resolved_contacts.where( 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]}%" search: "%#{params[:q]}%"
) )
@contacts_count = contacts.count @contacts_count = contacts.count
@ -87,11 +88,15 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def fetch_contacts_with_conversation_count(contacts) def fetch_contacts_with_conversation_count(contacts)
filtrate(contacts).left_outer_joins(:conversations) contacts_with_conversation_count = filtrate(contacts).left_outer_joins(:conversations)
.select('contacts.*, COUNT(conversations.id) as conversations_count') .select('contacts.*, COUNT(conversations.id) as conversations_count')
.group('contacts.id') .group('contacts.id')
.includes([{ avatar_attachment: [:blob] }, { contact_inboxes: [:inbox] }]) .includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE) .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 end
def build_contact_inbox def build_contact_inbox
@ -103,7 +108,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def contact_params 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 end
def contact_custom_attributes 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 }) contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes })
end 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 def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end end

View File

@ -1,5 +1,6 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types include Events::Types
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create] before_action :conversation, except: [:index, :meta, :search, :create]
before_action :contact_inbox, only: [:create] before_action :contact_inbox, only: [:create]
@ -49,8 +50,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def toggle_status def toggle_status
if params[:status] if params[:status]
@conversation.status = params[:status] set_conversation_status
@status = @conversation.save @status = @conversation.save!
else else
@status = @conversation.toggle_status @status = @conversation.toggle_status
end end
@ -73,6 +74,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
private 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) def trigger_typing_event(event)
user = current_user.presence || @resource user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user) 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 def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {} additional_attributes = params[:additional_attributes]&.permit! || {}
status = params[:status].present? ? { status: params[:status] } : {} 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, account_id: Current.account.id,
inbox_id: @contact_inbox.inbox_id, inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id, contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id, contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes additional_attributes: additional_attributes,
snoozed_until: params[:snoozed_until]
}.merge(status) }.merge(status)
end 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 update_agents_list
head :ok head :ok
rescue StandardError => e 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') render_could_not_create_error('Could not add agents to inbox')
end end
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
module AuthHelper module AuthHelper
def send_auth_headers(user) def send_auth_headers(user)
data = user.create_new_auth_token data = user.create_new_auth_token
response.headers[DeviseTokenAuth.headers_names[:"access-token"]] = data['access-token'] response.headers[DeviseTokenAuth.headers_names[:'access-token']] = data['access-token']
response.headers[DeviseTokenAuth.headers_names[:"token-type"]] = 'Bearer' response.headers[DeviseTokenAuth.headers_names[:'token-type']] = 'Bearer'
response.headers[DeviseTokenAuth.headers_names[:client]] = data['client'] response.headers[DeviseTokenAuth.headers_names[:client]] = data['client']
response.headers[DeviseTokenAuth.headers_names[:expiry]] = data['expiry'] response.headers[DeviseTokenAuth.headers_names[:expiry]] = data['expiry']
response.headers[DeviseTokenAuth.headers_names[:uid]] = data['uid'] 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 def update
@message.update!(message_update_params) @message.update!(message_update_params)
rescue StandardError => e 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 end
private 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? if Rails.env.development? || Rails.env.test?
render inline: File.read(Rails.root.join('swagger', derived_path)) render inline: File.read(Rails.root.join('swagger', derived_path))
else else
head 404 head :not_found
end end
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 class WidgetsController < ActionController::Base
before_action :set_global_config before_action :set_global_config
before_action :set_web_widget before_action :set_web_widget

View File

@ -114,13 +114,13 @@ class ConversationFinder
end end
def current_page def current_page
params[:page] params[:page] || 1
end end
def conversations def conversations
@conversations = @conversations.includes( @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
end end

View File

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

View File

@ -2,7 +2,7 @@
import ApiClient from './ApiClient'; import ApiClient from './ApiClient';
export const buildContactParams = (page, sortAttr, label, search) => { 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) { if (search) {
params = `${params}&q=${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`, { return axios.post(`${this.url}/${conversationId}/toggle_status`, {
status, status,
snoozed_until: snoozedUntil,
}); });
} }

View File

@ -2,6 +2,33 @@
/* global axios */ /* global axios */
import ApiClient from '../ApiClient'; 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 { class MessageApi extends ApiClient {
constructor() { constructor() {
super('conversations', { accountScoped: true }); super('conversations', { accountScoped: true });
@ -15,18 +42,16 @@ class MessageApi extends ApiClient {
echo_id: echoId, echo_id: echoId,
file, 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({ return axios({
method: 'post', method: 'post',
url: `${this.url}/${conversationId}/messages`, url: `${this.url}/${conversationId}/messages`,
data: formData, data: buildCreatePayload({
message,
isPrivate,
contentAttributes,
echoId,
file,
}),
}); });
} }

View File

@ -17,7 +17,7 @@ describe('#ContactsAPI', () => {
it('#get', () => { it('#get', () => {
contactAPI.get(1, 'name', 'customer-support'); contactAPI.get(1, 'name', 'customer-support');
expect(context.axiosMock.get).toHaveBeenCalledWith( 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', () => { it('#search', () => {
contactAPI.search('leads', 1, 'date', 'customer-support'); contactAPI.search('leads', 1, 'date', 'customer-support');
expect(context.axiosMock.get).toHaveBeenCalledWith( 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', () => { describe('#buildContactParams', () => {
it('returns correct string', () => { 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( 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( expect(
buildContactParams(1, 'name', 'customer-support', 'message-content') 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`, `/api/v1/conversations/12/toggle_status`,
{ {
status: 'online', 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 ApiClient from '../../ApiClient';
import describeWithAPIMock from '../apiSpecHelper'; 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 { .multiselect--disabled .multiselect__select {
background: transparent; background: transparent;
} }
.multiselect__tags-wrap {
flex-shrink: 0;
}
} }

View File

@ -8,6 +8,22 @@
font-weight: var(--font-weight-light); font-weight: var(--font-weight-light);
margin-top: var(--space-large); 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 { .alert-box {
@ -20,17 +36,4 @@
text-align: center; 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; font-size: $font-size-mini;
vertical-align: top; vertical-align: top;
} }
.message-from-agent {
color: $color-gray;
font-size: $font-size-mini;
}
} }
.conversation--meta { .conversation--meta {

View File

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

View File

@ -24,7 +24,7 @@
{{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }} {{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }}
</woot-button> </woot-button>
<woot-button <woot-button
v-else-if="isBot" v-else-if="showOpenButton"
class-names="resolve" class-names="resolve"
color-scheme="primary" color-scheme="primary"
icon="ion-person" icon="ion-person"
@ -34,7 +34,8 @@
{{ this.$t('CONVERSATION.HEADER.OPEN_ACTION') }} {{ this.$t('CONVERSATION.HEADER.OPEN_ACTION') }}
</woot-button> </woot-button>
<woot-button <woot-button
v-if="showDropDown" v-if="showAdditionalActions"
ref="arrowDownButton"
:color-scheme="buttonClass" :color-scheme="buttonClass"
:disabled="isLoading" :disabled="isLoading"
icon="ion-arrow-down-b" icon="ion-arrow-down-b"
@ -43,19 +44,53 @@
/> />
</div> </div>
<div <div
v-if="showDropdown" v-if="showActionsDropdown"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open" class="dropdown-pane dropdown-pane--open"
> >
<woot-dropdown-menu> <woot-dropdown-menu>
<woot-dropdown-item v-if="!isBot"> <woot-dropdown-item v-if="!isPending">
<woot-button <woot-button
variant="clear" 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-button>
</woot-dropdown-item> </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> </woot-dropdown-menu>
</div> </div>
</div> </div>
@ -65,22 +100,37 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin'; 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 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 WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from '../../constants'; import wootConstants from '../../constants';
import {
getUnixTime,
addHours,
addWeeks,
startOfTomorrow,
startOfWeek,
} from 'date-fns';
export default { export default {
components: { components: {
WootDropdownItem, WootDropdownItem,
WootDropdownMenu, WootDropdownMenu,
WootDropdownSubMenu,
}, },
mixins: [clickaway, alertMixin], mixins: [clickaway, alertMixin, eventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } }, props: { conversationId: { type: [String, Number], required: true } },
data() { data() {
return { return {
isLoading: false, isLoading: false,
showDropdown: false, showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
}; };
}, },
@ -91,36 +141,83 @@ export default {
isOpen() { isOpen() {
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN; return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
}, },
isBot() { isPending() {
return this.currentChat.status === wootConstants.STATUS_TYPE.BOT; return this.currentChat.status === wootConstants.STATUS_TYPE.PENDING;
}, },
isResolved() { isResolved() {
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED; return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
}, },
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
buttonClass() { buttonClass() {
if (this.isBot) return 'primary'; if (this.isPending) return 'primary';
if (this.isOpen) return 'success'; if (this.isOpen) return 'success';
if (this.isResolved) return 'warning'; if (this.isResolved) return 'warning';
return ''; return '';
}, },
showDropDown() { showAdditionalActions() {
return !this.isBot; 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: { 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() { closeDropdown() {
this.showDropdown = false; this.showActionsDropdown = false;
}, },
openDropdown() { openDropdown() {
this.showDropdown = true; this.showActionsDropdown = true;
}, },
toggleStatus(status) { toggleStatus(status, snoozedUntil) {
this.closeDropdown(); this.closeDropdown();
this.isLoading = true; this.isLoading = true;
this.$store this.$store
.dispatch('toggleStatus', { .dispatch('toggleStatus', {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
status, status,
snoozedUntil,
}) })
.then(() => { .then(() => {
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS')); this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));

View File

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

View File

@ -59,10 +59,17 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import router from '../../routes'; import router from '../../routes';
import {
hasPressedAltAndCKey,
hasPressedAltAndVKey,
hasPressedAltAndRKey,
hasPressedAltAndSKey,
} from 'shared/helpers/KeyboardHelpers';
import adminMixin from '../../mixins/isAdmin'; import adminMixin from '../../mixins/isAdmin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { getInboxClassByType } from 'dashboard/helper/inbox'; import { getInboxClassByType } from 'dashboard/helper/inbox';
export default { export default {
mixins: [adminMixin], mixins: [adminMixin, eventListenerMixins],
props: { props: {
menuItem: { menuItem: {
type: Object, 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) { showItem(item) {
return this.isAdmin && item.newLink !== undefined; return this.isAdmin && item.newLink !== undefined;
}, },

View File

@ -9,6 +9,14 @@
:header-title="$t('CREATE_ACCOUNT.NEW_ACCOUNT')" :header-title="$t('CREATE_ACCOUNT.NEW_ACCOUNT')"
:header-content="$t('CREATE_ACCOUNT.SELECTOR_SUBTITLE')" :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"> <form class="row" @submit.prevent="addAccount">
<div class="medium-12 columns"> <div class="medium-12 columns">
@ -53,6 +61,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
hasAccounts: {
type: Boolean,
default: true,
},
}, },
data() { data() {
return { return {
@ -90,3 +102,19 @@ export default {
}, },
}; };
</script> </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 Contact Support
</woot-button> </woot-button>
</woot-dropdown-item> </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> <woot-dropdown-item>
<router-link <router-link
:to="`/app/accounts/${accountId}/profile/settings`" :to="`/app/accounts/${accountId}/profile/settings`"

View File

@ -10,8 +10,11 @@
</template> </template>
<script> <script>
import wootConstants from '../../constants'; import wootConstants from '../../constants';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
export default { export default {
mixins: [eventListenerMixins],
props: { props: {
items: { items: {
type: Array, type: Array,
@ -28,6 +31,15 @@ export default {
}, },
}, },
methods: { methods: {
handleKeyEvents(e) {
if (hasPressedAltAndNKey(e)) {
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(0);
} else {
this.onTabChange(this.activeTabIndex + 1);
}
}
},
onTabChange(selectedTabIndex) { onTabChange(selectedTabIndex) {
if (this.items[selectedTabIndex].key !== this.activeTab) { if (this.items[selectedTabIndex].key !== this.activeTab) {
this.$emit('chatTabChange', this.items[selectedTabIndex].key); 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: '' }, value: { type: String, default: '' },
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '' },
isPrivate: { type: Boolean, default: false }, isPrivate: { type: Boolean, default: false },
isFormatMode: { type: Boolean, default: false },
}, },
data() { data() {
return { return {
@ -139,8 +140,17 @@ export default {
value(newValue = '') { value(newValue = '') {
if (newValue !== this.lastValue) { if (newValue !== this.lastValue) {
const { tr } = this.state; const { tr } = this.state;
tr.insertText(newValue, 0, tr.doc.content.size); if (this.isFormatMode) {
this.state = this.view.state.apply(tr); 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); this.view.updateState(this.state);
} }
}, },
@ -271,4 +281,25 @@ export default {
padding: 0 var(--space-smaller); 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> </style>

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,12 @@
{{ currentContact.name }} {{ currentContact.name }}
</h4> </h4>
<p v-if="lastMessageInChat" class="conversation--message"> <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"> <span v-if="lastMessageInChat.content">
{{ parsedLastMessage }} {{ parsedLastMessage }}
</span> </span>
@ -144,6 +149,18 @@ export default {
return messageType === MESSAGE_TYPE.OUTGOING; 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() { parsedLastMessage() {
const { content_attributes: contentAttributes } = this.lastMessageInChat; const { content_attributes: contentAttributes } = this.lastMessageInChat;
const { email: { subject } = {} } = contentAttributes || {}; const { email: { subject } = {} } = contentAttributes || {};
@ -230,4 +247,9 @@ export default {
font-size: var(--font-size-mini); font-size: var(--font-size-mini);
} }
} }
.last-message-icon {
color: var(--s-600);
font-size: var(--font-size-mini);
}
</style> </style>

View File

@ -32,33 +32,6 @@
class="header-actions-wrap" class="header-actions-wrap"
:class="{ 'has-open-sidebar': isContactPanelOpen }" :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" /> <more-actions :conversation-id="currentChat.id" />
</div> </div>
</div> </div>
@ -68,15 +41,15 @@ import { mapGetters } from 'vuex';
import MoreActions from './MoreActions'; import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail'; import Thumbnail from '../Thumbnail';
import agentMixin from '../../../mixins/agentMixin.js'; 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 { export default {
components: { components: {
MoreActions, MoreActions,
Thumbnail, Thumbnail,
AvailabilityStatusBadge,
}, },
mixins: [agentMixin], mixins: [agentMixin, eventListenerMixins],
props: { props: {
chat: { chat: {
type: Object, type: Object,
@ -117,17 +90,11 @@ export default {
}, },
methods: { methods: {
assignAgent(agent) { handleKeyEvents(e) {
this.$store if (hasPressedAltAndOKey(e)) {
.dispatch('assignAgent', { this.$emit('contact-panel-toggle');
conversationId: this.currentChat.id, }
agentId: agent.id,
})
.then(() => {
bus.$emit('newToastMessage', this.$t('CONVERSATION.CHANGE_AGENT'));
});
}, },
removeAgent() {},
}, },
}; };
</script> </script>

View File

@ -2,6 +2,11 @@
<li v-if="hasAttachments || data.content" :class="alignBubble"> <li v-if="hasAttachments || data.content" :class="alignBubble">
<div :class="wrapClass"> <div :class="wrapClass">
<div v-tooltip.top-start="sentByMessage" :class="bubbleClass"> <div v-tooltip.top-start="sentByMessage" :class="bubbleClass">
<bubble-mail-head
v-if="isEmailContentType"
:email-attributes="contentAttributes.email"
:is-incoming="isIncoming"
/>
<bubble-text <bubble-text
v-if="data.content" v-if="data.content"
:message="message" :message="message"
@ -41,6 +46,7 @@
:message-type="data.message_type" :message-type="data.message_type"
:readable-time="readableTime" :readable-time="readableTime"
:source-id="data.source_id" :source-id="data.source_id"
:inbox-id="data.inbox_id"
/> />
</div> </div>
<spinner v-if="isPending" size="tiny" /> <spinner v-if="isPending" size="tiny" />
@ -79,16 +85,19 @@ import copy from 'copy-text-to-clipboard';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import timeMixin from '../../../mixins/time'; import timeMixin from '../../../mixins/time';
import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text'; import BubbleText from './bubble/Text';
import BubbleImage from './bubble/Image'; import BubbleImage from './bubble/Image';
import BubbleFile from './bubble/File'; import BubbleFile from './bubble/File';
import BubbleActions from './bubble/Actions';
import Spinner from 'shared/components/Spinner'; import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
import { isEmptyObject } from 'dashboard/helper/commons'; import { isEmptyObject } from 'dashboard/helper/commons';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin'; import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import BubbleActions from './bubble/Actions';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages'; import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper'; import { generateBotMessageContent } from './helpers/botMessageContentHelper';
@ -98,6 +107,7 @@ export default {
BubbleText, BubbleText,
BubbleImage, BubbleImage,
BubbleFile, BubbleFile,
BubbleMailHead,
ContextMenu, ContextMenu,
Spinner, Spinner,
}, },
@ -133,11 +143,11 @@ export default {
const { const {
email: { email: {
content_type: contentType = '',
html_content: { full: fullHTMLContent, reply: replyHTMLContent } = {}, html_content: { full: fullHTMLContent, reply: replyHTMLContent } = {},
text_content: { full: fullTextContent, reply: replyTextContent } = {}, text_content: { full: fullTextContent, reply: replyTextContent } = {},
} = {}, } = {},
} = this.contentAttributes; } = this.contentAttributes;
let contentToBeParsed = let contentToBeParsed =
replyHTMLContent || replyHTMLContent ||
replyTextContent || replyTextContent ||
@ -147,7 +157,12 @@ export default {
if (contentToBeParsed && this.isIncoming) { if (contentToBeParsed && this.isIncoming) {
const parsedContent = this.stripStyleCharacters(contentToBeParsed); const parsedContent = this.stripStyleCharacters(contentToBeParsed);
if (parsedContent) { 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 ( return (

View File

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

View File

@ -74,6 +74,7 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import EmojiInput from 'shared/components/emoji/EmojiInput'; import EmojiInput from 'shared/components/emoji/EmojiInput';
import CannedResponse from './CannedResponse'; import CannedResponse from './CannedResponse';
@ -105,11 +106,21 @@ export default {
ReplyBottomPanel, ReplyBottomPanel,
WootMessageEditor, WootMessageEditor,
}, },
mixins: [clickaway, inboxMixin, uiSettingsMixin, alertMixin], mixins: [
clickaway,
inboxMixin,
uiSettingsMixin,
alertMixin,
eventListenerMixins,
],
props: { props: {
inReplyTo: { selectedTweet: {
type: [String, Number], type: [Object, String],
default: '', default: () => ({}),
},
isATweet: {
type: Boolean,
default: false,
}, },
}, },
data() { data() {
@ -169,11 +180,14 @@ export default {
return this.maxLength - this.message.length; return this.maxLength - this.message.length;
}, },
isReplyButtonDisabled() { isReplyButtonDisabled() {
const isMessageEmpty = !this.message.trim().replace(/\n/g, '').length; if (this.isATweet && !this.inReplyTo) {
return true;
}
if (this.hasAttachments) return false; if (this.hasAttachments) return false;
return ( return (
isMessageEmpty || this.isMessageEmpty ||
this.message.length === 0 || this.message.length === 0 ||
this.message.length > this.maxLength this.message.length > this.maxLength
); );
@ -198,7 +212,7 @@ export default {
} }
if (this.isATwitterInbox) { if (this.isATwitterInbox) {
if (this.conversationType === 'tweet') { if (this.conversationType === 'tweet') {
return MESSAGE_MAX_LENGTH.TWEET; return MESSAGE_MAX_LENGTH.TWEET - this.replyToUserLength - 2;
} }
} }
return MESSAGE_MAX_LENGTH.GENERAL; return MESSAGE_MAX_LENGTH.GENERAL;
@ -235,6 +249,25 @@ export default {
isOnPrivateNote() { isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE; 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: { watch: {
currentChat(conversation) { currentChat(conversation) {
@ -263,12 +296,6 @@ export default {
} }
}, },
}, },
mounted() {
document.addEventListener('keydown', this.handleKeyEvents);
},
destroyed() {
document.removeEventListener('keydown', this.handleKeyEvents);
},
methods: { methods: {
toggleUserMention(currentMentionState) { toggleUserMention(currentMentionState) {
this.hasUserMention = currentMentionState; this.hasUserMention = currentMentionState;
@ -327,6 +354,9 @@ export default {
if (this.showRichContentEditor) { if (this.showRichContentEditor) {
return; return;
} }
if (this.$refs.messageInput === undefined) {
return;
}
this.$nextTick(() => this.$refs.messageInput.focus()); this.$nextTick(() => this.$refs.messageInput.focus());
}, },
emojiOnClick(emoji) { 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" @mouseleave="isHovered = false"
/> />
<i <i
v-if="isATweet && isIncoming" v-if="isATweet && (isIncoming || isOutgoing) && sourceId"
v-tooltip.top-start="$t('CHAT_LIST.REPLY_TO_TWEET')" v-tooltip.top-start="$t('CHAT_LIST.REPLY_TO_TWEET')"
class="icon ion-reply cursor-pointer" class="icon ion-reply cursor-pointer"
@click="onTweetReply" @click="onTweetReply"
/> />
<a <a
v-if="isATweet && isIncoming" v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
:href="linkToTweet" :href="linkToTweet"
target="_blank" target="_blank"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
@ -71,19 +71,33 @@ export default {
type: [String, Number], type: [String, Number],
default: '', default: '',
}, },
inboxId: {
type: [String, Number],
default: 0,
},
}, },
computed: { computed: {
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
isIncoming() { isIncoming() {
return MESSAGE_TYPE.INCOMING === this.messageType; return MESSAGE_TYPE.INCOMING === this.messageType;
}, },
isOutgoing() {
return MESSAGE_TYPE.OUTGOING === this.messageType;
},
screenName() { screenName() {
const { additional_attributes: additionalAttributes = {} } = const { additional_attributes: additionalAttributes = {} } =
this.sender || {}; this.sender || {};
return additionalAttributes?.screen_name || ''; return additionalAttributes?.screen_name || '';
}, },
linkToTweet() { linkToTweet() {
if (!this.sourceId || !this.inbox.name) {
return '';
}
const { screenName, sourceId } = this; const { screenName, sourceId } = this;
return `https://twitter.com/${screenName}/status/${sourceId}`; return `https://twitter.com/${screenName ||
this.inbox.name}/status/${sourceId}`;
}, },
}, },
methods: { methods: {
@ -113,6 +127,13 @@ export default {
} }
} }
.right {
.ion-reply,
.ion-android-open {
color: var(--white);
}
}
.message-text--metadata { .message-text--metadata {
align-items: flex-end; align-items: flex-end;
display: flex; display: flex;

View File

@ -36,7 +36,7 @@ export default {
methods: { methods: {
openLink() { openLink() {
const win = window.open(this.url, '_blank', 'noopener'); 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: { STATUS_TYPE: {
OPEN: 'open', OPEN: 'open',
RESOLVED: 'resolved', RESOLVED: 'resolved',
BOT: 'bot', PENDING: 'pending',
SNOOZED: 'snoozed',
}, },
}; };
export const DEFAULT_REDIRECT_URL = '/app/'; export const DEFAULT_REDIRECT_URL = '/app/';

View File

@ -47,6 +47,13 @@ export const getSidebarItems = accountId => ({
toState: frontendURL(`accounts/${accountId}/reports`), toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'settings_account_reports', toStateName: 'settings_account_reports',
}, },
campaigns: {
icon: 'ion-speakerphone',
label: 'CAMPAIGNS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'settings_account_campaigns',
},
settings: { settings: {
icon: 'ion-settings', icon: 'ion-settings',
label: '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: { settings: {
routes: [ routes: [
'agent_list', 'agent_list',

View File

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

View File

@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Actualitza la configuració de la safata d'entrada", "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", "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_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": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Reautoritza", "TITLE": "Reautoritza",

View File

@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Aktualizujte nastavení doručené pošty", "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.", "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_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": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Znovu autorizovat", "TITLE": "Znovu autorizovat",

View File

@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Opdater dine indbakkeindstillinger", "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.", "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_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": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Genautorisér", "TITLE": "Genautorisér",

View File

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

View File

@ -273,7 +273,7 @@
"INBOX_UPDATE_SUB_TEXT": "Posteingangseinstellungen aktualisieren", "INBOX_UPDATE_SUB_TEXT": "Posteingangseinstellungen aktualisieren",
"AUTO_ASSIGNMENT_SUB_TEXT": "Aktivieren oder deaktivieren Sie die automatische Zuweisung verfügbarer Agenten für neue Konversationen", "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_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": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Neu autorisieren", "TITLE": "Neu autorisieren",

View File

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

View File

@ -91,6 +91,23 @@
}, },
"SEARCH": { "SEARCH": {
"NO_RESULTS": "No results found." "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": { "CAMPAIGN": {
"HEADER": "Campaigns", "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.", "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": { "ADD": {
"TITLE": "Create a campaign", "TITLE": "Create a campaign",
"DESC": "Proactive messages allow the customer to send outbound messages to their contacts which would trigger more conversations.", "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", "PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required" "ERROR": "Audience is required"
}, },
"INBOX": {
"LABEL": "Select Inbox",
"PLACEHOLDER": "Select Inbox",
"ERROR": "Inbox is required"
},
"MESSAGE": { "MESSAGE": {
"LABEL": "Message", "LABEL": "Message",
"PLACEHOLDER": "Please enter the message of campaign", "PLACEHOLDER": "Please enter the message of campaign",
@ -80,6 +88,7 @@
"TABLE_HEADER": { "TABLE_HEADER": {
"TITLE": "Title", "TITLE": "Title",
"MESSAGE": "Message", "MESSAGE": "Message",
"INBOX": "Inbox",
"STATUS": "Status", "STATUS": "Status",
"SENDER": "Sender", "SENDER": "Sender",
"URL": "URL", "URL": "URL",
@ -101,6 +110,16 @@
"SENDER": { "SENDER": {
"BOT": "Bot" "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" "VALUE": "resolved"
}, },
{ {
"TEXT": "Bot", "TEXT": "Pending",
"VALUE": "bot" "VALUE": "pending"
},
{
"TEXT": "Snoozed",
"VALUE": "snoozed"
} }
], ],
"ATTACHMENTS": { "ATTACHMENTS": {

View File

@ -9,6 +9,7 @@
"SEARCH_MESSAGES": "Search for messages in conversations", "SEARCH_MESSAGES": "Search for messages in conversations",
"SEARCH": { "SEARCH": {
"TITLE": "Search messages", "TITLE": "Search messages",
"RESULT_TITLE": "Search Results",
"LOADING_MESSAGE": "Crunching data...", "LOADING_MESSAGE": "Crunching data...",
"PLACEHOLDER": "Type any text to search messages", "PLACEHOLDER": "Type any text to search messages",
"NO_MATCHING_RESULTS": "No results found." "NO_MATCHING_RESULTS": "No results found."
@ -22,7 +23,7 @@
"24_HOURS_WINDOW": "24 hour message window restriction", "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_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", "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:", "REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection", "REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
@ -41,7 +42,13 @@
"DETAILS": "details" "DETAILS": "details"
}, },
"RESOLVE_DROPDOWN": { "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": { "FOOTER": {
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.", "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_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files", "TIP_ATTACH_ICON": "Attach files",
"ENTER_TO_SEND": "Enter to send", "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", "VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
"CHANGE_STATUS": "Conversation status changed", "CHANGE_STATUS": "Conversation status changed",
@ -123,5 +143,11 @@
"SELECT": { "SELECT": {
"PLACEHOLDER": "None" "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", "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.", "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_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": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize", "TITLE": "Reauthorize",

View File

@ -98,6 +98,7 @@
"CHANGE_ACCOUNTS": "Switch Account", "CHANGE_ACCOUNTS": "Switch Account",
"SELECTOR_SUBTITLE": "Select an account from the following list", "SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile Settings", "PROFILE_SETTINGS": "Profile Settings",
"KEYBOARD_SHORTCUTS": "Keyboard Shortcuts",
"LOGOUT": "Logout" "LOGOUT": "Logout"
}, },
"APP_GLOBAL": { "APP_GLOBAL": {
@ -126,8 +127,8 @@
"SIDEBAR": { "SIDEBAR": {
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"REPORTS": "Reports", "REPORTS": "Reports",
"CONTACTS": "Contacts",
"SETTINGS": "Settings", "SETTINGS": "Settings",
"CONTACTS": "Contacts",
"HOME": "Home", "HOME": "Home",
"AGENTS": "Agents", "AGENTS": "Agents",
"INBOXES": "Inboxes", "INBOXES": "Inboxes",
@ -141,9 +142,13 @@
"ALL_CONTACTS": "All Contacts", "ALL_CONTACTS": "All Contacts",
"TAGGED_WITH": "Tagged with", "TAGGED_WITH": "Tagged with",
"REPORTS_OVERVIEW": "Overview", "REPORTS_OVERVIEW": "Overview",
"CSAT": "CSAT" "CSAT": "CSAT",
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
"ONE_OFF": "One off"
}, },
"CREATE_ACCOUNT": { "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", "NEW_ACCOUNT": "New Account",
"SELECTOR_SUBTITLE": "Create a new account", "SELECTOR_SUBTITLE": "Create a new account",
"API": { "API": {
@ -158,5 +163,30 @@
}, },
"SUBMIT": "Submit" "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", "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.", "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_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": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Reautorizar", "TITLE": "Reautorizar",

View File

@ -3,7 +3,7 @@
"HEADER": "Edustajat", "HEADER": "Edustajat",
"HEADER_BTN_TXT": "Lisää edustaja", "HEADER_BTN_TXT": "Lisää edustaja",
"LOADING": "Haetaan Edustajalistaa", "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": { "AGENT_TYPES": {
"ADMINISTRATOR": "Ylläpitäjä", "ADMINISTRATOR": "Ylläpitäjä",
"AGENT": "Edustajat" "AGENT": "Edustajat"
@ -29,9 +29,9 @@
"PLACEHOLDER": "Ole hyvä ja kirjoita edustajan nimi" "PLACEHOLDER": "Ole hyvä ja kirjoita edustajan nimi"
}, },
"AGENT_TYPE": { "AGENT_TYPE": {
"LABEL": "Agent Type", "LABEL": "Edustajan tyyppi",
"PLACEHOLDER": "Please select a type", "PLACEHOLDER": "Valitse tyyppi",
"ERROR": "Agent type is required" "ERROR": "Edustajan tyyppi on pakollinen"
}, },
"EMAIL": { "EMAIL": {
"LABEL": "Sähköpostiosoite", "LABEL": "Sähköpostiosoite",
@ -66,9 +66,9 @@
"PLACEHOLDER": "Ole hyvä ja kirjoita edustajan nimi" "PLACEHOLDER": "Ole hyvä ja kirjoita edustajan nimi"
}, },
"AGENT_TYPE": { "AGENT_TYPE": {
"LABEL": "Agent Type", "LABEL": "Edustajan tyyppi",
"PLACEHOLDER": "Please select a type", "PLACEHOLDER": "Valitse edustajan tyyppi",
"ERROR": "Agent type is required" "ERROR": "Edustajan tyyppi on pakollinen"
}, },
"EMAIL": { "EMAIL": {
"LABEL": "Sähköpostiosoite", "LABEL": "Sähköpostiosoite",
@ -90,7 +90,7 @@
} }
}, },
"SEARCH": { "SEARCH": {
"NO_RESULTS": "No results found." "NO_RESULTS": "Ei hakutuloksia."
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,7 +43,7 @@
"CUSTOM_EMAIL_DOMAIN_ENABLED": "Voit nyt vastaanottaa sähköposteja mukautetulla verkkotunnuksellasi." "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": { "FORMS": {
"MULTISELECT": { "MULTISELECT": {
@ -54,10 +54,10 @@
}, },
"NOTIFICATIONS_PAGE": { "NOTIFICATIONS_PAGE": {
"HEADER": "Ilmoitukset", "HEADER": "Ilmoitukset",
"MARK_ALL_DONE": "Mark All Done", "MARK_ALL_DONE": "Merkkaa kaikki luetuksi",
"LIST": { "LIST": {
"LOADING_MESSAGE": "Loading notifications...", "LOADING_MESSAGE": "Ladataan ilmoituksia...",
"404": "No Notifications", "404": "Ei ilmoituksia",
"TABLE_HEADER": [ "TABLE_HEADER": [
"Nimi", "Nimi",
"Puhelinnumero", "Puhelinnumero",
@ -66,10 +66,10 @@
] ]
}, },
"TYPE_LABEL": { "TYPE_LABEL": {
"conversation_creation": "New conversation", "conversation_creation": "Uusi keskustelu",
"conversation_assignment": "Conversation Assigned", "conversation_assignment": "Keskustelu määrätty",
"assigned_conversation_new_message": "New Message", "assigned_conversation_new_message": "Uusi viesti",
"conversation_mention": "Mention" "conversation_mention": "Mainitse"
} }
} }
} }

View File

@ -1,7 +1,7 @@
{ {
"INBOX_MGMT": { "INBOX_MGMT": {
"HEADER": "Saapuneet-kansiot", "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": { "LIST": {
"404": "Tähän tiliin ei ole liitetty saapuneet-kansiota." "404": "Tähän tiliin ei ole liitetty saapuneet-kansiota."
}, },
@ -30,7 +30,7 @@
"ADD": { "ADD": {
"CHANNEL_NAME": { "CHANNEL_NAME": {
"LABEL": "Kansion nimi", "LABEL": "Kansion nimi",
"PLACEHOLDER": "Enter your inbox name (eg: Acme Inc)" "PLACEHOLDER": "Valitse kansion nimi (esim: Acme Oy)"
}, },
"WEBSITE_NAME": { "WEBSITE_NAME": {
"LABEL": "Sivuston nimi", "LABEL": "Sivuston nimi",
@ -47,7 +47,7 @@
}, },
"TWITTER": { "TWITTER": {
"HELP": "Lisätäksesi twitter-profiilin kanavaksesi, sinun tulee autentikoida twitter-tilisi klikkaamalla \"Kirjaudu sisään Twitterillä\" ", "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": { "WEBSITE_CHANNEL": {
"TITLE": "Sivuston chat", "TITLE": "Sivuston chat",
@ -83,7 +83,7 @@
"IN_A_FEW_MINUTES": "Muutamassa minuutissa", "IN_A_FEW_MINUTES": "Muutamassa minuutissa",
"IN_A_FEW_HOURS": "Muutamassa tunnissa", "IN_A_FEW_HOURS": "Muutamassa tunnissa",
"IN_A_DAY": "Päivän kuluessa", "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": { "WIDGET_COLOR": {
"LABEL": "Widgetin väri", "LABEL": "Widgetin väri",
@ -128,12 +128,12 @@
} }
}, },
"SMS": { "SMS": {
"TITLE": "SMS Channel via Twilio", "TITLE": "SMS Twilio:n kautta",
"DESC": "Start supporting your customers via SMS with Twilio integration." "DESC": "Ala palvelemaan asiakkaitasi tekstiviestien avulla käyttäen Twilio-alustaa"
}, },
"WHATSAPP": { "WHATSAPP": {
"TITLE": "Whatsapp Channel via Twilio", "TITLE": "Whatsapp Twilio:n kautta",
"DESC": "Start supporting your customers via Whatsapp with Twilio integration." "DESC": "Ala palvelemaan asiakkaitasi Whatsapp-viestien avulla käyttäen Twilio-alustaa"
}, },
"API_CHANNEL": { "API_CHANNEL": {
"TITLE": "API-rajapinta", "TITLE": "API-rajapinta",
@ -173,8 +173,8 @@
"FINISH_MESSAGE": "Aloita välittämällä sähköpostit seuraavaan osoitteeseen." "FINISH_MESSAGE": "Aloita välittämällä sähköpostit seuraavaan osoitteeseen."
}, },
"AUTH": { "AUTH": {
"TITLE": "Choose a channel", "TITLE": "Valitse kanava",
"DESC": "Chatwoot supports live-chat widget, Facebook page, Twitter profile, Whatsapp, Email etc., as channels. If you want to build a custom channel, you can create it using the API channel. Select one channel from the options below to proceed." "DESC": "Chatwoot tukee live-chat widgettiä, Facebook-sivua, Twitter-profiilia, Whatsappia, Sähköpostia jne., as channels. Voit luoda oman kanavan API-rajapinnalla."
}, },
"AGENTS": { "AGENTS": {
"TITLE": "Edustajat", "TITLE": "Edustajat",
@ -264,16 +264,16 @@
"INBOX_AGENTS": "Edustajat", "INBOX_AGENTS": "Edustajat",
"INBOX_AGENTS_SUB_TEXT": "Lisää tai poista edustajia tästä saapuneet-kansiosta", "INBOX_AGENTS_SUB_TEXT": "Lisää tai poista edustajia tästä saapuneet-kansiosta",
"UPDATE": "Päivitä", "UPDATE": "Päivitä",
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box", "ENABLE_EMAIL_COLLECT_BOX": "Aktivoi sähköpostilaatikko",
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation", "ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Aktivoi tai disabloi sähköpostilaatikko uusissa keskusteluissa",
"AUTO_ASSIGNMENT": "Ota automaattinen delegointi käyttöön", "AUTO_ASSIGNMENT": "Ota automaattinen delegointi käyttöön",
"ENABLE_CSAT": "Enable CSAT", "ENABLE_CSAT": "Aktivoi ASTK",
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation", "ENABLE_CSAT_SUB_TEXT": "Aktivoi/Disabloi ASTK(ASiakasTyytyväisyysKysely) keskustelun ratkaisun jälkeen",
"INBOX_UPDATE_TITLE": "Postilaatikon tiedot", "INBOX_UPDATE_TITLE": "Postilaatikon tiedot",
"INBOX_UPDATE_SUB_TEXT": "Päivitä postilaatikon asetukset", "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.", "AUTO_ASSIGNMENT_SUB_TEXT": "Ota käyttöön tai poista käytöstä automaattinen keskusteluiden delegointi edustajille.",
"HMAC_VERIFICATION": "User Identity Validation", "HMAC_VERIFICATION": "Käyttäjän identiteettivarmistus",
"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": "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": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Uudelleenvaltuuta", "TITLE": "Uudelleenvaltuuta",
@ -282,38 +282,38 @@
"MESSAGE_ERROR": "Tapahtui virhe, yritä uudelleen" "MESSAGE_ERROR": "Tapahtui virhe, yritä uudelleen"
}, },
"PRE_CHAT_FORM": { "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": { "ENABLE": {
"LABEL": "Enable pre chat form", "LABEL": "Aktivoi pikakysely",
"OPTIONS": { "OPTIONS": {
"ENABLED": "Yes", "ENABLED": "Kyllä",
"DISABLED": "No" "DISABLED": "Ei"
} }
}, },
"PRE_CHAT_MESSAGE": { "PRE_CHAT_MESSAGE": {
"LABEL": "Pre Chat Message", "LABEL": "Pikakyselyviesti",
"PLACEHOLDER": "This message would be visible to the users along with the form" "PLACEHOLDER": "Tämä viesti näytetään käyttäjille joille pikakysely näytetään"
}, },
"REQUIRE_EMAIL": { "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": { "BUSINESS_HOURS": {
"TITLE": "Set your availability", "TITLE": "Aseta tilasi",
"SUBTITLE": "Set your availability on your livechat widget", "SUBTITLE": "Aseta tilasi livechat widgetissä",
"WEEKLY_TITLE": "Set your weekly hours", "WEEKLY_TITLE": "Aseta viikkotyöaikasi",
"TIMEZONE_LABEL": "Select timezone", "TIMEZONE_LABEL": "Aseta aikavyöhyke",
"UPDATE": "Update business hours settings", "UPDATE": "Aseta työaika",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox", "TOGGLE_AVAILABILITY": "Aktivoi työaikasaatavuus tälle kansiolle",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", "UNAVAILABLE_MESSAGE_LABEL": "Ei saatavilla-viesti käyttäjille",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "UNAVAILABLE_MESSAGE_DEFAULT": "Emme ole saatavilla juuri nyt. Jätä viesti ja vastaamme heti kun pystymme.",
"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.", "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": { "DAY": {
"ENABLE": "Enable availability for this day", "ENABLE": "Aktivoi saatavuus tälle päivälle",
"UNAVAILABLE": "Unavailable", "UNAVAILABLE": "Ei saatavilla",
"HOURS": "hours", "HOURS": "tuntia",
"VALIDATION_ERROR": "Starting time should be before closing time.", "VALIDATION_ERROR": "Avaamisajan tulisi olla ennen sulkemisaikaa.",
"CHOOSE": "Choose" "CHOOSE": "Valitse"
} }
} }
} }

View File

@ -1,36 +1,36 @@
{ {
"INTEGRATION_APPS": { "INTEGRATION_APPS": {
"FETCHING": "Fetching Integrations", "FETCHING": "Haetaan integraatioita",
"NO_HOOK_CONFIGURED": "There are no %{integrationId} integrations configured in this account.", "NO_HOOK_CONFIGURED": "Integraatiota IDllä %{integrationId} ei löytynyt tai ei ole konfiguroitu.",
"HEADER": "Applications", "HEADER": "Applikaatiot",
"STATUS": { "STATUS": {
"ENABLED": "Käytössä", "ENABLED": "Käytössä",
"DISABLED": "Pois käytöstä" "DISABLED": "Pois käytöstä"
}, },
"CONFIGURE": "Määrittele", "CONFIGURE": "Määrittele",
"ADD_BUTTON": "Add a new hook", "ADD_BUTTON": "Lisää uusi webhook",
"DELETE": { "DELETE": {
"TITLE": { "TITLE": {
"INBOX": "Confirm deletion", "INBOX": "Varmista poisto",
"ACCOUNT": "Disconnect" "ACCOUNT": "Katkaise"
}, },
"MESSAGE": { "MESSAGE": {
"INBOX": "Oletko varma että haluat poistaa?", "INBOX": "Oletko varma että haluat poistaa?",
"ACCOUNT": "Are you sure to disconnect?" "ACCOUNT": "Oletko varma että haluat katkaista?"
}, },
"CONFIRM_BUTTON_TEXT": { "CONFIRM_BUTTON_TEXT": {
"INBOX": "Kyllä, poista", "INBOX": "Kyllä, poista",
"ACCOUNT": "Yes, Disconnect" "ACCOUNT": "Kyllä, katkaise"
}, },
"CANCEL_BUTTON_TEXT": "Peruuta", "CANCEL_BUTTON_TEXT": "Peruuta",
"API": { "API": {
"SUCCESS_MESSAGE": "Hook deleted successfully", "SUCCESS_MESSAGE": "Webhook poistettu onnistuneesti",
"ERROR_MESSAGE": "Yhteyden muodostaminen Woot-palvelimelle ei onnistunut, yritä myöhemmin uudelleen" "ERROR_MESSAGE": "Yhteyden muodostaminen Woot-palvelimelle ei onnistunut, yritä myöhemmin uudelleen"
} }
}, },
"LIST": { "LIST": {
"FETCHING": "Fetching integration hooks", "FETCHING": "Haetaan integraatiowebhookkeja",
"INBOX": "Inbox", "INBOX": "Kansio",
"DELETE": { "DELETE": {
"BUTTON_TEXT": "Poista" "BUTTON_TEXT": "Poista"
} }
@ -38,14 +38,14 @@
"ADD": { "ADD": {
"FORM": { "FORM": {
"INBOX": { "INBOX": {
"LABEL": "Select Inbox", "LABEL": "Valitse kansio",
"PLACEHOLDER": "Select Inbox" "PLACEHOLDER": "Valitse kansio"
}, },
"SUBMIT": "Luo", "SUBMIT": "Luo",
"CANCEL": "Peruuta" "CANCEL": "Peruuta"
}, },
"API": { "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" "ERROR_MESSAGE": "Yhteyden muodostaminen Woot-palvelimelle ei onnistunut, yritä myöhemmin uudelleen"
} }
}, },
@ -53,7 +53,7 @@
"BUTTON_TEXT": "Yhdistä" "BUTTON_TEXT": "Yhdistä"
}, },
"DISCONNECT": { "DISCONNECT": {
"BUTTON_TEXT": "Disconnect" "BUTTON_TEXT": "Katkaise"
}, },
"SIDEBAR_DESCRIPTION": { "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." "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" "PLACEHOLDER": "Salasana"
}, },
"API": { "API": {
"SUCCESS_MESSAGE": "Kirjautuminen Onnistui", "SUCCESS_MESSAGE": "Kirjautuminen onnistui",
"ERROR_MESSAGE": "Yhteyden muodostaminen Woot-palvelimelle ei onnistunut, yritä myöhemmin uudelleen", "ERROR_MESSAGE": "Yhteyden muodostaminen Woot-palvelimelle ei onnistunut, yritä myöhemmin uudelleen",
"UNAUTH": "Käyttäjätunnus / salasana virheellinen. Yritä 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