Merge branch 'release/2.14.0'

This commit is contained in:
Sojan 2023-02-16 13:42:21 +05:30
commit 0e3eb51d7a
630 changed files with 7475 additions and 1557 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: cimg/ruby:3.0.4-browsers - image: cimg/ruby:3.1.3-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
@ -38,6 +38,18 @@ jobs:
name: Which bundler? name: Which bundler?
command: bundle -v command: bundle -v
- run:
name: Swap node versions
command: |
set +e
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm install v16
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
# Run bundler # Run bundler
# Load installed gems from cache if possible, bundle install then save cache # Load installed gems from cache if possible, bundle install then save cache
# Multiple caches are used to increase the chance of a cache hit # Multiple caches are used to increase the chance of a cache hit
@ -193,4 +205,3 @@ workflows:
- upload-coverage: - upload-coverage:
requires: requires:
- build - build

View File

@ -131,6 +131,11 @@ TWITTER_ENVIRONMENT=
SLACK_CLIENT_ID= SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET= SLACK_CLIENT_SECRET=
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_CALLBACK_URL=
### Change this env variable only if you are using a custom build mobile app ### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables ## Mobile app env variables
IOS_APP_ID=L7YLMN4634.com.chatwoot.app IOS_APP_ID=L7YLMN4634.com.chatwoot.app

View File

@ -19,6 +19,7 @@ concurrency:
jobs: jobs:
action: action:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.repository == 'chatwoot/chatwoot' }}
steps: steps:
- uses: dessant/lock-threads@v3 - uses: dessant/lock-threads@v3
with: with:

View File

@ -47,7 +47,6 @@ jobs:
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.0.4 # Not needed with a .ruby-version file
bundler-cache: true # runs 'bundle install' and caches installed gems automatically bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: yarn - name: yarn

28
.github/workflows/stale.yml vendored Normal file
View File

@ -0,0 +1,28 @@
# This workflow warns and then closes PRs that have had no activity for a specified amount of time.
#
# You can adjust the behavior by modifying this file.
# For more information, see:
# https://github.com/actions/stale
name: Mark stale issues and pull requests
on:
schedule:
- cron: '28 3 * * *'
jobs:
stale:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/stale@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-close: -1,
days-before-issue-stale: -1
days-before-pr-close: -1,
days-before-pr-stale: 30,
stale-pr-message: '🐢 Turtley slow progress alert! This pull request has been idle for over 30 days. Can we please speed things up and either merge it or release it back into the wild?'
stale-pr-label: 'stale'

4
.gitignore vendored
View File

