Merge branch 'release/2.14.0'
This commit is contained in:
@@ -7,7 +7,7 @@ defaults: &defaults
|
||||
working_directory: ~/build
|
||||
docker:
|
||||
# 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
|
||||
# CircleCI maintains a library of pre-built images
|
||||
@@ -38,6 +38,18 @@ jobs:
|
||||
name: Which bundler?
|
||||
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
|
||||
# Load installed gems from cache if possible, bundle install then save cache
|
||||
# Multiple caches are used to increase the chance of a cache hit
|
||||
@@ -193,4 +205,3 @@ workflows:
|
||||
- upload-coverage:
|
||||
requires:
|
||||
- build
|
||||
|
||||
|
||||
@@ -131,6 +131,11 @@ TWITTER_ENVIRONMENT=
|
||||
SLACK_CLIENT_ID=
|
||||
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
|
||||
## Mobile app env variables
|
||||
IOS_APP_ID=L7YLMN4634.com.chatwoot.app
|
||||
|
||||
1
.github/workflows/lock.yml
vendored
1
.github/workflows/lock.yml
vendored
@@ -19,6 +19,7 @@ concurrency:
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'chatwoot/chatwoot' }}
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v3
|
||||
with:
|
||||
|
||||
1
.github/workflows/run_foss_spec.yml
vendored
1
.github/workflows/run_foss_spec.yml
vendored
@@ -47,7 +47,6 @@ jobs:
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.0.4 # Not needed with a .ruby-version file
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- name: yarn
|
||||
|
||||
28
.github/workflows/stale.yml
vendored
Normal file
28
.github/workflows/stale.yml
vendored
Normal 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
4
.gitignore
vendored
@@ -62,3 +62,7 @@ test/cypress/videos/*
|
||||
/config/*.enc
|
||||
|
||||
.vscode/settings.json
|
||||
|
||||
# yalc for local testing
|
||||
.yalc
|
||||
yalc.lock
|
||||
@@ -1,5 +1,11 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
npm run eslint
|
||||
bundle exec rubocop -a
|
||||
git add
|
||||
|
||||
# lint js and vue files
|
||||
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
|
||||
|
||||
@@ -17,7 +17,6 @@ Metrics/ClassLength:
|
||||
- 'app/builders/messages/facebook/message_builder.rb'
|
||||
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
|
||||
- 'app/listeners/action_cable_listener.rb'
|
||||
- 'app/models/conversation.rb'
|
||||
RSpec/ExampleLength:
|
||||
Max: 25
|
||||
Style/Documentation:
|
||||
@@ -86,6 +85,10 @@ Style/ClassAndModuleChildren:
|
||||
- 'config/application.rb'
|
||||
Style/MapToHash:
|
||||
Enabled: false
|
||||
Style/HashSyntax:
|
||||
Enabled: true
|
||||
EnforcedStyle: no_mixed_keys
|
||||
EnforcedShorthandSyntax: never
|
||||
RSpec/NestedGroups:
|
||||
Enabled: true
|
||||
Max: 4
|
||||
@@ -159,7 +162,7 @@ RSpec/NamedSubject:
|
||||
Enabled: false
|
||||
# we should bring this down
|
||||
RSpec/MultipleMemoizedHelpers:
|
||||
Max: 12
|
||||
Max: 14
|
||||
|
||||
AllCops:
|
||||
NewCops: enable
|
||||
@@ -184,4 +187,3 @@ AllCops:
|
||||
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
|
||||
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
|
||||
- db/migrate/20220809104508_revert_cascading_indexes.rb
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ Naming/AccessorMethodName:
|
||||
- 'app/controllers/api/v1/accounts_controller.rb'
|
||||
- 'app/controllers/api/v1/callbacks_controller.rb'
|
||||
- 'app/controllers/api/v1/conversations_controller.rb'
|
||||
- 'app/controllers/passwords_controller.rb'
|
||||
|
||||
# Offense count: 9
|
||||
# Configuration parameters: EnforcedStyleForLeadingUnderscores.
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.0.4
|
||||
3.1.3
|
||||
|
||||
29
Gemfile
29
Gemfile
@@ -1,10 +1,10 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.0.4'
|
||||
ruby '3.1.3'
|
||||
|
||||
##-- base gems for rails --##
|
||||
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
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
@@ -39,6 +39,10 @@ gem 'rack-attack'
|
||||
gem 'down', '~> 5.0'
|
||||
# authentication type to fetch and send mail over oauth2.0
|
||||
gem 'gmail_xoauth'
|
||||
# Prevent CSV injection
|
||||
gem 'csv-safe'
|
||||
# Support message translation
|
||||
gem 'google-cloud-translate'
|
||||
|
||||
##-- for active storage --##
|
||||
gem 'aws-sdk-s3', require: false
|
||||
@@ -98,16 +102,16 @@ gem 'newrelic_rpm'
|
||||
gem 'scout_apm'
|
||||
gem 'sentry-rails', '~> 5.3', '>= 5.3.1'
|
||||
gem 'sentry-ruby', '~> 5.3'
|
||||
gem 'sentry-sidekiq', '~> 5.3'
|
||||
gem 'sentry-sidekiq', '~> 5.3', '>= 5.3.1'
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '~> 6.4.0'
|
||||
gem 'sidekiq', '~> 6.4.2'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '~> 1.3'
|
||||
gem 'sidekiq-cron', '~> 1.6', '>= 1.6.0'
|
||||
|
||||
##-- Push notification service --##
|
||||
gem 'fcm'
|
||||
gem 'webpush'
|
||||
gem 'web-push'
|
||||
|
||||
##-- geocoding / parse location from ip --##
|
||||
# http://www.rubygeocoder.com/
|
||||
@@ -137,6 +141,11 @@ gem 'stripe'
|
||||
## to populate db with sample data
|
||||
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
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
@@ -157,7 +166,7 @@ end
|
||||
|
||||
group :test do
|
||||
# Cypress in rails.
|
||||
gem 'cypress-on-rails', '~> 1.0'
|
||||
gem 'cypress-on-rails', '~> 1.13', '>= 1.13.1'
|
||||
# fast cleaning of database
|
||||
gem 'database_cleaner'
|
||||
# mock http calls
|
||||
@@ -190,5 +199,11 @@ group :development, :test do
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
# worked with microsoft refresh token
|
||||
gem 'omniauth-oauth2'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth'
|
||||
gem 'omniauth-google-oauth2'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
||||
|
||||
189
Gemfile.lock
189
Gemfile.lock
@@ -9,63 +9,63 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
actioncable (6.1.7.1)
|
||||
actionpack (= 6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activejob (= 6.1.6.1)
|
||||
activerecord (= 6.1.6.1)
|
||||
activestorage (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
actionmailbox (6.1.7.1)
|
||||
actionpack (= 6.1.7.1)
|
||||
activejob (= 6.1.7.1)
|
||||
activerecord (= 6.1.7.1)
|
||||
activestorage (= 6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
mail (>= 2.7.1)
|
||||
actionmailer (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
actionview (= 6.1.6.1)
|
||||
activejob (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
actionmailer (6.1.7.1)
|
||||
actionpack (= 6.1.7.1)
|
||||
actionview (= 6.1.7.1)
|
||||
activejob (= 6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (6.1.6.1)
|
||||
actionview (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
actionpack (6.1.7.1)
|
||||
actionview (= 6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
rack (~> 2.0, >= 2.0.9)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activerecord (= 6.1.6.1)
|
||||
activestorage (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
actiontext (6.1.7.1)
|
||||
actionpack (= 6.1.7.1)
|
||||
activerecord (= 6.1.7.1)
|
||||
activestorage (= 6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
actionview (6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
activejob (6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
activerecord (6.1.6.1)
|
||||
activemodel (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
activemodel (6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
activerecord (6.1.7.1)
|
||||
activemodel (= 6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
activerecord-import (1.4.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activejob (= 6.1.6.1)
|
||||
activerecord (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
activestorage (6.1.7.1)
|
||||
actionpack (= 6.1.7.1)
|
||||
activejob (= 6.1.7.1)
|
||||
activerecord (= 6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (6.1.6.1)
|
||||
activesupport (6.1.7.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@@ -73,8 +73,8 @@ GEM
|
||||
zeitwerk (~> 2.3)
|
||||
acts-as-taggable-on (9.0.1)
|
||||
activerecord (>= 6.0, < 7.1)
|
||||
addressable (2.8.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
addressable (2.8.1)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
administrate (0.17.0)
|
||||
actionpack (>= 5.0)
|
||||
actionview (>= 5.0)
|
||||
@@ -135,12 +135,13 @@ GEM
|
||||
byebug (11.1.3)
|
||||
climate_control (1.1.1)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.6)
|
||||
commonmarker (0.23.7)
|
||||
concurrent-ruby (1.1.10)
|
||||
connection_pool (2.2.5)
|
||||
crack (0.4.5)
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv-safe (3.1.1)
|
||||
cypress-on-rails (1.13.1)
|
||||
rack
|
||||
database_cleaner (2.0.1)
|
||||
@@ -247,7 +248,7 @@ GEM
|
||||
grpc (~> 1.36)
|
||||
geocoder (1.8.0)
|
||||
gli (2.21.0)
|
||||
globalid (1.0.0)
|
||||
globalid (1.0.1)
|
||||
activesupport (>= 5.0)
|
||||
gmail_xoauth (0.4.2)
|
||||
oauth (>= 0.3.6)
|
||||
@@ -288,6 +289,19 @@ GEM
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
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-x86_64-darwin)
|
||||
google-protobuf (3.21.7-x86_64-linux)
|
||||
@@ -323,7 +337,7 @@ GEM
|
||||
hana (1.3.7)
|
||||
hashdiff (1.0.1)
|
||||
hashie (5.0.0)
|
||||
hkdf (0.3.0)
|
||||
hkdf (1.0.0)
|
||||
html2text (0.2.1)
|
||||
nokogiri (~> 1.6)
|
||||
http (5.1.0)
|
||||
@@ -360,7 +374,7 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
jwt (2.4.1)
|
||||
jwt (2.5.0)
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
@@ -426,8 +440,16 @@ GEM
|
||||
multipart-post (2.2.3)
|
||||
net-http-persistent (4.0.1)
|
||||
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)
|
||||
newrelic_rpm (8.9.0)
|
||||
newrelic_rpm (8.15.0)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.13.10)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
@@ -450,9 +472,18 @@ GEM
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
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)
|
||||
oauth2 (>= 1.4, < 3)
|
||||
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)
|
||||
os (1.1.4)
|
||||
parallel (1.22.1)
|
||||
@@ -469,14 +500,14 @@ GEM
|
||||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (4.0.7)
|
||||
public_suffix (5.0.1)
|
||||
puma (5.6.4)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.2.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.6.1)
|
||||
rack (2.2.4)
|
||||
rack (2.2.6.2)
|
||||
rack-attack (6.6.1)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-cors (1.1.1)
|
||||
@@ -488,29 +519,29 @@ GEM
|
||||
rack-test (2.0.2)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rails (6.1.6.1)
|
||||
actioncable (= 6.1.6.1)
|
||||
actionmailbox (= 6.1.6.1)
|
||||
actionmailer (= 6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
actiontext (= 6.1.6.1)
|
||||
actionview (= 6.1.6.1)
|
||||
activejob (= 6.1.6.1)
|
||||
activemodel (= 6.1.6.1)
|
||||
activerecord (= 6.1.6.1)
|
||||
activestorage (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
rails (6.1.7.1)
|
||||
actioncable (= 6.1.7.1)
|
||||
actionmailbox (= 6.1.7.1)
|
||||
actionmailer (= 6.1.7.1)
|
||||
actionpack (= 6.1.7.1)
|
||||
actiontext (= 6.1.7.1)
|
||||
actionview (= 6.1.7.1)
|
||||
activejob (= 6.1.7.1)
|
||||
activemodel (= 6.1.7.1)
|
||||
activerecord (= 6.1.7.1)
|
||||
activestorage (= 6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 6.1.6.1)
|
||||
railties (= 6.1.7.1)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.4.4)
|
||||
loofah (~> 2.19, >= 2.19.1)
|
||||
railties (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
railties (6.1.7.1)
|
||||
actionpack (= 6.1.7.1)
|
||||
activesupport (= 6.1.7.1)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
@@ -662,6 +693,7 @@ GEM
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
timeout (0.3.1)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (5.68.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
@@ -693,7 +725,11 @@ GEM
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.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)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -702,9 +738,6 @@ GEM
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
webpush (1.1.0)
|
||||
hkdf (~> 0.2)
|
||||
jwt (~> 2.0)
|
||||
webrick (1.7.0)
|
||||
websocket-driver (0.7.5)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
@@ -741,7 +774,8 @@ DEPENDENCIES
|
||||
byebug
|
||||
climate_control
|
||||
commonmarker
|
||||
cypress-on-rails (~> 1.0)
|
||||
csv-safe
|
||||
cypress-on-rails (~> 1.13, >= 1.13.1)
|
||||
database_cleaner
|
||||
ddtrace
|
||||
devise
|
||||
@@ -761,6 +795,7 @@ DEPENDENCIES
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow
|
||||
google-cloud-storage
|
||||
google-cloud-translate
|
||||
groupdate
|
||||
haikunator
|
||||
hairtrigger
|
||||
@@ -779,8 +814,14 @@ DEPENDENCIES
|
||||
listen
|
||||
maxminddb
|
||||
mock_redis
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
newrelic_rpm
|
||||
omniauth
|
||||
omniauth-google-oauth2
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
pg
|
||||
pg_search
|
||||
procore-sift
|
||||
@@ -790,7 +831,7 @@ DEPENDENCIES
|
||||
rack-attack
|
||||
rack-cors
|
||||
rack-timeout
|
||||
rails (~> 6.1, >= 6.1.6.1)
|
||||
rails (~> 6.1, >= 6.1.7.1)
|
||||
redis
|
||||
redis-namespace
|
||||
responders
|
||||
@@ -805,10 +846,10 @@ DEPENDENCIES
|
||||
seed_dump
|
||||
sentry-rails (~> 5.3, >= 5.3.1)
|
||||
sentry-ruby (~> 5.3)
|
||||
sentry-sidekiq (~> 5.3)
|
||||
sentry-sidekiq (~> 5.3, >= 5.3.1)
|
||||
shoulda-matchers
|
||||
sidekiq (~> 6.4.0)
|
||||
sidekiq-cron (~> 1.3)
|
||||
sidekiq (~> 6.4.2)
|
||||
sidekiq-cron (~> 1.6, >= 1.6.0)
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client
|
||||
spring
|
||||
@@ -824,14 +865,14 @@ DEPENDENCIES
|
||||
uglifier
|
||||
valid_email2
|
||||
web-console
|
||||
web-push
|
||||
webmock
|
||||
webpacker (~> 5.4, >= 5.4.3)
|
||||
webpush
|
||||
wisper (= 2.0.0)
|
||||
working_hours
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.0.4p208
|
||||
ruby 3.1.3p185
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.16
|
||||
2.3.26
|
||||
|
||||
@@ -48,13 +48,25 @@ class Messages::MessageBuilder
|
||||
def process_emails
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails]
|
||||
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails]
|
||||
cc_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[:bcc_emails] = bcc_emails
|
||||
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
|
||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
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]
|
||||
|
||||
def index
|
||||
@portal_articles = @portal.articles
|
||||
@all_articles = @portal_articles.search(list_params)
|
||||
@articles_count = @all_articles.count
|
||||
@articles = @all_articles.page(@current_page)
|
||||
@articles = @all_articles.order_by_updated_at.page(@current_page)
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -23,7 +23,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
def show; end
|
||||
|
||||
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
|
||||
|
||||
def destroy
|
||||
@@ -31,6 +32,17 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
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
|
||||
|
||||
def fetch_article
|
||||
@@ -43,7 +55,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
|
||||
def article_params
|
||||
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: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
@@ -33,7 +33,10 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
|
||||
|
||||
def canned_responses
|
||||
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
|
||||
Current.account.canned_responses
|
||||
end
|
||||
|
||||
@@ -18,6 +18,24 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
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
|
||||
|
||||
def message
|
||||
@@ -29,6 +47,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -14,7 +14,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
|
||||
# Deprecated: This API will be removed in 2.7.0
|
||||
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
|
||||
|
||||
def campaigns
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
include ::FileTypeHelper
|
||||
|
||||
before_action :fetch_portal, except: [:index, :create]
|
||||
before_action :fetch_portal, except: [:index, :create, :attach_file]
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
@@ -48,7 +48,19 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
@@ -2,6 +2,24 @@ class ApiController < ApplicationController
|
||||
skip_before_action :set_current_user, only: [: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
|
||||
|
||||
@@ -3,25 +3,25 @@ module SwitchLocale
|
||||
|
||||
private
|
||||
|
||||
def switch_locale(&action)
|
||||
def switch_locale(&)
|
||||
# priority is for locale set in query string (mostly for widget/from js sdk)
|
||||
locale ||= locale_from_params
|
||||
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
|
||||
locale ||= locale_from_env_variable
|
||||
set_locale(locale, &action)
|
||||
set_locale(locale, &)
|
||||
end
|
||||
|
||||
def switch_locale_using_account_locale(&action)
|
||||
def switch_locale_using_account_locale(&)
|
||||
locale = locale_from_account(@current_account)
|
||||
set_locale(locale, &action)
|
||||
set_locale(locale, &)
|
||||
end
|
||||
|
||||
def set_locale(locale, &action)
|
||||
def set_locale(locale, &)
|
||||
# if locale is empty, use default_locale
|
||||
locale ||= I18n.default_locale
|
||||
# Ensure locale won't bleed into other requests
|
||||
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
|
||||
I18n.with_locale(locale, &action)
|
||||
I18n.with_locale(locale, &)
|
||||
end
|
||||
|
||||
def locale_from_params
|
||||
|
||||
@@ -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
|
||||
17
app/controllers/microsoft_controller.rb
Normal file
17
app/controllers/microsoft_controller.rb
Normal 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
|
||||
@@ -13,8 +13,7 @@ class Platform::Api::V1::UsersController < PlatformController
|
||||
end
|
||||
|
||||
def login
|
||||
encoded_email = ERB::Util.url_encode(@resource.email)
|
||||
render json: { url: "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
|
||||
render json: { url: @resource.generate_sso_link }
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -16,6 +16,7 @@ class Public::Api::V1::Portals::ArticlesController < PublicController
|
||||
|
||||
def set_article
|
||||
@article = @category.articles.find(params[:id])
|
||||
@article.increment_view_count
|
||||
@parsed_content = render_article_content(@article.content)
|
||||
end
|
||||
|
||||
|
||||
44
app/controllers/super_admin/instance_statuses_controller.rb
Normal file
44
app/controllers/super_admin/instance_statuses_controller.rb
Normal 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
|
||||
@@ -97,6 +97,8 @@ class ConversationFinder
|
||||
when 'mention'
|
||||
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
|
||||
@conversations = @conversations.where(id: conversation_ids)
|
||||
when 'participating'
|
||||
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
|
||||
when 'unattended'
|
||||
@conversations = @conversations.where(first_reply_created_at: nil)
|
||||
end
|
||||
|
||||
6
app/helpers/email_helper.rb
Normal file
6
app/helpers/email_helper.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
module EmailHelper
|
||||
def extract_domain_without_tld(email)
|
||||
domain = email.split('@').last
|
||||
domain.split('.').first
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,8 @@ module MessageFormatHelper
|
||||
include RegexHelper
|
||||
|
||||
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
|
||||
|
||||
def render_message_content(message_content)
|
||||
|
||||
@@ -46,6 +46,20 @@ class ArticlesAPI extends PortalsAPI {
|
||||
deleteArticle({ articleId, portalSlug }) {
|
||||
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();
|
||||
|
||||
@@ -80,6 +80,15 @@ class MessageApi extends ApiClient {
|
||||
params: { before },
|
||||
});
|
||||
}
|
||||
|
||||
translateMessage(conversationId, messageId, targetLanguage) {
|
||||
return axios.post(
|
||||
`${this.url}/${conversationId}/messages/${messageId}/translate`,
|
||||
{
|
||||
target_language: targetLanguage,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageApi();
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
background-color: var(--black-transparent);
|
||||
border-radius: $space-smaller;
|
||||
font-size: $font-size-mini;
|
||||
max-width: 15rem;
|
||||
max-width: var(--space-giga);
|
||||
padding: $space-smaller $space-small;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
@@ -151,13 +151,14 @@
|
||||
</div>
|
||||
<woot-modal
|
||||
:show.sync="showAdvancedFilters"
|
||||
:on-close="onToggleAdvanceFiltersModal"
|
||||
:on-close="closeAdvanceFiltersModal"
|
||||
size="medium"
|
||||
>
|
||||
<conversation-advanced-filter
|
||||
v-if="showAdvancedFilters"
|
||||
:initial-filter-types="advancedFilterTypes"
|
||||
:on-close="onToggleAdvanceFiltersModal"
|
||||
:initial-applied-filters="appliedFilter"
|
||||
:on-close="closeAdvanceFiltersModal"
|
||||
@applyFilter="onApplyFilter"
|
||||
/>
|
||||
</woot-modal>
|
||||
@@ -181,6 +182,7 @@ import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomView
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import filterMixin from 'shared/mixins/filterMixin';
|
||||
|
||||
import {
|
||||
hasPressedAltAndJKey,
|
||||
@@ -202,7 +204,13 @@ export default {
|
||||
DeleteCustomViews,
|
||||
ConversationBulkActions,
|
||||
},
|
||||
mixins: [timeMixin, conversationMixin, eventListenerMixins, alertMixin],
|
||||
mixins: [
|
||||
timeMixin,
|
||||
conversationMixin,
|
||||
eventListenerMixins,
|
||||
alertMixin,
|
||||
filterMixin,
|
||||
],
|
||||
props: {
|
||||
conversationInbox: {
|
||||
type: [String, Number],
|
||||
@@ -248,11 +256,13 @@ export default {
|
||||
selectedConversations: [],
|
||||
selectedInboxes: [],
|
||||
isContextMenuOpen: false,
|
||||
appliedFilter: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
currentUser: 'getCurrentUser',
|
||||
chatLists: 'getAllConversations',
|
||||
mineChatsList: 'getMineChats',
|
||||
allChatList: 'getAllStatusChats',
|
||||
@@ -288,6 +298,13 @@ export default {
|
||||
!this.chatListLoading
|
||||
);
|
||||
},
|
||||
currentUserDetails() {
|
||||
const { id, name } = this.currentUser;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
};
|
||||
},
|
||||
assigneeTabItems() {
|
||||
const ASSIGNEE_TYPE_TAB_KEYS = {
|
||||
me: 'mineCount',
|
||||
@@ -360,6 +377,9 @@ export default {
|
||||
if (this.conversationType === 'mention') {
|
||||
return this.$t('CHAT_LIST.MENTION_HEADING');
|
||||
}
|
||||
if (this.conversationType === 'participating') {
|
||||
return this.$t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
|
||||
}
|
||||
if (this.conversationType === 'unattended') {
|
||||
return this.$t('CHAT_LIST.UNATTENDED_HEADING');
|
||||
}
|
||||
@@ -461,7 +481,14 @@ export default {
|
||||
this.showDeleteFoldersModal = false;
|
||||
},
|
||||
onToggleAdvanceFiltersModal() {
|
||||
this.showAdvancedFilters = !this.showAdvancedFilters;
|
||||
if (!this.hasAppliedFilters) {
|
||||
this.initializeExistingFilterToModal();
|
||||
}
|
||||
this.showAdvancedFilters = true;
|
||||
},
|
||||
closeAdvanceFiltersModal() {
|
||||
this.showAdvancedFilters = false;
|
||||
this.appliedFilter = [];
|
||||
},
|
||||
getKeyboardListenerParams() {
|
||||
const allConversations = this.$refs.activeConversation.querySelectorAll(
|
||||
@@ -520,6 +547,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.fetchConversations();
|
||||
this.appliedFilter = [];
|
||||
},
|
||||
fetchConversations() {
|
||||
this.$store
|
||||
|
||||
@@ -14,6 +14,8 @@ const conversations = accountId => ({
|
||||
'conversations_through_team',
|
||||
'conversation_mentions',
|
||||
'conversation_through_mentions',
|
||||
'conversation_participating',
|
||||
'conversation_through_participating',
|
||||
'folder_conversations',
|
||||
'conversations_through_folders',
|
||||
'conversation_unattended',
|
||||
|
||||
@@ -154,6 +154,7 @@ export default {
|
||||
left: var(--space-slab);
|
||||
bottom: var(--space-larger);
|
||||
min-width: 22rem;
|
||||
top: unset;
|
||||
z-index: var(--z-index-low);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -1,7 +1,14 @@
|
||||
<template>
|
||||
<span class="time-ago">
|
||||
<span>{{ timeAgo }}</span>
|
||||
</span>
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1500, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="time-ago"
|
||||
>
|
||||
<span>{{ `${createdAtTime} • ${lastActivityTime}` }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -19,20 +26,55 @@ export default {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
timestamp: {
|
||||
lastActivityTimestamp: {
|
||||
type: [String, Date, Number],
|
||||
default: '',
|
||||
},
|
||||
createdAtTimestamp: {
|
||||
type: [String, Date, Number],
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timeAgo: this.dynamicTime(this.timestamp),
|
||||
lastActivityAtTimeAgo: this.dynamicTime(this.lastActivityTimestamp),
|
||||
createdAtTimeAgo: this.dynamicTime(this.createdAtTimestamp),
|
||||
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: {
|
||||
timestamp() {
|
||||
this.timeAgo = this.dynamicTime(this.timestamp);
|
||||
lastActivityTimestamp() {
|
||||
this.lastActivityAtTimeAgo = this.dynamicTime(this.lastActivityTimestamp);
|
||||
},
|
||||
createdAtTimestamp() {
|
||||
this.createdAtTimeAgo = this.dynamicTime(this.createdAtTimestamp);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
@@ -46,12 +88,15 @@ export default {
|
||||
methods: {
|
||||
createTimer() {
|
||||
this.timer = setTimeout(() => {
|
||||
this.timeAgo = this.dynamicTime(this.timestamp);
|
||||
this.lastActivityAtTimeAgo = this.dynamicTime(
|
||||
this.lastActivityTimestamp
|
||||
);
|
||||
this.createdAtTimeAgo = this.dynamicTime(this.createdAtTimestamp);
|
||||
this.createTimer();
|
||||
}, this.refreshTime());
|
||||
},
|
||||
refreshTime() {
|
||||
const timeDiff = Date.now() - this.timestamp * 1000;
|
||||
const timeDiff = Date.now() - this.lastActivityTimestamp * 1000;
|
||||
if (timeDiff > DAY_IN_MILLI_SECONDS) {
|
||||
return DAY_IN_MILLI_SECONDS;
|
||||
}
|
||||
@@ -71,5 +116,9 @@ export default {
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--space-normal);
|
||||
margin-left: auto;
|
||||
|
||||
&:hover {
|
||||
color: var(--b-900);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
v-if="inputType === 'textarea'"
|
||||
v-model="castMessageVmodel"
|
||||
rows="4"
|
||||
:enable-variables="true"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
class="action-message"
|
||||
/>
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
@click="insertMentionNode"
|
||||
/>
|
||||
<canned-response
|
||||
v-if="showCannedMenu && !isPrivate"
|
||||
v-if="shouldShowCannedResponses"
|
||||
:search-key="cannedSearchTerm"
|
||||
@click="insertCannedResponse"
|
||||
/>
|
||||
<variable-list
|
||||
v-if="shouldShowVariables"
|
||||
:search-key="variableSearchTerm"
|
||||
@click="insertVariable"
|
||||
/>
|
||||
<div ref="editor" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,6 +36,7 @@ import {
|
||||
|
||||
import TagAgents from '../conversation/TagAgents';
|
||||
import CannedResponse from '../conversation/CannedResponse';
|
||||
import VariableList from '../conversation/VariableList';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
|
||||
@@ -43,9 +49,8 @@ import {
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper';
|
||||
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
|
||||
const createState = (content, placeholder, plugins = []) => {
|
||||
return EditorState.create({
|
||||
@@ -60,7 +65,7 @@ const createState = (content, placeholder, plugins = []) => {
|
||||
|
||||
export default {
|
||||
name: 'WootMessageEditor',
|
||||
components: { TagAgents, CannedResponse },
|
||||
components: { TagAgents, CannedResponse, VariableList },
|
||||
mixins: [eventListenerMixins, uiSettingsMixin],
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
@@ -70,13 +75,18 @@ export default {
|
||||
enableSuggestions: { type: Boolean, default: true },
|
||||
overrideLineBreaks: { type: Boolean, default: false },
|
||||
updateSelectionWith: { type: String, default: '' },
|
||||
enableVariables: { type: Boolean, default: false },
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
variables: { type: Object, default: () => ({}) },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showUserMentions: false,
|
||||
showCannedMenu: false,
|
||||
showVariables: false,
|
||||
mentionSearchKey: '',
|
||||
cannedSearchTerm: '',
|
||||
variableSearchTerm: '',
|
||||
editorView: null,
|
||||
range: null,
|
||||
state: undefined,
|
||||
@@ -86,6 +96,14 @@ export default {
|
||||
contentFromEditor() {
|
||||
return MessageMarkdownSerializer.serialize(this.editorView.state.doc);
|
||||
},
|
||||
shouldShowVariables() {
|
||||
return this.enableVariables && this.showVariables && !this.isPrivate;
|
||||
},
|
||||
shouldShowCannedResponses() {
|
||||
return (
|
||||
this.enableCannedResponses && this.showCannedMenu && !this.isPrivate
|
||||
);
|
||||
},
|
||||
plugins() {
|
||||
if (!this.enableSuggestions) {
|
||||
return [];
|
||||
@@ -105,6 +123,7 @@ export default {
|
||||
this.range = args.range;
|
||||
|
||||
this.mentionSearchKey = args.text.replace('@', '');
|
||||
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
@@ -144,6 +163,34 @@ export default {
|
||||
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) {
|
||||
this.$emit('toggle-canned-menu', !this.isPrivate && updatedValue);
|
||||
},
|
||||
showVariables(updatedValue) {
|
||||
this.$emit('toggle-variables-menu', !this.isPrivate && updatedValue);
|
||||
},
|
||||
value(newValue = '') {
|
||||
if (newValue !== this.contentFromEditor) {
|
||||
this.reloadState();
|
||||
}
|
||||
},
|
||||
editorId() {
|
||||
this.showCannedMenu = false;
|
||||
this.cannedSearchTerm = '';
|
||||
this.reloadState();
|
||||
},
|
||||
isPrivate() {
|
||||
@@ -265,23 +317,26 @@ export default {
|
||||
);
|
||||
this.state = this.editorView.state.apply(tr);
|
||||
this.emitOnChange();
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.USED_MENTIONS);
|
||||
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
insertCannedResponse(cannedItem) {
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message: cannedItem,
|
||||
variables: this.variables,
|
||||
});
|
||||
if (!this.editorView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let from = this.range.from - 1;
|
||||
let node = new MessageMarkdownTransformer(messageSchema).parse(
|
||||
cannedItem
|
||||
updatedMessage
|
||||
);
|
||||
|
||||
if (node.textContent === cannedItem) {
|
||||
node = this.editorView.state.schema.text(cannedItem);
|
||||
if (node.textContent === updatedMessage) {
|
||||
node = this.editorView.state.schema.text(updatedMessage);
|
||||
from = this.range.from;
|
||||
}
|
||||
|
||||
@@ -295,7 +350,30 @@ export default {
|
||||
this.emitOnChange();
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
@@ -16,23 +23,31 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
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({
|
||||
doc: new ArticleMarkdownTransformer(fullSchema).parse(content),
|
||||
plugins: wootArticleWriterSetup({
|
||||
schema: fullSchema,
|
||||
placeholder,
|
||||
plugins,
|
||||
onImageUpload,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
mixins: [eventListenerMixins, uiSettingsMixin],
|
||||
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
@@ -64,7 +79,12 @@ export default {
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.state = createState(this.value, this.placeholder, this.plugins);
|
||||
this.state = createState(
|
||||
this.value,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
this.openFileBrowser
|
||||
);
|
||||
},
|
||||
mounted() {
|
||||
this.createEditorView();
|
||||
@@ -73,8 +93,67 @@ export default {
|
||||
this.focusEditorInputField();
|
||||
},
|
||||
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() {
|
||||
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.focusEditorInputField();
|
||||
},
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<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>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
>
|
||||
{{ value['TEXT'] }}
|
||||
</option>
|
||||
<option value="all">
|
||||
{{ $t('CHAT_LIST.FILTER_ALL') }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ import { mapGetters } from 'vuex';
|
||||
import { filterAttributeGroups } from './advancedFilterItems';
|
||||
import filterMixin from 'shared/mixins/filterMixin';
|
||||
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -76,6 +77,10 @@ export default {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
initialAppliedFilters: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
appliedFilters: {
|
||||
@@ -101,7 +106,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
show: true,
|
||||
appliedFilters: [],
|
||||
appliedFilters: this.initialAppliedFilters,
|
||||
filterTypes: this.initialFilterTypes,
|
||||
filterAttributeGroups,
|
||||
filterGroups: [],
|
||||
@@ -119,6 +124,7 @@ export default {
|
||||
this.setFilterAttributes();
|
||||
this.$store.dispatch('campaigns/get');
|
||||
if (this.getAppliedConversationFilters.length) {
|
||||
this.appliedFilters = [];
|
||||
this.appliedFilters = [...this.getAppliedConversationFilters];
|
||||
} else {
|
||||
this.appliedFilters.push({
|
||||
@@ -229,10 +235,6 @@ export default {
|
||||
name: statusFilters[status].TEXT,
|
||||
};
|
||||
}),
|
||||
{
|
||||
id: 'all',
|
||||
name: this.$t('CHAT_LIST.FILTER_ALL'),
|
||||
},
|
||||
];
|
||||
case 'assignee_id':
|
||||
return this.$store.getters['agents/getAgents'];
|
||||
@@ -287,6 +289,12 @@ export default {
|
||||
JSON.parse(JSON.stringify(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) {
|
||||
this.appliedFilters[index].filter_operator = this.filterTypes.find(
|
||||
|
||||
@@ -87,7 +87,10 @@
|
||||
</p>
|
||||
<div class="conversation--meta">
|
||||
<span class="timestamp">
|
||||
<time-ago :timestamp="chat.timestamp" />
|
||||
<time-ago
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
/>
|
||||
</span>
|
||||
<span class="unread">{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
:longitude="attachment.coordinates_long"
|
||||
: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" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,6 +73,39 @@
|
||||
:created-at="createdAt"
|
||||
/>
|
||||
</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" />
|
||||
<div
|
||||
v-if="showAvatar"
|
||||
@@ -102,11 +143,13 @@
|
||||
v-if="isBubble && !isMessageDeleted"
|
||||
:is-open="showContextMenu"
|
||||
:show-copy="hasText"
|
||||
:show-canned-response-option="isOutgoing"
|
||||
:show-delete="hasText || hasAttachments"
|
||||
:show-canned-response-option="isOutgoing && hasText"
|
||||
:menu-position="contextMenuPosition"
|
||||
:message-content="data.content"
|
||||
@toggle="handleContextMenuClick"
|
||||
@delete="handleDelete"
|
||||
@translate="handleTranslate"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
@@ -121,13 +164,15 @@ import BubbleLocation from './bubble/Location';
|
||||
import BubbleMailHead from './bubble/MailHead';
|
||||
import BubbleText from './bubble/Text';
|
||||
import BubbleVideo from './bubble/Video.vue';
|
||||
import BubbleContact from './bubble/Contact';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
|
||||
|
||||
import instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
|
||||
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
|
||||
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -139,8 +184,10 @@ export default {
|
||||
BubbleMailHead,
|
||||
BubbleText,
|
||||
BubbleVideo,
|
||||
BubbleContact,
|
||||
ContextMenu,
|
||||
Spinner,
|
||||
instagramImageErrorPlaceholder,
|
||||
},
|
||||
mixins: [alertMixin, messageFormatterMixin, contentTypeMixin],
|
||||
props: {
|
||||
@@ -169,9 +216,14 @@ export default {
|
||||
return {
|
||||
showContextMenu: false,
|
||||
hasImageError: false,
|
||||
showTranslateModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
shouldRenderMessage() {
|
||||
return (
|
||||
this.hasAttachments ||
|
||||
@@ -187,6 +239,9 @@ export default {
|
||||
} = this.contentAttributes.email || {};
|
||||
return fullHTMLContent || fullTextContent || '';
|
||||
},
|
||||
translations() {
|
||||
return this.contentAttributes.translations || {};
|
||||
},
|
||||
displayQuotedButton() {
|
||||
if (!this.isIncoming) {
|
||||
return false;
|
||||
@@ -198,6 +253,9 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
translationsAvailable() {
|
||||
return !!Object.keys(this.translations).length;
|
||||
},
|
||||
message() {
|
||||
if (this.contentType === 'input_csat') {
|
||||
return this.$t('CONVERSATION.CSAT_REPLY_MESSAGE');
|
||||
@@ -420,6 +478,19 @@ export default {
|
||||
onImageLoadError() {
|
||||
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>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<canned-response
|
||||
v-if="showMentions && hasSlashCommand"
|
||||
v-on-clickaway="hideMentions"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="mentionSearchKey"
|
||||
@click="replaceText"
|
||||
/>
|
||||
@@ -63,12 +64,15 @@
|
||||
:placeholder="messagePlaceHolder"
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
:enable-variables="true"
|
||||
:variables="messageVariables"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@toggle-user-mention="toggleUserMention"
|
||||
@toggle-canned-menu="toggleCannedMenu"
|
||||
@toggle-variables-menu="toggleVariablesMenu"
|
||||
@clear-selection="clearEditorSelection"
|
||||
/>
|
||||
</div>
|
||||
@@ -126,6 +130,12 @@
|
||||
@on-send="onSendWhatsAppReply"
|
||||
@cancel="hideWhatsappTemplatesModal"
|
||||
/>
|
||||
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
|
||||
:description="undefinedVariableMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -152,7 +162,11 @@ import {
|
||||
AUDIO_FORMATS,
|
||||
} from 'shared/constants/messages';
|
||||
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 { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
|
||||
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 wootConstants from 'dashboard/constants';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
|
||||
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
|
||||
|
||||
@@ -210,7 +222,6 @@ export default {
|
||||
message: '',
|
||||
isFocused: false,
|
||||
showEmojiPicker: false,
|
||||
showMentions: false,
|
||||
attachedFiles: [],
|
||||
isRecordingAudio: false,
|
||||
recordingAudioState: '',
|
||||
@@ -218,13 +229,17 @@ export default {
|
||||
isUploading: false,
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
mentionSearchKey: '',
|
||||
hasUserMention: false,
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
doAutoSaveDraft: () => {},
|
||||
showWhatsAppTemplatesModal: false,
|
||||
updateEditorSelectionWith: '',
|
||||
undefinedVariableMessage: '',
|
||||
showMentions: false,
|
||||
showUserMentions: false,
|
||||
showCannedMenu: false,
|
||||
showVariablesMenu: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -471,6 +486,12 @@ export default {
|
||||
}
|
||||
return AUDIO_FORMATS.OGG;
|
||||
},
|
||||
messageVariables() {
|
||||
const variables = getMessageVariables({
|
||||
conversation: this.currentChat,
|
||||
});
|
||||
return variables;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation) {
|
||||
@@ -612,8 +633,10 @@ export default {
|
||||
},
|
||||
isAValidEvent(selectedKey) {
|
||||
return (
|
||||
!this.hasUserMention &&
|
||||
!this.showUserMentions &&
|
||||
!this.showMentions &&
|
||||
!this.showCannedMenu &&
|
||||
!this.showVariablesMenu &&
|
||||
this.isFocused &&
|
||||
isEditorHotKeyEnabled(this.uiSettings, selectedKey)
|
||||
);
|
||||
@@ -632,11 +655,14 @@ export default {
|
||||
});
|
||||
},
|
||||
toggleUserMention(currentMentionState) {
|
||||
this.hasUserMention = currentMentionState;
|
||||
this.showUserMentions = currentMentionState;
|
||||
},
|
||||
toggleCannedMenu(value) {
|
||||
this.showCannedMenu = value;
|
||||
},
|
||||
toggleVariablesMenu(value) {
|
||||
this.showVariablesMenu = value;
|
||||
},
|
||||
openWhatsappTemplateModal() {
|
||||
this.showWhatsAppTemplatesModal = true;
|
||||
},
|
||||
@@ -666,7 +692,7 @@ export default {
|
||||
};
|
||||
this.assignedAgent = selfAssign;
|
||||
},
|
||||
async onSendReply() {
|
||||
confirmOnSendReply() {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
@@ -675,18 +701,57 @@ export default {
|
||||
if (this.isSignatureEnabledForInbox && 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) {
|
||||
this.clearEmailField();
|
||||
}
|
||||
this.sendMessage(messagePayload);
|
||||
|
||||
this.clearMessage();
|
||||
this.hideEmojiPicker();
|
||||
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) {
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
@@ -709,9 +774,13 @@ export default {
|
||||
this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message,
|
||||
variables: this.messageVariables,
|
||||
});
|
||||
setTimeout(() => {
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
this.message = message;
|
||||
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
this.message = updatedMessage;
|
||||
}, 100);
|
||||
},
|
||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||
@@ -897,6 +966,33 @@ export default {
|
||||
(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) {
|
||||
const messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
@@ -992,6 +1088,7 @@ export default {
|
||||
}
|
||||
|
||||
.reply-box__top {
|
||||
position: relative;
|
||||
padding: 0 var(--space-normal);
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: -1px;
|
||||
@@ -1029,4 +1126,9 @@ export default {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.normal-editor__canned-box {
|
||||
width: calc(100% - 2 * var(--space-normal));
|
||||
left: var(--space-normal);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<ul
|
||||
v-if="items.length"
|
||||
class="vertical dropdown menu mention--box"
|
||||
:style="{ top: getTopSpacing() + 'rem' }"
|
||||
:class="{ 'with-bottom-border': items.length <= 4 }"
|
||||
>
|
||||
<li
|
||||
@@ -69,12 +68,6 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
getTopSpacing() {
|
||||
if (this.items.length <= 4) {
|
||||
return -(this.items.length * 5 + 1.7);
|
||||
}
|
||||
return -20;
|
||||
},
|
||||
handleKeyboardEvent(e) {
|
||||
this.processKeyDownEvent(e);
|
||||
this.$el.scrollTop = 50 * this.selectedIndex;
|
||||
@@ -101,6 +94,7 @@ export default {
|
||||
box-shadow: var(--shadow-medium);
|
||||
font-size: var(--font-size-small);
|
||||
left: 0;
|
||||
bottom: 100%;
|
||||
line-height: 1.2;
|
||||
max-height: 20rem;
|
||||
overflow: auto;
|
||||
|
||||
@@ -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>
|
||||
@@ -53,6 +53,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// TODO: Remove this when we support all formats
|
||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
||||
|
||||
export default {
|
||||
props: {
|
||||
inboxId: {
|
||||
@@ -67,7 +70,14 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
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() {
|
||||
return this.whatsAppTemplateMessages.filter(template =>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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({});
|
||||
@@ -1,22 +1,23 @@
|
||||
<template>
|
||||
<ul
|
||||
v-if="items.length"
|
||||
class="vertical dropdown menu mention--box"
|
||||
:style="{ top: getTopPadding() + 'rem' }"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:id="`mention-item-${index}`"
|
||||
:key="item.key"
|
||||
:class="{ active: index === selectedIndex }"
|
||||
@click="onListItemSelection(index)"
|
||||
@mouseover="onHover(index)"
|
||||
>
|
||||
<a class="text-truncate">
|
||||
<strong>{{ item.label }}</strong> - {{ item.description }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="items.length" ref="mentionsListContainer" class="mention--box">
|
||||
<ul class="vertical dropdown menu">
|
||||
<woot-dropdown-item
|
||||
v-for="(item, index) in items"
|
||||
:id="`mention-item-${index}`"
|
||||
:key="item.key"
|
||||
@mouseover="onHover(index)"
|
||||
>
|
||||
<woot-button
|
||||
class="canned-item__button"
|
||||
:variant="index === selectedIndex ? '' : 'clear'"
|
||||
:class="{ active: index === selectedIndex }"
|
||||
@click="onListItemSelection(index)"
|
||||
>
|
||||
<strong>{{ item.label }}</strong> - {{ item.description }}
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -40,17 +41,27 @@ export default {
|
||||
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: {
|
||||
getTopPadding() {
|
||||
if (this.items.length <= 4) {
|
||||
return -(this.items.length * 2.9 + 1.7);
|
||||
}
|
||||
return -14;
|
||||
},
|
||||
handleKeyboardEvent(e) {
|
||||
this.processKeyDownEvent(e);
|
||||
this.$el.scrollTop = 29 * this.selectedIndex;
|
||||
},
|
||||
onHover(index) {
|
||||
this.selectedIndex = index;
|
||||
@@ -69,20 +80,40 @@ export default {
|
||||
<style scoped lang="scss">
|
||||
.mention--box {
|
||||
background: var(--white);
|
||||
border-bottom: var(--space-small) solid var(--white);
|
||||
border-radius: var(--border-radius-normal);
|
||||
border-top: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-medium);
|
||||
left: 0;
|
||||
max-height: 14rem;
|
||||
bottom: 100%;
|
||||
max-height: 15.6rem;
|
||||
overflow: auto;
|
||||
padding-top: var(--space-small);
|
||||
padding: var(--space-small) var(--space-small) 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
|
||||
.active a {
|
||||
background: var(--w-500);
|
||||
.dropdown-menu__item:last-child {
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,76 @@
|
||||
export const EXECUTED_A_MACRO = 'Executed a macro';
|
||||
export const SENT_MESSAGE = 'Sent a message';
|
||||
export const SENT_PRIVATE_NOTE = 'Sent a private note';
|
||||
export const INSERTED_A_CANNED_RESPONSE = 'Inserted a canned response';
|
||||
export const USED_MENTIONS = 'Used mentions';
|
||||
export const MERGED_CONTACTS = 'Used merge contact option';
|
||||
export const ADDED_TO_CANNED_RESPONSE = 'Used added to canned response option';
|
||||
export const ADDED_A_CUSTOM_ATTRIBUTE = 'Added a custom attribute';
|
||||
export const ADDED_AN_INBOX = 'Added an inbox';
|
||||
export const CONVERSATION_EVENTS = Object.freeze({
|
||||
EXECUTED_A_MACRO: 'Executed a macro',
|
||||
SENT_MESSAGE: 'Sent a message',
|
||||
SENT_PRIVATE_NOTE: 'Sent a private note',
|
||||
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
|
||||
INSERTED_A_VARIABLE: 'Inserted a variable',
|
||||
USED_MENTIONS: 'Used mentions',
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
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 } = {}) {
|
||||
this.analyticsToken = analyticsToken;
|
||||
this.analytics = null;
|
||||
this.user = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize analytics
|
||||
* @function
|
||||
* @async
|
||||
*/
|
||||
async init() {
|
||||
if (!this.analyticsToken) {
|
||||
return;
|
||||
@@ -18,6 +32,11 @@ class AnalyticsHelper {
|
||||
this.analytics = analytics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the user
|
||||
* @function
|
||||
* @param {Object} user - User object
|
||||
*/
|
||||
identify(user) {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
@@ -41,6 +60,12 @@ class AnalyticsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track any event
|
||||
* @function
|
||||
* @param {string} eventName - event name
|
||||
* @param {Object} [properties={}] - event properties
|
||||
*/
|
||||
track(eventName, properties = {}) {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
@@ -53,6 +78,11 @@ class AnalyticsHelper {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the page views
|
||||
* @function
|
||||
* @param {Object} params - Page view properties
|
||||
*/
|
||||
page(params) {
|
||||
if (!this.analytics) {
|
||||
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);
|
||||
|
||||
11
app/javascript/dashboard/helper/AnalyticsHelper/plugin.js
Normal file
11
app/javascript/dashboard/helper/AnalyticsHelper/plugin.js
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,8 @@ export const conversationUrl = ({
|
||||
url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`;
|
||||
} else if (conversationType === 'mention') {
|
||||
url = `accounts/${accountId}/mentions/conversations/${id}`;
|
||||
} else if (conversationType === 'participating') {
|
||||
url = `accounts/${accountId}/participating/conversations/${id}`;
|
||||
} else if (conversationType === 'unattended') {
|
||||
url = `accounts/${accountId}/unattended/conversations/${id}`;
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
|
||||
59
app/javascript/dashboard/helper/messageHelper.js
Normal file
59
app/javascript/dashboard/helper/messageHelper.js
Normal 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];
|
||||
});
|
||||
};
|
||||
138
app/javascript/dashboard/helper/specs/messageHelper.spec.js
Normal file
138
app/javascript/dashboard/helper/specs/messageHelper.spec.js
Normal 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'])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,9 @@
|
||||
},
|
||||
"snoozed": {
|
||||
"TEXT": "غفوة"
|
||||
},
|
||||
"all": {
|
||||
"TEXT": "الكل"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"INITIATED_FROM": "تم البدء من",
|
||||
"INITIATED_AT": "تم البدء في",
|
||||
"IP_ADDRESS": "عنوان IP",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "رسالة جديدة",
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "لا توجد محادثات سابقة مرتبطة بجهة الاتصال هذه.",
|
||||
@@ -206,6 +207,7 @@
|
||||
"PHONE_NUMBER": "رقم الهاتف",
|
||||
"CONVERSATIONS": "المحادثات",
|
||||
"LAST_ACTIVITY": "آخر نشاط",
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"COUNTRY": "الدولة",
|
||||
"CITY": "المدينة",
|
||||
"SOCIAL_PROFILES": "حسابات التواصل الاجتماعي",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"REMOVE_SELECTION": "إزالة التحديد",
|
||||
"DOWNLOAD": "تنزيل",
|
||||
"UNKNOWN_FILE_TYPE": "ملف غير معروف",
|
||||
"SAVE_CONTACT": "Save",
|
||||
"UPLOADING_ATTACHMENTS": "جاري تحميل المرفقات...",
|
||||
"SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح",
|
||||
"FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى",
|
||||
@@ -132,6 +133,14 @@
|
||||
"PLACEHOLDER": "البريد الإلكتروني مفصولة بفاصلة",
|
||||
"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": "ملاحظة خاصة: مرئية فقط لأعضاء فريق العمل والموظفين",
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
"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": {
|
||||
"TITLE": "قناة الموقع",
|
||||
"DESC": "قم بإنشاء قناة تواصل لموقع الويب الخاص بك وابدأ في استقبال الرسائل من عملائك عبر صندوق الدردشة المباشرة.",
|
||||
@@ -348,6 +344,17 @@
|
||||
"FINISH": {
|
||||
"TITLE": "تم!",
|
||||
"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": {
|
||||
@@ -465,7 +472,11 @@
|
||||
"ALLOW_MESSAGES_AFTER_RESOLVED": "السماح بالرسائل بعد حل المحادثة",
|
||||
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "السماح للمستخدمين النهائيين بإرسال رسائل حتى بعد تسوية المحادثة.",
|
||||
"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": {
|
||||
"MAX_ASSIGNMENT_LIMIT": "حد الإسناد التلقائي",
|
||||
@@ -674,6 +685,10 @@
|
||||
},
|
||||
"BRANDING_TEXT": "مدعوم بواسطة Chatwoot",
|
||||
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
|
||||
},
|
||||
"EMAIL_PROVIDERS": {
|
||||
"MICROSOFT": "Microsoft",
|
||||
"OTHER_PROVIDERS": "Other Providers"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"CONVERSATION_UPDATED": "تم تحديث المحادثة",
|
||||
"MESSAGE_CREATED": "تم إنشاء رسالة",
|
||||
"MESSAGE_UPDATED": "تم تحديث الرسالة",
|
||||
"WEBWIDGET_TRIGGERED": "أداة الدردشة المباشرة مفتوحة من قبل المستخدم"
|
||||
"WEBWIDGET_TRIGGERED": "أداة الدردشة المباشرة مفتوحة من قبل المستخدم",
|
||||
"CONTACT_CREATED": "Contact created",
|
||||
"CONTACT_UPDATED": "Contact updated"
|
||||
}
|
||||
},
|
||||
"END_POINT": {
|
||||
|
||||
@@ -170,7 +170,8 @@
|
||||
},
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "تنزيل",
|
||||
"UPLOADING": "جاري الرفع..."
|
||||
"UPLOADING": "جاري الرفع...",
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "هذه القصة لم تعد متاحة."
|
||||
},
|
||||
"LOCATION_BUBBLE": {
|
||||
"SEE_ON_MAP": "مشاهدة على الخريطة"
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
},
|
||||
"snoozed": {
|
||||
"TEXT": "Отложен"
|
||||
},
|
||||
"all": {
|
||||
"TEXT": "Всички"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"INITIATED_FROM": "Иницирано от",
|
||||
"INITIATED_AT": "Иницирано в",
|
||||
"IP_ADDRESS": "IP адрес",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "Ново съобщение",
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "Няма предишни разговори асоцирани с този контакт.",
|
||||
@@ -206,6 +207,7 @@
|
||||
"PHONE_NUMBER": "Телефон",
|
||||
"CONVERSATIONS": "Разговори",
|
||||
"LAST_ACTIVITY": "Последна активност",
|
||||
"CREATED_AT": "Created At",
|
||||
"COUNTRY": "Държава",
|
||||
"CITY": "Град",
|
||||
"SOCIAL_PROFILES": "Социални профили",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
"UNKNOWN_FILE_TYPE": "Unknown File",
|
||||
"SAVE_CONTACT": "Save",
|
||||
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
@@ -132,6 +133,14 @@
|
||||
"PLACEHOLDER": "Emails separated by commas",
|
||||
"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",
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
"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": {
|
||||
"TITLE": "Website channel",
|
||||
"DESC": "Create a channel for your website and start supporting your customers via our website widget.",
|
||||
@@ -348,6 +344,17 @@
|
||||
"FINISH": {
|
||||
"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 :)"
|
||||
},
|
||||
"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": {
|
||||
@@ -465,7 +472,11 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
|
||||
@@ -674,6 +685,10 @@
|
||||
},
|
||||
"BRANDING_TEXT": "Осъществено от Chatwoot",
|
||||
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
|
||||
},
|
||||
"EMAIL_PROVIDERS": {
|
||||
"MICROSOFT": "Microsoft",
|
||||
"OTHER_PROVIDERS": "Other Providers"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"CONVERSATION_UPDATED": "Conversation Updated",
|
||||
"MESSAGE_CREATED": "Message created",
|
||||
"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": {
|
||||
|
||||
@@ -170,7 +170,8 @@
|
||||
},
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Качване..."
|
||||
"UPLOADING": "Качване...",
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
|
||||
},
|
||||
"LOCATION_BUBBLE": {
|
||||
"SEE_ON_MAP": "See on map"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"REGISTER": {
|
||||
"TRY_WOOT": "Register an account",
|
||||
"TRY_WOOT": "Create an account",
|
||||
"TITLE": "Register",
|
||||
"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.",
|
||||
"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": {
|
||||
"LABEL": "Company name",
|
||||
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
|
||||
@@ -35,7 +35,7 @@
|
||||
"SUCCESS_MESSAGE": "Registration Successfull",
|
||||
"ERROR_MESSAGE": "Не можа да се свърже с Woot сървър. Моля, опитайте отново по-късно"
|
||||
},
|
||||
"SUBMIT": "Изпращане",
|
||||
"SUBMIT": "Create account",
|
||||
"HAVE_AN_ACCOUNT": "Already have an account?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
},
|
||||
"snoozed": {
|
||||
"TEXT": "Posposat"
|
||||
},
|
||||
"all": {
|
||||
"TEXT": "Totes"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"INITIATED_FROM": "Iniciada des de",
|
||||
"INITIATED_AT": "Iniciada a les",
|
||||
"IP_ADDRESS": "Adreça IP",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "Nou missatge",
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "No hi han converses prèvies associades a aquest contacte.",
|
||||
@@ -206,6 +207,7 @@
|
||||
"PHONE_NUMBER": "Número de telèfon",
|
||||
"CONVERSATIONS": "Converses",
|
||||
"LAST_ACTIVITY": "Last Activity",
|
||||
"CREATED_AT": "Created At",
|
||||
"COUNTRY": "Country",
|
||||
"CITY": "City",
|
||||
"SOCIAL_PROFILES": "Social Profiles",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"REMOVE_SELECTION": "Elimina la selecció",
|
||||
"DOWNLOAD": "Descarrega",
|
||||
"UNKNOWN_FILE_TYPE": "Unknown File",
|
||||
"SAVE_CONTACT": "Save",
|
||||
"UPLOADING_ATTACHMENTS": "Pujant fitxers adjunts...",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
@@ -132,6 +133,14 @@
|
||||
"PLACEHOLDER": "Emails separated by commas",
|
||||
"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",
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -348,6 +344,17 @@
|
||||
"FINISH": {
|
||||
"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 l’ajuda 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": {
|
||||
@@ -465,7 +472,11 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
|
||||
@@ -667,13 +678,17 @@
|
||||
"BODY": {
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "We are Online",
|
||||
"OFFLINE": "We are away at the moment"
|
||||
"OFFLINE": "Estem fora en aquest moment"
|
||||
},
|
||||
"USER_MESSAGE": "Hi",
|
||||
"AGENT_MESSAGE": "Hello"
|
||||
},
|
||||
"BRANDING_TEXT": "Desenvolupat per Chatwoot",
|
||||
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
|
||||
},
|
||||
"EMAIL_PROVIDERS": {
|
||||
"MICROSOFT": "Microsoft",
|
||||
"OTHER_PROVIDERS": "Other Providers"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"CONVERSATION_UPDATED": "Conversation Updated",
|
||||
"MESSAGE_CREATED": "Message created",
|
||||
"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": {
|
||||
|
||||
@@ -170,7 +170,8 @@
|
||||
},
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Descarrega",
|
||||
"UPLOADING": "S'està carregant..."
|
||||
"UPLOADING": "S'està carregant...",
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
|
||||
},
|
||||
"LOCATION_BUBBLE": {
|
||||
"SEE_ON_MAP": "See on map"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"REGISTER": {
|
||||
"TRY_WOOT": "Registra un compte",
|
||||
"TRY_WOOT": "Create an account",
|
||||
"TITLE": "Registre",
|
||||
"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.",
|
||||
"TERMS_ACCEPT": "En registrar-vos, esteu d’acord 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": {
|
||||
"LABEL": "Company name",
|
||||
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
|
||||
@@ -35,7 +35,7 @@
|
||||
"SUCCESS_MESSAGE": "Registrat correctament",
|
||||
"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?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
},
|
||||
"snoozed": {
|
||||
"TEXT": "Odložené"
|
||||
},
|
||||
"all": {
|
||||
"TEXT": "Vše"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"INITIATED_FROM": "Zahájeno od",
|
||||
"INITIATED_AT": "Zahájeno v",
|
||||
"IP_ADDRESS": "IP adresa",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "New message",
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "K tomuto kontaktu nejsou přiřazeny žádné předchozí konverzace.",
|
||||
@@ -206,6 +207,7 @@
|
||||
"PHONE_NUMBER": "Telefonní číslo",
|
||||
"CONVERSATIONS": "Konverzace",
|
||||
"LAST_ACTIVITY": "Poslední aktivita",
|
||||
"CREATED_AT": "Vytvořeno",
|
||||
"COUNTRY": "Země",
|
||||
"CITY": "Město",
|
||||
"SOCIAL_PROFILES": "Sociální profily",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"REMOVE_SELECTION": "Odstranit výběr",
|
||||
"DOWNLOAD": "Stáhnout",
|
||||
"UNKNOWN_FILE_TYPE": "Neznámý soubor",
|
||||
"SAVE_CONTACT": "Save",
|
||||
"UPLOADING_ATTACHMENTS": "Nahrávání příloh...",
|
||||
"SUCCESS_DELETE_MESSAGE": "Zpráva byla úspěšně smazána",
|
||||
"FAIL_DELETE_MESSSAGE": "Zpráva se nepodařilo odstranit! Zkuste to znovu",
|
||||
@@ -132,6 +133,14 @@
|
||||
"PLACEHOLDER": "E-maily oddělené čárkami",
|
||||
"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",
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -348,6 +344,17 @@
|
||||
"FINISH": {
|
||||
"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 :)"
|
||||
},
|
||||
"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": {
|
||||
@@ -465,7 +472,11 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
|
||||
@@ -674,6 +685,10 @@
|
||||
},
|
||||
"BRANDING_TEXT": "Powered by Chatwoot",
|
||||
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
|
||||
},
|
||||
"EMAIL_PROVIDERS": {
|
||||
"MICROSOFT": "Microsoft",
|
||||
"OTHER_PROVIDERS": "Other Providers"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"CONVERSATION_UPDATED": "Conversation Updated",
|
||||
"MESSAGE_CREATED": "Message created",
|
||||
"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": {
|
||||
|
||||
@@ -170,7 +170,8 @@
|
||||
},
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Stáhnout",
|
||||
"UPLOADING": "Nahrávání..."
|
||||
"UPLOADING": "Nahrávání...",
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
|
||||
},
|
||||
"LOCATION_BUBBLE": {
|
||||
"SEE_ON_MAP": "See on map"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"REGISTER": {
|
||||
"TRY_WOOT": "Registrovat účet",
|
||||
"TRY_WOOT": "Create an account",
|
||||
"TITLE": "Registrovat se",
|
||||
"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.",
|
||||
"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": {
|
||||
"LABEL": "Company name",
|
||||
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
|
||||
@@ -35,7 +35,7 @@
|
||||
"SUCCESS_MESSAGE": "Registrace byla úspěšná",
|
||||
"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?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
},
|
||||
"snoozed": {
|
||||
"TEXT": "Udsat"
|
||||
},
|
||||
"all": {
|
||||
"TEXT": "Alle"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"INITIATED_FROM": "Startet fra",
|
||||
"INITIATED_AT": "Startet fra",
|
||||
"IP_ADDRESS": "Ip Adresse",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "Ny besked",
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "Der er ingen tidligere samtaler tilknyttet denne kontakt.",
|
||||
@@ -206,6 +207,7 @@
|
||||
"PHONE_NUMBER": "Telefonnummer",
|
||||
"CONVERSATIONS": "Samtaler",
|
||||
"LAST_ACTIVITY": "Sidste Aktivitet",
|
||||
"CREATED_AT": "Oprettet Den",
|
||||
"COUNTRY": "Land",
|
||||
"CITY": "By",
|
||||
"SOCIAL_PROFILES": "Social Profiles",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"REMOVE_SELECTION": "Fjern Markering",
|
||||
"DOWNLOAD": "Download",
|
||||
"UNKNOWN_FILE_TYPE": "Ukendt Fil",
|
||||
"SAVE_CONTACT": "Save",
|
||||
"UPLOADING_ATTACHMENTS": "Uploader vedhæftede filer...",
|
||||
"SUCCESS_DELETE_MESSAGE": "Besked slettet",
|
||||
"FAIL_DELETE_MESSSAGE": "Kunne ikke slette beskeden! Prøv igen",
|
||||
@@ -132,6 +133,14 @@
|
||||
"PLACEHOLDER": "E-mails adskilt af kommaer",
|
||||
"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",
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
"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": {
|
||||
"TITLE": "Hjemmesidekanal",
|
||||
"DESC": "Opret en kanal til din hjemmeside og begynde at supporte dine kunder via vores hjemmeside widget.",
|
||||
@@ -348,6 +344,17 @@
|
||||
"FINISH": {
|
||||
"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 :)"
|
||||
},
|
||||
"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": {
|
||||
@@ -465,7 +472,11 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"MAX_ASSIGNMENT_LIMIT": "Grænse for automatisk tildeling",
|
||||
@@ -674,6 +685,10 @@
|
||||
},
|
||||
"BRANDING_TEXT": "Drevet af Chatwoot",
|
||||
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
|
||||
},
|
||||
"EMAIL_PROVIDERS": {
|
||||
"MICROSOFT": "Microsoft",
|
||||
"OTHER_PROVIDERS": "Other Providers"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"CONVERSATION_UPDATED": "Samtale Opdateret",
|
||||
"MESSAGE_CREATED": "Besked oprettet",
|
||||
"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": {
|
||||
|
||||
@@ -170,7 +170,8 @@
|
||||
},
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploader..."
|
||||
"UPLOADING": "Uploader...",
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "Denne historie er ikke længere tilgængelig."
|
||||
},
|
||||
"LOCATION_BUBBLE": {
|
||||
"SEE_ON_MAP": "Se på kort"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"REGISTER": {
|
||||
"TRY_WOOT": "Registrer en konto",
|
||||
"TRY_WOOT": "Create an account",
|
||||
"TITLE": "Registrer",
|
||||
"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.",
|
||||
"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": {
|
||||
"LABEL": "Firmanavn",
|
||||
"PLACEHOLDER": "Indtast dit firmanavn, fx: Wayne Enterprises",
|
||||
@@ -35,7 +35,7 @@
|
||||
"SUCCESS_MESSAGE": "Registrering Succesfuld",
|
||||
"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?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
"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.",
|
||||
"SUBMIT": "Aktualisieren",
|
||||
"DISCONNECT": "Disconnect Bot",
|
||||
"DISCONNECT": "Bot trennen",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"API": {
|
||||
"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": {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
},
|
||||
"API": {
|
||||
"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": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user