@ -62,3 +62,7 @@ test/cypress/videos/*
/config/*.enc /config/*.enc
.vscode/settings.json .vscode/settings.json
# yalc for local testing
.yalc
yalc.lock

View File

@ -1,5 +1,11 @@
#!/bin/sh #!/bin/sh
. "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
npm run eslint
bundle exec rubocop -a # lint js and vue files
git add npx --no-install lint-staged
# lint only staged ruby files
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop -a
# stage rubocop changes to files
git diff --name-only --cached | xargs git add

View File

@ -17,7 +17,6 @@ Metrics/ClassLength:
- 'app/builders/messages/facebook/message_builder.rb' - 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb' - 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/listeners/action_cable_listener.rb' - 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:
@ -86,6 +85,10 @@ Style/ClassAndModuleChildren:
- 'config/application.rb' - 'config/application.rb'
Style/MapToHash: Style/MapToHash:
Enabled: false Enabled: false
Style/HashSyntax:
Enabled: true
EnforcedStyle: no_mixed_keys
EnforcedShorthandSyntax: never
RSpec/NestedGroups: RSpec/NestedGroups:
Enabled: true Enabled: true
Max: 4 Max: 4
@ -159,7 +162,7 @@ RSpec/NamedSubject:
Enabled: false Enabled: false
# we should bring this down # we should bring this down
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 12 Max: 14
AllCops: AllCops:
NewCops: enable NewCops: enable
@ -184,4 +187,3 @@ AllCops:
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb - db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb - db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
- db/migrate/20220809104508_revert_cascading_indexes.rb - db/migrate/20220809104508_revert_cascading_indexes.rb

View File

@ -68,7 +68,6 @@ Naming/AccessorMethodName:
- 'app/controllers/api/v1/accounts_controller.rb' - 'app/controllers/api/v1/accounts_controller.rb'
- 'app/controllers/api/v1/callbacks_controller.rb' - 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/controllers/api/v1/conversations_controller.rb' - 'app/controllers/api/v1/conversations_controller.rb'
- 'app/controllers/passwords_controller.rb'
# Offense count: 9 # Offense count: 9
# Configuration parameters: EnforcedStyleForLeadingUnderscores. # Configuration parameters: EnforcedStyleForLeadingUnderscores.

View File

@ -1 +1 @@
3.0.4 3.1.3

29
Gemfile
View File

@ -1,10 +1,10 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.0.4' ruby '3.1.3'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 6.1', '>= 6.1.6.1' gem 'rails', '~> 6.1', '>= 6.1.7.1'
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false gem 'bootsnap', require: false
@ -39,6 +39,10 @@ gem 'rack-attack'
gem 'down', '~> 5.0' gem 'down', '~> 5.0'
# authentication type to fetch and send mail over oauth2.0 # authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth' gem 'gmail_xoauth'
# Prevent CSV injection
gem 'csv-safe'
# Support message translation
gem 'google-cloud-translate'
##-- for active storage --## ##-- for active storage --##
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
@ -98,16 +102,16 @@ gem 'newrelic_rpm'
gem 'scout_apm' gem 'scout_apm'
gem 'sentry-rails', '~> 5.3', '>= 5.3.1' gem 'sentry-rails', '~> 5.3', '>= 5.3.1'
gem 'sentry-ruby', '~> 5.3' gem 'sentry-ruby', '~> 5.3'
gem 'sentry-sidekiq', '~> 5.3' gem 'sentry-sidekiq', '~> 5.3', '>= 5.3.1'
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '~> 6.4.0' gem 'sidekiq', '~> 6.4.2'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron', '~> 1.3' gem 'sidekiq-cron', '~> 1.6', '>= 1.6.0'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'
gem 'webpush' gem 'web-push'
##-- geocoding / parse location from ip --## ##-- geocoding / parse location from ip --##
# http://www.rubygeocoder.com/ # http://www.rubygeocoder.com/
@ -137,6 +141,11 @@ gem 'stripe'
## to populate db with sample data ## to populate db with sample data
gem 'faker' gem 'faker'
# Can remove this in rails 7
gem 'net-imap', require: false
gem 'net-pop', require: false
gem 'net-smtp', require: false
group :production, :staging do group :production, :staging do
# we dont want request timing out in development while using byebug # we dont want request timing out in development while using byebug
gem 'rack-timeout' gem 'rack-timeout'
@ -157,7 +166,7 @@ end
group :test do group :test do
# Cypress in rails. # Cypress in rails.
gem 'cypress-on-rails', '~> 1.0' gem 'cypress-on-rails', '~> 1.13', '>= 1.13.1'
# fast cleaning of database # fast cleaning of database
gem 'database_cleaner' gem 'database_cleaner'
# mock http calls # mock http calls
@ -190,5 +199,11 @@ group :development, :test do
gem 'spring' gem 'spring'
gem 'spring-watcher-listen' gem 'spring-watcher-listen'
end end
# worked with microsoft refresh token # worked with microsoft refresh token
gem 'omniauth-oauth2' gem 'omniauth-oauth2'
# need for google auth
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'

View File

@ -9,63 +9,63 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (6.1.6.1) actioncable (6.1.7.1)
actionpack (= 6.1.6.1) actionpack (= 6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (6.1.6.1) actionmailbox (6.1.7.1)
actionpack (= 6.1.6.1) actionpack (= 6.1.7.1)
activejob (= 6.1.6.1) activejob (= 6.1.7.1)
activerecord (= 6.1.6.1) activerecord (= 6.1.7.1)
activestorage (= 6.1.6.1) activestorage (= 6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
mail (>= 2.7.1) mail (>= 2.7.1)
actionmailer (6.1.6.1) actionmailer (6.1.7.1)
actionpack (= 6.1.6.1) actionpack (= 6.1.7.1)
actionview (= 6.1.6.1) actionview (= 6.1.7.1)
activejob (= 6.1.6.1) activejob (= 6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (6.1.6.1) actionpack (6.1.7.1)
actionview (= 6.1.6.1) actionview (= 6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
rack (~> 2.0, >= 2.0.9) 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.1.6.1) actiontext (6.1.7.1)
actionpack (= 6.1.6.1) actionpack (= 6.1.7.1)
activerecord (= 6.1.6.1) activerecord (= 6.1.7.1)
activestorage (= 6.1.6.1) activestorage (= 6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (6.1.6.1) actionview (6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
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)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (6.1.6.1) activejob (6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (6.1.6.1) activemodel (6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
activerecord (6.1.6.1) activerecord (6.1.7.1)
activemodel (= 6.1.6.1) activemodel (= 6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
activerecord-import (1.4.0) activerecord-import (1.4.0)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (6.1.6.1) activestorage (6.1.7.1)
actionpack (= 6.1.6.1) actionpack (= 6.1.7.1)
activejob (= 6.1.6.1) activejob (= 6.1.7.1)
activerecord (= 6.1.6.1) activerecord (= 6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (6.1.6.1) activesupport (6.1.7.1)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
@ -73,8 +73,8 @@ GEM
zeitwerk (~> 2.3) zeitwerk (~> 2.3)
acts-as-taggable-on (9.0.1) acts-as-taggable-on (9.0.1)
activerecord (>= 6.0, < 7.1) activerecord (>= 6.0, < 7.1)
addressable (2.8.0) addressable (2.8.1)
public_suffix (>= 2.0.2, < 5.0) public_suffix (>= 2.0.2, < 6.0)
administrate (0.17.0) administrate (0.17.0)
actionpack (>= 5.0) actionpack (>= 5.0)
actionview (>= 5.0) actionview (>= 5.0)
@ -135,12 +135,13 @@ GEM
byebug (11.1.3) byebug (11.1.3)
climate_control (1.1.1) climate_control (1.1.1)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.6) commonmarker (0.23.7)
concurrent-ruby (1.1.10) concurrent-ruby (1.1.10)
connection_pool (2.2.5) connection_pool (2.2.5)
crack (0.4.5) crack (0.4.5)
rexml rexml
crass (1.0.6) crass (1.0.6)
csv-safe (3.1.1)
cypress-on-rails (1.13.1) cypress-on-rails (1.13.1)
rack rack
database_cleaner (2.0.1) database_cleaner (2.0.1)
@ -247,7 +248,7 @@ GEM
grpc (~> 1.36) grpc (~> 1.36)
geocoder (1.8.0) geocoder (1.8.0)
gli (2.21.0) gli (2.21.0)
globalid (1.0.0) globalid (1.0.1)
activesupport (>= 5.0) activesupport (>= 5.0)
gmail_xoauth (0.4.2) gmail_xoauth (0.4.2)
oauth (>= 0.3.6) oauth (>= 0.3.6)
@ -288,6 +289,19 @@ GEM
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
google-cloud-translate (3.3.0)
google-cloud-core (~> 1.6)
google-cloud-translate-v2 (>= 0.0, < 2.a)
google-cloud-translate-v3 (>= 0.0, < 2.a)
google-cloud-translate-v2 (0.4.0)
faraday (>= 0.17.3, < 2.a)
google-cloud-core (~> 1.6)
googleapis-common-protos (>= 1.3.10, < 2.a)
googleapis-common-protos-types (>= 1.0.5, < 2.a)
googleauth (>= 0.16.2, < 2.a)
google-cloud-translate-v3 (0.5.0)
gapic-common (>= 0.10, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.21.7) google-protobuf (3.21.7)
google-protobuf (3.21.7-x86_64-darwin) google-protobuf (3.21.7-x86_64-darwin)
google-protobuf (3.21.7-x86_64-linux) google-protobuf (3.21.7-x86_64-linux)
@ -323,7 +337,7 @@ GEM
hana (1.3.7) hana (1.3.7)
hashdiff (1.0.1) hashdiff (1.0.1)
hashie (5.0.0) hashie (5.0.0)
hkdf (0.3.0) hkdf (1.0.0)
html2text (0.2.1) html2text (0.2.1)
nokogiri (~> 1.6) nokogiri (~> 1.6)
http (5.1.0) http (5.1.0)
@ -360,7 +374,7 @@ GEM
hana (~> 1.3) hana (~> 1.3)
regexp_parser (~> 2.0) regexp_parser (~> 2.0)
uri_template (~> 0.7) uri_template (~> 0.7)
jwt (2.4.1) jwt (2.5.0)
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2) kaminari-actionview (= 1.2.2)
@ -426,8 +440,16 @@ GEM
multipart-post (2.2.3) multipart-post (2.2.3)
net-http-persistent (4.0.1) net-http-persistent (4.0.1)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.3.1)
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.1)
timeout
net-smtp (0.3.3)
net-protocol
netrc (0.11.0) netrc (0.11.0)
newrelic_rpm (8.9.0) newrelic_rpm (8.15.0)
nio4r (2.5.8) nio4r (2.5.8)
nokogiri (1.13.10) nokogiri (1.13.10)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
@ -450,9 +472,18 @@ GEM
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0)
oauth2 (~> 2.0.6)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-oauth2 (1.8.0) omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3) oauth2 (>= 1.4, < 3)
omniauth (~> 2.0) omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.1.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
parallel (1.22.1) parallel (1.22.1)
@ -469,14 +500,14 @@ GEM
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.7) public_suffix (5.0.1)
puma (5.6.4) puma (5.6.4)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.2.0) pundit (2.2.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.6.1) racc (1.6.1)
rack (2.2.4) rack (2.2.6.2)
rack-attack (6.6.1) rack-attack (6.6.1)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-cors (1.1.1) rack-cors (1.1.1)
@ -488,29 +519,29 @@ GEM
rack-test (2.0.2) rack-test (2.0.2)
rack (>= 1.3) rack (>= 1.3)
rack-timeout (0.6.3) rack-timeout (0.6.3)
rails (6.1.6.1) rails (6.1.7.1)
actioncable (= 6.1.6.1) actioncable (= 6.1.7.1)
actionmailbox (= 6.1.6.1) actionmailbox (= 6.1.7.1)
actionmailer (= 6.1.6.1) actionmailer (= 6.1.7.1)
actionpack (= 6.1.6.1) actionpack (= 6.1.7.1)
actiontext (= 6.1.6.1) actiontext (= 6.1.7.1)
actionview (= 6.1.6.1) actionview (= 6.1.7.1)
activejob (= 6.1.6.1) activejob (= 6.1.7.1)
activemodel (= 6.1.6.1) activemodel (= 6.1.7.1)
activerecord (= 6.1.6.1) activerecord (= 6.1.7.1)
activestorage (= 6.1.6.1) activestorage (= 6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 6.1.6.1) railties (= 6.1.7.1)
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.4.4) rails-html-sanitizer (1.4.4)
loofah (~> 2.19, >= 2.19.1) loofah (~> 2.19, >= 2.19.1)
railties (6.1.6.1) railties (6.1.7.1)
actionpack (= 6.1.6.1) actionpack (= 6.1.7.1)
activesupport (= 6.1.6.1) activesupport (= 6.1.7.1)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
@ -662,6 +693,7 @@ GEM
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
timeout (0.3.1)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
twilio-ruby (5.68.0) twilio-ruby (5.68.0)
faraday (>= 0.9, < 3.0) faraday (>= 0.9, < 3.0)
@ -693,7 +725,11 @@ GEM
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.14.0) web-push (3.0.0)
hkdf (~> 1.0)
jwt (~> 2.0)
openssl (~> 3.0)
webmock (3.18.1)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
@ -702,9 +738,6 @@ GEM
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.1.0)
hkdf (~> 0.2)
jwt (~> 2.0)
webrick (1.7.0) webrick (1.7.0)
websocket-driver (0.7.5) websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
@ -741,7 +774,8 @@ DEPENDENCIES
byebug byebug
climate_control climate_control
commonmarker commonmarker
cypress-on-rails (~> 1.0) csv-safe
cypress-on-rails (~> 1.13, >= 1.13.1)
database_cleaner database_cleaner
ddtrace ddtrace
devise devise
@ -761,6 +795,7 @@ DEPENDENCIES
gmail_xoauth gmail_xoauth
google-cloud-dialogflow google-cloud-dialogflow
google-cloud-storage google-cloud-storage
google-cloud-translate
groupdate groupdate
haikunator haikunator
hairtrigger hairtrigger
@ -779,8 +814,14 @@ DEPENDENCIES
listen listen
maxminddb maxminddb
mock_redis mock_redis
net-imap
net-pop
net-smtp
newrelic_rpm newrelic_rpm
omniauth
omniauth-google-oauth2
omniauth-oauth2 omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0)
pg pg
pg_search pg_search
procore-sift procore-sift
@ -790,7 +831,7 @@ DEPENDENCIES
rack-attack rack-attack
rack-cors rack-cors
rack-timeout rack-timeout
rails (~> 6.1, >= 6.1.6.1) rails (~> 6.1, >= 6.1.7.1)
redis redis
redis-namespace redis-namespace
responders responders
@ -805,10 +846,10 @@ DEPENDENCIES
seed_dump seed_dump
sentry-rails (~> 5.3, >= 5.3.1) sentry-rails (~> 5.3, >= 5.3.1)
sentry-ruby (~> 5.3) sentry-ruby (~> 5.3)
sentry-sidekiq (~> 5.3) sentry-sidekiq (~> 5.3, >= 5.3.1)
shoulda-matchers shoulda-matchers
sidekiq (~> 6.4.0) sidekiq (~> 6.4.2)
sidekiq-cron (~> 1.3) sidekiq-cron (~> 1.6, >= 1.6.0)
simplecov (= 0.17.1) simplecov (= 0.17.1)
slack-ruby-client slack-ruby-client
spring spring
@ -824,14 +865,14 @@ DEPENDENCIES
uglifier uglifier
valid_email2 valid_email2
web-console web-console
web-push
webmock webmock
webpacker (~> 5.4, >= 5.4.3) webpacker (~> 5.4, >= 5.4.3)
webpush
wisper (= 2.0.0) wisper (= 2.0.0)
working_hours working_hours
RUBY VERSION RUBY VERSION
ruby 3.0.4p208 ruby 3.1.3p185
BUNDLED WITH BUNDLED WITH
2.3.16 2.3.26

View File

@ -48,13 +48,25 @@ class Messages::MessageBuilder
def process_emails def process_emails
return unless @conversation.inbox&.inbox_type == 'Email' return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails] cc_emails = []
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails] cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails].present?
bcc_emails = []
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails].present?
all_email_addresses = cc_emails + bcc_emails
validate_email_addresses(all_email_addresses)
@message.content_attributes[:cc_emails] = cc_emails @message.content_attributes[:cc_emails] = cc_emails
@message.content_attributes[:bcc_emails] = bcc_emails @message.content_attributes[:bcc_emails] = bcc_emails
end end
def validate_email_addresses(all_emails)
all_emails&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
def message_type def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes' raise StandardError, 'Incoming messages are only allowed in Api inboxes'

View File

@ -1,14 +1,14 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal before_action :portal
before_action :check_authorization before_action :check_authorization
before_action :fetch_article, except: [:index, :create] before_action :fetch_article, except: [:index, :create, :attach_file]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
def index def index
@portal_articles = @portal.articles @portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params) @all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count @articles_count = @all_articles.count
@articles = @all_articles.page(@current_page) @articles = @all_articles.order_by_updated_at.page(@current_page)
end end
def create def create
@ -23,7 +23,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def show; end def show; end
def update def update
@article.update!(article_params) @article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end end
def destroy def destroy
@ -31,6 +32,17 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:background_image].tempfile,
filename: params[:background_image].original_filename,
content_type: params[:background_image].content_type
)
file_blob.save!
render json: { file_url: url_for(file_blob) }
end
private private
def fetch_article def fetch_article
@ -43,7 +55,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params def article_params
params.require(:article).permit( params.require(:article).permit(
:title, :slug, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description, :title, :slug, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title,
:description,
{ tags: [] }] { tags: [] }]
) )
end end

View File

@ -33,7 +33,10 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
def canned_responses def canned_responses
if params[:search] if params[:search]
Current.account.canned_responses.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%") Current.account.canned_responses
.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%")
.order_by_search(params[:search])
else else
Current.account.canned_responses Current.account.canned_responses
end end

View File

@ -18,6 +18,24 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end end
end end
def translate
return head :ok if already_translated_content_available?
translated_content = Integrations::GoogleTranslate::ProcessorService.new(
message: message,
target_language: permitted_params[:target_language]
).perform
if translated_content.present?
translations = {}
translations[permitted_params[:target_language]] = translated_content
translations = message.translations.merge!(translations) if message.translations.present?
message.update!(translations: translations)
end
render json: { content: translated_content }
end
private private
def message def message
@ -29,6 +47,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end end
def permitted_params def permitted_params
params.permit(:id) params.permit(:id, :target_language)
end
def already_translated_content_available?
message.translations.present? && message.translations[permitted_params[:target_language]].present?
end end
end end

View File

@ -0,0 +1,41 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
def show
@participants = @conversation.conversation_participants
end
def create
ActiveRecord::Base.transaction do
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end
end
def update
ActiveRecord::Base.transaction do
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
head :ok
end
private
def participants_to_be_added_ids
params[:user_ids] - current_participant_ids
end
def participants_to_be_removed_ids
current_participant_ids - params[:user_ids]
end
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
end

View File

@ -14,7 +14,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
# Deprecated: This API will be removed in 2.7.0 # Deprecated: This API will be removed in 2.7.0
def assignable_agents def assignable_agents
@assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq @assignable_agents = @inbox.assignable_agents
end end
def campaigns def campaigns

View File

@ -1,7 +1,7 @@
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
include ::FileTypeHelper include ::FileTypeHelper
before_action :fetch_portal, except: [:index, :create] before_action :fetch_portal, except: [:index, :create, :attach_file]
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
@ -48,7 +48,19 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end end
def process_attached_logo def process_attached_logo
@portal.logo.attach(params[:logo]) blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id)
@portal.logo.attach(blob)
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:logo].tempfile,
filename: params[:logo].original_filename,
content_type: params[:logo].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end end
private private

View File

@ -2,6 +2,24 @@ class ApiController < ApplicationController
skip_before_action :set_current_user, only: [:index] skip_before_action :set_current_user, only: [:index]
def index def index
render json: { version: Chatwoot.config[:version], timestamp: Time.now.utc.to_formatted_s(:db) } render json: { version: Chatwoot.config[:version],
timestamp: Time.now.utc.to_formatted_s(:db),
queue_services: redis_status,
data_services: postgres_status }
end
private
def redis_status
r = Redis.new(Redis::Config.app)
return 'ok' if r.ping
rescue Redis::CannotConnectError
'failing'
end
def postgres_status
ActiveRecord::Base.connection.active? ? 'ok' : 'failing'
rescue ActiveRecord::ConnectionNotEstablished
'failing'
end end
end end

View File

@ -3,25 +3,25 @@ module SwitchLocale
private private
def switch_locale(&action) def switch_locale(&)
# priority is for locale set in query string (mostly for widget/from js sdk) # priority is for locale set in query string (mostly for widget/from js sdk)
locale ||= locale_from_params locale ||= locale_from_params
# if locale is not set in account, let's use DEFAULT_LOCALE env variable # if locale is not set in account, let's use DEFAULT_LOCALE env variable
locale ||= locale_from_env_variable locale ||= locale_from_env_variable
set_locale(locale, &action) set_locale(locale, &)
end end
def switch_locale_using_account_locale(&action) def switch_locale_using_account_locale(&)
locale = locale_from_account(@current_account) locale = locale_from_account(@current_account)
set_locale(locale, &action) set_locale(locale, &)
end end
def set_locale(locale, &action) def set_locale(locale, &)
# if locale is empty, use default_locale # if locale is empty, use default_locale
locale ||= I18n.default_locale locale ||= I18n.default_locale
# Ensure locale won't bleed into other requests # Ensure locale won't bleed into other requests
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests # https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
I18n.with_locale(locale, &action) I18n.with_locale(locale, &)
end end
def locale_from_params def locale_from_params

View File

@ -0,0 +1,75 @@
class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController
include EmailHelper
def omniauth_success
get_resource_from_auth_hash
@resource.present? ? sign_in_user : sign_up_user
end
private
def sign_in_user
@resource.skip_confirmation! if confirmable_enabled?
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
# that will log them in
encoded_email = ERB::Util.url_encode(@resource.email)
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
end
def sign_up_user
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account?
create_account_for_user
token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
end
def login_page_url(error: nil, email: nil, sso_auth_token: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
params = { email: email, sso_auth_token: sso_auth_token }.compact
params[:error] = error if error.present?
"#{frontend_url}/app/login?#{params.to_query}"
end
def account_signup_allowed?
# set it to true by default, this is the behaviour across the app
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
end
def resource_class(_mapping = nil)
User
end
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
# find the user with their email instead of UID and token
@resource = resource_class.where(
email: auth_hash['info']['email']
).first
end
def validate_business_account?
# return true if the user is a business account, false if it is a gmail account
auth_hash['info']['email'].exclude?('@gmail.com')
end
def create_account_for_user
@resource, @account = AccountBuilder.new(
account_name: extract_domain_without_tld(auth_hash['info']['email']),
user_full_name: auth_hash['info']['name'],
email: auth_hash['info']['email'],
locale: I18n.locale,
confirmed: auth_hash['info']['email_verified']
).perform
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
end
def default_devise_mapping
'user'
end
end

View File

@ -0,0 +1,17 @@
class MicrosoftController < ApplicationController
after_action :set_version_header
def identity_association
microsoft_indentity
end
private
def set_version_header
response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length
end
def microsoft_indentity
@identity_json = ENV.fetch('AZURE_APP_ID', nil)
end
end

View File

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

View File

@ -16,6 +16,7 @@ class Public::Api::V1::Portals::ArticlesController < PublicController
def set_article def set_article
@article = @category.articles.find(params[:id]) @article = @category.articles.find(params[:id])
@article.increment_view_count
@parsed_content = render_article_content(@article.content) @parsed_content = render_article_content(@article.content)
end end

View File

@ -0,0 +1,44 @@
class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
def show
@metrics = {}
chatwoot_version
sha
postgres_status
redis_metrics
end
def chatwoot_version
@metrics['Chatwoot version'] = Chatwoot.config[:version]
end
def sha
sha = `git rev-parse HEAD`
@metrics['Git SHA'] = sha.presence || 'n/a'
end
def postgres_status
@metrics['Postgres alive'] = if ActiveRecord::Base.connection.active?
'true'
else
'false'
end
end
def redis_metrics
r = Redis.new(Redis::Config.app)
if r.ping == 'PONG'
redis_server = r.info
@metrics['Redis alive'] = 'true'
@metrics['Redis version'] = redis_server['redis_version']
@metrics['Redis number of connected clients'] = redis_server['connected_clients']
@metrics["Redis 'maxclients' setting"] = redis_server['maxclients']
@metrics['Redis memory used'] = redis_server['used_memory_human']
@metrics['Redis memory peak'] = redis_server['used_memory_peak_human']
@metrics['Redis total memory available'] = redis_server['total_system_memory_human']
@metrics["Redis 'maxmemory' setting"] = redis_server['maxmemory']
@metrics["Redis 'maxmemory_policy' setting"] = redis_server['maxmemory_policy']
end
rescue Redis::CannotConnectError
@metrics['Redis alive'] = false
end
end

View File

@ -97,6 +97,8 @@ class ConversationFinder
when 'mention' when 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id) conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids) @conversations = @conversations.where(id: conversation_ids)
when 'participating'
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
when 'unattended' when 'unattended'
@conversations = @conversations.where(first_reply_created_at: nil) @conversations = @conversations.where(first_reply_created_at: nil)
end end

View File

@ -0,0 +1,6 @@
module EmailHelper
def extract_domain_without_tld(email)
domain = email.split('@').last
domain.split('.').first
end
end

View File

@ -2,7 +2,8 @@ module MessageFormatHelper
include RegexHelper include RegexHelper
def transform_user_mention_content(message_content) def transform_user_mention_content(message_content)
message_content.gsub(MENTION_REGEX, '\1') # attachment message without content, message_content is nil
message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : ''
end end
def render_message_content(message_content) def render_message_content(message_content)

View File

@ -46,6 +46,20 @@ class ArticlesAPI extends PortalsAPI {
deleteArticle({ articleId, portalSlug }) { deleteArticle({ articleId, portalSlug }) {
return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`); return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`);
} }
uploadImage({ portalSlug, file }) {
let formData = new FormData();
formData.append('background_image', file);
return axios.post(
`${this.url}/${portalSlug}/articles/attach_file`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
}
} }
export default new ArticlesAPI(); export default new ArticlesAPI();

View File

@ -80,6 +80,15 @@ class MessageApi extends ApiClient {
params: { before }, params: { before },
}); });
} }
translateMessage(conversationId, messageId, targetLanguage) {
return axios.post(
`${this.url}/${conversationId}/messages/${messageId}/translate`,
{
target_language: targetLanguage,
}
);
}
} }
export default new MessageApi(); export default new MessageApi();

View File

@ -17,9 +17,10 @@
} }
.tooltip { .tooltip {
background-color: var(--black-transparent);
border-radius: $space-smaller; border-radius: $space-smaller;
font-size: $font-size-mini; font-size: $font-size-mini;
max-width: 15rem; max-width: var(--space-giga);
padding: $space-smaller $space-small; padding: $space-smaller $space-small;
z-index: 999; z-index: 999;
} }

View File

@ -151,13 +151,14 @@
</div> </div>
<woot-modal <woot-modal
:show.sync="showAdvancedFilters" :show.sync="showAdvancedFilters"
:on-close="onToggleAdvanceFiltersModal" :on-close="closeAdvanceFiltersModal"
size="medium" size="medium"
> >
<conversation-advanced-filter <conversation-advanced-filter
v-if="showAdvancedFilters" v-if="showAdvancedFilters"
:initial-filter-types="advancedFilterTypes" :initial-filter-types="advancedFilterTypes"
:on-close="onToggleAdvanceFiltersModal" :initial-applied-filters="appliedFilter"
:on-close="closeAdvanceFiltersModal"
@applyFilter="onApplyFilter" @applyFilter="onApplyFilter"
/> />
</woot-modal> </woot-modal>
@ -181,6 +182,7 @@ import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomView
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue'; import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue'; import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import filterMixin from 'shared/mixins/filterMixin';
import { import {
hasPressedAltAndJKey, hasPressedAltAndJKey,
@ -202,7 +204,13 @@ export default {
DeleteCustomViews, DeleteCustomViews,
ConversationBulkActions, ConversationBulkActions,
}, },
mixins: [timeMixin, conversationMixin, eventListenerMixins, alertMixin], mixins: [
timeMixin,
conversationMixin,
eventListenerMixins,
alertMixin,
filterMixin,
],
props: { props: {
conversationInbox: { conversationInbox: {
type: [String, Number], type: [String, Number],
@ -248,11 +256,13 @@ export default {
selectedConversations: [], selectedConversations: [],
selectedInboxes: [], selectedInboxes: [],
isContextMenuOpen: false, isContextMenuOpen: false,
appliedFilter: [],
}; };
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
currentUser: 'getCurrentUser',
chatLists: 'getAllConversations', chatLists: 'getAllConversations',
mineChatsList: 'getMineChats', mineChatsList: 'getMineChats',
allChatList: 'getAllStatusChats', allChatList: 'getAllStatusChats',
@ -288,6 +298,13 @@ export default {
!this.chatListLoading !this.chatListLoading
); );
}, },
currentUserDetails() {
const { id, name } = this.currentUser;
return {
id,
name,
};
},
assigneeTabItems() { assigneeTabItems() {
const ASSIGNEE_TYPE_TAB_KEYS = { const ASSIGNEE_TYPE_TAB_KEYS = {
me: 'mineCount', me: 'mineCount',
@ -360,6 +377,9 @@ export default {
if (this.conversationType === 'mention') { if (this.conversationType === 'mention') {
return this.$t('CHAT_LIST.MENTION_HEADING'); return this.$t('CHAT_LIST.MENTION_HEADING');
} }
if (this.conversationType === 'participating') {
return this.$t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
}
if (this.conversationType === 'unattended') { if (this.conversationType === 'unattended') {
return this.$t('CHAT_LIST.UNATTENDED_HEADING'); return this.$t('CHAT_LIST.UNATTENDED_HEADING');
} }
@ -461,7 +481,14 @@ export default {
this.showDeleteFoldersModal = false; this.showDeleteFoldersModal = false;
}, },
onToggleAdvanceFiltersModal() { onToggleAdvanceFiltersModal() {
this.showAdvancedFilters = !this.showAdvancedFilters; if (!this.hasAppliedFilters) {
this.initializeExistingFilterToModal();
}
this.showAdvancedFilters = true;
},
closeAdvanceFiltersModal() {
this.showAdvancedFilters = false;
this.appliedFilter = [];
}, },
getKeyboardListenerParams() { getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll( const allConversations = this.$refs.activeConversation.querySelectorAll(
@ -520,6 +547,7 @@ export default {
return; return;
} }
this.fetchConversations(); this.fetchConversations();
this.appliedFilter = [];
}, },
fetchConversations() { fetchConversations() {
this.$store this.$store

View File

@ -14,6 +14,8 @@ const conversations = accountId => ({
'conversations_through_team', 'conversations_through_team',
'conversation_mentions', 'conversation_mentions',
'conversation_through_mentions', 'conversation_through_mentions',
'conversation_participating',
'conversation_through_participating',
'folder_conversations', 'folder_conversations',
'conversations_through_folders', 'conversations_through_folders',
'conversation_unattended', 'conversation_unattended',

View File

@ -154,6 +154,7 @@ export default {
left: var(--space-slab); left: var(--space-slab);
bottom: var(--space-larger); bottom: var(--space-larger);
min-width: 22rem; min-width: 22rem;
top: unset;
z-index: var(--z-index-low); z-index: var(--z-index-low);
} }
</style> </style>

View File

@ -0,0 +1,69 @@
import { shallowMount } from '@vue/test-utils';
import GoogleOAuthButton from './GoogleOAuthButton.vue';
function getWrapper(showSeparator, buttonSize) {
return shallowMount(GoogleOAuthButton, {
propsData: { showSeparator: showSeparator, buttonSize: buttonSize },
methods: {
$t(text) {
return text;
},
},
});
}
describe('GoogleOAuthButton.vue', () => {
beforeEach(() => {
window.chatwootConfig = {
googleOAuthClientId: 'clientId',
googleOAuthCallbackUrl: 'http://localhost:3000/test-callback',
};
});
afterEach(() => {
window.chatwootConfig = {};
});
it('renders the OR separator if showSeparator is true', () => {
const wrapper = getWrapper(true);
expect(wrapper.find('.separator').exists()).toBe(true);
});
it('does not render the OR separator if showSeparator is false', () => {
const wrapper = getWrapper(false);
expect(wrapper.find('.separator').exists()).toBe(false);
});
it('generates the correct Google Auth URL', () => {
const wrapper = getWrapper();
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
const params = googleAuthUrl.searchParams;
expect(googleAuthUrl.origin).toBe('https://accounts.google.com');
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth/oauthchooseaccount');
expect(params.get('client_id')).toBe('clientId');
expect(params.get('redirect_uri')).toBe(
'http://localhost:3000/test-callback'
);
expect(params.get('response_type')).toBe('code');
expect(params.get('scope')).toBe('email profile');
});
it('responds to buttonSize prop properly', () => {
let wrapper = getWrapper(true, 'tiny');
expect(wrapper.find('.button.tiny').exists()).toBe(true);
wrapper = getWrapper(true, 'small');
expect(wrapper.find('.button.small').exists()).toBe(true);
wrapper = getWrapper(true, 'large');
expect(wrapper.find('.button.large').exists()).toBe(true);
// should not render either
wrapper = getWrapper(true, 'default');
expect(wrapper.find('.button.small').exists()).toBe(false);
expect(wrapper.find('.button.tiny').exists()).toBe(false);
expect(wrapper.find('.button.large').exists()).toBe(false);
expect(wrapper.find('.button').exists()).toBe(true);
});
});

View File

@ -0,0 +1,96 @@
<template>
<div>
<div v-if="showSeparator" class="separator">
OR
</div>
<a :href="getGoogleAuthUrl()">
<button
class="button expanded button__google_login"
:class="{
// Explicit checking to ensure no other value is used
large: buttonSize === 'large',
small: buttonSize === 'small',
tiny: buttonSize === 'tiny',
}"
>
<img
src="/assets/images/auth/google.svg"
alt="Google Logo"
class="icon"
/>
<slot>{{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }}</slot>
</button>
</a>
</div>
</template>
<script>
const validButtonSizes = ['small', 'tiny', 'large'];
export default {
props: {
showSeparator: {
type: Boolean,
default: true,
},
buttonSize: {
type: String,
default: undefined,
validator: value =>
validButtonSizes.includes(value) || value === undefined,
},
},
methods: {
getGoogleAuthUrl() {
// Ideally a request to /auth/google_oauth2 should be made
// Creating the URL manually because the devise-token-auth with
// omniauth has a standing issue on redirecting the post request
// https://github.com/lynndylanhurley/devise_token_auth/issues/1466
const baseUrl =
'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount';
const clientId = window.chatwootConfig.googleOAuthClientId;
const redirectUri = window.chatwootConfig.googleOAuthCallbackUrl;
const responseType = 'code';
const scope = 'email profile';
// Build the query string
const queryString = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope: scope,
}).toString();
// Construct the full URL
return `${baseUrl}?${queryString}`;
},
},
};
</script>
<style lang="scss" scoped>
.separator {
display: flex;
align-items: center;
margin: var(--space-two) var(--space-zero);
gap: var(--space-one);
color: var(--s-300);
font-size: var(--font-size-small);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--s-100);
}
}
.button__google_login {
background: var(--white);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-one);
border: 1px solid var(--s-100);
color: var(--b-800);
}
</style>

View File

@ -1,7 +1,14 @@
<template> <template>
<span class="time-ago"> <div
<span>{{ timeAgo }}</span> v-tooltip.top="{
</span> content: tooltipText,
delay: { show: 1500, hide: 0 },
hideOnClick: true,
}"
class="time-ago"
>
<span>{{ `${createdAtTime}${lastActivityTime}` }}</span>
</div>
</template> </template>
<script> <script>
@ -19,20 +26,55 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
timestamp: { lastActivityTimestamp: {
type: [String, Date, Number],
default: '',
},
createdAtTimestamp: {
type: [String, Date, Number], type: [String, Date, Number],
default: '', default: '',
}, },
}, },
data() { data() {
return { return {
timeAgo: this.dynamicTime(this.timestamp), lastActivityAtTimeAgo: this.dynamicTime(this.lastActivityTimestamp),
createdAtTimeAgo: this.dynamicTime(this.createdAtTimestamp),
timer: null, timer: null,
}; };
}, },
computed: {
lastActivityTime() {
return this.shortTimestamp(this.lastActivityAtTimeAgo);
},
createdAtTime() {
return this.shortTimestamp(this.createdAtTimeAgo);
},
createdAt() {
const createdTimeDiff = Date.now() - this.createdAtTimestamp * 1000;
const isBeforeAMonth = createdTimeDiff > DAY_IN_MILLI_SECONDS * 30;
return !isBeforeAMonth
? `Created ${this.createdAtTimeAgo}`
: `Created at: ${this.dateFormat(this.createdAtTimestamp)}`;
},
lastActivity() {
const lastActivityTimeDiff =
Date.now() - this.lastActivityTimestamp * 1000;
const isNotActive = lastActivityTimeDiff > DAY_IN_MILLI_SECONDS * 30;
return !isNotActive
? `Last activity ${this.lastActivityAtTimeAgo}`
: `Last activity: ${this.dateFormat(this.lastActivityTimestamp)}`;
},
tooltipText() {
return `${this.createdAt}
${this.lastActivity}`;
},
},
watch: { watch: {
timestamp() { lastActivityTimestamp() {
this.timeAgo = this.dynamicTime(this.timestamp); this.lastActivityAtTimeAgo = this.dynamicTime(this.lastActivityTimestamp);
},
createdAtTimestamp() {
this.createdAtTimeAgo = this.dynamicTime(this.createdAtTimestamp);
}, },
}, },
mounted() { mounted() {
@ -46,12 +88,15 @@ export default {
methods: { methods: {
createTimer() { createTimer() {
this.timer = setTimeout(() => { this.timer = setTimeout(() => {
this.timeAgo = this.dynamicTime(this.timestamp); this.lastActivityAtTimeAgo = this.dynamicTime(
this.lastActivityTimestamp
);
this.createdAtTimeAgo = this.dynamicTime(this.createdAtTimestamp);
this.createTimer(); this.createTimer();
}, this.refreshTime()); }, this.refreshTime());
}, },
refreshTime() { refreshTime() {
const timeDiff = Date.now() - this.timestamp * 1000; const timeDiff = Date.now() - this.lastActivityTimestamp * 1000;
if (timeDiff > DAY_IN_MILLI_SECONDS) { if (timeDiff > DAY_IN_MILLI_SECONDS) {
return DAY_IN_MILLI_SECONDS; return DAY_IN_MILLI_SECONDS;
} }
@ -71,5 +116,9 @@ export default {
font-weight: var(--font-weight-normal); font-weight: var(--font-weight-normal);
line-height: var(--space-normal); line-height: var(--space-normal);
margin-left: auto; margin-left: auto;
&:hover {
color: var(--b-900);
}
} }
</style> </style>

View File

@ -92,6 +92,7 @@
v-if="inputType === 'textarea'" v-if="inputType === 'textarea'"
v-model="castMessageVmodel" v-model="castMessageVmodel"
rows="4" rows="4"
:enable-variables="true"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')" :placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="action-message" class="action-message"
/> />

View File

@ -6,10 +6,15 @@
@click="insertMentionNode" @click="insertMentionNode"
/> />
<canned-response <canned-response
v-if="showCannedMenu && !isPrivate" v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm" :search-key="cannedSearchTerm"
@click="insertCannedResponse" @click="insertCannedResponse"
/> />
<variable-list
v-if="shouldShowVariables"
:search-key="variableSearchTerm"
@click="insertVariable"
/>
<div ref="editor" /> <div ref="editor" />
</div> </div>
</template> </template>
@ -31,6 +36,7 @@ import {
import TagAgents from '../conversation/TagAgents'; import TagAgents from '../conversation/TagAgents';
import CannedResponse from '../conversation/CannedResponse'; import CannedResponse from '../conversation/CannedResponse';
import VariableList from '../conversation/VariableList';
const TYPING_INDICATOR_IDLE_TIME = 4000; const TYPING_INDICATOR_IDLE_TIME = 4000;
@ -43,9 +49,8 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, { import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
ANALYTICS_EVENTS, import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
} from '../../../helper/AnalyticsHelper';
const createState = (content, placeholder, plugins = []) => { const createState = (content, placeholder, plugins = []) => {
return EditorState.create({ return EditorState.create({
@ -60,7 +65,7 @@ const createState = (content, placeholder, plugins = []) => {
export default { export default {
name: 'WootMessageEditor', name: 'WootMessageEditor',
components: { TagAgents, CannedResponse }, components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin], mixins: [eventListenerMixins, uiSettingsMixin],
props: { props: {
value: { type: String, default: '' }, value: { type: String, default: '' },
@ -70,13 +75,18 @@ export default {
enableSuggestions: { type: Boolean, default: true }, enableSuggestions: { type: Boolean, default: true },
overrideLineBreaks: { type: Boolean, default: false }, overrideLineBreaks: { type: Boolean, default: false },
updateSelectionWith: { type: String, default: '' }, updateSelectionWith: { type: String, default: '' },
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
variables: { type: Object, default: () => ({}) },
}, },
data() { data() {
return { return {
showUserMentions: false, showUserMentions: false,
showCannedMenu: false, showCannedMenu: false,
showVariables: false,
mentionSearchKey: '', mentionSearchKey: '',
cannedSearchTerm: '', cannedSearchTerm: '',
variableSearchTerm: '',
editorView: null, editorView: null,
range: null, range: null,
state: undefined, state: undefined,
@ -86,6 +96,14 @@ export default {
contentFromEditor() { contentFromEditor() {
return MessageMarkdownSerializer.serialize(this.editorView.state.doc); return MessageMarkdownSerializer.serialize(this.editorView.state.doc);
}, },
shouldShowVariables() {
return this.enableVariables && this.showVariables && !this.isPrivate;
},
shouldShowCannedResponses() {
return (
this.enableCannedResponses && this.showCannedMenu && !this.isPrivate
);
},
plugins() { plugins() {
if (!this.enableSuggestions) { if (!this.enableSuggestions) {
return []; return [];
@ -105,6 +123,7 @@ export default {
this.range = args.range; this.range = args.range;
this.mentionSearchKey = args.text.replace('@', ''); this.mentionSearchKey = args.text.replace('@', '');
return false; return false;
}, },
onExit: () => { onExit: () => {
@ -144,6 +163,34 @@ export default {
return event.keyCode === 13 && this.showCannedMenu; return event.keyCode === 13 && this.showCannedMenu;
}, },
}), }),
suggestionsPlugin({
matcher: triggerCharacters('{{'),
suggestionClass: '',
onEnter: args => {
if (this.isPrivate) {
return false;
}
this.showVariables = true;
this.range = args.range;
this.editorView = args.view;
return false;
},
onChange: args => {
this.editorView = args.view;
this.range = args.range;
this.variableSearchTerm = args.text.replace('{{', '');
return false;
},
onExit: () => {
this.variableSearchTerm = '';
this.showVariables = false;
return false;
},
onKeyDown: ({ event }) => {
return event.keyCode === 13 && this.showVariables;
},
}),
]; ];
}, },
}, },
@ -154,12 +201,17 @@ export default {
showCannedMenu(updatedValue) { showCannedMenu(updatedValue) {
this.$emit('toggle-canned-menu', !this.isPrivate && updatedValue); this.$emit('toggle-canned-menu', !this.isPrivate && updatedValue);
}, },
showVariables(updatedValue) {
this.$emit('toggle-variables-menu', !this.isPrivate && updatedValue);
},
value(newValue = '') { value(newValue = '') {
if (newValue !== this.contentFromEditor) { if (newValue !== this.contentFromEditor) {
this.reloadState(); this.reloadState();
} }
}, },
editorId() { editorId() {
this.showCannedMenu = false;
this.cannedSearchTerm = '';
this.reloadState(); this.reloadState();
}, },
isPrivate() { isPrivate() {
@ -265,23 +317,26 @@ export default {
); );
this.state = this.editorView.state.apply(tr); this.state = this.editorView.state.apply(tr);
this.emitOnChange(); this.emitOnChange();
AnalyticsHelper.track(ANALYTICS_EVENTS.USED_MENTIONS); this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false; return false;
}, },
insertCannedResponse(cannedItem) { insertCannedResponse(cannedItem) {
const updatedMessage = replaceVariablesInMessage({
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) { if (!this.editorView) {
return null; return null;
} }
let from = this.range.from - 1; let from = this.range.from - 1;
let node = new MessageMarkdownTransformer(messageSchema).parse( let node = new MessageMarkdownTransformer(messageSchema).parse(
cannedItem updatedMessage
); );
if (node.textContent === cannedItem) { if (node.textContent === updatedMessage) {
node = this.editorView.state.schema.text(cannedItem); node = this.editorView.state.schema.text(updatedMessage);
from = this.range.from; from = this.range.from;
} }
@ -295,7 +350,30 @@ export default {
this.emitOnChange(); this.emitOnChange();
tr.scrollIntoView(); tr.scrollIntoView();
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE); this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
insertVariable(variable) {
if (!this.editorView) {
return null;
}
let node = this.editorView.state.schema.text(`{{${variable}}}`);
const from = this.range.from;
const tr = this.editorView.state.tr.replaceWith(
from,
this.range.to,
node
);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
// The `{{ }}` are added to the message, but the cursor is placed
// and onExit of suggestionsPlugin is not called. So we need to manually hide
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
tr.scrollIntoView();
return false; return false;
}, },

View File

@ -1,6 +1,13 @@
<template> <template>
<div> <div>
<div class="editor-root editor--article"> <div class="editor-root editor--article">
<input
ref="imageUploadInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
hidden
@change="onFileChange"
/>
<div ref="editor" /> <div ref="editor" />
</div> </div>
</div> </div>
@ -16,23 +23,31 @@ import {
EditorState, EditorState,
Selection, Selection,
} from '@chatwoot/prosemirror-schema'; } from '@chatwoot/prosemirror-schema';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import alertMixin from 'shared/mixins/alertMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
const createState = (content, placeholder, plugins = []) => { const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const createState = (
content,
placeholder,
plugins = [],
onImageUpload = () => {}
) => {
return EditorState.create({ return EditorState.create({
doc: new ArticleMarkdownTransformer(fullSchema).parse(content), doc: new ArticleMarkdownTransformer(fullSchema).parse(content),
plugins: wootArticleWriterSetup({ plugins: wootArticleWriterSetup({
schema: fullSchema, schema: fullSchema,
placeholder, placeholder,
plugins, plugins,
onImageUpload,
}), }),
}); });
}; };
export default { export default {
mixins: [eventListenerMixins, uiSettingsMixin], mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
props: { props: {
value: { type: String, default: '' }, value: { type: String, default: '' },
editorId: { type: String, default: '' }, editorId: { type: String, default: '' },
@ -64,7 +79,12 @@ export default {
}, },
}, },
created() { created() {
this.state = createState(this.value, this.placeholder, this.plugins); this.state = createState(
this.value,
this.placeholder,
this.plugins,
this.openFileBrowser
);
}, },
mounted() { mounted() {
this.createEditorView(); this.createEditorView();
@ -73,8 +93,67 @@ export default {
this.focusEditorInputField(); this.focusEditorInputField();
}, },
methods: { methods: {
openFileBrowser() {
this.$refs.imageUploadInput.click();
},
onFileChange() {
const file = this.$refs.imageUploadInput.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadImageToStorage(file);
} else {
this.showAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR_FILE_SIZE', {
size: MAXIMUM_FILE_UPLOAD_SIZE,
})
);
}
this.$refs.imageUploadInput.value = '';
},
async uploadImageToStorage(file) {
try {
const fileUrl = await this.$store.dispatch('articles/attachImage', {
portalSlug: this.$route.params.portalSlug,
file,
});
if (fileUrl) {
this.onImageUploadStart(fileUrl);
}
this.showAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.SUCCESS')
);
} catch (error) {
this.showAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR')
);
}
},
onImageUploadStart(fileUrl) {
const { selection } = this.editorView.state;
const from = selection.from;
const node = this.editorView.state.schema.nodes.image.create({
src: fileUrl,
});
const paragraphNode = this.editorView.state.schema.node('paragraph');
if (node) {
// Insert the image and the caption wrapped inside a paragraph
const tr = this.editorView.state.tr
.replaceSelectionWith(paragraphNode)
.insert(from + 1, node);
this.editorView.dispatch(tr.scrollIntoView());
this.focusEditorInputField();
}
},
reloadState() { reloadState() {
this.state = createState(this.value, this.placeholder, this.plugins); this.state = createState(
this.value,
this.placeholder,
this.plugins,
this.openFileBrowser
);
this.editorView.updateState(this.state); this.editorView.updateState(this.state);
this.focusEditorInputField(); this.focusEditorInputField();
}, },

View File

@ -1,5 +1,9 @@
<template> <template>
<mention-box :items="items" @mention-select="handleMentionClick" /> <mention-box :items="items" @mention-select="handleMentionClick">
<template slot-scope="{ item }">
<strong>{{ item.label }}</strong> - {{ item.description }}
</template>
</mention-box>
</template> </template>
<script> <script>

View File

@ -7,9 +7,6 @@
> >
{{ value['TEXT'] }} {{ value['TEXT'] }}
</option> </option>
<option value="all">
{{ $t('CHAT_LIST.FILTER_ALL') }}
</option>
</select> </select>
</template> </template>

View File

@ -61,6 +61,7 @@ import { mapGetters } from 'vuex';
import { filterAttributeGroups } from './advancedFilterItems'; import { filterAttributeGroups } from './advancedFilterItems';
import filterMixin from 'shared/mixins/filterMixin'; import filterMixin from 'shared/mixins/filterMixin';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'; import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default { export default {
components: { components: {
@ -76,6 +77,10 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
initialAppliedFilters: {
type: Array,
default: () => [],
},
}, },
validations: { validations: {
appliedFilters: { appliedFilters: {
@ -101,7 +106,7 @@ export default {
data() { data() {
return { return {
show: true, show: true,
appliedFilters: [], appliedFilters: this.initialAppliedFilters,
filterTypes: this.initialFilterTypes, filterTypes: this.initialFilterTypes,
filterAttributeGroups, filterAttributeGroups,
filterGroups: [], filterGroups: [],
@ -119,6 +124,7 @@ export default {
this.setFilterAttributes(); this.setFilterAttributes();
this.$store.dispatch('campaigns/get'); this.$store.dispatch('campaigns/get');
if (this.getAppliedConversationFilters.length) { if (this.getAppliedConversationFilters.length) {
this.appliedFilters = [];
this.appliedFilters = [...this.getAppliedConversationFilters]; this.appliedFilters = [...this.getAppliedConversationFilters];
} else { } else {
this.appliedFilters.push({ this.appliedFilters.push({
@ -229,10 +235,6 @@ export default {
name: statusFilters[status].TEXT, name: statusFilters[status].TEXT,
}; };
}), }),
{
id: 'all',
name: this.$t('CHAT_LIST.FILTER_ALL'),
},
]; ];
case 'assignee_id': case 'assignee_id':
return this.$store.getters['agents/getAgents']; return this.$store.getters['agents/getAgents'];
@ -287,6 +289,12 @@ export default {
JSON.parse(JSON.stringify(this.appliedFilters)) JSON.parse(JSON.stringify(this.appliedFilters))
); );
this.$emit('applyFilter', this.appliedFilters); this.$emit('applyFilter', this.appliedFilters);
this.$track(CONVERSATION_EVENTS.APPLY_FILTER, {
applied_filters: this.appliedFilters.map(filter => ({
key: filter.attribute_key,
operator: filter.filter_operator,
})),
});
}, },
resetFilter(index, currentFilter) { resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find( this.appliedFilters[index].filter_operator = this.filterTypes.find(

View File

@ -87,7 +87,10 @@
</p> </p>
<div class="conversation--meta"> <div class="conversation--meta">
<span class="timestamp"> <span class="timestamp">
<time-ago :timestamp="chat.timestamp" /> <time-ago
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span> </span>
<span class="unread">{{ unreadCount > 9 ? '9+' : unreadCount }}</span> <span class="unread">{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
</div> </div>

View File

@ -45,6 +45,14 @@
:longitude="attachment.coordinates_long" :longitude="attachment.coordinates_long"
:name="attachment.fallback_title" :name="attachment.fallback_title"
/> />
<bubble-contact
v-else-if="attachment.file_type === 'contact'"
:name="data.content"
:phone-number="attachment.fallback_title"
/>
<instagram-image-error-placeholder
v-else-if="hasImageError && hasInstagramStory"
/>
<bubble-file v-else :url="attachment.data_url" /> <bubble-file v-else :url="attachment.data_url" />
</div> </div>
</div> </div>
@ -65,6 +73,39 @@
:created-at="createdAt" :created-at="createdAt"
/> />
</div> </div>
<woot-modal
v-if="showTranslateModal"
modal-type="right-aligned"
show
:on-close="onCloseTranslateModal"
>
<div class="column content">
<p>
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
</p>
<p v-dompurify-html="data.content" />
<br />
<hr />
<div v-if="translationsAvailable">
<p>
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
</p>
<div
v-for="(translation, language) in translations"
:key="language"
>
<p>
<strong>{{ language }}:</strong>
</p>
<p v-dompurify-html="translation" />
<br />
</div>
</div>
<p v-else>
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
</p>
</div>
</woot-modal>
<spinner v-if="isPending" size="tiny" /> <spinner v-if="isPending" size="tiny" />
<div <div
v-if="showAvatar" v-if="showAvatar"
@ -102,11 +143,13 @@
v-if="isBubble && !isMessageDeleted" v-if="isBubble && !isMessageDeleted"
:is-open="showContextMenu" :is-open="showContextMenu"
:show-copy="hasText" :show-copy="hasText"
:show-canned-response-option="isOutgoing" :show-delete="hasText || hasAttachments"
:show-canned-response-option="isOutgoing && hasText"
:menu-position="contextMenuPosition" :menu-position="contextMenuPosition"
:message-content="data.content" :message-content="data.content"
@toggle="handleContextMenuClick" @toggle="handleContextMenuClick"
@delete="handleDelete" @delete="handleDelete"
@translate="handleTranslate"
/> />
</div> </div>
</li> </li>
@ -121,13 +164,15 @@ import BubbleLocation from './bubble/Location';
import BubbleMailHead from './bubble/MailHead'; import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text'; import BubbleText from './bubble/Text';
import BubbleVideo from './bubble/Video.vue'; import BubbleVideo from './bubble/Video.vue';
import BubbleContact from './bubble/Contact';
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 instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue';
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 { 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';
import { mapGetters } from 'vuex';
export default { export default {
components: { components: {
@ -139,8 +184,10 @@ export default {
BubbleMailHead, BubbleMailHead,
BubbleText, BubbleText,
BubbleVideo, BubbleVideo,
BubbleContact,
ContextMenu, ContextMenu,
Spinner, Spinner,
instagramImageErrorPlaceholder,
}, },
mixins: [alertMixin, messageFormatterMixin, contentTypeMixin], mixins: [alertMixin, messageFormatterMixin, contentTypeMixin],
props: { props: {
@ -169,9 +216,14 @@ export default {
return { return {
showContextMenu: false, showContextMenu: false,
hasImageError: false, hasImageError: false,
showTranslateModal: false,
}; };
}, },
computed: { computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
currentAccountId: 'getCurrentAccountId',
}),
shouldRenderMessage() { shouldRenderMessage() {
return ( return (
this.hasAttachments || this.hasAttachments ||
@ -187,6 +239,9 @@ export default {
} = this.contentAttributes.email || {}; } = this.contentAttributes.email || {};
return fullHTMLContent || fullTextContent || ''; return fullHTMLContent || fullTextContent || '';
}, },
translations() {
return this.contentAttributes.translations || {};
},
displayQuotedButton() { displayQuotedButton() {
if (!this.isIncoming) { if (!this.isIncoming) {
return false; return false;
@ -198,6 +253,9 @@ export default {
return false; return false;
}, },
translationsAvailable() {
return !!Object.keys(this.translations).length;
},
message() { message() {
if (this.contentType === 'input_csat') { if (this.contentType === 'input_csat') {
return this.$t('CONVERSATION.CSAT_REPLY_MESSAGE'); return this.$t('CONVERSATION.CSAT_REPLY_MESSAGE');
@ -420,6 +478,19 @@ export default {
onImageLoadError() { onImageLoadError() {
this.hasImageError = true; this.hasImageError = true;
}, },
handleTranslate() {
const { locale } = this.getAccount(this.currentAccountId);
const { conversation_id: conversationId, id: messageId } = this.data;
this.$store.dispatch('translateMessage', {
conversationId,
messageId,
targetLanguage: locale || 'en',
});
this.showTranslateModal = true;
},
onCloseTranslateModal() {
this.showTranslateModal = false;
},
}, },
}; };
</script> </script>

View File

@ -20,6 +20,7 @@
<canned-response <canned-response
v-if="showMentions && hasSlashCommand" v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions" v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey" :search-key="mentionSearchKey"
@click="replaceText" @click="replaceText"
/> />
@ -63,12 +64,15 @@
:placeholder="messagePlaceHolder" :placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith" :update-selection-with="updateEditorSelectionWith"
:min-height="4" :min-height="4"
:enable-variables="true"
:variables="messageVariables"
@typing-off="onTypingOff" @typing-off="onTypingOff"
@typing-on="onTypingOn" @typing-on="onTypingOn"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@toggle-user-mention="toggleUserMention" @toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu" @toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection" @clear-selection="clearEditorSelection"
/> />
</div> </div>
@ -126,6 +130,12 @@
@on-send="onSendWhatsAppReply" @on-send="onSendWhatsAppReply"
@cancel="hideWhatsappTemplatesModal" @cancel="hideWhatsappTemplatesModal"
/> />
<woot-confirm-modal
ref="confirmDialog"
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
:description="undefinedVariableMessage"
/>
</div> </div>
</template> </template>
@ -152,7 +162,11 @@ import {
AUDIO_FORMATS, AUDIO_FORMATS,
} from 'shared/constants/messages'; } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
} from 'dashboard/helper/messageHelper';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers'; import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
@ -164,9 +178,7 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
import { trimContent, debounce } from '@chatwoot/utils'; import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants'; import wootConstants from 'dashboard/constants';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, { import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
const EmojiInput = () => import('shared/components/emoji/EmojiInput'); const EmojiInput = () => import('shared/components/emoji/EmojiInput');
@ -210,7 +222,6 @@ export default {
message: '', message: '',
isFocused: false, isFocused: false,
showEmojiPicker: false, showEmojiPicker: false,
showMentions: false,
attachedFiles: [], attachedFiles: [],
isRecordingAudio: false, isRecordingAudio: false,
recordingAudioState: '', recordingAudioState: '',
@ -218,13 +229,17 @@ export default {
isUploading: false, isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY, replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '', mentionSearchKey: '',
hasUserMention: false,
hasSlashCommand: false, hasSlashCommand: false,
bccEmails: '', bccEmails: '',
ccEmails: '', ccEmails: '',
doAutoSaveDraft: () => {}, doAutoSaveDraft: () => {},
showWhatsAppTemplatesModal: false, showWhatsAppTemplatesModal: false,
updateEditorSelectionWith: '', updateEditorSelectionWith: '',
undefinedVariableMessage: '',
showMentions: false,
showUserMentions: false,
showCannedMenu: false,
showVariablesMenu: false,
}; };
}, },
computed: { computed: {
@ -471,6 +486,12 @@ export default {
} }
return AUDIO_FORMATS.OGG; return AUDIO_FORMATS.OGG;
}, },
messageVariables() {
const variables = getMessageVariables({
conversation: this.currentChat,
});
return variables;
},
}, },
watch: { watch: {
currentChat(conversation) { currentChat(conversation) {
@ -612,8 +633,10 @@ export default {
}, },
isAValidEvent(selectedKey) { isAValidEvent(selectedKey) {
return ( return (
!this.hasUserMention && !this.showUserMentions &&
!this.showMentions &&
!this.showCannedMenu && !this.showCannedMenu &&
!this.showVariablesMenu &&
this.isFocused && this.isFocused &&
isEditorHotKeyEnabled(this.uiSettings, selectedKey) isEditorHotKeyEnabled(this.uiSettings, selectedKey)
); );
@ -632,11 +655,14 @@ export default {
}); });
}, },
toggleUserMention(currentMentionState) { toggleUserMention(currentMentionState) {
this.hasUserMention = currentMentionState; this.showUserMentions = currentMentionState;
}, },
toggleCannedMenu(value) { toggleCannedMenu(value) {
this.showCannedMenu = value; this.showCannedMenu = value;
}, },
toggleVariablesMenu(value) {
this.showVariablesMenu = value;
},
openWhatsappTemplateModal() { openWhatsappTemplateModal() {
this.showWhatsAppTemplatesModal = true; this.showWhatsAppTemplatesModal = true;
}, },
@ -666,7 +692,7 @@ export default {
}; };
this.assignedAgent = selfAssign; this.assignedAgent = selfAssign;
}, },
async onSendReply() { confirmOnSendReply() {
if (this.isReplyButtonDisabled) { if (this.isReplyButtonDisabled) {
return; return;
} }
@ -675,18 +701,57 @@ export default {
if (this.isSignatureEnabledForInbox && this.messageSignature) { if (this.isSignatureEnabledForInbox && this.messageSignature) {
newMessage += '\n\n' + this.messageSignature; newMessage += '\n\n' + this.messageSignature;
} }
const messagePayload = this.getMessagePayload(newMessage);
this.clearMessage(); const isOnWhatsApp =
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel;
if (isOnWhatsApp && !this.isPrivate) {
this.sendMessageAsMultipleMessages(newMessage);
} else {
const messagePayload = this.getMessagePayload(newMessage);
this.sendMessage(messagePayload);
}
if (!this.isPrivate) { if (!this.isPrivate) {
this.clearEmailField(); this.clearEmailField();
} }
this.sendMessage(messagePayload);
this.clearMessage(); this.clearMessage();
this.hideEmojiPicker(); this.hideEmojiPicker();
this.$emit('update:popoutReplyBox', false); this.$emit('update:popoutReplyBox', false);
} }
}, },
sendMessageAsMultipleMessages(message) {
const messages = this.getMessagePayloadForWhatsapp(message);
messages.forEach(messagePayload => {
this.sendMessage(messagePayload);
});
},
async onSendReply() {
const undefinedVariables = getUndefinedVariablesInMessage({
message: this.message,
variables: this.messageVariables,
});
if (undefinedVariables.length > 0) {
const undefinedVariablesCount =
undefinedVariables.length > 1 ? undefinedVariables.length : 1;
this.undefinedVariableMessage = this.$t(
'CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.MESSAGE',
{
undefinedVariablesCount,
undefinedVariables: undefinedVariables.join(', '),
}
);
const ok = await this.$refs.confirmDialog.showConfirmation();
if (ok) {
this.confirmOnSendReply();
}
} else {
this.confirmOnSendReply();
}
},
async sendMessage(messagePayload) { async sendMessage(messagePayload) {
try { try {
await this.$store.dispatch( await this.$store.dispatch(
@ -709,9 +774,13 @@ export default {
this.hideWhatsappTemplatesModal(); this.hideWhatsappTemplatesModal();
}, },
replaceText(message) { replaceText(message) {
const updatedMessage = replaceVariablesInMessage({
message,
variables: this.messageVariables,
});
setTimeout(() => { setTimeout(() => {
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE); this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = message; this.message = updatedMessage;
}, 100); }, 100);
}, },
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) { setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
@ -897,6 +966,33 @@ export default {
(item, index) => itemIndex !== index (item, index) => itemIndex !== index
); );
}, },
getMessagePayloadForWhatsapp(message) {
const multipleMessagePayload = [];
const messagePayload = {
conversationId: this.currentChat.id,
message,
private: false,
};
multipleMessagePayload.push(messagePayload);
if (this.attachedFiles && this.attachedFiles.length) {
this.attachedFiles.forEach(attachment => {
const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId
: attachment.resource.file;
const attachmentPayload = {
conversationId: this.currentChat.id,
files: [attachedFile],
private: false,
message: '',
};
multipleMessagePayload.push(attachmentPayload);
});
}
return multipleMessagePayload;
},
getMessagePayload(message) { getMessagePayload(message) {
const messagePayload = { const messagePayload = {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
@ -992,6 +1088,7 @@ export default {
} }
.reply-box__top { .reply-box__top {
position: relative;
padding: 0 var(--space-normal); padding: 0 var(--space-normal);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
margin-top: -1px; margin-top: -1px;
@ -1029,4 +1126,9 @@ export default {
margin-bottom: 0; margin-bottom: 0;
} }
} }
.normal-editor__canned-box {
width: calc(100% - 2 * var(--space-normal));
left: var(--space-normal);
}
</style> </style>

View File

@ -2,7 +2,6 @@
<ul <ul
v-if="items.length" v-if="items.length"
class="vertical dropdown menu mention--box" class="vertical dropdown menu mention--box"
:style="{ top: getTopSpacing() + 'rem' }"
:class="{ 'with-bottom-border': items.length <= 4 }" :class="{ 'with-bottom-border': items.length <= 4 }"
> >
<li <li
@ -69,12 +68,6 @@ export default {
}, },
methods: { methods: {
getTopSpacing() {
if (this.items.length <= 4) {
return -(this.items.length * 5 + 1.7);
}
return -20;
},
handleKeyboardEvent(e) { handleKeyboardEvent(e) {
this.processKeyDownEvent(e); this.processKeyDownEvent(e);
this.$el.scrollTop = 50 * this.selectedIndex; this.$el.scrollTop = 50 * this.selectedIndex;
@ -101,6 +94,7 @@ export default {
box-shadow: var(--shadow-medium); box-shadow: var(--shadow-medium);
font-size: var(--font-size-small); font-size: var(--font-size-small);
left: 0; left: 0;
bottom: 100%;
line-height: 1.2; line-height: 1.2;
max-height: 20rem; max-height: 20rem;
overflow: auto; overflow: auto;

View File

@ -0,0 +1,49 @@
<template>
<mention-box :items="items" @mention-select="handleVariableClick">
<template slot-scope="{ item }">
<span class="text-capitalize variable--list-label">
{{ item.description }}
</span>
({{ item.label }})
</template>
</mention-box>
</template>
<script>
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
import MentionBox from '../mentions/MentionBox.vue';
export default {
components: { MentionBox },
props: {
searchKey: {
type: String,
default: '',
},
},
computed: {
items() {
return MESSAGE_VARIABLES.filter(variable => {
return (
variable.label.includes(this.searchKey) ||
variable.key.includes(this.searchKey)
);
}).map(variable => ({
label: variable.key,
key: variable.key,
description: variable.label,
}));
},
},
methods: {
handleVariableClick(item = {}) {
this.$emit('click', item.key);
},
},
};
</script>
<style scoped>
.variable--list-label {
font-weight: var(--font-weight-bold);
}
</style>

View File

@ -53,6 +53,9 @@
</template> </template>
<script> <script>
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
export default { export default {
props: { props: {
inboxId: { inboxId: {
@ -67,7 +70,14 @@ export default {
}, },
computed: { computed: {
whatsAppTemplateMessages() { whatsAppTemplateMessages() {
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId); // TODO: Remove the last filter when we support all formats
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
.filter(template => template.status.toLowerCase() === 'approved')
.filter(template => {
return template.components.every(component => {
return !formatsToRemove.includes(component.format);
});
});
}, },
filteredTemplateMessages() { filteredTemplateMessages() {
return this.whatsAppTemplateMessages.filter(template => return this.whatsAppTemplateMessages.filter(template =>

View File

@ -0,0 +1,115 @@
<template>
<div class="contact--group">
<fluent-icon icon="call" class="file--icon" size="18" />
<div class="meta">
<p class="text-truncate margin-bottom-0">
{{ phoneNumber }}
</p>
</div>
<div v-if="formattedPhoneNumber" class="link-wrap">
<woot-button variant="clear" size="small" @click.prevent="addContact">
{{ $t('CONVERSATION.SAVE_CONTACT') }}
</woot-button>
</div>
</div>
</template>
<script>
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
name: {
type: String,
default: '',
},
phoneNumber: {
type: String,
default: '',
},
},
computed: {
formattedPhoneNumber() {
return this.phoneNumber.replace(/\s|-|[A-Za-z]/g, '');
},
rawPhoneNumber() {
return this.phoneNumber.replace(/\D/g, '');
},
},
methods: {
async addContact() {
try {
let contact = await this.filterContactByNumber(this.rawPhoneNumber);
if (!contact) {
contact = await this.$store.dispatch(
'contacts/create',
this.getContactObject()
);
this.showAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
}
this.openContactNewTab(contact.id);
} catch (error) {
if (error instanceof DuplicateContactException) {
if (error.data.includes('phone_number')) {
this.showAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
}
} else if (error instanceof ExceptionWithMessage) {
this.showAlert(error.data);
} else {
this.showAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
getContactObject() {
const contactItem = {
name: this.name,
phone_number: `+${this.rawPhoneNumber}`,
};
return contactItem;
},
async filterContactByNumber(phoneNumber) {
const query = {
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: [phoneNumber],
attribute_model: 'standard',
custom_attribute_type: '',
};
const queryPayload = { payload: [query] };
const contacts = await this.$store.dispatch('contacts/filter', {
queryPayload,
resetState: false,
});
return contacts.shift();
},
openContactNewTab(contactId) {
const accountId = window.location.pathname.split('/')[3];
const url = `/app/accounts/${accountId}/contacts/${contactId}`;
window.open(url, '_blank');
},
},
};
</script>
<style lang="scss" scoped>
.contact--group {
align-items: center;
display: flex;
margin-top: var(--space-smaller);
.meta {
flex: 1;
margin-left: var(--space-small);
}
.link-wrap {
margin-left: var(--space-small);
}
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<div class="image-placeholder">
<fluent-icon icon="document-error" size="32" />
<p>{{ $t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_UNAVAILABLE') }}</p>
</div>
</template>
<script>
export default {};
</script>
<style scoped>
.image-placeholder {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
padding: var(--space-slab);
height: calc(var(--space-large) * 10);
margin-top: var(--space-one);
width: 100%;
background-color: var(--s-75);
color: var(--s-800);
border-radius: var(--border-radius-normal);
}
</style>

View File

@ -0,0 +1,28 @@
import ContactBubble from '../bubble/Contact.vue';
export default {
title: 'Components/Messaging/ContactBubble',
component: ContactBubble,
argTypes: {
name: {
defaultValue: 'Eden Hazard',
control: {
type: 'string',
},
},
phoneNumber: {
defaultValue: '+517554433220',
control: {
type: 'string',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactBubble },
template: '<contact-bubble v-bind="$props" />',
});
export const ContactBubbleView = Template.bind({});

View File

@ -1,22 +1,23 @@
<template> <template>
<ul <div v-if="items.length" ref="mentionsListContainer" class="mention--box">
v-if="items.length" <ul class="vertical dropdown menu">
class="vertical dropdown menu mention--box" <woot-dropdown-item
:style="{ top: getTopPadding() + 'rem' }" v-for="(item, index) in items"
> :id="`mention-item-${index}`"
<li :key="item.key"
v-for="(item, index) in items" @mouseover="onHover(index)"
:id="`mention-item-${index}`" >
:key="item.key" <woot-button
:class="{ active: index === selectedIndex }" class="canned-item__button"
@click="onListItemSelection(index)" :variant="index === selectedIndex ? '' : 'clear'"
@mouseover="onHover(index)" :class="{ active: index === selectedIndex }"
> @click="onListItemSelection(index)"
<a class="text-truncate"> >
<strong>{{ item.label }}</strong> - {{ item.description }} <strong>{{ item.label }}</strong> - {{ item.description }}
</a> </woot-button>
</li> </woot-dropdown-item>
</ul> </ul>
</div>
</template> </template>
<script> <script>
@ -40,17 +41,27 @@ export default {
this.selectedIndex = 0; this.selectedIndex = 0;
} }
}, },
selectedIndex() {
const container = this.$refs.mentionsListContainer;
const item = container.querySelector(
`#mention-item-${this.selectedIndex}`
);
if (item) {
const itemTop = item.offsetTop;
const itemBottom = itemTop + item.offsetHeight;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.offsetHeight;
if (itemTop < containerTop) {
container.scrollTop = itemTop;
} else if (itemBottom + 34 > containerBottom) {
container.scrollTop = itemBottom - container.offsetHeight + 34;
}
}
},
}, },
methods: { methods: {
getTopPadding() {
if (this.items.length <= 4) {
return -(this.items.length * 2.9 + 1.7);
}
return -14;
},
handleKeyboardEvent(e) { handleKeyboardEvent(e) {
this.processKeyDownEvent(e); this.processKeyDownEvent(e);
this.$el.scrollTop = 29 * this.selectedIndex;
}, },
onHover(index) { onHover(index) {
this.selectedIndex = index; this.selectedIndex = index;
@ -69,20 +80,40 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
.mention--box { .mention--box {
background: var(--white); background: var(--white);
border-bottom: var(--space-small) solid var(--white);
border-radius: var(--border-radius-normal); border-radius: var(--border-radius-normal);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
box-shadow: var(--shadow-medium); box-shadow: var(--shadow-medium);
left: 0; left: 0;
max-height: 14rem; bottom: 100%;
max-height: 15.6rem;
overflow: auto; overflow: auto;
padding-top: var(--space-small); padding: var(--space-small) var(--space-small) 0;
position: absolute; position: absolute;
width: 100%; width: 100%;
z-index: 100; z-index: 100;
.active a { .dropdown-menu__item:last-child {
background: var(--w-500); padding-bottom: var(--space-smaller);
}
.active {
color: var(--white);
&:hover {
color: var(--w-700);
}
}
.button {
transition: none;
height: var(--space-large);
line-height: 1.4;
} }
} }
.canned-item__button::v-deep .button__content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style> </style>

View File

@ -1,9 +1,76 @@
export const EXECUTED_A_MACRO = 'Executed a macro'; export const CONVERSATION_EVENTS = Object.freeze({
export const SENT_MESSAGE = 'Sent a message'; EXECUTED_A_MACRO: 'Executed a macro',
export const SENT_PRIVATE_NOTE = 'Sent a private note'; SENT_MESSAGE: 'Sent a message',
export const INSERTED_A_CANNED_RESPONSE = 'Inserted a canned response'; SENT_PRIVATE_NOTE: 'Sent a private note',
export const USED_MENTIONS = 'Used mentions'; INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
export const MERGED_CONTACTS = 'Used merge contact option'; INSERTED_A_VARIABLE: 'Inserted a variable',
export const ADDED_TO_CANNED_RESPONSE = 'Used added to canned response option'; USED_MENTIONS: 'Used mentions',
export const ADDED_A_CUSTOM_ATTRIBUTE = 'Added a custom attribute';
export const ADDED_AN_INBOX = 'Added an inbox'; APPLY_FILTER: 'Applied filters in the conversation list',
});
export const ACCOUNT_EVENTS = Object.freeze({
ADDED_TO_CANNED_RESPONSE: 'Used added to canned response option',
ADDED_A_CUSTOM_ATTRIBUTE: 'Added a custom attribute',
ADDED_AN_INBOX: 'Added an inbox',
});
export const LABEL_EVENTS = Object.freeze({
CREATE: 'Created a label',
UPDATE: 'Updated a label',
DELETED: 'Deleted a label',
APPLY_LABEL: 'Applied a label',
});
// REPORTS EVENTS
export const REPORTS_EVENTS = Object.freeze({
DOWNLOAD_REPORT: 'Downloaded a report',
FILTER_REPORT: 'Used filters in the reports',
});
// CONTACTS PAGE EVENTS
export const CONTACTS_EVENTS = Object.freeze({
APPLY_FILTER: 'Applied filters in the contacts list',
SAVE_FILTER: 'Saved a filter in the contacts list',
DELETE_FILTER: 'Deleted a filter in the contacts list',
APPLY_SORT: 'Sorted contacts list',
SEARCH: 'Searched contacts list',
CREATE_CONTACT: 'Created a contact',
MERGED_CONTACTS: 'Used merge contact option',
IMPORT_MODAL_OPEN: 'Opened import contacts modal',
IMPORT_FAILURE: 'Import contacts failed',
IMPORT_SUCCESS: 'Imported contacts successfully',
});
// CAMPAIGN EVENTS
export const CAMPAIGNS_EVENTS = Object.freeze({
OPEN_NEW_CAMPAIGN_MODAL: 'Opened new campaign modal',
CREATE_CAMPAIGN: 'Created a new campaign',
UPDATE_CAMPAIGN: 'Updated a campaign',
DELETE_CAMPAIGN: 'Deleted a campaign',
});
// PORTAL EVENTS
export const PORTALS_EVENTS = Object.freeze({
ONBOARD_BASIC_INFORMATION: 'New Portal: Completed basic information',
ONBOARD_CUSTOMIZATION: 'New portal: Completed customization',
CREATE_PORTAL: 'Created a portal',
DELETE_PORTAL: 'Deleted a portal',
UPDATE_PORTAL: 'Updated a portal',
CREATE_LOCALE: 'Created a portal locale',
SET_DEFAULT_LOCALE: 'Set default portal locale',
DELETE_LOCALE: 'Deleted a portal locale',
SWITCH_LOCALE: 'Switched portal locale',
CREATE_CATEGORY: 'Created a portal category',
DELETE_CATEGORY: 'Deleted a portal category',
EDIT_CATEGORY: 'Edited a portal category',
CREATE_ARTICLE: 'Created an article',
PUBLISH_ARTICLE: 'Published an article',
ARCHIVE_ARTICLE: 'Archived an article',
DELETE_ARTICLE: 'Deleted an article',
PREVIEW_ARTICLE: 'Previewed article',
});

View File

@ -1,12 +1,26 @@
import { AnalyticsBrowser } from '@june-so/analytics-next'; import { AnalyticsBrowser } from '@june-so/analytics-next';
class AnalyticsHelper { /**
* AnalyticsHelper class to initialize and track user analytics
* @class AnalyticsHelper
*/
export class AnalyticsHelper {
/**
* @constructor
* @param {Object} [options={}] - options for analytics
* @param {string} [options.token] - analytics token
*/
constructor({ token: analyticsToken } = {}) { constructor({ token: analyticsToken } = {}) {
this.analyticsToken = analyticsToken; this.analyticsToken = analyticsToken;
this.analytics = null; this.analytics = null;
this.user = {}; this.user = {};
} }
/**
* Initialize analytics
* @function
* @async
*/
async init() { async init() {
if (!this.analyticsToken) { if (!this.analyticsToken) {
return; return;
@ -18,6 +32,11 @@ class AnalyticsHelper {
this.analytics = analytics; this.analytics = analytics;
} }
/**
* Identify the user
* @function
* @param {Object} user - User object
*/
identify(user) { identify(user) {
if (!this.analytics) { if (!this.analytics) {
return; return;
@ -41,6 +60,12 @@ class AnalyticsHelper {
} }
} }
/**
* Track any event
* @function
* @param {string} eventName - event name
* @param {Object} [properties={}] - event properties
*/
track(eventName, properties = {}) { track(eventName, properties = {}) {
if (!this.analytics) { if (!this.analytics) {
return; return;
@ -53,6 +78,11 @@ class AnalyticsHelper {
}); });
} }
/**
* Track the page views
* @function
* @param {Object} params - Page view properties
*/
page(params) { page(params) {
if (!this.analytics) { if (!this.analytics) {
return; return;
@ -62,6 +92,5 @@ class AnalyticsHelper {
} }
} }
export * as ANALYTICS_EVENTS from './events'; // This object is shared across, the init is called in app/javascript/packs/application.js
export default new AnalyticsHelper(window.analyticsConfig); export default new AnalyticsHelper(window.analyticsConfig);

View File

@ -0,0 +1,11 @@
import analyticsHelper from '.';
export default {
// This function is called when the Vue plugin is installed
install(Vue) {
analyticsHelper.init();
Vue.prototype.$analytics = analyticsHelper;
// Add a shorthand function for the track method on the helper module
Vue.prototype.$track = analyticsHelper.track.bind(analyticsHelper);
},
};

View File

@ -0,0 +1,26 @@
import * as AnalyticsEvents from '../events';
describe('Analytics Events', () => {
it('should be frozen', () => {
Object.entries(AnalyticsEvents).forEach(([, value]) => {
expect(Object.isFrozen(value)).toBe(true);
});
});
it('event names should be unique across the board', () => {
const allValues = Object.values(AnalyticsEvents).reduce(
(acc, curr) => acc.concat(Object.values(curr)),
[]
);
const uniqueValues = new Set(allValues);
expect(allValues.length).toBe(uniqueValues.size);
});
it('should not allow properties to be modified', () => {
Object.values(AnalyticsEvents).forEach(eventsObject => {
expect(() => {
eventsObject.NEW_PROPERTY = 'new value';
}).toThrow();
});
});
});

View File

@ -0,0 +1,139 @@
import helperObject, { AnalyticsHelper } from '../';
jest.mock('@june-so/analytics-next', () => ({
AnalyticsBrowser: {
load: () => [
{
identify: jest.fn(),
track: jest.fn(),
page: jest.fn(),
group: jest.fn(),
},
],
},
}));
describe('helperObject', () => {
it('should return an instance of AnalyticsHelper', () => {
expect(helperObject).toBeInstanceOf(AnalyticsHelper);
});
});
describe('AnalyticsHelper', () => {
let analyticsHelper;
beforeEach(() => {
analyticsHelper = new AnalyticsHelper({ token: 'test_token' });
});
describe('init', () => {
it('should initialize the analytics browser with the correct token', async () => {
await analyticsHelper.init();
expect(analyticsHelper.analytics).not.toBe(null);
});
it('should not initialize the analytics browser if token is not provided', async () => {
analyticsHelper = new AnalyticsHelper();
await analyticsHelper.init();
expect(analyticsHelper.analytics).toBe(null);
});
});
describe('identify', () => {
beforeEach(() => {
analyticsHelper.analytics = { identify: jest.fn(), group: jest.fn() };
});
it('should call identify on analytics browser with correct arguments', () => {
analyticsHelper.identify({
id: '123',
email: 'test@example.com',
name: 'Test User',
avatar_url: 'avatar_url',
accounts: [{ id: '1', name: 'Account 1' }],
account_id: '1',
});
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith(
'test@example.com',
{
userId: '123',
email: 'test@example.com',
name: 'Test User',
avatar: 'avatar_url',
}
);
expect(analyticsHelper.analytics.group).toHaveBeenCalled();
});
it('should call identify on analytics browser without group', () => {
analyticsHelper.identify({
id: '123',
email: 'test@example.com',
name: 'Test User',
avatar_url: 'avatar_url',
accounts: [{ id: '1', name: 'Account 1' }],
account_id: '5',
});
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
});
it('should not call analytics.page if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.identify({});
expect(analyticsHelper.analytics).toBe(null);
});
});
describe('track', () => {
beforeEach(() => {
analyticsHelper.analytics = { track: jest.fn() };
analyticsHelper.user = { id: '123' };
});
it('should call track on analytics browser with correct arguments', () => {
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
userId: '123',
event: 'Test Event',
properties: { prop1: 'value1', prop2: 'value2' },
});
});
it('should call track on analytics browser with default properties', () => {
analyticsHelper.track('Test Event');
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
userId: '123',
event: 'Test Event',
properties: {},
});
});
it('should not call track on analytics browser if analytics is not initialized', () => {
analyticsHelper.analytics = null;
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics).toBe(null);
});
});
describe('page', () => {
beforeEach(() => {
analyticsHelper.analytics = { page: jest.fn() };
});
it('should call the analytics.page method with the correct arguments', () => {
const params = {
name: 'Test page',
url: '/test',
};
analyticsHelper.page(params);
expect(analyticsHelper.analytics.page).toHaveBeenCalledWith(params);
});
it('should not call analytics.page if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.page();
expect(analyticsHelper.analytics).toBe(null);
});
});
});

View File

@ -0,0 +1,35 @@
import Vue from 'vue';
import plugin from '../plugin';
import analyticsHelper from '../index';
describe('Vue Analytics Plugin', () => {
beforeEach(() => {
jest.spyOn(analyticsHelper, 'init');
jest.spyOn(analyticsHelper, 'track');
Vue.use(plugin);
});
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
it('should call the init method on the analyticsHelper', () => {
expect(analyticsHelper.init).toHaveBeenCalled();
});
it('should add the analyticsHelper to the Vue prototype', () => {
expect(Vue.prototype.$analytics).toBe(analyticsHelper);
});
it('should add the track method to the Vue prototype', () => {
expect(typeof Vue.prototype.$track).toBe('function');
Vue.prototype.$track('eventName');
expect(analyticsHelper.track).toHaveBeenCalledWith('eventName');
});
it('should call the track method on the analyticsHelper when $track is called', () => {
Vue.prototype.$track('eventName');
expect(analyticsHelper.track).toHaveBeenCalledWith('eventName');
});
});

View File

@ -56,6 +56,8 @@ export const conversationUrl = ({
url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`; url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`;
} else if (conversationType === 'mention') { } else if (conversationType === 'mention') {
url = `accounts/${accountId}/mentions/conversations/${id}`; url = `accounts/${accountId}/mentions/conversations/${id}`;
} else if (conversationType === 'participating') {
url = `accounts/${accountId}/participating/conversations/${id}`;
} else if (conversationType === 'unattended') { } else if (conversationType === 'unattended') {
url = `accounts/${accountId}/unattended/conversations/${id}`; url = `accounts/${accountId}/unattended/conversations/${id}`;
} }

View File

@ -165,6 +165,17 @@ export const getDefaultConditions = eventName => {
}, },
]; ];
} }
if (eventName === 'conversation_opened') {
return [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
}
return [ return [
{ {
attribute_key: 'status', attribute_key: 'status',

View File

@ -0,0 +1,59 @@
const MESSAGE_VARIABLES_REGEX = /{{(.*?)}}/g;
export const replaceVariablesInMessage = ({ message, variables }) => {
return message.replace(MESSAGE_VARIABLES_REGEX, (match, replace) => {
return variables[replace.trim()]
? variables[replace.trim().toLowerCase()]
: '';
});
};
const skipCodeBlocks = str => str.replace(/```(?:.|\n)+?```/g, '');
export const getFirstName = ({ user }) => {
return user?.name ? user.name.split(' ').shift() : '';
};
export const getLastName = ({ user }) => {
if (user && user.name) {
return user.name.split(' ').length > 1 ? user.name.split(' ').pop() : '';
}
return '';
};
export const getMessageVariables = ({ conversation }) => {
const {
meta: { assignee = {}, sender = {} },
id,
} = conversation;
return {
'contact.name': sender?.name,
'contact.first_name': getFirstName({ user: sender }),
'contact.last_name': getLastName({ user: sender }),
'contact.email': sender?.email,
'contact.phone': sender?.phone_number,
'contact.id': sender?.id,
'conversation.id': id,
'agent.name': assignee?.name ? assignee?.name : '',
'agent.first_name': getFirstName({ user: assignee }),
'agent.last_name': getLastName({ user: assignee }),
'agent.email': assignee?.email ?? '',
};
};
export const getUndefinedVariablesInMessage = ({ message, variables }) => {
const messageWithOutCodeBlocks = skipCodeBlocks(message);
const matches = messageWithOutCodeBlocks.match(MESSAGE_VARIABLES_REGEX);
if (!matches) return [];
return matches
.map(match => {
return match
.replace('{{', '')
.replace('}}', '')
.trim();
})
.filter(variable => {
return !variables[variable];
});
};

View File

@ -0,0 +1,138 @@
import {
replaceVariablesInMessage,
getFirstName,
getLastName,
getMessageVariables,
getUndefinedVariablesInMessage,
} from '../messageHelper';
const variables = {
'contact.name': 'John Doe',
'contact.first_name': 'John',
'contact.last_name': 'Doe',
'contact.email': 'john.p@example.com',
'contact.phone': '1234567890',
'conversation.id': 1,
'agent.first_name': 'Samuel',
'agent.last_name': 'Smith',
'agent.email': 'samuel@gmail.com',
};
describe('#replaceVariablesInMessage', () => {
it('returns the message with variable name', () => {
const message =
'No issues. Hey {{contact.first_name}}, we will send the reset instructions to your email {{ contact.email}}. The {{ agent.first_name }} {{ agent.last_name }} will take care of everything. Your conversation id is {{ conversation.id }}.';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'No issues. Hey John, we will send the reset instructions to your email john.p@example.com. The Samuel Smith will take care of everything. Your conversation id is 1.'
);
});
it('returns the message with variable name having white space', () => {
const message = 'hey {{contact.name}} how may I help you?';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'hey John Doe how may I help you?'
);
});
it('returns the message with variable email', () => {
const message =
'No issues. We will send the reset instructions to your email at {{contact.email}}';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'No issues. We will send the reset instructions to your email at john.p@example.com'
);
});
it('returns the message with multiple variables', () => {
const message =
'hey {{ contact.name }}, no issues. We will send the reset instructions to your email at {{contact.email}}';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'hey John Doe, no issues. We will send the reset instructions to your email at john.p@example.com'
);
});
it('returns the message if the variable is not present in variables', () => {
const message = 'Please dm me at {{contact.twitter}}';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'Please dm me at '
);
});
});
describe('#getFirstName', () => {
it('returns the first name of the contact', () => {
const assignee = { name: 'John Doe' };
expect(getFirstName({ user: assignee })).toBe('John');
});
it('returns the first name of the contact with multiple names', () => {
const assignee = { name: 'John Doe Smith' };
expect(getFirstName({ user: assignee })).toBe('John');
});
});
describe('#getLastName', () => {
it('returns the last name of the contact', () => {
const assignee = { name: 'John Doe' };
expect(getLastName({ user: assignee })).toBe('Doe');
});
it('returns the last name of the contact with multiple names', () => {
const assignee = { name: 'John Doe Smith' };
expect(getLastName({ user: assignee })).toBe('Smith');
});
});
describe('#getMessageVariables', () => {
it('returns the variables', () => {
const conversation = {
meta: {
assignee: {
name: 'Samuel Smith',
email: 'samuel@example.com',
},
sender: {
name: 'John Doe',
email: 'john.doe@gmail.com',
phone_number: '1234567890',
},
},
id: 1,
};
expect(getMessageVariables({ conversation })).toEqual({
'contact.name': 'John Doe',
'contact.first_name': 'John',
'contact.last_name': 'Doe',
'contact.email': 'john.doe@gmail.com',
'contact.phone': '1234567890',
'conversation.id': 1,
'agent.name': 'Samuel Smith',
'agent.first_name': 'Samuel',
'agent.last_name': 'Smith',
'agent.email': 'samuel@example.com',
});
});
});
describe('#getUndefinedVariablesInMessage', () => {
it('returns the undefined variables', () => {
const message = 'Please dm me at {{contact.twitter}}';
expect(
getUndefinedVariablesInMessage({ message, variables }).length
).toEqual(1);
expect(getUndefinedVariablesInMessage({ message, variables })).toEqual(
expect.arrayContaining(['contact.twitter'])
);
});
it('skip variables in string with code blocks', () => {
const message =
'hey {{contact_name}} how are you? ``` code: {{contact_name}} ```';
const undefinedVariables = getUndefinedVariablesInMessage({
message,
variables,
});
expect(undefinedVariables.length).toEqual(1);
expect(undefinedVariables).toEqual(
expect.arrayContaining(['contact_name'])
);
});
});

View File

@ -30,6 +30,9 @@
}, },
"snoozed": { "snoozed": {
"TEXT": "غفوة" "TEXT": "غفوة"
},
"all": {
"TEXT": "الكل"
} }
}, },
"ATTACHMENTS": { "ATTACHMENTS": {

View File

@ -15,6 +15,7 @@
"INITIATED_FROM": "تم البدء من", "INITIATED_FROM": "تم البدء من",
"INITIATED_AT": "تم البدء في", "INITIATED_AT": "تم البدء في",
"IP_ADDRESS": "عنوان IP", "IP_ADDRESS": "عنوان IP",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "رسالة جديدة", "NEW_MESSAGE": "رسالة جديدة",
"CONVERSATIONS": { "CONVERSATIONS": {
"NO_RECORDS_FOUND": "لا توجد محادثات سابقة مرتبطة بجهة الاتصال هذه.", "NO_RECORDS_FOUND": "لا توجد محادثات سابقة مرتبطة بجهة الاتصال هذه.",
@ -206,6 +207,7 @@
"PHONE_NUMBER": "رقم الهاتف", "PHONE_NUMBER": "رقم الهاتف",
"CONVERSATIONS": "المحادثات", "CONVERSATIONS": "المحادثات",
"LAST_ACTIVITY": "آخر نشاط", "LAST_ACTIVITY": "آخر نشاط",
"CREATED_AT": "تم إنشاؤها في",
"COUNTRY": "الدولة", "COUNTRY": "الدولة",
"CITY": "المدينة", "CITY": "المدينة",
"SOCIAL_PROFILES": "حسابات التواصل الاجتماعي", "SOCIAL_PROFILES": "حسابات التواصل الاجتماعي",

View File

@ -35,6 +35,7 @@
"REMOVE_SELECTION": "إزالة التحديد", "REMOVE_SELECTION": "إزالة التحديد",
"DOWNLOAD": "تنزيل", "DOWNLOAD": "تنزيل",
"UNKNOWN_FILE_TYPE": "ملف غير معروف", "UNKNOWN_FILE_TYPE": "ملف غير معروف",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "جاري تحميل المرفقات...", "UPLOADING_ATTACHMENTS": "جاري تحميل المرفقات...",
"SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح", "SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح",
"FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى", "FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى",
@ -132,6 +133,14 @@
"PLACEHOLDER": "البريد الإلكتروني مفصولة بفاصلة", "PLACEHOLDER": "البريد الإلكتروني مفصولة بفاصلة",
"ERROR": "الرجاء إدخال عنوان بريد إلكتروني صحيح" "ERROR": "الرجاء إدخال عنوان بريد إلكتروني صحيح"
} }
},
"UNDEFINED_VARIABLES": {
"TITLE": "Undefined variables",
"MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
"CONFIRM": {
"YES": "إرسال",
"CANCEL": "إلغاء"
}
} }
}, },
"VISIBLE_TO_AGENTS": "ملاحظة خاصة: مرئية فقط لأعضاء فريق العمل والموظفين", "VISIBLE_TO_AGENTS": "ملاحظة خاصة: مرئية فقط لأعضاء فريق العمل والموظفين",

View File

@ -53,10 +53,6 @@
"ENABLE": "إنشاء محادثات من التغريدات المشار إليها" "ENABLE": "إنشاء محادثات من التغريدات المشار إليها"
} }
}, },
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": { "WEBSITE_CHANNEL": {
"TITLE": "قناة الموقع", "TITLE": "قناة الموقع",
"DESC": "قم بإنشاء قناة تواصل لموقع الويب الخاص بك وابدأ في استقبال الرسائل من عملائك عبر صندوق الدردشة المباشرة.", "DESC": "قم بإنشاء قناة تواصل لموقع الويب الخاص بك وابدأ في استقبال الرسائل من عملائك عبر صندوق الدردشة المباشرة.",
@ -348,6 +344,17 @@
"FINISH": { "FINISH": {
"TITLE": "تم!", "TITLE": "تم!",
"DESC": "لقد تم بنجاح ربط صفحة فيسبوك الخاصة بك مع Chatwoot. في المرة القادمة التي يرسل فيها العملاء رسالة إلى صفحتك، ستظهر المحادثة تلقائيًا على صندوق الوارد الخاص بك هنا.<br>نحن نزودك أيضًا بالكود النصي لصندوق دردشة الماسنجر والذي يمكنك إضافته بسهولة إلى الموقع الخاص بك لاستقبال الرسائل من الزوار كذلك. بمجرد أن يتم ذلك على موقع الويب الخاص بك، يمكن للعملاء مراسلتك من موقع الويب الخاص بك بدون الحاجة لأي أدوات خارجية وستظهر المحادثة هنا على Chatwoot.<br>رائع، أليس كذلك؟ نحن بالتأكيد نحاول أن نكون الأفضل :)" "DESC": "لقد تم بنجاح ربط صفحة فيسبوك الخاصة بك مع Chatwoot. في المرة القادمة التي يرسل فيها العملاء رسالة إلى صفحتك، ستظهر المحادثة تلقائيًا على صندوق الوارد الخاص بك هنا.<br>نحن نزودك أيضًا بالكود النصي لصندوق دردشة الماسنجر والذي يمكنك إضافته بسهولة إلى الموقع الخاص بك لاستقبال الرسائل من الزوار كذلك. بمجرد أن يتم ذلك على موقع الويب الخاص بك، يمكن للعملاء مراسلتك من موقع الويب الخاص بك بدون الحاجة لأي أدوات خارجية وستظهر المحادثة هنا على Chatwoot.<br>رائع، أليس كذلك؟ نحن بالتأكيد نحاول أن نكون الأفضل :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
} }
}, },
"DETAILS": { "DETAILS": {
@ -465,7 +472,11 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "السماح بالرسائل بعد حل المحادثة", "ALLOW_MESSAGES_AFTER_RESOLVED": "السماح بالرسائل بعد حل المحادثة",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "السماح للمستخدمين النهائيين بإرسال رسائل حتى بعد تسوية المحادثة.", "ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "السماح للمستخدمين النهائيين بإرسال رسائل حتى بعد تسوية المحادثة.",
"WHATSAPP_SECTION_SUBHEADER": "يتم استخدام مفتاح API هذا للتكامل مع واتسب APIs.", "WHATSAPP_SECTION_SUBHEADER": "يتم استخدام مفتاح API هذا للتكامل مع واتسب APIs.",
"WHATSAPP_SECTION_TITLE": "مفتاح API" "WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "مفتاح API",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "تحديث"
}, },
"AUTO_ASSIGNMENT": { "AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "حد الإسناد التلقائي", "MAX_ASSIGNMENT_LIMIT": "حد الإسناد التلقائي",
@ -674,6 +685,10 @@
}, },
"BRANDING_TEXT": "مدعوم بواسطة Chatwoot", "BRANDING_TEXT": "مدعوم بواسطة Chatwoot",
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};" "SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"OTHER_PROVIDERS": "Other Providers"
} }
} }
} }

View File

@ -14,7 +14,9 @@
"CONVERSATION_UPDATED": "تم تحديث المحادثة", "CONVERSATION_UPDATED": "تم تحديث المحادثة",
"MESSAGE_CREATED": "تم إنشاء رسالة", "MESSAGE_CREATED": "تم إنشاء رسالة",
"MESSAGE_UPDATED": "تم تحديث الرسالة", "MESSAGE_UPDATED": "تم تحديث الرسالة",
"WEBWIDGET_TRIGGERED": "أداة الدردشة المباشرة مفتوحة من قبل المستخدم" "WEBWIDGET_TRIGGERED": "أداة الدردشة المباشرة مفتوحة من قبل المستخدم",
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated"
} }
}, },
"END_POINT": { "END_POINT": {

View File

@ -170,7 +170,8 @@
}, },
"FILE_BUBBLE": { "FILE_BUBBLE": {
"DOWNLOAD": "تنزيل", "DOWNLOAD": "تنزيل",
"UPLOADING": "جاري الرفع..." "UPLOADING": "جاري الرفع...",
"INSTAGRAM_STORY_UNAVAILABLE": "هذه القصة لم تعد متاحة."
}, },
"LOCATION_BUBBLE": { "LOCATION_BUBBLE": {
"SEE_ON_MAP": "مشاهدة على الخريطة" "SEE_ON_MAP": "مشاهدة على الخريطة"

View File

@ -30,6 +30,9 @@
}, },
"snoozed": { "snoozed": {
"TEXT": "Отложен" "TEXT": "Отложен"
},
"all": {
"TEXT": "Всички"
} }
}, },
"ATTACHMENTS": { "ATTACHMENTS": {

View File

@ -15,6 +15,7 @@
"INITIATED_FROM": "Иницирано от", "INITIATED_FROM": "Иницирано от",
"INITIATED_AT": "Иницирано в", "INITIATED_AT": "Иницирано в",
"IP_ADDRESS": "IP адрес", "IP_ADDRESS": "IP адрес",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "Ново съобщение", "NEW_MESSAGE": "Ново съобщение",
"CONVERSATIONS": { "CONVERSATIONS": {
"NO_RECORDS_FOUND": "Няма предишни разговори асоцирани с този контакт.", "NO_RECORDS_FOUND": "Няма предишни разговори асоцирани с този контакт.",
@ -206,6 +207,7 @@
"PHONE_NUMBER": "Телефон", "PHONE_NUMBER": "Телефон",
"CONVERSATIONS": "Разговори", "CONVERSATIONS": "Разговори",
"LAST_ACTIVITY": "Последна активност", "LAST_ACTIVITY": "Последна активност",
"CREATED_AT": "Created At",
"COUNTRY": "Държава", "COUNTRY": "Държава",
"CITY": "Град", "CITY": "Град",
"SOCIAL_PROFILES": "Социални профили", "SOCIAL_PROFILES": "Социални профили",

View File

@ -35,6 +35,7 @@
"REMOVE_SELECTION": "Remove Selection", "REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
"UNKNOWN_FILE_TYPE": "Unknown File", "UNKNOWN_FILE_TYPE": "Unknown File",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Uploading attachments...", "UPLOADING_ATTACHMENTS": "Uploading attachments...",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully", "SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again", "FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
@ -132,6 +133,14 @@
"PLACEHOLDER": "Emails separated by commas", "PLACEHOLDER": "Emails separated by commas",
"ERROR": "Please enter valid email addresses" "ERROR": "Please enter valid email addresses"
} }
},
"UNDEFINED_VARIABLES": {
"TITLE": "Undefined variables",
"MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
"CONFIRM": {
"YES": "Send",
"CANCEL": "Отмени"
}
} }
}, },
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team", "VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",

View File

@ -53,10 +53,6 @@
"ENABLE": "Create conversations from mentioned Tweets" "ENABLE": "Create conversations from mentioned Tweets"
} }
}, },
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": { "WEBSITE_CHANNEL": {
"TITLE": "Website channel", "TITLE": "Website channel",
"DESC": "Create a channel for your website and start supporting your customers via our website widget.", "DESC": "Create a channel for your website and start supporting your customers via our website widget.",
@ -348,6 +344,17 @@
"FINISH": { "FINISH": {
"TITLE": "Nailed It!", "TITLE": "Nailed It!",
"DESC": "You have successfully finished integrating your Facebook Page with Chatwoot. Next time a customer messages your Page, the conversation will automatically appear on your inbox.<br>We are also providing you with a widget script that you can easily add to your website. Once this is live on your website, customers can message you right from your website without the help of any external tool and the conversation will appear right here, on Chatwoot.<br>Cool, huh? Well, we sure try to be :)" "DESC": "You have successfully finished integrating your Facebook Page with Chatwoot. Next time a customer messages your Page, the conversation will automatically appear on your inbox.<br>We are also providing you with a widget script that you can easily add to your website. Once this is live on your website, customers can message you right from your website without the help of any external tool and the conversation will appear right here, on Chatwoot.<br>Cool, huh? Well, we sure try to be :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
} }
}, },
"DETAILS": { "DETAILS": {
@ -465,7 +472,11 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved", "ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.", "ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.", "WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key" "WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Обновяване"
}, },
"AUTO_ASSIGNMENT": { "AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit", "MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
@ -674,6 +685,10 @@
}, },
"BRANDING_TEXT": "Осъществено от Chatwoot", "BRANDING_TEXT": "Осъществено от Chatwoot",
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};" "SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"OTHER_PROVIDERS": "Other Providers"
} }
} }
} }

View File

@ -14,7 +14,9 @@
"CONVERSATION_UPDATED": "Conversation Updated", "CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message created", "MESSAGE_CREATED": "Message created",
"MESSAGE_UPDATED": "Message updated", "MESSAGE_UPDATED": "Message updated",
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user" "WEBWIDGET_TRIGGERED": "Live chat widget opened by the user",
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated"
} }
}, },
"END_POINT": { "END_POINT": {

View File

@ -170,7 +170,8 @@
}, },
"FILE_BUBBLE": { "FILE_BUBBLE": {
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
"UPLOADING": "Качване..." "UPLOADING": "Качване...",
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
}, },
"LOCATION_BUBBLE": { "LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map" "SEE_ON_MAP": "See on map"

View File

@ -1,10 +1,10 @@
{ {
"REGISTER": { "REGISTER": {
"TRY_WOOT": "Register an account", "TRY_WOOT": "Create an account",
"TITLE": "Register", "TITLE": "Register",
"TESTIMONIAL_HEADER": "All it takes is one step to move forward", "TESTIMONIAL_HEADER": "All it takes is one step to move forward",
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.", "TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
"TERMS_ACCEPT": "By signing up, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>", "TERMS_ACCEPT": "By creating an account, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"COMPANY_NAME": { "COMPANY_NAME": {
"LABEL": "Company name", "LABEL": "Company name",
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises", "PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
@ -35,7 +35,7 @@
"SUCCESS_MESSAGE": "Registration Successfull", "SUCCESS_MESSAGE": "Registration Successfull",
"ERROR_MESSAGE": "Не можа да се свърже с Woot сървър. Моля, опитайте отново по-късно" "ERROR_MESSAGE": "Не можа да се свърже с Woot сървър. Моля, опитайте отново по-късно"
}, },
"SUBMIT": "Изпращане", "SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Already have an account?" "HAVE_AN_ACCOUNT": "Already have an account?"
} }
} }

View File

@ -30,6 +30,9 @@
}, },
"snoozed": { "snoozed": {
"TEXT": "Posposat" "TEXT": "Posposat"
},
"all": {
"TEXT": "Totes"
} }
}, },
"ATTACHMENTS": { "ATTACHMENTS": {

View File

@ -15,6 +15,7 @@
"INITIATED_FROM": "Iniciada des de", "INITIATED_FROM": "Iniciada des de",
"INITIATED_AT": "Iniciada a les", "INITIATED_AT": "Iniciada a les",
"IP_ADDRESS": "Adreça IP", "IP_ADDRESS": "Adreça IP",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "Nou missatge", "NEW_MESSAGE": "Nou missatge",
"CONVERSATIONS": { "CONVERSATIONS": {
"NO_RECORDS_FOUND": "No hi han converses prèvies associades a aquest contacte.", "NO_RECORDS_FOUND": "No hi han converses prèvies associades a aquest contacte.",
@ -206,6 +207,7 @@
"PHONE_NUMBER": "Número de telèfon", "PHONE_NUMBER": "Número de telèfon",
"CONVERSATIONS": "Converses", "CONVERSATIONS": "Converses",
"LAST_ACTIVITY": "Last Activity", "LAST_ACTIVITY": "Last Activity",
"CREATED_AT": "Created At",
"COUNTRY": "Country", "COUNTRY": "Country",
"CITY": "City", "CITY": "City",
"SOCIAL_PROFILES": "Social Profiles", "SOCIAL_PROFILES": "Social Profiles",

View File

@ -35,6 +35,7 @@
"REMOVE_SELECTION": "Elimina la selecció", "REMOVE_SELECTION": "Elimina la selecció",
"DOWNLOAD": "Descarrega", "DOWNLOAD": "Descarrega",
"UNKNOWN_FILE_TYPE": "Unknown File", "UNKNOWN_FILE_TYPE": "Unknown File",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Pujant fitxers adjunts...", "UPLOADING_ATTACHMENTS": "Pujant fitxers adjunts...",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully", "SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again", "FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
@ -132,6 +133,14 @@
"PLACEHOLDER": "Emails separated by commas", "PLACEHOLDER": "Emails separated by commas",
"ERROR": "Please enter valid email addresses" "ERROR": "Please enter valid email addresses"
} }
},
"UNDEFINED_VARIABLES": {
"TITLE": "Undefined variables",
"MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
"CONFIRM": {
"YES": "Envia",
"CANCEL": "Cancel·la"
}
} }
}, },
"VISIBLE_TO_AGENTS": "Nota privada: Només és visible per tu i el vostre equip", "VISIBLE_TO_AGENTS": "Nota privada: Només és visible per tu i el vostre equip",

View File

@ -53,10 +53,6 @@
"ENABLE": "Create conversations from mentioned Tweets" "ENABLE": "Create conversations from mentioned Tweets"
} }
}, },
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": { "WEBSITE_CHANNEL": {
"TITLE": "Canal Web", "TITLE": "Canal Web",
"DESC": "Crea un canal per al vostre lloc web i comença a donar suport als vostres clients mitjançant el teu widget del lloc web.", "DESC": "Crea un canal per al vostre lloc web i comença a donar suport als vostres clients mitjançant el teu widget del lloc web.",
@ -348,6 +344,17 @@
"FINISH": { "FINISH": {
"TITLE": "L'has clavat!", "TITLE": "L'has clavat!",
"DESC": "Heu acabat d'integrar la vostra pàgina de Facebook amb Chatwoot. La propera vegada que un client escrigui un missatge a la vostra pàgina, la conversa apareixerà automàticament a la safata d'entrada. <br>També us proporcionem un script del widget que podeu afegir fàcilment al vostre web. Una vegada que estigui operatiu al vostre web, els clients us podran enviar missatges des del web sense lajuda de cap eina externa i la conversa apareixerà aquí mateix, a Chatwoot. <br>Genial, eh? Bé, segur que intentem ser-ho :)" "DESC": "Heu acabat d'integrar la vostra pàgina de Facebook amb Chatwoot. La propera vegada que un client escrigui un missatge a la vostra pàgina, la conversa apareixerà automàticament a la safata d'entrada. <br>També us proporcionem un script del widget que podeu afegir fàcilment al vostre web. Una vegada que estigui operatiu al vostre web, els clients us podran enviar missatges des del web sense lajuda de cap eina externa i la conversa apareixerà aquí mateix, a Chatwoot. <br>Genial, eh? Bé, segur que intentem ser-ho :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
} }
}, },
"DETAILS": { "DETAILS": {
@ -465,7 +472,11 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved", "ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.", "ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.", "WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key" "WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Actualitza"
}, },
"AUTO_ASSIGNMENT": { "AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit", "MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
@ -667,13 +678,17 @@
"BODY": { "BODY": {
"TEAM_AVAILABILITY": { "TEAM_AVAILABILITY": {
"ONLINE": "We are Online", "ONLINE": "We are Online",
"OFFLINE": "We are away at the moment" "OFFLINE": "Estem fora en aquest moment"
}, },
"USER_MESSAGE": "Hi", "USER_MESSAGE": "Hi",
"AGENT_MESSAGE": "Hello" "AGENT_MESSAGE": "Hello"
}, },
"BRANDING_TEXT": "Desenvolupat per Chatwoot", "BRANDING_TEXT": "Desenvolupat per Chatwoot",
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};" "SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"OTHER_PROVIDERS": "Other Providers"
} }
} }
} }

View File

@ -14,7 +14,9 @@
"CONVERSATION_UPDATED": "Conversation Updated", "CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message created", "MESSAGE_CREATED": "Message created",
"MESSAGE_UPDATED": "Message updated", "MESSAGE_UPDATED": "Message updated",
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user" "WEBWIDGET_TRIGGERED": "Live chat widget opened by the user",
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated"
} }
}, },
"END_POINT": { "END_POINT": {

View File

@ -170,7 +170,8 @@
}, },
"FILE_BUBBLE": { "FILE_BUBBLE": {
"DOWNLOAD": "Descarrega", "DOWNLOAD": "Descarrega",
"UPLOADING": "S'està carregant..." "UPLOADING": "S'està carregant...",
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
}, },
"LOCATION_BUBBLE": { "LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map" "SEE_ON_MAP": "See on map"

View File

@ -1,10 +1,10 @@
{ {
"REGISTER": { "REGISTER": {
"TRY_WOOT": "Registra un compte", "TRY_WOOT": "Create an account",
"TITLE": "Registre", "TITLE": "Registre",
"TESTIMONIAL_HEADER": "All it takes is one step to move forward", "TESTIMONIAL_HEADER": "All it takes is one step to move forward",
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.", "TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
"TERMS_ACCEPT": "En registrar-vos, esteu dacord amb el nostre <a href=\"https://www.chatwoot.com/terms\">T & C</a> i <a href=\"https://www.chatwoot.com/privacy-policy\">Polítiques de Privadesa</a>", "TERMS_ACCEPT": "By creating an account, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"COMPANY_NAME": { "COMPANY_NAME": {
"LABEL": "Company name", "LABEL": "Company name",
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises", "PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
@ -35,7 +35,7 @@
"SUCCESS_MESSAGE": "Registrat correctament", "SUCCESS_MESSAGE": "Registrat correctament",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant" "ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
}, },
"SUBMIT": "Envia", "SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Ja tens un compte?" "HAVE_AN_ACCOUNT": "Ja tens un compte?"
} }
} }

View File

@ -30,6 +30,9 @@
}, },
"snoozed": { "snoozed": {
"TEXT": "Odložené" "TEXT": "Odložené"
},
"all": {
"TEXT": "Vše"
} }
}, },
"ATTACHMENTS": { "ATTACHMENTS": {

View File

@ -15,6 +15,7 @@
"INITIATED_FROM": "Zahájeno od", "INITIATED_FROM": "Zahájeno od",
"INITIATED_AT": "Zahájeno v", "INITIATED_AT": "Zahájeno v",
"IP_ADDRESS": "IP adresa", "IP_ADDRESS": "IP adresa",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message", "NEW_MESSAGE": "New message",
"CONVERSATIONS": { "CONVERSATIONS": {
"NO_RECORDS_FOUND": "K tomuto kontaktu nejsou přiřazeny žádné předchozí konverzace.", "NO_RECORDS_FOUND": "K tomuto kontaktu nejsou přiřazeny žádné předchozí konverzace.",
@ -206,6 +207,7 @@
"PHONE_NUMBER": "Telefonní číslo", "PHONE_NUMBER": "Telefonní číslo",
"CONVERSATIONS": "Konverzace", "CONVERSATIONS": "Konverzace",
"LAST_ACTIVITY": "Poslední aktivita", "LAST_ACTIVITY": "Poslední aktivita",
"CREATED_AT": "Vytvořeno",
"COUNTRY": "Země", "COUNTRY": "Země",
"CITY": "Město", "CITY": "Město",
"SOCIAL_PROFILES": "Sociální profily", "SOCIAL_PROFILES": "Sociální profily",

View File

@ -35,6 +35,7 @@
"REMOVE_SELECTION": "Odstranit výběr", "REMOVE_SELECTION": "Odstranit výběr",
"DOWNLOAD": "Stáhnout", "DOWNLOAD": "Stáhnout",
"UNKNOWN_FILE_TYPE": "Neznámý soubor", "UNKNOWN_FILE_TYPE": "Neznámý soubor",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Nahrávání příloh...", "UPLOADING_ATTACHMENTS": "Nahrávání příloh...",
"SUCCESS_DELETE_MESSAGE": "Zpráva byla úspěšně smazána", "SUCCESS_DELETE_MESSAGE": "Zpráva byla úspěšně smazána",
"FAIL_DELETE_MESSSAGE": "Zpráva se nepodařilo odstranit! Zkuste to znovu", "FAIL_DELETE_MESSSAGE": "Zpráva se nepodařilo odstranit! Zkuste to znovu",
@ -132,6 +133,14 @@
"PLACEHOLDER": "E-maily oddělené čárkami", "PLACEHOLDER": "E-maily oddělené čárkami",
"ERROR": "Zadejte prosím platnou e-mailovou adresu" "ERROR": "Zadejte prosím platnou e-mailovou adresu"
} }
},
"UNDEFINED_VARIABLES": {
"TITLE": "Undefined variables",
"MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
"CONFIRM": {
"YES": "Poslat",
"CANCEL": "Zrušit"
}
} }
}, },
"VISIBLE_TO_AGENTS": "Soukromá poznámka: Viditelné pouze pro vás a váš tým", "VISIBLE_TO_AGENTS": "Soukromá poznámka: Viditelné pouze pro vás a váš tým",

View File

@ -53,10 +53,6 @@
"ENABLE": "Create conversations from mentioned Tweets" "ENABLE": "Create conversations from mentioned Tweets"
} }
}, },
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": { "WEBSITE_CHANNEL": {
"TITLE": "Kanál webové stránky", "TITLE": "Kanál webové stránky",
"DESC": "Vytvořte si kanál pro vaše webové stránky a začněte podporovat své zákazníky prostřednictvím našeho widgetu.", "DESC": "Vytvořte si kanál pro vaše webové stránky a začněte podporovat své zákazníky prostřednictvím našeho widgetu.",
@ -348,6 +344,17 @@
"FINISH": { "FINISH": {
"TITLE": "Nalezeno to!", "TITLE": "Nalezeno to!",
"DESC": "Úspěšně jste dokončili integraci vaší facebookové stránky s Chatwootem. Až příště přijde zákaznická zpráva, konverzace se automaticky objeví ve vaší schránce.<br>Poskytujeme vám také widget skript, který můžete snadno přidat na vaše webové stránky. Jakmile je toto aktivní na vašich webových stránkách, zákazníci vám mohou posílat zprávy přímo z vašich webových stránek bez pomoci externího nástroje a konverzace se objeví přímo zde, na Chatwoot.<br>Skvěle, co? No, určitě se pokusíme být :)" "DESC": "Úspěšně jste dokončili integraci vaší facebookové stránky s Chatwootem. Až příště přijde zákaznická zpráva, konverzace se automaticky objeví ve vaší schránce.<br>Poskytujeme vám také widget skript, který můžete snadno přidat na vaše webové stránky. Jakmile je toto aktivní na vašich webových stránkách, zákazníci vám mohou posílat zprávy přímo z vašich webových stránek bez pomoci externího nástroje a konverzace se objeví přímo zde, na Chatwoot.<br>Skvěle, co? No, určitě se pokusíme být :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
} }
}, },
"DETAILS": { "DETAILS": {
@ -465,7 +472,11 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved", "ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.", "ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.", "WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key" "WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Aktualizovat"
}, },
"AUTO_ASSIGNMENT": { "AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit", "MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
@ -674,6 +685,10 @@
}, },
"BRANDING_TEXT": "Powered by Chatwoot", "BRANDING_TEXT": "Powered by Chatwoot",
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};" "SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"OTHER_PROVIDERS": "Other Providers"
} }
} }
} }

View File

@ -14,7 +14,9 @@
"CONVERSATION_UPDATED": "Conversation Updated", "CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message created", "MESSAGE_CREATED": "Message created",
"MESSAGE_UPDATED": "Message updated", "MESSAGE_UPDATED": "Message updated",
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user" "WEBWIDGET_TRIGGERED": "Live chat widget opened by the user",
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated"
} }
}, },
"END_POINT": { "END_POINT": {

View File

@ -170,7 +170,8 @@
}, },
"FILE_BUBBLE": { "FILE_BUBBLE": {
"DOWNLOAD": "Stáhnout", "DOWNLOAD": "Stáhnout",
"UPLOADING": "Nahrávání..." "UPLOADING": "Nahrávání...",
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
}, },
"LOCATION_BUBBLE": { "LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map" "SEE_ON_MAP": "See on map"

View File

@ -1,10 +1,10 @@
{ {
"REGISTER": { "REGISTER": {
"TRY_WOOT": "Registrovat účet", "TRY_WOOT": "Create an account",
"TITLE": "Registrovat se", "TITLE": "Registrovat se",
"TESTIMONIAL_HEADER": "All it takes is one step to move forward", "TESTIMONIAL_HEADER": "All it takes is one step to move forward",
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.", "TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
"TERMS_ACCEPT": "Registrací souhlasíte s našimi <a href=\"https://www.chatwoot.com/terms\">T & C</a> a <a href=\"https://www.chatwoot.com/privacy-policy\">Zásadami ochrany osobních údajů</a>", "TERMS_ACCEPT": "By creating an account, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"COMPANY_NAME": { "COMPANY_NAME": {
"LABEL": "Company name", "LABEL": "Company name",
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises", "PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
@ -35,7 +35,7 @@
"SUCCESS_MESSAGE": "Registrace byla úspěšná", "SUCCESS_MESSAGE": "Registrace byla úspěšná",
"ERROR_MESSAGE": "Nelze se připojit k Woot serveru, opakujte akci později" "ERROR_MESSAGE": "Nelze se připojit k Woot serveru, opakujte akci později"
}, },
"SUBMIT": "Odeslat", "SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Máte již účet?" "HAVE_AN_ACCOUNT": "Máte již účet?"
} }
} }

View File

@ -30,6 +30,9 @@
}, },
"snoozed": { "snoozed": {
"TEXT": "Udsat" "TEXT": "Udsat"
},
"all": {
"TEXT": "Alle"
} }
}, },
"ATTACHMENTS": { "ATTACHMENTS": {

View File

@ -15,6 +15,7 @@
"INITIATED_FROM": "Startet fra", "INITIATED_FROM": "Startet fra",
"INITIATED_AT": "Startet fra", "INITIATED_AT": "Startet fra",
"IP_ADDRESS": "Ip Adresse", "IP_ADDRESS": "Ip Adresse",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "Ny besked", "NEW_MESSAGE": "Ny besked",
"CONVERSATIONS": { "CONVERSATIONS": {
"NO_RECORDS_FOUND": "Der er ingen tidligere samtaler tilknyttet denne kontakt.", "NO_RECORDS_FOUND": "Der er ingen tidligere samtaler tilknyttet denne kontakt.",
@ -206,6 +207,7 @@
"PHONE_NUMBER": "Telefonnummer", "PHONE_NUMBER": "Telefonnummer",
"CONVERSATIONS": "Samtaler", "CONVERSATIONS": "Samtaler",
"LAST_ACTIVITY": "Sidste Aktivitet", "LAST_ACTIVITY": "Sidste Aktivitet",
"CREATED_AT": "Oprettet Den",
"COUNTRY": "Land", "COUNTRY": "Land",
"CITY": "By", "CITY": "By",
"SOCIAL_PROFILES": "Social Profiles", "SOCIAL_PROFILES": "Social Profiles",

View File

@ -35,6 +35,7 @@
"REMOVE_SELECTION": "Fjern Markering", "REMOVE_SELECTION": "Fjern Markering",
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
"UNKNOWN_FILE_TYPE": "Ukendt Fil", "UNKNOWN_FILE_TYPE": "Ukendt Fil",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Uploader vedhæftede filer...", "UPLOADING_ATTACHMENTS": "Uploader vedhæftede filer...",
"SUCCESS_DELETE_MESSAGE": "Besked slettet", "SUCCESS_DELETE_MESSAGE": "Besked slettet",
"FAIL_DELETE_MESSSAGE": "Kunne ikke slette beskeden! Prøv igen", "FAIL_DELETE_MESSSAGE": "Kunne ikke slette beskeden! Prøv igen",
@ -132,6 +133,14 @@
"PLACEHOLDER": "E-mails adskilt af kommaer", "PLACEHOLDER": "E-mails adskilt af kommaer",
"ERROR": "Indtast venligst gyldige e-mailadresser" "ERROR": "Indtast venligst gyldige e-mailadresser"
} }
},
"UNDEFINED_VARIABLES": {
"TITLE": "Undefined variables",
"MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
"CONFIRM": {
"YES": "Send",
"CANCEL": "Annuller"
}
} }
}, },
"VISIBLE_TO_AGENTS": "Privat Note: Kun synlig for dig og dit team", "VISIBLE_TO_AGENTS": "Privat Note: Kun synlig for dig og dit team",

View File

@ -53,10 +53,6 @@
"ENABLE": "Opret samtaler fra nævnte Tweets" "ENABLE": "Opret samtaler fra nævnte Tweets"
} }
}, },
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": { "WEBSITE_CHANNEL": {
"TITLE": "Hjemmesidekanal", "TITLE": "Hjemmesidekanal",
"DESC": "Opret en kanal til din hjemmeside og begynde at supporte dine kunder via vores hjemmeside widget.", "DESC": "Opret en kanal til din hjemmeside og begynde at supporte dine kunder via vores hjemmeside widget.",
@ -348,6 +344,17 @@
"FINISH": { "FINISH": {
"TITLE": "Du klarede det!", "TITLE": "Du klarede det!",
"DESC": "Du er færdig med at integrere din Facebook-side med Chatwoot. Næste gang en kunde sender en meddelelse til din side, vil samtalen automatisk dukke op i din indbakke.<br>Vi giver dig også et widget script, som du nemt kan tilføje til din hjemmeside. Når dette er live på din hjemmeside, kan kunder sende dig en besked direkte fra din hjemmeside uden hjælp fra et eksternt værktøj, og samtalen vises lige her, på Chatwoot.<br>Cool, var? Nå ikke, vi forsøge at være :)" "DESC": "Du er færdig med at integrere din Facebook-side med Chatwoot. Næste gang en kunde sender en meddelelse til din side, vil samtalen automatisk dukke op i din indbakke.<br>Vi giver dig også et widget script, som du nemt kan tilføje til din hjemmeside. Når dette er live på din hjemmeside, kan kunder sende dig en besked direkte fra din hjemmeside uden hjælp fra et eksternt værktøj, og samtalen vises lige her, på Chatwoot.<br>Cool, var? Nå ikke, vi forsøge at være :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
} }
}, },
"DETAILS": { "DETAILS": {
@ -465,7 +472,11 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Tillad beskeder efter samtalen løst", "ALLOW_MESSAGES_AFTER_RESOLVED": "Tillad beskeder efter samtalen løst",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Tillad slutbrugere at sende beskeder, selv efter samtalen er løst.", "ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Tillad slutbrugere at sende beskeder, selv efter samtalen er løst.",
"WHATSAPP_SECTION_SUBHEADER": "Denne API-nøgle bruges til integration med WhatsApp API'erne.", "WHATSAPP_SECTION_SUBHEADER": "Denne API-nøgle bruges til integration med WhatsApp API'erne.",
"WHATSAPP_SECTION_TITLE": "API Nøgle" "WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Nøgle",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Opdater"
}, },
"AUTO_ASSIGNMENT": { "AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "Grænse for automatisk tildeling", "MAX_ASSIGNMENT_LIMIT": "Grænse for automatisk tildeling",
@ -674,6 +685,10 @@
}, },
"BRANDING_TEXT": "Drevet af Chatwoot", "BRANDING_TEXT": "Drevet af Chatwoot",
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};" "SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"OTHER_PROVIDERS": "Other Providers"
} }
} }
} }

View File

@ -14,7 +14,9 @@
"CONVERSATION_UPDATED": "Samtale Opdateret", "CONVERSATION_UPDATED": "Samtale Opdateret",
"MESSAGE_CREATED": "Besked oprettet", "MESSAGE_CREATED": "Besked oprettet",
"MESSAGE_UPDATED": "Besked opdateret", "MESSAGE_UPDATED": "Besked opdateret",
"WEBWIDGET_TRIGGERED": "Live chat widget åbnet af brugeren" "WEBWIDGET_TRIGGERED": "Live chat widget åbnet af brugeren",
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated"
} }
}, },
"END_POINT": { "END_POINT": {

View File

@ -170,7 +170,8 @@
}, },
"FILE_BUBBLE": { "FILE_BUBBLE": {
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
"UPLOADING": "Uploader..." "UPLOADING": "Uploader...",
"INSTAGRAM_STORY_UNAVAILABLE": "Denne historie er ikke længere tilgængelig."
}, },
"LOCATION_BUBBLE": { "LOCATION_BUBBLE": {
"SEE_ON_MAP": "Se på kort" "SEE_ON_MAP": "Se på kort"

View File

@ -1,10 +1,10 @@
{ {
"REGISTER": { "REGISTER": {
"TRY_WOOT": "Registrer en konto", "TRY_WOOT": "Create an account",
"TITLE": "Registrer", "TITLE": "Registrer",
"TESTIMONIAL_HEADER": "Alt, hvad der skal til, er blot et skridt for at komme videre", "TESTIMONIAL_HEADER": "Alt, hvad der skal til, er blot et skridt for at komme videre",
"TESTIMONIAL_CONTENT": "Du er et skridt fra at engagere dine kunder, fastholde dem og finde nye kunder.", "TESTIMONIAL_CONTENT": "Du er et skridt fra at engagere dine kunder, fastholde dem og finde nye kunder.",
"TERMS_ACCEPT": "Ved at tilmelde dig, accepterer du vores <a href=\"https://www.chatwoot.com/terms\">T & C</a> og <a href=\"https://www.chatwoot.com/privacy-policy\">Privatlivspolitik</a>", "TERMS_ACCEPT": "By creating an account, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"COMPANY_NAME": { "COMPANY_NAME": {
"LABEL": "Firmanavn", "LABEL": "Firmanavn",
"PLACEHOLDER": "Indtast dit firmanavn, fx: Wayne Enterprises", "PLACEHOLDER": "Indtast dit firmanavn, fx: Wayne Enterprises",
@ -35,7 +35,7 @@
"SUCCESS_MESSAGE": "Registrering Succesfuld", "SUCCESS_MESSAGE": "Registrering Succesfuld",
"ERROR_MESSAGE": "Kunne ikke oprette forbindelse til Woot Server, Prøv igen senere" "ERROR_MESSAGE": "Kunne ikke oprette forbindelse til Woot Server, Prøv igen senere"
}, },
"SUBMIT": "Send", "SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Har du allerede en konto?" "HAVE_AN_ACCOUNT": "Har du allerede en konto?"
} }
} }

View File

@ -24,11 +24,11 @@
"TITLE": "Agenten-Bot auswählen", "TITLE": "Agenten-Bot auswählen",
"DESC": "Sie können einen Agenten-Bot aus der Liste in diesen Posteingang setzen. Der Bot kann die Unterhaltung anfangs bearbeiten und bei Bedarf an einen Agenten übertragen.", "DESC": "Sie können einen Agenten-Bot aus der Liste in diesen Posteingang setzen. Der Bot kann die Unterhaltung anfangs bearbeiten und bei Bedarf an einen Agenten übertragen.",
"SUBMIT": "Aktualisieren", "SUBMIT": "Aktualisieren",
"DISCONNECT": "Disconnect Bot", "DISCONNECT": "Bot trennen",
"SUCCESS_MESSAGE": "Agenten-Bot erfolgreich aktualisiert", "SUCCESS_MESSAGE": "Agenten-Bot erfolgreich aktualisiert",
"DISCONNECTED_SUCCESS_MESSAGE": "Successfully disconnected the agent bot", "DISCONNECTED_SUCCESS_MESSAGE": "Agent-Bot erfolgreich getrennt",
"ERROR_MESSAGE": "Konnte den Agenten-Bot nicht aktualisieren, bitte versuchen Sie es später erneut", "ERROR_MESSAGE": "Konnte den Agenten-Bot nicht aktualisieren, bitte versuchen Sie es später erneut",
"DISCONNECTED_ERROR_MESSAGE": "Could not disconnect the agent bot, please try again later", "DISCONNECTED_ERROR_MESSAGE": "Konnte den Agenten-Bot nicht trennen, bitte versuchen Sie es später erneut",
"SELECT_PLACEHOLDER": "Bot auswählen" "SELECT_PLACEHOLDER": "Bot auswählen"
}, },
"ADD": { "ADD": {
@ -52,7 +52,7 @@
"DESCRIPTION": "Sind Sie sicher, dass Sie diesen Bot löschen wollen? Diese Aktion kann nicht rückgängig gemacht werden", "DESCRIPTION": "Sind Sie sicher, dass Sie diesen Bot löschen wollen? Diese Aktion kann nicht rückgängig gemacht werden",
"API": { "API": {
"SUCCESS_MESSAGE": "Bot erfolgreich gelöscht", "SUCCESS_MESSAGE": "Bot erfolgreich gelöscht",
"ERROR_MESSAGE": "Could not able to delete bot, Please try again later" "ERROR_MESSAGE": "Konnte den Bot nicht löschen, bitte versuche es später erneut"
} }
}, },
"EDIT": { "EDIT": {

View File

@ -43,7 +43,7 @@
}, },
"API": { "API": {
"SUCCESS_MESSAGE": "Benutzerdefiniertes Attribut erfolgreich hinzugefügt!", "SUCCESS_MESSAGE": "Benutzerdefiniertes Attribut erfolgreich hinzugefügt!",
"ERROR_MESSAGE": "Could not create a Custom Attribute. Please try again later." "ERROR_MESSAGE": "Konnte kein benutzerdefiniertes Attribut erstellen, bitte versuchen Sie es später erneut."
} }
}, },
"DELETE": { "DELETE": {

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