Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff531dde50 | ||
|
|
5c98414334 | ||
|
|
bedf15eadb | ||
|
|
52adc6b4ab | ||
|
|
285017647b | ||
|
|
2de42b7de2 | ||
|
|
9c34a96242 | ||
|
|
61805d7bf3 | ||
|
|
7c6dd3d750 |
+6
-8
@@ -1,9 +1,4 @@
|
||||
# Learn about the various environment variables at
|
||||
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
|
||||
|
||||
# Used to verify the integrity of signed cookies. so ensure a secure value is set
|
||||
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
|
||||
# Use `rake secret` to generate this variable
|
||||
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
|
||||
|
||||
# Replace with the URL you are planning to use for your app
|
||||
@@ -28,9 +23,6 @@ FORCE_SSL=false
|
||||
ENABLE_ACCOUNT_SIGNUP=false
|
||||
|
||||
# Redis config
|
||||
# specify the configs via single URL or individual variables
|
||||
# ref: https://www.iana.org/assignments/uri-schemes/prov/redis
|
||||
# You can also use the following format for the URL: redis://:password@host:port/db_number
|
||||
REDIS_URL=redis://redis:6379
|
||||
# If you are using docker-compose, set this variable's value to be any string,
|
||||
# which will be the password for the redis service running inside the docker-compose
|
||||
@@ -189,6 +181,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
# SENTRY_DSN=
|
||||
|
||||
|
||||
# MICROSOFT CLARITY
|
||||
# MS_CLARITY_TOKEN=xxxxxxxxx
|
||||
|
||||
# GOOGLE_TAG_MANAGER
|
||||
# GOOGLE_TAG = GTM-XXXXXXX
|
||||
|
||||
## Scout
|
||||
## https://scoutapm.com/docs/ruby/configuration
|
||||
# SCOUT_KEY=YOURKEY
|
||||
|
||||
@@ -16,34 +16,15 @@ on:
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
# - name: Setup tmate session
|
||||
# uses: mxschmitt/action-tmate@v3
|
||||
# if: always()
|
||||
# ## temporary fix for gh actions since node 20 installed via script is not taking effect till 30june2024
|
||||
# # ## https://github.blog/changelog/2024-05-17-updated-dates-for-actions-runner-using-node20-instead-of-node16-by-default
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20'
|
||||
- name: check node version
|
||||
run: |
|
||||
node --version
|
||||
which node
|
||||
- name: check yarn version
|
||||
run: |
|
||||
yarn --version
|
||||
which yarn
|
||||
|
||||
- name: get installer
|
||||
run: |
|
||||
wget https://get.chatwoot.app/linux/install.sh
|
||||
chmod +x install.sh
|
||||
#fix for postgtres not starting automatically in gh action env
|
||||
sed -i '/function configure_db() {/a sudo service postgresql start' install.sh
|
||||
#sed -i '/function setup_chatwoot() {/a export PATH="/usr/bin/:$PATH"' install.sh
|
||||
#sed -i '/git checkout "\$BRANCH"/a export PATH="/usr/bin/:$PATH"' install.sh
|
||||
# sed -i '/function setup_chatwoot() {/a sudo chown -R chatwoot:chatwoot /home/runner/.config' install.sh
|
||||
|
||||
- name: create input file
|
||||
run: |
|
||||
@@ -63,19 +44,6 @@ jobs:
|
||||
# sudo systemctl restart chatwoot.target
|
||||
# curl http://localhost:3000/api
|
||||
|
||||
- name: check node/yarn inside chatwoot
|
||||
run: |
|
||||
sudo -i -u chatwoot << EOF
|
||||
ls
|
||||
cd chatwoot
|
||||
node --version
|
||||
yarn --versio
|
||||
yarn
|
||||
EOF
|
||||
if: always()
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
if: always()
|
||||
- name: Upload chatwoot setup log file as an artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
|
||||
@@ -71,13 +71,13 @@ gem 'barnes'
|
||||
##--- gems for authentication & authorization ---##
|
||||
gem 'devise', '>= 4.9.4'
|
||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||
gem 'devise_token_auth', '>= 1.2.3'
|
||||
gem 'devise_token_auth'
|
||||
# authorization
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.20.1'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.3'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.2'
|
||||
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
|
||||
|
||||
##--- gems for pubsub service ---##
|
||||
@@ -166,7 +166,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
# need for google auth
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.2'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
||||
|
||||
## Gems for reponse bot
|
||||
# adds cosine similarity to postgres using vector extension
|
||||
|
||||
+72
-74
@@ -33,70 +33,70 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actioncable (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
activejob (= 7.0.8.3)
|
||||
activerecord (= 7.0.8.3)
|
||||
activestorage (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actionmailbox (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activestorage (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
actionview (= 7.0.8.3)
|
||||
activejob (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actionmailer (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
actionview (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.8.3)
|
||||
actionview (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actionpack (7.0.8.1)
|
||||
actionview (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
activerecord (= 7.0.8.3)
|
||||
activestorage (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actiontext (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activestorage (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actionview (7.0.8.1)
|
||||
activesupport (= 7.0.8.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 (7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
activejob (7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
activerecord (7.0.8.3)
|
||||
activemodel (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
activemodel (7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
activerecord (7.0.8.1)
|
||||
activemodel (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
activerecord-import (1.4.1)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
activejob (= 7.0.8.3)
|
||||
activerecord (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
activestorage (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.8.3)
|
||||
activesupport (7.0.8.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@@ -113,7 +113,7 @@ GEM
|
||||
kaminari (~> 1.2.2)
|
||||
sassc-rails (~> 2.1)
|
||||
selectize-rails (~> 0.6)
|
||||
administrate-field-active_storage (1.0.3)
|
||||
administrate-field-active_storage (1.0.2)
|
||||
administrate (>= 0.2.2)
|
||||
rails (>= 7.0)
|
||||
administrate-field-belongs_to_search (0.9.0)
|
||||
@@ -148,7 +148,7 @@ GEM
|
||||
barnes (0.0.9)
|
||||
multi_json (~> 1)
|
||||
statsd-ruby (~> 1.1)
|
||||
base64 (0.2.0)
|
||||
base64 (0.1.1)
|
||||
bcrypt (3.1.20)
|
||||
bigdecimal (3.1.7)
|
||||
bindex (0.8.1)
|
||||
@@ -200,10 +200,10 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise_token_auth (1.2.3)
|
||||
devise_token_auth (1.2.1)
|
||||
bcrypt (~> 3.0)
|
||||
devise (> 3.5.2, < 5)
|
||||
rails (>= 4.2.0, < 7.2)
|
||||
rails (>= 4.2.0, < 7.1)
|
||||
diff-lcs (1.5.0)
|
||||
digest-crc (0.6.4)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
@@ -369,7 +369,7 @@ GEM
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.14.5)
|
||||
i18n (1.14.4)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
@@ -461,7 +461,7 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.6)
|
||||
minitest (5.23.0)
|
||||
minitest (5.22.3)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.7.0)
|
||||
@@ -474,7 +474,7 @@ GEM
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.11)
|
||||
net-imap (0.4.10)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -489,15 +489,15 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.5)
|
||||
nio4r (2.7.1)
|
||||
nokogiri (1.16.4)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-arm64-darwin)
|
||||
nokogiri (1.16.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86_64-darwin)
|
||||
nokogiri (1.16.4-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86_64-linux)
|
||||
nokogiri (1.16.4-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -524,7 +524,7 @@ GEM
|
||||
omniauth-oauth2 (1.8.0)
|
||||
oauth2 (>= 1.4, < 3)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
omniauth-rails_csrf_protection (1.0.1)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
openssl (3.1.0)
|
||||
@@ -555,7 +555,7 @@ GEM
|
||||
rack (2.2.9)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
rack-contrib (2.4.0)
|
||||
rack (< 4)
|
||||
rack-cors (2.0.0)
|
||||
rack (>= 2.0.0)
|
||||
@@ -569,20 +569,20 @@ GEM
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rails (7.0.8.3)
|
||||
actioncable (= 7.0.8.3)
|
||||
actionmailbox (= 7.0.8.3)
|
||||
actionmailer (= 7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
actiontext (= 7.0.8.3)
|
||||
actionview (= 7.0.8.3)
|
||||
activejob (= 7.0.8.3)
|
||||
activemodel (= 7.0.8.3)
|
||||
activerecord (= 7.0.8.3)
|
||||
activestorage (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
rails (7.0.8.1)
|
||||
actioncable (= 7.0.8.1)
|
||||
actionmailbox (= 7.0.8.1)
|
||||
actionmailer (= 7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
actiontext (= 7.0.8.1)
|
||||
actionview (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activemodel (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activestorage (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.8.3)
|
||||
railties (= 7.0.8.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -590,9 +590,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
railties (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
@@ -628,8 +628,7 @@ GEM
|
||||
retriable (3.1.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.2.8)
|
||||
strscan (>= 3.0.9)
|
||||
rexml (3.2.5)
|
||||
rspec-core (3.12.2)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-expectations (3.12.3)
|
||||
@@ -759,7 +758,6 @@ GEM
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (8.5.0)
|
||||
strscan (3.1.0)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.3.1)
|
||||
@@ -821,7 +819,7 @@ GEM
|
||||
working_hours (1.4.1)
|
||||
activesupport (>= 3.2)
|
||||
tzinfo
|
||||
zeitwerk (2.6.14)
|
||||
zeitwerk (2.6.13)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
@@ -839,7 +837,7 @@ DEPENDENCIES
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-active_storage (>= 1.0.2)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
annotate
|
||||
attr_extras
|
||||
@@ -862,7 +860,7 @@ DEPENDENCIES
|
||||
debug (~> 1.8)
|
||||
devise (>= 4.9.4)
|
||||
devise-secure_password!
|
||||
devise_token_auth (>= 1.2.3)
|
||||
devise_token_auth
|
||||
dotenv-rails
|
||||
down
|
||||
elastic-apm
|
||||
@@ -906,7 +904,7 @@ DEPENDENCIES
|
||||
omniauth (>= 2.1.2)
|
||||
omniauth-google-oauth2 (>= 1.1.2)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"plan": "heroku-redis:mini"
|
||||
},
|
||||
{
|
||||
"plan": "heroku-postgresql:essential-0"
|
||||
"plan": "heroku-postgresql:mini"
|
||||
}
|
||||
],
|
||||
"stack": "heroku-20",
|
||||
|
||||
@@ -19,9 +19,9 @@ class ContactInboxWithContactBuilder
|
||||
|
||||
ActiveRecord::Base.transaction(requires_new: true) do
|
||||
build_contact_with_contact_inbox
|
||||
update_contact_avatar(@contact) unless @contact.avatar.attached?
|
||||
@contact_inbox
|
||||
end
|
||||
update_contact_avatar(@contact) unless @contact.avatar.attached?
|
||||
@contact_inbox
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
class V2::Reports::Conversations::BaseReportBuilder
|
||||
pattr_initialize :account, :params
|
||||
|
||||
private
|
||||
|
||||
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
|
||||
COUNT_METRICS = %w[
|
||||
conversations_count
|
||||
incoming_messages_count
|
||||
outgoing_messages_count
|
||||
resolutions_count
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].freeze
|
||||
|
||||
def builder_class(metric)
|
||||
case metric
|
||||
when *AVG_METRICS
|
||||
V2::Reports::Timeseries::AverageReportBuilder
|
||||
when *COUNT_METRICS
|
||||
V2::Reports::Timeseries::CountReportBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def log_invalid_metric
|
||||
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
|
||||
|
||||
{}
|
||||
end
|
||||
end
|
||||
@@ -1,30 +0,0 @@
|
||||
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
|
||||
def summary
|
||||
{
|
||||
conversations_count: count('conversations_count'),
|
||||
incoming_messages_count: count('incoming_messages_count'),
|
||||
outgoing_messages_count: count('outgoing_messages_count'),
|
||||
avg_first_response_time: count('avg_first_response_time'),
|
||||
avg_resolution_time: count('avg_resolution_time'),
|
||||
resolutions_count: count('resolutions_count'),
|
||||
reply_time: count('reply_time')
|
||||
}
|
||||
end
|
||||
|
||||
def bot_summary
|
||||
{
|
||||
bot_resolutions_count: count('bot_resolutions_count'),
|
||||
bot_handoffs_count: count('bot_handoffs_count')
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count(metric)
|
||||
builder_class(metric).new(account, builder_params(metric)).aggregate_value
|
||||
end
|
||||
|
||||
def builder_params(metric)
|
||||
params.merge({ metric: metric })
|
||||
end
|
||||
end
|
||||
@@ -1,21 +0,0 @@
|
||||
class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder
|
||||
def timeseries
|
||||
perform_action(:timeseries)
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
perform_action(:aggregate_value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_action(method_name)
|
||||
return builder.new(account, params).public_send(method_name) if builder.present?
|
||||
|
||||
log_invalid_metric
|
||||
end
|
||||
|
||||
def builder
|
||||
builder_class(params[:metric])
|
||||
end
|
||||
end
|
||||
@@ -1,48 +0,0 @@
|
||||
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_average_time = reporting_events.average(average_value_key)
|
||||
grouped_event_count = reporting_events.count
|
||||
grouped_average_time.each_with_object([]) do |element, arr|
|
||||
event_date, average_time = element
|
||||
arr << {
|
||||
value: average_time,
|
||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
||||
count: grouped_event_count[event_date]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.average(average_value_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
metric_to_event_name = {
|
||||
avg_first_response_time: :first_response,
|
||||
avg_resolution_time: :conversation_resolved,
|
||||
reply_time: :reply_time
|
||||
}
|
||||
metric_to_event_name[params[:metric].to_sym]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
scope.reporting_events.where(name: event_name, created_at: range)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -1,46 +0,0 @@
|
||||
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
include TimezoneHelper
|
||||
include DateRangeHelper
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
def scope
|
||||
case params[:type].to_sym
|
||||
when :account
|
||||
account
|
||||
when :inbox
|
||||
inbox
|
||||
when :agent
|
||||
user
|
||||
when :label
|
||||
label
|
||||
when :team
|
||||
team
|
||||
end
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find(params[:id])
|
||||
end
|
||||
|
||||
def user
|
||||
@user ||= account.users.find(params[:id])
|
||||
end
|
||||
|
||||
def label
|
||||
@label ||= account.labels.find(params[:id])
|
||||
end
|
||||
|
||||
def team
|
||||
@team ||= account.teams.find(params[:id])
|
||||
end
|
||||
|
||||
def group_by
|
||||
@group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY
|
||||
end
|
||||
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
end
|
||||
end
|
||||
@@ -1,71 +0,0 @@
|
||||
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_count.each_with_object([]) do |element, arr|
|
||||
event_date, event_count = element
|
||||
|
||||
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
|
||||
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
|
||||
# because it converts the date to 12:00 AM server timezone.
|
||||
# The desired output should be 12:00 AM in the specified timezone.
|
||||
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metric
|
||||
@metric ||= params[:metric]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
send("scope_for_#{metric}")
|
||||
end
|
||||
|
||||
def scope_for_conversations_count
|
||||
scope.conversations.where(account_id: account.id, created_at: range)
|
||||
end
|
||||
|
||||
def scope_for_incoming_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_outgoing_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_resolutions_count
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_resolved,
|
||||
conversations: { status: :resolved }, created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def scope_for_bot_resolutions_count
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_bot_resolved,
|
||||
conversations: { status: :resolved }, created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def scope_for_bot_handoffs_count
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_bot_handoff,
|
||||
created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
).count
|
||||
end
|
||||
end
|
||||
@@ -21,6 +21,6 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
|
||||
params.permit(:type, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -46,8 +46,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def export
|
||||
column_names = params['column_names']
|
||||
filter_params = { :payload => params.permit!['payload'], :label => params.permit!['label'] }
|
||||
Account::ContactsExportJob.perform_later(Current.account.id, Current.user.id, column_names, filter_params)
|
||||
Account::ContactsExportJob.perform_later(Current.account.id, column_names, Current.user.email)
|
||||
head :ok, message: I18n.t('errors.contacts.export.success')
|
||||
end
|
||||
|
||||
@@ -62,7 +61,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
def show; end
|
||||
|
||||
def filter
|
||||
result = ::Contacts::FilterService.new(Current.account, Current.user, params.permit!).perform
|
||||
result = ::Contacts::FilterService.new(params.permit!, current_user).perform
|
||||
contacts = result[:contacts]
|
||||
@contacts_count = result[:count]
|
||||
@contacts = fetch_contacts(contacts)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
class Api::V1::Accounts::Conversations::DraftMessagesController < Api::V1::Accounts::Conversations::BaseController
|
||||
def show
|
||||
render json: { has_draft: false } and return unless Redis::Alfred.exists?(draft_redis_key)
|
||||
|
||||
draft_message = Redis::Alfred.get(draft_redis_key)
|
||||
render json: { has_draft: true, message: draft_message }
|
||||
end
|
||||
|
||||
def update
|
||||
Redis::Alfred.set(draft_redis_key, draft_message_params)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
Redis::Alfred.delete(draft_redis_key)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def draft_redis_key
|
||||
format(Redis::Alfred::CONVERSATION_DRAFT_MESSAGE, id: @conversation.id)
|
||||
end
|
||||
|
||||
def draft_message_params
|
||||
params.dig(:draft_message, :message) || ''
|
||||
end
|
||||
end
|
||||
@@ -1,93 +0,0 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
|
||||
|
||||
def teams
|
||||
teams = linear_processor_service.teams
|
||||
if teams[:error]
|
||||
render json: { error: teams[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: teams[:data], status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
def team_entities
|
||||
team_id = permitted_params[:team_id]
|
||||
team_entities = linear_processor_service.team_entities(team_id)
|
||||
if team_entities[:error]
|
||||
render json: { error: team_entities[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: team_entities[:data], status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
def create_issue
|
||||
issue = linear_processor_service.create_issue(permitted_params)
|
||||
if issue[:error]
|
||||
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: issue[:data], status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
def link_issue
|
||||
issue_id = permitted_params[:issue_id]
|
||||
title = permitted_params[:title]
|
||||
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
|
||||
if issue[:error]
|
||||
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: issue[:data], status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
def unlink_issue
|
||||
link_id = permitted_params[:link_id]
|
||||
issue = linear_processor_service.unlink_issue(link_id)
|
||||
|
||||
if issue[:error]
|
||||
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: issue[:data], status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
def linked_issues
|
||||
issues = linear_processor_service.linked_issues(conversation_link)
|
||||
|
||||
if issues[:error]
|
||||
render json: { error: issues[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: issues[:data], status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
def search_issue
|
||||
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
||||
|
||||
term = params[:q]
|
||||
issues = linear_processor_service.search_issue(term)
|
||||
if issues[:error]
|
||||
render json: { error: issues[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: issues[:data], status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversation_link
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}"
|
||||
end
|
||||
|
||||
def fetch_conversation
|
||||
@conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id])
|
||||
end
|
||||
|
||||
def linear_processor_service
|
||||
Integrations::Linear::ProcessorService.new(account: Current.account)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
|
||||
end
|
||||
end
|
||||
@@ -5,17 +5,19 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params)
|
||||
data = builder.timeseries
|
||||
builder = V2::ReportBuilder.new(Current.account, report_params)
|
||||
data = builder.build
|
||||
render json: data
|
||||
end
|
||||
|
||||
def summary
|
||||
render json: build_summary(:summary)
|
||||
render json: summary_metrics
|
||||
end
|
||||
|
||||
def bot_summary
|
||||
render json: build_summary(:bot_summary)
|
||||
summary = V2::ReportBuilder.new(Current.account, current_summary_params).bot_summary
|
||||
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).bot_summary
|
||||
render json: summary
|
||||
end
|
||||
|
||||
def agents
|
||||
@@ -124,11 +126,10 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
}
|
||||
end
|
||||
|
||||
def build_summary(method)
|
||||
builder = V2::Reports::Conversations::MetricBuilder
|
||||
current_summary = builder.new(Current.account, current_summary_params).send(method)
|
||||
previous_summary = builder.new(Current.account, previous_summary_params).send(method)
|
||||
current_summary.merge(previous: previous_summary)
|
||||
def summary_metrics
|
||||
summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary
|
||||
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
|
||||
summary
|
||||
end
|
||||
|
||||
def conversation_metrics
|
||||
|
||||
@@ -2,10 +2,7 @@ module MicrosoftConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def microsoft_client
|
||||
app_id = GlobalConfigService.load('AZURE_APP_ID', nil)
|
||||
app_secret = GlobalConfigService.load('AZURE_APP_SECRET', nil)
|
||||
|
||||
::OAuth2::Client.new(app_id, app_secret,
|
||||
::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil),
|
||||
{
|
||||
site: 'https://login.microsoftonline.com',
|
||||
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
||||
|
||||
@@ -3,7 +3,6 @@ class DashboardController < ActionController::Base
|
||||
|
||||
before_action :set_application_pack
|
||||
before_action :set_global_config
|
||||
before_action :set_dashboard_scripts
|
||||
around_action :switch_locale
|
||||
before_action :ensure_installation_onboarding, only: [:index]
|
||||
before_action :render_hc_if_custom_domain, only: [:index]
|
||||
@@ -36,10 +35,6 @@ class DashboardController < ActionController::Base
|
||||
).merge(app_config)
|
||||
end
|
||||
|
||||
def set_dashboard_scripts
|
||||
@dashboard_scripts = GlobalConfig.get_value('DASHBOARD_SCRIPTS')
|
||||
end
|
||||
|
||||
def ensure_installation_onboarding
|
||||
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
|
||||
end
|
||||
@@ -63,7 +58,7 @@ class DashboardController < ActionController::Base
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH
|
||||
}
|
||||
end
|
||||
|
||||
@@ -7,14 +7,9 @@ class Microsoft::CallbacksController < ApplicationController
|
||||
redirect_uri: "#{base_url}/microsoft/callback"
|
||||
)
|
||||
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
inbox = find_or_create_inbox
|
||||
::Redis::Alfred.delete(users_data['email'].downcase)
|
||||
|
||||
if already_exists
|
||||
redirect_to app_microsoft_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
|
||||
else
|
||||
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
|
||||
end
|
||||
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
redirect_to '/'
|
||||
@@ -45,17 +40,9 @@ class Microsoft::CallbacksController < ApplicationController
|
||||
|
||||
def find_or_create_inbox
|
||||
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
|
||||
# we need this value to know where to redirect on sucessful processing of the callback
|
||||
channel_exists = channel_email.present?
|
||||
|
||||
channel_email ||= create_microsoft_channel_with_inbox
|
||||
update_microsoft_channel(channel_email)
|
||||
|
||||
# reauthorize channel, this code path only triggers when microsoft auth is successful
|
||||
# reauthorized will also update cache keys for the associated inbox
|
||||
channel_email.reauthorized!
|
||||
|
||||
[channel_email.inbox, channel_exists]
|
||||
channel_email.inbox
|
||||
end
|
||||
|
||||
# Fallback name, for when name field is missing from users_data
|
||||
|
||||
@@ -12,6 +12,6 @@ class MicrosoftController < ApplicationController
|
||||
end
|
||||
|
||||
def microsoft_indentity
|
||||
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
|
||||
@identity_json = ENV.fetch('AZURE_APP_ID', nil)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,8 +35,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
@allowed_configs = case @config
|
||||
when 'facebook'
|
||||
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
|
||||
when 'microsoft'
|
||||
%w[AZURE_APP_ID AZURE_APP_SECRET]
|
||||
when 'email'
|
||||
['MAILER_INBOUND_EMAIL_DOMAIN']
|
||||
else
|
||||
|
||||
@@ -10,15 +10,14 @@ class AsyncDispatcher < BaseDispatcher
|
||||
|
||||
def listeners
|
||||
[
|
||||
AutomationRuleListener.instance,
|
||||
CampaignListener.instance,
|
||||
CsatSurveyListener.instance,
|
||||
HookListener.instance,
|
||||
InstallationWebhookListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
ReportingEventListener.instance,
|
||||
WebhookListener.instance
|
||||
WebhookListener.instance,
|
||||
AutomationRuleListener.instance
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module Api::V2::Accounts::ReportsHelper
|
||||
def generate_agents_report
|
||||
Current.account.users.map do |agent|
|
||||
agent_report = report_builder({ type: :agent, id: agent.id }).summary
|
||||
agent_report = generate_report({ type: :agent, id: agent.id })
|
||||
[agent.name] + generate_readable_report_metrics(agent_report)
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,7 @@ module Api::V2::Accounts::ReportsHelper
|
||||
|
||||
def generate_teams_report
|
||||
Current.account.teams.map do |team|
|
||||
team_report = report_builder({ type: :team, id: team.id }).summary
|
||||
team_report = generate_report({ type: :team, id: team.id })
|
||||
[team.name] + generate_readable_report_metrics(team_report)
|
||||
end
|
||||
end
|
||||
@@ -27,7 +27,7 @@ module Api::V2::Accounts::ReportsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def report_builder(report_params)
|
||||
def generate_report(report_params)
|
||||
V2::ReportBuilder.new(
|
||||
Current.account,
|
||||
report_params.merge(
|
||||
@@ -37,11 +37,7 @@ module Api::V2::Accounts::ReportsHelper
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
}
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def generate_report(report_params)
|
||||
report_builder(report_params).short_summary
|
||||
).short_summary
|
||||
end
|
||||
|
||||
private
|
||||
@@ -50,9 +46,7 @@ module Api::V2::Accounts::ReportsHelper
|
||||
[
|
||||
report_metric[:conversations_count],
|
||||
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format,
|
||||
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format,
|
||||
Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format,
|
||||
report_metric[:resolutions_count]
|
||||
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
module TimezoneHelper
|
||||
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
||||
# would return the timezone without considering day light savings. To get the correct timezone,
|
||||
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
||||
#
|
||||
# https://github.com/rails/rails/pull/22243
|
||||
# https://github.com/rails/rails/issues/21501
|
||||
# https://github.com/rails/rails/issues/7297
|
||||
def timezone_name_from_offset(offset)
|
||||
return 'UTC' if offset.blank?
|
||||
|
||||
offset_in_seconds = offset.to_f * 3600
|
||||
matching_zone = ActiveSupport::TimeZone.all.find do |zone|
|
||||
zone.now.utc_offset == offset_in_seconds
|
||||
end
|
||||
|
||||
return matching_zone.name if matching_zone
|
||||
end
|
||||
end
|
||||
@@ -77,8 +77,8 @@ class ContactAPI extends ApiClient {
|
||||
return axios.delete(`${this.url}/${contactId}/avatar`);
|
||||
}
|
||||
|
||||
exportContacts(queryPayload) {
|
||||
return axios.post(`${this.url}/export`, queryPayload);
|
||||
exportContacts() {
|
||||
return axios.get(`${this.url}/export`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ class ConversationApi extends ApiClient {
|
||||
teamId,
|
||||
conversationType,
|
||||
sortBy,
|
||||
updatedWithin,
|
||||
}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
@@ -27,7 +26,6 @@ class ConversationApi extends ApiClient {
|
||||
labels,
|
||||
conversation_type: conversationType,
|
||||
sort_by: sortBy,
|
||||
updated_within: updatedWithin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class LinearAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('integrations/linear', { accountScoped: true });
|
||||
}
|
||||
|
||||
getTeams() {
|
||||
return axios.get(`${this.url}/teams`);
|
||||
}
|
||||
|
||||
getTeamEntities(teamId) {
|
||||
return axios.get(`${this.url}/team_entities?team_id=${teamId}`);
|
||||
}
|
||||
|
||||
createIssue(data) {
|
||||
return axios.post(`${this.url}/create_issue`, data);
|
||||
}
|
||||
|
||||
link_issue(conversationId, issueId, title) {
|
||||
return axios.post(`${this.url}/link_issue`, {
|
||||
issue_id: issueId,
|
||||
conversation_id: conversationId,
|
||||
title: title,
|
||||
});
|
||||
}
|
||||
|
||||
getLinkedIssue(conversationId) {
|
||||
return axios.get(
|
||||
`${this.url}/linked_issues?conversation_id=${conversationId}`
|
||||
);
|
||||
}
|
||||
|
||||
unlinkIssue(linkId) {
|
||||
return axios.post(`${this.url}/unlink_issue`, {
|
||||
link_id: linkId,
|
||||
});
|
||||
}
|
||||
|
||||
searchIssues(query) {
|
||||
return axios.get(`${this.url}/search_issue?q=${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new LinearAPI();
|
||||
@@ -46,7 +46,6 @@ describe('#ConversationAPI', () => {
|
||||
page: 1,
|
||||
labels: [],
|
||||
teamId: 1,
|
||||
updatedWithin: 20,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', {
|
||||
params: {
|
||||
@@ -56,7 +55,6 @@ describe('#ConversationAPI', () => {
|
||||
assignee_type: 'me',
|
||||
page: 1,
|
||||
labels: [],
|
||||
updated_within: 20,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import LinearAPIClient from '../../integrations/linear';
|
||||
import ApiClient from '../../ApiClient';
|
||||
|
||||
describe('#linearAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(LinearAPIClient).toBeInstanceOf(ApiClient);
|
||||
expect(LinearAPIClient).toHaveProperty('getTeams');
|
||||
expect(LinearAPIClient).toHaveProperty('getTeamEntities');
|
||||
expect(LinearAPIClient).toHaveProperty('createIssue');
|
||||
expect(LinearAPIClient).toHaveProperty('link_issue');
|
||||
expect(LinearAPIClient).toHaveProperty('getLinkedIssue');
|
||||
expect(LinearAPIClient).toHaveProperty('unlinkIssue');
|
||||
expect(LinearAPIClient).toHaveProperty('searchIssues');
|
||||
});
|
||||
|
||||
describe('getTeams', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
get: jest.fn(() => Promise.resolve()),
|
||||
patch: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('creates a valid request', () => {
|
||||
LinearAPIClient.getTeams();
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/teams'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamEntities', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
get: jest.fn(() => Promise.resolve()),
|
||||
patch: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('creates a valid request', () => {
|
||||
LinearAPIClient.getTeamEntities(1);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/team_entities?team_id=1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createIssue', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
get: jest.fn(() => Promise.resolve()),
|
||||
patch: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('creates a valid request', () => {
|
||||
const issueData = {
|
||||
title: 'New Issue',
|
||||
description: 'Issue description',
|
||||
};
|
||||
LinearAPIClient.createIssue(issueData);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/create_issue',
|
||||
issueData
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('link_issue', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
get: jest.fn(() => Promise.resolve()),
|
||||
patch: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('creates a valid request', () => {
|
||||
LinearAPIClient.link_issue(1, 2);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/link_issue',
|
||||
{
|
||||
issue_id: 2,
|
||||
conversation_id: 1,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinkedIssue', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
get: jest.fn(() => Promise.resolve()),
|
||||
patch: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('creates a valid request', () => {
|
||||
LinearAPIClient.getLinkedIssue(1);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/linked_issues?conversation_id=1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlinkIssue', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
get: jest.fn(() => Promise.resolve()),
|
||||
patch: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('creates a valid request', () => {
|
||||
LinearAPIClient.unlinkIssue(1);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/unlink_issue',
|
||||
{
|
||||
link_id: 1,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchIssues', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
get: jest.fn(() => Promise.resolve()),
|
||||
patch: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('creates a valid request', () => {
|
||||
LinearAPIClient.searchIssues('query');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/search_issue?q=query'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ h6 {
|
||||
|
||||
p {
|
||||
text-rendering: optimizeLegibility;
|
||||
word-spacing: 0.12em;
|
||||
|
||||
@apply mb-2 leading-[1.65] text-sm;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<button
|
||||
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
|
||||
class="sm:w-[50%] md:w-1/3 lg:w-1/4 bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
|
||||
<img :src="src" :alt="title" class="w-[50%] my-4 mx-auto" />
|
||||
<h3
|
||||
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
|
||||
>
|
||||
|
||||
@@ -7,17 +7,79 @@
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
<chat-list-header
|
||||
:page-title="pageTitle"
|
||||
:has-applied-filters="hasAppliedFilters"
|
||||
:has-active-folders="hasActiveFolders"
|
||||
:active-status="activeStatus"
|
||||
@add-folders="onClickOpenAddFoldersModal"
|
||||
@delete-folders="onClickOpenDeleteFoldersModal"
|
||||
@filters-modal="onToggleAdvanceFiltersModal"
|
||||
@reset-filters="resetAndFetchData"
|
||||
@basic-filter-change="onBasicFilterChange"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-0"
|
||||
:class="{
|
||||
'pb-3 border-b border-slate-75 dark:border-slate-700':
|
||||
hasAppliedFiltersOrActiveFolders,
|
||||
}"
|
||||
>
|
||||
<div class="flex max-w-[85%] justify-center items-center">
|
||||
<h1
|
||||
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
|
||||
:title="pageTitle"
|
||||
>
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="hasAppliedFilters && !hasActiveFolders">
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="save"
|
||||
@click="onClickOpenAddFoldersModal"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
icon="dismiss-circle"
|
||||
@click="resetAndFetchData"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="hasActiveFolders">
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
@click="onToggleAdvanceFiltersModal"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
icon="delete"
|
||||
@click="onClickOpenDeleteFoldersModal"
|
||||
/>
|
||||
</div>
|
||||
<woot-button
|
||||
v-else
|
||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="filter"
|
||||
size="tiny"
|
||||
@click="onToggleAdvanceFiltersModal"
|
||||
/>
|
||||
<conversation-basic-filter
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
@changeFilter="onBasicFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<add-custom-views
|
||||
v-if="showAddFoldersModal"
|
||||
@@ -111,15 +173,6 @@
|
||||
@updateFolder="onUpdateSavedFilter"
|
||||
/>
|
||||
</woot-modal>
|
||||
<woot-modal
|
||||
:show.sync="showCustomSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<custom-snooze-modal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="chooseSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -127,8 +180,8 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import VirtualList from 'vue-virtual-scroll-list';
|
||||
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
||||
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import timeMixin from '../mixins/time';
|
||||
@@ -152,15 +205,10 @@ import {
|
||||
isOnUnattendedView,
|
||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatListHeader,
|
||||
AddCustomViews,
|
||||
ChatTypeTabs,
|
||||
// eslint-disable-next-line vue/no-unused-components
|
||||
@@ -168,9 +216,9 @@ export default {
|
||||
ConversationAdvancedFilter,
|
||||
DeleteCustomViews,
|
||||
ConversationBulkActions,
|
||||
ConversationBasicFilter,
|
||||
IntersectionObserver,
|
||||
VirtualList,
|
||||
CustomSnoozeModal,
|
||||
},
|
||||
mixins: [
|
||||
timeMixin,
|
||||
@@ -240,6 +288,7 @@ export default {
|
||||
foldersQuery: {},
|
||||
showAddFoldersModal: false,
|
||||
showDeleteFoldersModal: false,
|
||||
selectedConversations: [],
|
||||
selectedInboxes: [],
|
||||
isContextMenuOpen: false,
|
||||
appliedFilter: [],
|
||||
@@ -247,7 +296,6 @@ export default {
|
||||
root: this.$refs.conversationList,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
},
|
||||
showCustomSnoozeModal: false,
|
||||
|
||||
itemComponent: ConversationItem,
|
||||
// virtualListExtraProps is to pass the props to the conversationItem component.
|
||||
@@ -268,7 +316,6 @@ export default {
|
||||
chatLists: 'getAllConversations',
|
||||
mineChatsList: 'getMineChats',
|
||||
allChatList: 'getAllStatusChats',
|
||||
chatListFilters: 'getChatListFilters',
|
||||
unAssignedChatsList: 'getUnAssignedChats',
|
||||
chatListLoading: 'getChatListLoadingStatus',
|
||||
currentUserID: 'getCurrentUserID',
|
||||
@@ -282,18 +329,23 @@ export default {
|
||||
inboxesList: 'inboxes/getInboxes',
|
||||
campaigns: 'campaigns/getAllCampaigns',
|
||||
labels: 'labels/getLabels',
|
||||
selectedConversations: 'bulkActions/getSelectedConversationIds',
|
||||
contextMenuChatId: 'getContextMenuChatId',
|
||||
}),
|
||||
hasAppliedFilters() {
|
||||
return this.appliedFilters.length !== 0;
|
||||
},
|
||||
hasActiveFolders() {
|
||||
return Boolean(this.activeFolder && this.foldersId !== 0);
|
||||
return this.activeFolder && this.foldersId !== 0;
|
||||
},
|
||||
hasAppliedFiltersOrActiveFolders() {
|
||||
return this.hasAppliedFilters || this.hasActiveFolders;
|
||||
},
|
||||
savedFoldersValue() {
|
||||
if (this.hasActiveFolders) {
|
||||
const payload = this.activeFolder.query;
|
||||
this.fetchSavedFilteredConversations(payload);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
showEndOfListMessage() {
|
||||
return (
|
||||
this.conversationList.length &&
|
||||
@@ -369,6 +421,7 @@ export default {
|
||||
labels: this.label ? [this.label] : undefined,
|
||||
teamId: this.teamId || undefined,
|
||||
conversationType: this.conversationType || undefined,
|
||||
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
|
||||
};
|
||||
},
|
||||
conversationListPagination() {
|
||||
@@ -481,13 +534,7 @@ export default {
|
||||
this.resetAndFetchData();
|
||||
this.updateVirtualListProps('conversationType', this.conversationType);
|
||||
},
|
||||
activeFolder(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.$store.dispatch(
|
||||
'customViews/setActiveConversationFolder',
|
||||
newVal || null
|
||||
);
|
||||
}
|
||||
activeFolder() {
|
||||
this.resetAndFetchData();
|
||||
this.updateVirtualListProps('foldersId', this.foldersId);
|
||||
},
|
||||
@@ -497,14 +544,8 @@ export default {
|
||||
showAssigneeInConversationCard(newVal) {
|
||||
this.updateVirtualListProps('showAssignee', newVal);
|
||||
},
|
||||
conversationFilters(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.$store.dispatch('updateChatListFilters', newVal);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('setChatListFilters', this.conversationFilters);
|
||||
this.setFiltersFromUISettings();
|
||||
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
|
||||
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
|
||||
@@ -517,11 +558,6 @@ export default {
|
||||
bus.$on('fetch_conversation_stats', () => {
|
||||
this.$store.dispatch('conversationStats/get', this.conversationFilters);
|
||||
});
|
||||
|
||||
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
},
|
||||
beforeDestroy() {
|
||||
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
},
|
||||
methods: {
|
||||
updateVirtualListProps(key, value) {
|
||||
@@ -700,9 +736,8 @@ export default {
|
||||
this.fetchConversations();
|
||||
},
|
||||
fetchConversations() {
|
||||
this.$store.dispatch('updateChatListFilters', this.conversationFilters);
|
||||
this.$store
|
||||
.dispatch('fetchAllConversations')
|
||||
.dispatch('fetchAllConversations', this.conversationFilters)
|
||||
.then(this.emitConversationLoaded);
|
||||
},
|
||||
loadMoreConversations() {
|
||||
@@ -764,7 +799,7 @@ export default {
|
||||
});
|
||||
},
|
||||
resetBulkActions() {
|
||||
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
this.selectedConversations = [];
|
||||
this.selectedInboxes = [];
|
||||
},
|
||||
onBasicFilterChange(value, type) {
|
||||
@@ -795,16 +830,12 @@ export default {
|
||||
return this.selectedConversations.includes(id);
|
||||
},
|
||||
selectConversation(conversationId, inboxId) {
|
||||
this.$store.dispatch(
|
||||
'bulkActions/setSelectedConversationIds',
|
||||
conversationId
|
||||
);
|
||||
this.selectedConversations.push(conversationId);
|
||||
this.selectedInboxes.push(inboxId);
|
||||
},
|
||||
deSelectConversation(conversationId, inboxId) {
|
||||
this.$store.dispatch(
|
||||
'bulkActions/removeSelectedConversationIds',
|
||||
conversationId
|
||||
this.selectedConversations = this.selectedConversations.filter(
|
||||
item => item !== conversationId
|
||||
);
|
||||
this.selectedInboxes = this.selectedInboxes.filter(
|
||||
item => item !== inboxId
|
||||
@@ -812,10 +843,7 @@ export default {
|
||||
},
|
||||
selectAllConversations(check) {
|
||||
if (check) {
|
||||
this.$store.dispatch(
|
||||
'bulkActions/setSelectedConversationIds',
|
||||
this.conversationList.map(item => item.id)
|
||||
);
|
||||
this.selectedConversations = this.conversationList.map(item => item.id);
|
||||
this.selectedInboxes = this.conversationList.map(item => item.inbox_id);
|
||||
} else {
|
||||
this.resetBulkActions();
|
||||
@@ -831,7 +859,7 @@ export default {
|
||||
assignee_id: agent.id,
|
||||
},
|
||||
});
|
||||
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
this.selectedConversations = [];
|
||||
if (conversationId) {
|
||||
this.showAlert(
|
||||
this.$t(
|
||||
@@ -929,7 +957,7 @@ export default {
|
||||
add: labels,
|
||||
},
|
||||
});
|
||||
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
this.selectedConversations = [];
|
||||
if (conversationId) {
|
||||
this.showAlert(
|
||||
this.$t(
|
||||
@@ -956,13 +984,13 @@ export default {
|
||||
team_id: team.id,
|
||||
},
|
||||
});
|
||||
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
|
||||
}
|
||||
},
|
||||
async onUpdateConversations(status, snoozedUntil) {
|
||||
async onUpdateConversations(status) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
@@ -970,9 +998,8 @@ export default {
|
||||
fields: {
|
||||
status,
|
||||
},
|
||||
snoozed_until: snoozedUntil,
|
||||
});
|
||||
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||
@@ -993,49 +1020,12 @@ export default {
|
||||
allSelectedConversationsStatus(status) {
|
||||
if (!this.selectedConversations.length) return false;
|
||||
return this.selectedConversations.every(item => {
|
||||
return this.$store.getters.getConversationById(item)?.status === status;
|
||||
return this.$store.getters.getConversationById(item).status === status;
|
||||
});
|
||||
},
|
||||
onContextMenuToggle(state) {
|
||||
this.isContextMenuOpen = state;
|
||||
},
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomSnoozeModal = true;
|
||||
} else {
|
||||
this.toggleStatus(
|
||||
wootConstants.STATUS_TYPE.SNOOZED,
|
||||
findSnoozeTime(snoozeType) || null
|
||||
);
|
||||
}
|
||||
},
|
||||
chooseSnoozeTime(customSnoozeTime) {
|
||||
this.showCustomSnoozeModal = false;
|
||||
if (customSnoozeTime) {
|
||||
this.toggleStatus(
|
||||
wootConstants.STATUS_TYPE.SNOOZED,
|
||||
getUnixTime(customSnoozeTime)
|
||||
);
|
||||
}
|
||||
},
|
||||
toggleStatus(status, snoozedUntil) {
|
||||
this.$store
|
||||
.dispatch('toggleStatus', {
|
||||
conversationId: this.currentChat?.id || this.contextMenuChatId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
})
|
||||
.then(() => {
|
||||
this.$store.dispatch('setContextMenuChatId', null);
|
||||
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
// if we select custom snooze and then the custom snooze modal is open
|
||||
// Then if the custom snooze modal is closed and set the context menu chat id to null
|
||||
this.$store.dispatch('setContextMenuChatId', null);
|
||||
this.showCustomSnoozeModal = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
|
||||
|
||||
const props = defineProps({
|
||||
pageTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hasAppliedFilters: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
hasActiveFolders: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
activeStatus: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits([
|
||||
'add-folders',
|
||||
'delete-folders',
|
||||
'reset-filters',
|
||||
'basic-filter-change',
|
||||
'filters-modal',
|
||||
]);
|
||||
|
||||
const onBasicFilterChange = (value, type) => {
|
||||
emits('basic-filter-change', value, type);
|
||||
};
|
||||
|
||||
const hasAppliedFiltersOrActiveFolders = computed(() => {
|
||||
return props.hasAppliedFilters || props.hasActiveFolders;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-0"
|
||||
:class="{
|
||||
'pb-3 border-b border-slate-75 dark:border-slate-700':
|
||||
hasAppliedFiltersOrActiveFolders,
|
||||
}"
|
||||
>
|
||||
<div class="flex max-w-[85%] justify-center items-center">
|
||||
<h1
|
||||
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
|
||||
:title="pageTitle"
|
||||
>
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="hasAppliedFilters && !hasActiveFolders">
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="save"
|
||||
@click="emits('add-folders')"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
icon="dismiss-circle"
|
||||
@click="emits('reset-filters')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="hasActiveFolders">
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
@click="emits('filters-modal')"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
icon="delete"
|
||||
@click="emits('delete-folders')"
|
||||
/>
|
||||
</div>
|
||||
<woot-button
|
||||
v-else
|
||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="filter"
|
||||
size="tiny"
|
||||
@click="emits('filters-modal')"
|
||||
/>
|
||||
<conversation-basic-filter
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
@changeFilter="onBasicFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -27,7 +27,7 @@
|
||||
/>
|
||||
</span>
|
||||
<woot-button
|
||||
v-if="showActions && value"
|
||||
v-if="showCopyAndDeleteButton"
|
||||
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
|
||||
variant="link"
|
||||
size="medium"
|
||||
@@ -90,7 +90,7 @@
|
||||
</p>
|
||||
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
|
||||
<woot-button
|
||||
v-if="showActions && value"
|
||||
v-if="showCopyAndDeleteButton"
|
||||
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
|
||||
variant="link"
|
||||
size="small"
|
||||
@@ -100,7 +100,7 @@
|
||||
@click="onCopy"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="showActions"
|
||||
v-if="showEditButton"
|
||||
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
|
||||
variant="link"
|
||||
size="small"
|
||||
@@ -174,6 +174,12 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showCopyAndDeleteButton() {
|
||||
return this.value && this.showActions;
|
||||
},
|
||||
showEditButton() {
|
||||
return !this.value && this.showActions;
|
||||
},
|
||||
displayValue() {
|
||||
if (this.isAttributeTypeDate) {
|
||||
return this.value
|
||||
|
||||
@@ -99,9 +99,7 @@ export default {
|
||||
onMouseUp() {
|
||||
if (this.mousedDownOnBackdrop) {
|
||||
this.mousedDownOnBackdrop = false;
|
||||
if (this.closeOnBackdropClick) {
|
||||
this.onClose();
|
||||
}
|
||||
this.onClose();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="relative flex items-center justify-end resolve-actions">
|
||||
<div class="resolve-actions relative flex items-center justify-end">
|
||||
<div class="button-group">
|
||||
<woot-button
|
||||
v-if="isOpen"
|
||||
@@ -73,13 +73,25 @@
|
||||
</woot-dropdown-item>
|
||||
</woot-dropdown-menu>
|
||||
</div>
|
||||
<woot-modal
|
||||
:show.sync="showCustomSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<custom-snooze-modal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="chooseSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { mapGetters } from 'vuex';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
|
||||
@@ -87,12 +99,14 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
CMD_REOPEN_CONVERSATION,
|
||||
CMD_RESOLVE_CONVERSATION,
|
||||
CMD_SNOOZE_CONVERSATION,
|
||||
} from '../../routes/dashboard/commands/commandBarBusEvents';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootDropdownItem,
|
||||
WootDropdownMenu,
|
||||
CustomSnoozeModal,
|
||||
},
|
||||
mixins: [alertMixin, keyboardEventListenerMixins],
|
||||
props: { conversationId: { type: [String, Number], required: true } },
|
||||
@@ -101,6 +115,7 @@ export default {
|
||||
isLoading: false,
|
||||
showActionsDropdown: false,
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
showCustomSnoozeModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -128,10 +143,12 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
bus.$on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
|
||||
bus.$on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
|
||||
},
|
||||
destroyed() {
|
||||
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
bus.$off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
|
||||
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
|
||||
},
|
||||
@@ -184,6 +201,28 @@ export default {
|
||||
// error
|
||||
}
|
||||
},
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomSnoozeModal = true;
|
||||
} else {
|
||||
this.toggleStatus(
|
||||
this.STATUS_TYPE.SNOOZED,
|
||||
findSnoozeTime(snoozeType) || null
|
||||
);
|
||||
}
|
||||
},
|
||||
chooseSnoozeTime(customSnoozeTime) {
|
||||
this.showCustomSnoozeModal = false;
|
||||
if (customSnoozeTime) {
|
||||
this.toggleStatus(
|
||||
this.STATUS_TYPE.SNOOZED,
|
||||
getUnixTime(customSnoozeTime)
|
||||
);
|
||||
}
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
this.showCustomSnoozeModal = false;
|
||||
},
|
||||
onCmdOpenConversation() {
|
||||
this.toggleStatus(this.STATUS_TYPE.OPEN);
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')"
|
||||
:header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')"
|
||||
/>
|
||||
<div class="px-8 py-4">
|
||||
<div class="px-8 pt-4 pb-8">
|
||||
<div
|
||||
v-for="account in currentUser.accounts"
|
||||
:id="`account-${account.id}`"
|
||||
@@ -45,10 +45,10 @@
|
||||
|
||||
<div
|
||||
v-if="globalConfig.createNewAccountFromDashboard"
|
||||
class="flex justify-end items-center px-8 pb-8 pt-4 gap-2"
|
||||
class="flex justify-end items-center p-8 gap-2"
|
||||
>
|
||||
<button
|
||||
class="button success large expanded nice w-full"
|
||||
class="button success large expanded nice"
|
||||
@click="$emit('show-create-account-modal')"
|
||||
>
|
||||
{{ $t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
|
||||
|
||||
+3
-3
@@ -65,11 +65,11 @@
|
||||
</div>
|
||||
<span
|
||||
v-if="warningIcon"
|
||||
class="inline-flex mr-1 bg-red-50 dark:bg-red-900 p-0.5 rounded-sm"
|
||||
class="inline-flex rounded-sm mr-1 bg-slate-100"
|
||||
>
|
||||
<fluent-icon
|
||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||
class="text-xxs text-red-500 dark:text-red-300"
|
||||
v-tooltip.top-end="$t('SIDEBAR.FACEBOOK_REAUTHORIZE')"
|
||||
class="text-xxs"
|
||||
:icon="warningIcon"
|
||||
size="12"
|
||||
/>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,7 +5,6 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import getUuid from 'widget/helpers/uuid';
|
||||
import 'video.js/dist/video-js.css';
|
||||
import 'videojs-record/dist/css/videojs.record.css';
|
||||
|
||||
@@ -29,25 +28,9 @@ import OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-
|
||||
|
||||
import { format, addSeconds } from 'date-fns';
|
||||
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
||||
import { convertWavToMp3 } from './utils/mp3ConversionUtils';
|
||||
|
||||
WaveSurfer.microphone = MicrophonePlugin;
|
||||
|
||||
const RECORDER_CONFIG = {
|
||||
[AUDIO_FORMATS.WAV]: {
|
||||
audioMimeType: 'audio/wav',
|
||||
audioWorkerURL: waveWorker,
|
||||
},
|
||||
[AUDIO_FORMATS.MP3]: {
|
||||
audioMimeType: 'audio/wav',
|
||||
audioWorkerURL: waveWorker,
|
||||
},
|
||||
[AUDIO_FORMATS.OGG]: {
|
||||
audioMimeType: 'audio/ogg',
|
||||
audioWorkerURL: encoderWorker,
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'WootAudioRecorder',
|
||||
mixins: [alertMixin],
|
||||
@@ -56,10 +39,6 @@ export default {
|
||||
type: String,
|
||||
default: AUDIO_FORMATS.WAV,
|
||||
},
|
||||
isAWhatsAppChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -109,7 +88,14 @@ export default {
|
||||
audioSampleRate: 48000,
|
||||
audioBitRate: 128,
|
||||
audioEngine: 'opus-recorder',
|
||||
...RECORDER_CONFIG[this.audioRecordFormat],
|
||||
...(this.audioRecordFormat === AUDIO_FORMATS.WAV && {
|
||||
audioMimeType: 'audio/wav',
|
||||
audioWorkerURL: waveWorker,
|
||||
}),
|
||||
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
|
||||
audioMimeType: 'audio/ogg',
|
||||
audioWorkerURL: encoderWorker,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -147,11 +133,7 @@ export default {
|
||||
methods: {
|
||||
deviceReady() {
|
||||
if (this.player.record().engine instanceof OpusRecorderEngine) {
|
||||
if (
|
||||
[AUDIO_FORMATS.WAV, AUDIO_FORMATS.MP3].includes(
|
||||
this.audioRecordFormat
|
||||
)
|
||||
) {
|
||||
if (this.audioRecordFormat === AUDIO_FORMATS.WAV) {
|
||||
this.player.record().engine.audioType = 'audio/wav';
|
||||
}
|
||||
}
|
||||
@@ -163,16 +145,12 @@ export default {
|
||||
stopRecord() {
|
||||
this.fireStateRecorderChanged('stopped');
|
||||
},
|
||||
async finishRecord() {
|
||||
let recordedContent = this.player.recordedData;
|
||||
let fileName = this.player.recordedData.name;
|
||||
let type = this.player.recordedData.type;
|
||||
if (this.audioRecordFormat === AUDIO_FORMATS.MP3) {
|
||||
recordedContent = await convertWavToMp3(this.player.recordedData);
|
||||
fileName = `${getUuid()}.mp3`;
|
||||
type = AUDIO_FORMATS.MP3;
|
||||
}
|
||||
const file = new File([recordedContent], fileName, { type });
|
||||
finishRecord() {
|
||||
const file = new File(
|
||||
[this.player.recordedData],
|
||||
this.player.recordedData.name,
|
||||
{ type: this.player.recordedData.type }
|
||||
);
|
||||
this.fireRecorderBlob(file);
|
||||
},
|
||||
progressRecord() {
|
||||
@@ -253,13 +231,11 @@ export default {
|
||||
@apply bg-transparent max-h-60 min-h-[3rem] pt-4 px-0 pb-0 resize-none;
|
||||
}
|
||||
}
|
||||
|
||||
// Added to override the default text and bg style to support dark and light mode.
|
||||
.video-js .vjs-control-bar,
|
||||
.vjs-record.video-js .vjs-control.vjs-record-indicator:before {
|
||||
@apply text-slate-600 dark:text-slate-200 bg-transparent dark:bg-transparent;
|
||||
}
|
||||
|
||||
// Added to fix div overlays the screen and takes over the button clicks
|
||||
// https://github.com/collab-project/videojs-record/issues/688
|
||||
// https://github.com/collab-project/videojs-record/pull/709
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import lamejs from 'lamejs';
|
||||
/**
|
||||
* Encodes a mono channel audio stream to MP3 format.
|
||||
* @param {number} channels - Number of audio channels.
|
||||
* @param {number} sampleRate - Sample rate in Hz.
|
||||
* @param {Int16Array} samples - Audio samples to be encoded.
|
||||
* @returns {Blob} - The MP3 encoded audio as a Blob.
|
||||
*/
|
||||
export const encodeToMP3 = (channels, sampleRate, samples) => {
|
||||
const outputBuffer = [];
|
||||
const encoder = new lamejs.Mp3Encoder(channels, sampleRate, 128);
|
||||
const maxSamplesPerFrame = 1152;
|
||||
|
||||
for (let offset = 0; offset < samples.length; offset += maxSamplesPerFrame) {
|
||||
const sliceEnd = Math.min(offset + maxSamplesPerFrame, samples.length);
|
||||
const sampleSlice = samples.subarray(offset, sliceEnd);
|
||||
const mp3Buffer = encoder.encodeBuffer(sampleSlice);
|
||||
|
||||
if (mp3Buffer.length > 0) {
|
||||
outputBuffer.push(new Int8Array(mp3Buffer));
|
||||
}
|
||||
}
|
||||
|
||||
const remainingData = encoder.flush();
|
||||
if (remainingData.length > 0) {
|
||||
outputBuffer.push(new Int8Array(remainingData));
|
||||
}
|
||||
|
||||
return new Blob(outputBuffer, { type: 'audio/mp3' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a WAV audio Blob to an MP3 format Blob.
|
||||
* @param {Blob} blob - The audio data in WAV format as a Blob.
|
||||
* @returns {Promise<Blob>} - A Blob containing the MP3 encoded audio.
|
||||
*/
|
||||
export const convertWavToMp3 = async blob => {
|
||||
try {
|
||||
const audioBuffer = await blob.arrayBuffer();
|
||||
const wavHeader = lamejs.WavHeader.readHeader(new DataView(audioBuffer));
|
||||
const samples = new Int16Array(
|
||||
audioBuffer,
|
||||
wavHeader.dataOffset,
|
||||
wavHeader.dataLen / 2
|
||||
);
|
||||
|
||||
return encodeToMP3(wavHeader.channels, wavHeader.sampleRate, samples);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line
|
||||
console.log('Failed to convert WAV to MP3:', error);
|
||||
throw new Error('Conversion from WAV to MP3 failed.');
|
||||
}
|
||||
};
|
||||
@@ -103,7 +103,6 @@
|
||||
:status="chat.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="hasUnread"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
|
||||
@@ -71,10 +71,6 @@
|
||||
:class="{ 'justify-end': isContactPanelOpen }"
|
||||
>
|
||||
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
|
||||
<linear
|
||||
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
||||
:conversation-id="currentChat.id"
|
||||
/>
|
||||
<more-actions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,8 +89,6 @@ import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Linear from './linear/index.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -103,7 +97,6 @@ export default {
|
||||
MoreActions,
|
||||
Thumbnail,
|
||||
SLACardLabel,
|
||||
Linear,
|
||||
},
|
||||
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@@ -128,9 +121,6 @@ export default {
|
||||
...mapGetters({
|
||||
uiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
currentChat: 'getSelectedChat',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
appIntegrations: 'integrations/getAppIntegrations',
|
||||
}),
|
||||
chatMetadata() {
|
||||
return this.chat.meta;
|
||||
@@ -188,17 +178,6 @@ export default {
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
isLinearIntegrationEnabled() {
|
||||
return this.appIntegrations.find(
|
||||
integration => integration.id === 'linear' && !!integration.hooks.length
|
||||
);
|
||||
},
|
||||
isLinearFeatureEnabled() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.LINEAR
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@@ -501,10 +501,11 @@ export default {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
|
||||
return AUDIO_FORMATS.MP3;
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
if (
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAPIInbox ||
|
||||
this.isATelegramChannel
|
||||
) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
return AUDIO_FORMATS.WAV;
|
||||
@@ -1252,7 +1253,6 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.send-button {
|
||||
@apply mb-0;
|
||||
}
|
||||
@@ -1277,7 +1277,6 @@ export default {
|
||||
|
||||
.emoji-dialog--rtl {
|
||||
@apply left-[unset] -right-80;
|
||||
|
||||
&::before {
|
||||
transform: rotate(90deg);
|
||||
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<woot-input
|
||||
v-model.trim="$v.toEmailsVal.$model"
|
||||
type="text"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
|
||||
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
|
||||
:class="{ error: $v.toEmailsVal.$error }"
|
||||
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
|
||||
@blur="onBlur"
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="rounded-none flex-1 min-w-0 m-0 whitespace-nowrap">
|
||||
<woot-input
|
||||
v-model.trim="$v.ccEmailsVal.$model"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
|
||||
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
|
||||
type="text"
|
||||
:class="{ error: $v.ccEmailsVal.$error }"
|
||||
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
|
||||
@@ -54,7 +54,7 @@
|
||||
<woot-input
|
||||
v-model.trim="$v.bccEmailsVal.$model"
|
||||
type="text"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
|
||||
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
|
||||
:class="{ error: $v.bccEmailsVal.$error }"
|
||||
:placeholder="
|
||||
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
|
||||
|
||||
@@ -7,10 +7,8 @@
|
||||
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
|
||||
'-mx-2': !parentHasAttachments,
|
||||
}"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<message-preview
|
||||
class="cursor-pointer"
|
||||
:message="message"
|
||||
:show-message-type="false"
|
||||
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
|
||||
@@ -21,7 +19,6 @@
|
||||
<script>
|
||||
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
export default {
|
||||
name: 'ReplyTo',
|
||||
@@ -45,10 +42,5 @@ export default {
|
||||
data() {
|
||||
return { MESSAGE_TYPE };
|
||||
},
|
||||
methods: {
|
||||
scrollToMessage() {
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId: this.message.id });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
+2
-5
@@ -49,7 +49,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { evaluateSLAStatus } from '../helpers/SLAHelper';
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
@@ -137,10 +137,7 @@ export default {
|
||||
}, REFRESH_INTERVAL);
|
||||
},
|
||||
updateSlaStatus() {
|
||||
this.slaStatus = evaluateSLAStatus({
|
||||
appliedSla: this.appliedSLA,
|
||||
chat: this.chat,
|
||||
});
|
||||
this.slaStatus = evaluateSLAStatus(this.appliedSLA, this.chat);
|
||||
},
|
||||
openSlaPopover() {
|
||||
if (!this.showExtendedInfo) return;
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ const toggleShowAllNRT = () => {
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<span class="text-sm font-medium text-slate-900 dark:text-slate-25">
|
||||
{{ $t('SLA.EVENTS.TITLE') }}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
/>
|
||||
</template>
|
||||
<menu-item
|
||||
v-if="showSnooze"
|
||||
v-if="show(snoozeOption.key)"
|
||||
:option="snoozeOption"
|
||||
variant="icon"
|
||||
@click="snoozeConversation()"
|
||||
@@ -86,10 +86,6 @@ export default {
|
||||
},
|
||||
mixins: [agentMixin],
|
||||
props: {
|
||||
chatId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
@@ -209,10 +205,6 @@ export default {
|
||||
...this.filteredAgentOnAvailability,
|
||||
];
|
||||
},
|
||||
showSnooze() {
|
||||
// Don't show snooze if the conversation is already snoozed/resolved/pending
|
||||
return this.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
||||
@@ -221,8 +213,7 @@ export default {
|
||||
toggleStatus(status, snoozedUntil) {
|
||||
this.$emit('update-conversation', status, snoozedUntil);
|
||||
},
|
||||
async snoozeConversation() {
|
||||
await this.$store.dispatch('setContextMenuChatId', this.chatId);
|
||||
snoozeConversation() {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'snooze_conversation' });
|
||||
},
|
||||
|
||||
+2
-61
@@ -95,40 +95,20 @@
|
||||
<div v-if="allConversationsSelected" class="bulk-action__alert">
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<woot-modal
|
||||
:show.sync="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<custom-snooze-modal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||
|
||||
import AgentSelector from './AgentSelector.vue';
|
||||
import UpdateActions from './UpdateActions.vue';
|
||||
import LabelActions from './LabelActions.vue';
|
||||
import TeamActions from './TeamActions.vue';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
export default {
|
||||
components: {
|
||||
AgentSelector,
|
||||
UpdateActions,
|
||||
LabelActions,
|
||||
TeamActions,
|
||||
CustomSnoozeModal,
|
||||
},
|
||||
props: {
|
||||
conversations: {
|
||||
@@ -163,56 +143,17 @@ export default {
|
||||
showLabelActions: false,
|
||||
showTeamsList: false,
|
||||
popoverPositions: {},
|
||||
showCustomTimeSnoozeModal: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
bus.$on(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
|
||||
bus.$on(
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
this.onCmdResolveConversation
|
||||
);
|
||||
},
|
||||
destroyed() {
|
||||
bus.$off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
bus.$off(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
|
||||
bus.$off(
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
this.onCmdResolveConversation
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomTimeSnoozeModal = true;
|
||||
} else {
|
||||
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
|
||||
}
|
||||
},
|
||||
onCmdReopenConversation() {
|
||||
this.updateConversations('open', null);
|
||||
},
|
||||
onCmdResolveConversation() {
|
||||
this.updateConversations('resolved', null);
|
||||
},
|
||||
customSnoozeTime(customSnoozedTime) {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
if (customSnoozedTime) {
|
||||
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
|
||||
}
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
},
|
||||
selectAll(e) {
|
||||
this.$emit('select-all-conversations', e.target.checked);
|
||||
},
|
||||
submit(agent) {
|
||||
this.$emit('assign-agent', agent);
|
||||
},
|
||||
updateConversations(status, snoozedUntil) {
|
||||
this.$emit('update-conversations', status, snoozedUntil);
|
||||
updateConversations(status) {
|
||||
this.$emit('update-conversations', status);
|
||||
},
|
||||
assignLabels(labels) {
|
||||
this.$emit('assign-labels', labels);
|
||||
|
||||
+118
-93
@@ -1,98 +1,12 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const emits = defineEmits(['update', 'close']);
|
||||
|
||||
const props = defineProps({
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
showResolve: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showReopen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSnooze: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const actions = ref([
|
||||
{ icon: 'checkmark', key: 'resolved' },
|
||||
{ icon: 'arrow-redo', key: 'open' },
|
||||
{ icon: 'send-clock', key: 'snoozed' },
|
||||
]);
|
||||
|
||||
const updateConversations = key => {
|
||||
if (key === 'snoozed') {
|
||||
// If the user clicks on the snooze option from the bulk action change status dropdown.
|
||||
// Open the snooze option for bulk action in the cmd bar.
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
|
||||
} else {
|
||||
emits('update', key);
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emits('close');
|
||||
};
|
||||
|
||||
const showAction = key => {
|
||||
const actionsMap = {
|
||||
resolved: props.showResolve,
|
||||
open: props.showReopen,
|
||||
snoozed: props.showSnooze,
|
||||
};
|
||||
return actionsMap[key] || false;
|
||||
};
|
||||
|
||||
const actionLabel = key => {
|
||||
const labelsMap = {
|
||||
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
|
||||
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
|
||||
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
|
||||
};
|
||||
return labelsMap[key] || '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-clickaway="onClose"
|
||||
class="absolute right-2 top-12 origin-top-right w-auto z-20 bg-white dark:bg-slate-800 rounded-lg border border-solid border-slate-50 dark:border-slate-700 shadow-md"
|
||||
>
|
||||
<div
|
||||
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
|
||||
>
|
||||
<div v-on-clickaway="onClose" class="actions-container">
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path
|
||||
d="M20 12l-8-8-12 12"
|
||||
fill-rule="evenodd"
|
||||
stroke-width="1px"
|
||||
class="fill-white dark:fill-slate-800 stroke-slate-50 dark:stroke-slate-600/50"
|
||||
/>
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="p-2.5 flex gap-1 items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-600 dark:text-slate-100">
|
||||
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
|
||||
</span>
|
||||
<div class="header flex items-center justify-between">
|
||||
<span>{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}</span>
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
@@ -101,8 +15,8 @@ const actionLabel = key => {
|
||||
@click="onClose"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-2.5 pt-0 pb-2.5">
|
||||
<woot-dropdown-menu class="m-0 list-none">
|
||||
<div class="container">
|
||||
<woot-dropdown-menu>
|
||||
<template v-for="action in actions">
|
||||
<woot-dropdown-item v-if="showAction(action.key)" :key="action.key">
|
||||
<woot-button
|
||||
@@ -120,3 +34,114 @@ const actionLabel = key => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootDropdownItem,
|
||||
WootDropdownMenu,
|
||||
},
|
||||
props: {
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
showResolve: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showReopen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSnooze: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedAction: null,
|
||||
actions: [
|
||||
{
|
||||
icon: 'checkmark',
|
||||
key: 'resolved',
|
||||
},
|
||||
{
|
||||
icon: 'arrow-redo',
|
||||
key: 'open',
|
||||
},
|
||||
{
|
||||
icon: 'send-clock',
|
||||
key: 'snoozed',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
updateConversations(key) {
|
||||
this.$emit('update', key);
|
||||
},
|
||||
goBack() {
|
||||
this.selectedAgent = null;
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
showAction(key) {
|
||||
const actionsMap = {
|
||||
resolved: this.showResolve,
|
||||
open: this.showReopen,
|
||||
snoozed: this.showSnooze,
|
||||
};
|
||||
return actionsMap[key] || false;
|
||||
},
|
||||
actionLabel(key) {
|
||||
const labelsMap = {
|
||||
resolved: this.$t('CONVERSATION.HEADER.RESOLVE_ACTION'),
|
||||
open: this.$t('CONVERSATION.HEADER.REOPEN_ACTION'),
|
||||
snoozed: this.$t('BULK_ACTION.UPDATE.SNOOZE_UNTIL_NEXT_REPLY'),
|
||||
};
|
||||
return labelsMap[key] || '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.actions-container {
|
||||
@apply absolute right-2 top-12 origin-top-right w-auto z-20 bg-white dark:bg-slate-800 rounded-lg border border-solid border-slate-50 dark:border-slate-700 shadow-md;
|
||||
|
||||
.header {
|
||||
@apply p-2.5;
|
||||
|
||||
span {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
}
|
||||
.container {
|
||||
@apply px-2.5 pt-0 pb-2.5;
|
||||
}
|
||||
|
||||
.triangle {
|
||||
right: var(--triangle-position);
|
||||
@apply block z-10 absolute text-left -top-3;
|
||||
|
||||
svg path {
|
||||
@apply fill-white dark:fill-slate-800 stroke-slate-50 dark:stroke-slate-600/50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply m-0 list-none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
const calculateThreshold = (timeOffset, threshold) => {
|
||||
// Calculate the time left for the SLA to breach or the time since the SLA has missed
|
||||
if (threshold === null) return null;
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return timeOffset + threshold - currentTime;
|
||||
};
|
||||
|
||||
const findMostUrgentSLAStatus = SLAStatuses => {
|
||||
// Sort the SLAs based on the threshold and return the most urgent SLA
|
||||
SLAStatuses.sort(
|
||||
(sla1, sla2) => Math.abs(sla1.threshold) - Math.abs(sla2.threshold)
|
||||
);
|
||||
return SLAStatuses[0];
|
||||
};
|
||||
|
||||
const formatSLATime = seconds => {
|
||||
const units = {
|
||||
y: 31536000, // 60 * 60 * 24 * 365
|
||||
mo: 2592000, // 60 * 60 * 24 * 30
|
||||
d: 86400, // 60 * 60 * 24
|
||||
h: 3600, // 60 * 60
|
||||
m: 60,
|
||||
};
|
||||
|
||||
if (seconds < 60) {
|
||||
return '1m';
|
||||
}
|
||||
|
||||
// we will only show two parts, two max granularity's, h-m, y-d, d-h, m, but no seconds
|
||||
const parts = [];
|
||||
|
||||
Object.keys(units).forEach(unit => {
|
||||
const value = Math.floor(seconds / units[unit]);
|
||||
if (seconds < 60 && parts.length > 0) return;
|
||||
if (parts.length === 2) return;
|
||||
if (value > 0) {
|
||||
parts.push(value + unit);
|
||||
seconds -= value * units[unit];
|
||||
}
|
||||
});
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const createSLAObject = (
|
||||
type,
|
||||
{
|
||||
sla_first_response_time_threshold: frtThreshold,
|
||||
sla_next_response_time_threshold: nrtThreshold,
|
||||
sla_resolution_time_threshold: rtThreshold,
|
||||
created_at: createdAt,
|
||||
} = {},
|
||||
{
|
||||
first_reply_created_at: firstReplyCreatedAt,
|
||||
waiting_since: waitingSince,
|
||||
status,
|
||||
} = {}
|
||||
) => {
|
||||
// Mapping of breach types to their logic
|
||||
const SLATypes = {
|
||||
FRT: {
|
||||
threshold: calculateThreshold(createdAt, frtThreshold),
|
||||
// Check FRT only if threshold is not null and first reply hasn't been made
|
||||
condition:
|
||||
frtThreshold !== null &&
|
||||
(!firstReplyCreatedAt || firstReplyCreatedAt === 0),
|
||||
},
|
||||
NRT: {
|
||||
threshold: calculateThreshold(waitingSince, nrtThreshold),
|
||||
// Check NRT only if threshold is not null, first reply has been made and we are waiting since
|
||||
condition:
|
||||
nrtThreshold !== null && !!firstReplyCreatedAt && !!waitingSince,
|
||||
},
|
||||
RT: {
|
||||
threshold: calculateThreshold(createdAt, rtThreshold),
|
||||
// Check RT only if the conversation is open and threshold is not null
|
||||
condition: status === 'open' && rtThreshold !== null,
|
||||
},
|
||||
};
|
||||
|
||||
const SLAStatus = SLATypes[type];
|
||||
return SLAStatus ? { ...SLAStatus, type } : null;
|
||||
};
|
||||
|
||||
const evaluateSLAConditions = (appliedSla, chat) => {
|
||||
// Filter out the SLA based on conditions and update the object with the breach status(icon, isSlaMissed)
|
||||
const SLATypes = ['FRT', 'NRT', 'RT'];
|
||||
return SLATypes.map(type => createSLAObject(type, appliedSla, chat))
|
||||
.filter(SLAStatus => SLAStatus && SLAStatus.condition)
|
||||
.map(SLAStatus => ({
|
||||
...SLAStatus,
|
||||
icon: SLAStatus.threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: SLAStatus.threshold <= 0,
|
||||
}));
|
||||
};
|
||||
|
||||
export const evaluateSLAStatus = (appliedSla, chat) => {
|
||||
if (!appliedSla || !chat)
|
||||
return { type: '', threshold: '', icon: '', isSlaMissed: false };
|
||||
|
||||
// Filter out the SLA and create the object for each breach
|
||||
const SLAStatuses = evaluateSLAConditions(appliedSla, chat);
|
||||
|
||||
// Return the most urgent SLA which is latest to breach or has missed
|
||||
const mostUrgent = findMostUrgentSLAStatus(SLAStatuses);
|
||||
return mostUrgent
|
||||
? {
|
||||
type: mostUrgent.type,
|
||||
threshold: formatSLATime(
|
||||
mostUrgent.threshold <= 0
|
||||
? -mostUrgent.threshold
|
||||
: mostUrgent.threshold
|
||||
),
|
||||
icon: mostUrgent.icon,
|
||||
isSlaMissed: mostUrgent.isSlaMissed,
|
||||
}
|
||||
: { type: '', threshold: '', icon: '', isSlaMissed: false };
|
||||
};
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { evaluateSLAStatus } from '../SLAHelper';
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => new Date('2024-01-01T00:00:00Z').getTime());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('SLAHelper', () => {
|
||||
describe('evaluateSLAStatus', () => {
|
||||
it('returns an empty object when sla or chat is not present', () => {
|
||||
expect(evaluateSLAStatus(null, null)).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when FRT SLA is missed
|
||||
it('correctly identifies a missed FRT SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704066540,
|
||||
};
|
||||
const chatMissed = {
|
||||
first_reply_created_at: 0,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
|
||||
type: 'FRT',
|
||||
threshold: '1m',
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when FRT SLA is not missed
|
||||
it('correctly identifies an FRT SLA not yet breached', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704066660,
|
||||
};
|
||||
const chatNotMissed = {
|
||||
first_reply_created_at: 0,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
|
||||
type: 'FRT',
|
||||
threshold: '1m',
|
||||
icon: 'alarm',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when NRT SLA is missed
|
||||
it('correctly identifies a missed NRT SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065200,
|
||||
};
|
||||
const chatMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 1704065940,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
|
||||
type: 'NRT',
|
||||
threshold: '1m',
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when NRT SLA is not missed
|
||||
it('correctly identifies an NRT SLA not yet breached', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065200 - 2000,
|
||||
};
|
||||
const chatNotMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 1704066060,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
|
||||
type: 'NRT',
|
||||
threshold: '1m',
|
||||
icon: 'alarm',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when RT SLA is missed
|
||||
it('correctly identifies a missed RT SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065340,
|
||||
};
|
||||
const chatMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
|
||||
type: 'RT',
|
||||
threshold: '1m',
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when RT SLA is not missed
|
||||
it('correctly identifies an RT SLA not yet breached', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065460,
|
||||
};
|
||||
const chatNotMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
|
||||
type: 'RT',
|
||||
threshold: '1m',
|
||||
icon: 'alarm',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
<template>
|
||||
<div @submit.prevent="onSubmit">
|
||||
<woot-input
|
||||
v-model="formState.title"
|
||||
:class="{ error: v$.title.$error }"
|
||||
class="w-full"
|
||||
:styles="{ ...inputStyles, padding: '6px 12px' }"
|
||||
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
|
||||
:placeholder="
|
||||
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
|
||||
"
|
||||
:error="nameError"
|
||||
@input="v$.title.$touch"
|
||||
/>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
|
||||
<textarea
|
||||
v-model="formState.description"
|
||||
:style="{ ...inputStyles, padding: '8px 12px' }"
|
||||
rows="3"
|
||||
class="text-sm"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<label :class="{ error: v$.teamId.$error }">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL') }}
|
||||
<select
|
||||
v-model="formState.teamId"
|
||||
:style="inputStyles"
|
||||
@change="onChangeTeam"
|
||||
>
|
||||
<option v-for="item in teams" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="v$.teamId.$error" class="message">
|
||||
{{ teamError }}
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL') }}
|
||||
<select v-model="formState.assigneeId" :style="inputStyles">
|
||||
<option v-for="item in assignees" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL') }}
|
||||
<select v-model="formState.labelId" :style="inputStyles">
|
||||
<option v-for="item in labels" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL') }}
|
||||
<select v-model="formState.priority" :style="inputStyles">
|
||||
<option v-for="item in priorities" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL') }}
|
||||
<select v-model="formState.projectId" :style="inputStyles">
|
||||
<option v-for="item in projects" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL') }}
|
||||
<select v-model="formState.stateId" :style="inputStyles">
|
||||
<option v-for="item in statuses" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex items-center justify-end w-full gap-2 mt-8">
|
||||
<woot-button
|
||||
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
|
||||
@click.prevent="onClose"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:is-disabled="isSubmitDisabled"
|
||||
class="px-4 rounded-xl"
|
||||
:is-loading="isCreating"
|
||||
@click.prevent="createIssue"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, computed, onMounted, ref } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import validations from './validations';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
const props = defineProps({
|
||||
accountId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const teams = ref([]);
|
||||
const assignees = ref([]);
|
||||
const projects = ref([]);
|
||||
const labels = ref([]);
|
||||
const statuses = ref([]);
|
||||
|
||||
const priorities = [
|
||||
{ id: 0, name: 'No priority' },
|
||||
{ id: 1, name: 'Urgent' },
|
||||
{ id: 2, name: 'High' },
|
||||
{ id: 3, name: 'Normal' },
|
||||
{ id: 4, name: 'Low' },
|
||||
];
|
||||
|
||||
const statusDesiredOrder = [
|
||||
'Backlog',
|
||||
'Todo',
|
||||
'In Progress',
|
||||
'Done',
|
||||
'Canceled',
|
||||
];
|
||||
|
||||
const isCreating = ref(false);
|
||||
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
|
||||
|
||||
const formState = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
teamId: '',
|
||||
assigneeId: '',
|
||||
labelId: '',
|
||||
stateId: '',
|
||||
priority: '',
|
||||
projectId: '',
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(validations, formState);
|
||||
|
||||
const isSubmitDisabled = computed(
|
||||
() => v$.value.title.$invalid || isCreating.value
|
||||
);
|
||||
const nameError = computed(() =>
|
||||
v$.value.title.$error
|
||||
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
|
||||
: ''
|
||||
);
|
||||
const teamError = computed(() =>
|
||||
v$.value.teamId.$error
|
||||
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const onClose = () => emit('close');
|
||||
|
||||
const getTeams = async () => {
|
||||
try {
|
||||
const response = await LinearAPI.getTeams();
|
||||
teams.value = response.data;
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const getTeamEntities = async () => {
|
||||
try {
|
||||
const response = await LinearAPI.getTeamEntities(formState.teamId);
|
||||
assignees.value = response.data.users;
|
||||
labels.value = response.data.labels;
|
||||
projects.value = response.data.projects;
|
||||
statuses.value = statusDesiredOrder
|
||||
.map(name => response.data.states.find(status => status.name === name))
|
||||
.filter(Boolean);
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeTeam = event => {
|
||||
formState.teamId = event.target.value;
|
||||
formState.assigneeId = '';
|
||||
formState.stateId = '';
|
||||
formState.labelId = '';
|
||||
getTeamEntities();
|
||||
};
|
||||
|
||||
const createIssue = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
const payload = {
|
||||
team_id: formState.teamId,
|
||||
title: formState.title,
|
||||
description: formState.description || undefined,
|
||||
assignee_id: formState.assigneeId || undefined,
|
||||
project_id: formState.projectId || undefined,
|
||||
state_id: formState.stateId || undefined,
|
||||
priority: formState.priority || undefined,
|
||||
label_ids: formState.labelId ? [formState.labelId] : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
isCreating.value = true;
|
||||
const response = await LinearAPI.createIssue(payload);
|
||||
const { id: issueId } = response.data;
|
||||
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(getTeams);
|
||||
</script>
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<woot-modal-header
|
||||
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
|
||||
:header-content="
|
||||
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<div class="flex flex-col px-8 pb-4">
|
||||
<woot-tabs
|
||||
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
|
||||
:index="selectedTabIndex"
|
||||
@change="onClickTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
/>
|
||||
</woot-tabs>
|
||||
</div>
|
||||
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
|
||||
<create-issue
|
||||
:account-id="accountId"
|
||||
:conversation-id="conversation.id"
|
||||
:title="title"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col px-8 pb-4">
|
||||
<link-issue
|
||||
:conversation-id="conversation.id"
|
||||
:title="title"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import LinkIssue from './LinkIssue.vue';
|
||||
import CreateIssue from './CreateIssue.vue';
|
||||
|
||||
const props = defineProps({
|
||||
accountId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedTabIndex = ref(0);
|
||||
|
||||
const title = computed(() => {
|
||||
const { meta: { sender: { name = null } = {} } = {} } = props.conversation;
|
||||
return t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_TITLE', {
|
||||
conversationId: props.conversation.id,
|
||||
name,
|
||||
});
|
||||
});
|
||||
|
||||
const emits = defineEmits(['close']);
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
key: 0,
|
||||
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
|
||||
},
|
||||
]);
|
||||
const onClose = () => {
|
||||
emits('close');
|
||||
};
|
||||
|
||||
const onClickTabChange = index => {
|
||||
selectedTabIndex.value = index;
|
||||
};
|
||||
</script>
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
|
||||
import IssueHeader from './IssueHeader.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const priorityMap = {
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
issue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
linkId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['unlink-issue']);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const { createdAt } = props.issue;
|
||||
return format(new Date(createdAt), 'hh:mm a, MMM dd');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const assigneeDetails = props.issue.assignee;
|
||||
|
||||
if (!assigneeDetails) return null;
|
||||
const { name, avatarUrl } = assigneeDetails;
|
||||
|
||||
return {
|
||||
name,
|
||||
thumbnail: avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const labels = computed(() => {
|
||||
return props.issue.labels?.nodes || [];
|
||||
});
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
return priorityMap[props.issue.priority];
|
||||
});
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlink-issue', props.linkId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<div class="flex flex-col w-full">
|
||||
<issue-header
|
||||
:identifier="issue.identifier"
|
||||
:link-id="linkId"
|
||||
:issue-url="issue.url"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
|
||||
<span class="mt-2 text-sm font-medium text-ash-900">
|
||||
{{ issue.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="issue.description"
|
||||
class="mt-1 text-sm text-ash-800 line-clamp-3"
|
||||
>
|
||||
{{ issue.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-row items-center h-6 gap-2">
|
||||
<user-avatar-with-name v-if="assignee" :user="assignee" class="py-1" />
|
||||
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
|
||||
<div class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
icon="status"
|
||||
size="14"
|
||||
:style="{ color: issue.state.color }"
|
||||
/>
|
||||
<h6 class="text-xs text-ash-900">
|
||||
{{ issue.state.name }}
|
||||
</h6>
|
||||
</div>
|
||||
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
|
||||
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
:icon="`priority-${priorityLabel.toLowerCase()}`"
|
||||
size="14"
|
||||
view-box="0 0 12 12"
|
||||
/>
|
||||
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
|
||||
<woot-label
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:title="label.name"
|
||||
:description="label.description"
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-ash-800">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
|
||||
createdAt: formattedDate,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-row justify-between">
|
||||
<div
|
||||
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="19"
|
||||
class="text-[#5E6AD2]"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
class="h-[24px]"
|
||||
:is-loading="isUnlinking"
|
||||
@click="unlinkIssue"
|
||||
>
|
||||
<fluent-icon
|
||||
v-if="!isUnlinking"
|
||||
icon="unlink"
|
||||
size="12"
|
||||
type="outline"
|
||||
icon-lib="lucide"
|
||||
/>
|
||||
</woot-button>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
class="h-[24px]"
|
||||
color-scheme="secondary"
|
||||
@click="openIssue"
|
||||
>
|
||||
<fluent-icon icon="arrow-up-right" size="14" />
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
const props = defineProps({
|
||||
identifier: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
issueUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isUnlinking = inject('isUnlinking');
|
||||
|
||||
const emit = defineEmits(['unlink-issue']);
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlink-issue');
|
||||
};
|
||||
|
||||
const openIssue = () => {
|
||||
window.open(props.issueUrl, '_blank');
|
||||
};
|
||||
</script>
|
||||
@@ -1,143 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between"
|
||||
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
|
||||
>
|
||||
<filter-button
|
||||
right-icon="chevron-down"
|
||||
:button-text="linkIssueTitle"
|
||||
class="justify-between w-full bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<template v-if="shouldShowDropdown" #dropdown>
|
||||
<filter-list-dropdown
|
||||
v-if="issues"
|
||||
v-on-clickaway="toggleDropdown"
|
||||
:show-clear-filter="false"
|
||||
:list-items="issues"
|
||||
:active-filter-id="selectedOption.id"
|
||||
:is-loading="isFetching"
|
||||
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
|
||||
:loading-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING')"
|
||||
enable-search
|
||||
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
|
||||
@on-search="onSearch"
|
||||
@click="onSelectIssue"
|
||||
/>
|
||||
</template>
|
||||
</filter-button>
|
||||
<div class="flex items-center justify-end w-full gap-2">
|
||||
<woot-button
|
||||
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
|
||||
@click.prevent="onClose"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:is-disabled="isSubmitDisabled"
|
||||
class="px-4 rounded-xl"
|
||||
:is-loading="isLinking"
|
||||
@click.prevent="linkIssue"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const issues = ref([]);
|
||||
const shouldShowDropdown = ref(false);
|
||||
const selectedOption = ref({ id: null, name: '' });
|
||||
const isFetching = ref(false);
|
||||
const isLinking = ref(false);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const toggleDropdown = () => {
|
||||
issues.value = [];
|
||||
shouldShowDropdown.value = !shouldShowDropdown.value;
|
||||
};
|
||||
|
||||
const linkIssueTitle = computed(() => {
|
||||
return selectedOption.value.id
|
||||
? selectedOption.value.name
|
||||
: t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT');
|
||||
});
|
||||
|
||||
const isSubmitDisabled = computed(() => {
|
||||
return !selectedOption.value.id || isLinking.value;
|
||||
});
|
||||
|
||||
const onSelectIssue = item => {
|
||||
selectedOption.value = item;
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emits('close');
|
||||
};
|
||||
|
||||
const onSearch = async value => {
|
||||
issues.value = [];
|
||||
if (!value) return;
|
||||
searchQuery.value = value;
|
||||
try {
|
||||
isFetching.value = true;
|
||||
const response = await LinearAPI.searchIssues(value);
|
||||
issues.value = response.data.map(issue => ({
|
||||
id: issue.id,
|
||||
name: `${issue.identifier} ${issue.title}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isFetching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const linkIssue = async () => {
|
||||
const { id: issueId } = selectedOption.value;
|
||||
try {
|
||||
isLinking.value = true;
|
||||
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS'));
|
||||
searchQuery.value = '';
|
||||
issues.value = [];
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isLinking.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<div class="relative" :class="{ group: linkedIssue }">
|
||||
<woot-button
|
||||
v-on-clickaway="closeIssue"
|
||||
v-tooltip="tooltipText"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
@click="openIssue"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="19"
|
||||
class="text-[#5E6AD2]"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span v-if="linkedIssue" class="text-xs font-medium text-ash-800">
|
||||
{{ linkedIssue.issue.identifier }}
|
||||
</span>
|
||||
</woot-button>
|
||||
<issue
|
||||
v-if="linkedIssue"
|
||||
:issue="linkedIssue.issue"
|
||||
:link-id="linkedIssue.id"
|
||||
class="absolute right-0 top-[40px] invisible group-hover:visible"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
<woot-modal
|
||||
:show.sync="shouldShowPopup"
|
||||
:on-close="closePopup"
|
||||
:close-on-backdrop-click="false"
|
||||
class="!items-start [&>div]:!top-12 [&>div]:sticky"
|
||||
>
|
||||
<create-or-link-issue
|
||||
:conversation="conversation"
|
||||
:account-id="currentAccountId"
|
||||
@close="closePopup"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch, defineComponent, provide } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
|
||||
import Issue from './Issue.vue';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
defineComponent({
|
||||
name: 'Linear',
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const linkedIssue = ref(null);
|
||||
const shouldShow = ref(false);
|
||||
const shouldShowPopup = ref(false);
|
||||
const isUnlinking = ref(false);
|
||||
|
||||
provide('isUnlinking', isUnlinking);
|
||||
|
||||
const currentAccountId = getters.getCurrentAccountId;
|
||||
|
||||
const conversation = computed(() =>
|
||||
getters.getConversationById.value(props.conversationId)
|
||||
);
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
return linkedIssue.value === null
|
||||
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
|
||||
: null;
|
||||
});
|
||||
|
||||
const loadLinkedIssue = async () => {
|
||||
linkedIssue.value = null;
|
||||
try {
|
||||
const response = await LinearAPI.getLinkedIssue(props.conversationId);
|
||||
const issues = response.data;
|
||||
linkedIssue.value = issues && issues.length ? issues[0] : null;
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkIssue = async linkId => {
|
||||
try {
|
||||
isUnlinking.value = true;
|
||||
await LinearAPI.unlinkIssue(linkId);
|
||||
linkedIssue.value = null;
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isUnlinking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openIssue = () => {
|
||||
if (!linkedIssue.value) shouldShowPopup.value = true;
|
||||
shouldShow.value = true;
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
shouldShowPopup.value = false;
|
||||
loadLinkedIssue();
|
||||
};
|
||||
|
||||
const closeIssue = () => {
|
||||
shouldShow.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
loadLinkedIssue();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadLinkedIssue();
|
||||
});
|
||||
</script>
|
||||
@@ -1,10 +0,0 @@
|
||||
import { required } from '@vuelidate/validators';
|
||||
|
||||
export default {
|
||||
title: {
|
||||
required,
|
||||
},
|
||||
teamId: {
|
||||
required,
|
||||
},
|
||||
};
|
||||
@@ -15,7 +15,6 @@
|
||||
<span v-if="error" class="message">
|
||||
{{ error }}
|
||||
</span>
|
||||
<slot name="masked" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
<template>
|
||||
<div class="phone-input--wrap relative">
|
||||
<div
|
||||
class="flex items-center dark:bg-slate-900 justify-start rounded-md border border-solid"
|
||||
:class="
|
||||
error
|
||||
? 'border border-solid border-red-400 dark:border-red-400 mb-1'
|
||||
: 'mb-4 border-slate-200 dark:border-slate-600'
|
||||
"
|
||||
>
|
||||
<div class="relative phone-input--wrap">
|
||||
<div class="phone-input" :class="{ 'has-error': error }">
|
||||
<div
|
||||
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
|
||||
@click.prevent="toggleCountryDropdown"
|
||||
@click="toggleCountryDropdown"
|
||||
>
|
||||
<h5 v-if="activeCountry" class="mb-0">
|
||||
{{ activeCountry.emoji }}
|
||||
@@ -20,15 +13,14 @@
|
||||
</div>
|
||||
<span
|
||||
v-if="activeDialCode"
|
||||
class="flex bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 font-normal text-base leading-normal py-2 pl-2 pr-0"
|
||||
class="flex py-2 pl-2 pr-0 text-base font-normal font-medium leading-normal bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100"
|
||||
>
|
||||
{{ activeDialCode }}
|
||||
</span>
|
||||
<input
|
||||
ref="phoneNumberInput"
|
||||
:value="phoneNumber"
|
||||
type="tel"
|
||||
class="!mb-0 !rounded-tl-none !rounded-bl-none !border-0 font-normal !w-full dark:!bg-slate-900 text-base !px-1.5 placeholder:font-normal"
|
||||
class="phone-input--field"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:style="styles"
|
||||
@@ -36,53 +28,41 @@
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
ref="dropdown"
|
||||
v-on-clickaway="onOutsideClick"
|
||||
tabindex="0"
|
||||
class="z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900"
|
||||
@keydown.prevent.up="moveUp"
|
||||
@keydown.prevent.down="moveDown"
|
||||
@keydown.prevent.enter="
|
||||
onSelectCountry(filteredCountriesBySearch[selectedIndex])
|
||||
"
|
||||
>
|
||||
<div class="top-0 sticky bg-white dark:bg-slate-900 p-1">
|
||||
<div v-if="showDropdown" ref="dropdown" class="country-dropdown">
|
||||
<div class="dropdown-search--wrap">
|
||||
<input
|
||||
ref="searchbar"
|
||||
v-model="searchCountry"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
class="!h-8 !mb-0 !text-sm !border !border-solid !border-slate-200 dark:!border-slate-600"
|
||||
@input="onSearchCountry"
|
||||
class="dropdown-search"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="(country, index) in filteredCountriesBySearch"
|
||||
ref="dropdownItem"
|
||||
:key="index"
|
||||
class="flex items-center h-7 py-0 px-1 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700"
|
||||
class="country-dropdown--item"
|
||||
:class="{
|
||||
'bg-slate-50 dark:bg-slate-700': country.id === activeCountryCode,
|
||||
'bg-slate-25 dark:bg-slate-800': index === selectedIndex,
|
||||
active: country.id === activeCountryCode,
|
||||
focus: index === selectedIndex,
|
||||
}"
|
||||
@click="onSelectCountry(country)"
|
||||
>
|
||||
<span class="text-base mr-1">{{ country.emoji }}</span>
|
||||
<span class="mr-1 text-base">{{ country.emoji }}</span>
|
||||
|
||||
<span
|
||||
class="max-w-[7.5rem] overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{{ country.name }}
|
||||
</span>
|
||||
<span class="ml-1 text-slate-300 dark:text-slate-300 text-xs">{{
|
||||
<span class="ml-1 text-xs text-slate-300 dark:text-slate-300">{{
|
||||
country.dial_code
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="filteredCountriesBySearch.length === 0">
|
||||
<span
|
||||
class="flex items-center justify-center text-sm text-slate-500 dark:text-slate-300 mt-4"
|
||||
class="flex items-center justify-center mt-4 text-sm text-slate-500 dark:text-slate-300"
|
||||
>
|
||||
No results found
|
||||
</span>
|
||||
@@ -94,8 +74,10 @@
|
||||
<script>
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import parsePhoneNumber from 'libphonenumber-js';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
export default {
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Number],
|
||||
@@ -120,6 +102,15 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
countries: [
|
||||
{
|
||||
name: 'Select Country',
|
||||
dial_code: '',
|
||||
emoji: '',
|
||||
id: '',
|
||||
},
|
||||
...countries,
|
||||
],
|
||||
selectedIndex: -1,
|
||||
showDropdown: false,
|
||||
searchCountry: '',
|
||||
@@ -129,20 +120,6 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
countries() {
|
||||
return [
|
||||
{
|
||||
name: this.dropdownFirstItemName,
|
||||
dial_code: '',
|
||||
emoji: '',
|
||||
id: '',
|
||||
},
|
||||
...countries,
|
||||
];
|
||||
},
|
||||
dropdownFirstItemName() {
|
||||
return this.activeCountryCode ? 'Clear selection' : 'Select Country';
|
||||
},
|
||||
filteredCountriesBySearch() {
|
||||
return this.countries.filter(country => {
|
||||
const { name, dial_code, id } = country;
|
||||
@@ -177,8 +154,12 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('mouseup', this.onOutsideClick);
|
||||
this.setActiveCountry();
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('mouseup', this.onOutsideClick);
|
||||
},
|
||||
methods: {
|
||||
onOutsideClick(e) {
|
||||
if (
|
||||
@@ -196,14 +177,28 @@ export default {
|
||||
onBlur(e) {
|
||||
this.$emit('blur', e.target.value);
|
||||
},
|
||||
onSearchCountry() {
|
||||
// Reset selected index to 0
|
||||
this.selectedIndex = 0;
|
||||
dropdownItem() {
|
||||
if (!this.showDropdown) return [];
|
||||
return Array.from(
|
||||
this.$refs.dropdown.querySelectorAll(
|
||||
'div.country-dropdown div.country-dropdown--item'
|
||||
)
|
||||
);
|
||||
},
|
||||
focusedItem() {
|
||||
if (!this.showDropdown) return [];
|
||||
return Array.from(
|
||||
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
|
||||
);
|
||||
},
|
||||
focusedItemIndex() {
|
||||
if (!this.showDropdown) return -1;
|
||||
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
|
||||
},
|
||||
moveUp() {
|
||||
if (!this.showDropdown) return;
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.scrollToSelected();
|
||||
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
|
||||
},
|
||||
moveDown() {
|
||||
if (!this.showDropdown) return;
|
||||
@@ -211,27 +206,14 @@ export default {
|
||||
this.selectedIndex + 1,
|
||||
this.filteredCountriesBySearch.length - 1
|
||||
);
|
||||
this.scrollToSelected();
|
||||
},
|
||||
scrollToSelected() {
|
||||
this.$nextTick(() => {
|
||||
const dropdown = this.$refs.dropdown;
|
||||
const selectedItem = this.$refs.dropdownItem[this.selectedIndex];
|
||||
const dropdownSearchbarHeight = 40;
|
||||
if (selectedItem) {
|
||||
const selectedItemTop = selectedItem.offsetTop;
|
||||
dropdown.scrollTop = selectedItemTop - dropdownSearchbarHeight;
|
||||
}
|
||||
});
|
||||
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
|
||||
},
|
||||
onSelectCountry(country) {
|
||||
if (!country || !this.showDropdown) return;
|
||||
this.activeCountryCode = country.id;
|
||||
this.searchCountry = '';
|
||||
this.activeDialCode = country.dial_code;
|
||||
this.$emit('setCode', country.dial_code);
|
||||
this.closeDropdown();
|
||||
this.$refs.phoneNumberInput.focus();
|
||||
},
|
||||
setActiveCountry() {
|
||||
const { phoneNumber } = this;
|
||||
@@ -242,6 +224,36 @@ export default {
|
||||
this.activeDialCode = number.countryCallingCode;
|
||||
}
|
||||
},
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
ArrowUp: {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
console.log('ArrowUp in PhoneInput');
|
||||
this.moveUp();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
ArrowDown: {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
console.log('ArrowDown in PhoneInput');
|
||||
this.moveDown();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
Enter: {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
console.log('Enter in PhoneInput');
|
||||
this.onSelectCountry(
|
||||
this.filteredCountriesBySearch[this.selectedIndex]
|
||||
);
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
toggleCountryDropdown() {
|
||||
this.showDropdown = !this.showDropdown;
|
||||
this.selectedIndex = -1;
|
||||
@@ -258,3 +270,46 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.phone-input--wrap {
|
||||
.phone-input {
|
||||
@apply flex items-center dark:bg-slate-900 justify-start mb-4 rounded-md border border-solid border-slate-200 dark:border-slate-600;
|
||||
|
||||
&.has-error {
|
||||
@apply border border-solid border-red-400 dark:border-red-400;
|
||||
}
|
||||
}
|
||||
|
||||
.phone-input--field {
|
||||
@apply mb-0 rounded-tl-none rounded-bl-none border-0 w-full dark:bg-slate-900 text-base px-1.5;
|
||||
|
||||
&::placeholder {
|
||||
@apply font-normal;
|
||||
}
|
||||
}
|
||||
|
||||
.country-dropdown {
|
||||
@apply z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900;
|
||||
|
||||
.dropdown-search--wrap {
|
||||
@apply top-0 sticky bg-white dark:bg-slate-900 p-1;
|
||||
|
||||
.dropdown-search {
|
||||
@apply h-8 mb-0 text-sm border border-solid border-slate-200 dark:border-slate-600;
|
||||
}
|
||||
}
|
||||
|
||||
.country-dropdown--item {
|
||||
@apply flex items-center h-7 py-0 px-1 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700;
|
||||
|
||||
&.active {
|
||||
@apply bg-slate-50 dark:bg-slate-700;
|
||||
}
|
||||
|
||||
&.focus {
|
||||
@apply bg-slate-25 dark:bg-slate-800;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,7 @@ export default {
|
||||
ArrowUp: {
|
||||
action: e => {
|
||||
this.moveSelectionUp();
|
||||
console.log('ArrowUp in mentionSelectionKeyboardMixin');
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
@@ -38,6 +39,7 @@ export default {
|
||||
ArrowDown: {
|
||||
action: e => {
|
||||
this.moveSelectionDown();
|
||||
console.log('ArrowDown in mentionSelectionKeyboardMixin');
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
@@ -52,6 +54,7 @@ export default {
|
||||
Enter: {
|
||||
action: e => {
|
||||
this.onSelect();
|
||||
console.log('Enter in mentionSelectionKeyboardMixin');
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
|
||||
@@ -30,5 +30,4 @@ export const FEATURE_FLAGS = {
|
||||
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
|
||||
INBOUND_EMAILS: 'inbound_emails',
|
||||
IP_LOOKUP: 'ip_lookup',
|
||||
LINEAR: 'linear_integration',
|
||||
};
|
||||
|
||||
@@ -87,8 +87,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
};
|
||||
|
||||
export const getInboxWarningIconClass = (type, reauthorizationRequired) => {
|
||||
const allowedInboxTypes = [INBOX_TYPES.FB, INBOX_TYPES.EMAIL];
|
||||
if (allowedInboxTypes.includes(type) && reauthorizationRequired) {
|
||||
if (type === INBOX_TYPES.FB && reauthorizationRequired) {
|
||||
return 'warning';
|
||||
}
|
||||
return '';
|
||||
|
||||
@@ -52,22 +52,8 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isAConversationRoute = (
|
||||
routeName,
|
||||
includeBase = false,
|
||||
includeExtended = true
|
||||
) => {
|
||||
const baseRoutes = [
|
||||
'home',
|
||||
'conversation_mentions',
|
||||
'conversation_unattended',
|
||||
'inbox_dashboard',
|
||||
'label_conversations',
|
||||
'team_conversations',
|
||||
'folder_conversations',
|
||||
'conversation_participating',
|
||||
];
|
||||
const extendedRoutes = [
|
||||
export const isAConversationRoute = routeName =>
|
||||
[
|
||||
'inbox_conversation',
|
||||
'conversation_through_mentions',
|
||||
'conversation_through_unattended',
|
||||
@@ -76,15 +62,7 @@ export const isAConversationRoute = (
|
||||
'conversations_through_team',
|
||||
'conversations_through_folders',
|
||||
'conversation_through_participating',
|
||||
];
|
||||
|
||||
const routes = [
|
||||
...(includeBase ? baseRoutes : []),
|
||||
...(includeExtended ? extendedRoutes : []),
|
||||
];
|
||||
|
||||
return routes.includes(routeName);
|
||||
};
|
||||
].includes(routeName);
|
||||
|
||||
export const getConversationDashboardRoute = routeName => {
|
||||
switch (routeName) {
|
||||
|
||||
@@ -106,51 +106,6 @@ describe('isAConversationRoute', () => {
|
||||
expect(isAConversationRoute('conversations_through_team')).toBe(true);
|
||||
expect(isAConversationRoute('dashboard')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if base conversation route name is provided and includeBase is true', () => {
|
||||
expect(isAConversationRoute('home', true)).toBe(true);
|
||||
expect(isAConversationRoute('conversation_mentions', true)).toBe(true);
|
||||
expect(isAConversationRoute('conversation_unattended', true)).toBe(true);
|
||||
expect(isAConversationRoute('inbox_dashboard', true)).toBe(true);
|
||||
expect(isAConversationRoute('label_conversations', true)).toBe(true);
|
||||
expect(isAConversationRoute('team_conversations', true)).toBe(true);
|
||||
expect(isAConversationRoute('folder_conversations', true)).toBe(true);
|
||||
expect(isAConversationRoute('conversation_participating', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if base conversation route name is provided and includeBase is false', () => {
|
||||
expect(isAConversationRoute('home', false)).toBe(false);
|
||||
expect(isAConversationRoute('conversation_mentions', false)).toBe(false);
|
||||
expect(isAConversationRoute('conversation_unattended', false)).toBe(false);
|
||||
expect(isAConversationRoute('inbox_dashboard', false)).toBe(false);
|
||||
expect(isAConversationRoute('label_conversations', false)).toBe(false);
|
||||
expect(isAConversationRoute('team_conversations', false)).toBe(false);
|
||||
expect(isAConversationRoute('folder_conversations', false)).toBe(false);
|
||||
expect(isAConversationRoute('conversation_participating', false)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true if base conversation route name is provided and includeBase and includeExtended is true', () => {
|
||||
expect(isAConversationRoute('home', true, true)).toBe(true);
|
||||
expect(isAConversationRoute('conversation_mentions', true, true)).toBe(
|
||||
true
|
||||
);
|
||||
expect(isAConversationRoute('conversation_unattended', true, true)).toBe(
|
||||
true
|
||||
);
|
||||
expect(isAConversationRoute('inbox_dashboard', true, true)).toBe(true);
|
||||
expect(isAConversationRoute('label_conversations', true, true)).toBe(true);
|
||||
expect(isAConversationRoute('team_conversations', true, true)).toBe(true);
|
||||
expect(isAConversationRoute('folder_conversations', true, true)).toBe(true);
|
||||
expect(isAConversationRoute('conversation_participating', true, true)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false if base conversation route name is not provided', () => {
|
||||
expect(isAConversationRoute('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConversationDashboardRoute', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"AGENT_LIST_LOADING": "Loading agents",
|
||||
"UPDATE": {
|
||||
"CHANGE_STATUS": "Change status",
|
||||
"SNOOZE_UNTIL": "Snooze",
|
||||
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply.",
|
||||
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
|
||||
"UPDATE_FAILED": "Failed to update conversations. Please try again."
|
||||
},
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
"SIDEBAR_SECTIONS": {
|
||||
"CUSTOM_ATTRIBUTES": "Custom Attributes",
|
||||
"CONTACT_LABELS": "Contact Labels",
|
||||
"PREVIOUS_CONVERSATIONS": "Previous Conversations",
|
||||
"NO_RECORDS_FOUND": "No attributes found"
|
||||
"PREVIOUS_CONVERSATIONS": "Previous Conversations"
|
||||
}
|
||||
},
|
||||
"EDIT_CONTACT": {
|
||||
@@ -84,7 +83,6 @@
|
||||
"CONFIRM": {
|
||||
"TITLE": "Export Contacts",
|
||||
"MESSAGE": "Are you sure you want to export all contacts?",
|
||||
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
|
||||
"YES": "Yes, Export",
|
||||
"NO": "No, Cancel"
|
||||
}
|
||||
|
||||
@@ -280,7 +280,6 @@
|
||||
},
|
||||
"CONVERSATION_CUSTOM_ATTRIBUTES": {
|
||||
"ADD_BUTTON_TEXT": "Create attribute",
|
||||
"NO_RECORDS_FOUND": "No attributes found",
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Attribute updated successfully",
|
||||
"ERROR": "Unable to update attribute. Please try again later"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"DATE_PICKER": {
|
||||
"APPLY_BUTTON": "Apply",
|
||||
"CLEAR_BUTTON": "Clear",
|
||||
"DATE_RANGE_INPUT": {
|
||||
"START": "Start Date",
|
||||
"END": "End Date"
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"TITLE": "DATE RANGE",
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
"LAST_YEAR": "Last year",
|
||||
"CUSTOM_RANGE": "Custom date range"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,6 @@
|
||||
"GENERAL": "General",
|
||||
"REPORTS": "Reports",
|
||||
"CONVERSATION": "Conversation",
|
||||
"BULK_ACTIONS": "Bulk Actions",
|
||||
"CHANGE_ASSIGNEE": "Change Assignee",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"CHANGE_TEAM": "Change Team",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"body": "Add agents to the created inbox."
|
||||
},
|
||||
{
|
||||
"title": "Voilà!",
|
||||
"title": "Voila!",
|
||||
"route": "settings_inbox_finish",
|
||||
"body": "You are all set to go!"
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
"CHOOSE_PLACEHOLDER": "Select a page from the list",
|
||||
"INBOX_NAME": "Inbox Name",
|
||||
"ADD_NAME": "Add a name for your inbox",
|
||||
"PICK_NAME": "Pick a Name for your Inbox",
|
||||
"PICK_NAME": "Pick A Name Your Inbox",
|
||||
"PICK_A_VALUE": "Pick a value"
|
||||
},
|
||||
"TWITTER": {
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"CHANNEL_WEBHOOK_URL": {
|
||||
"LABEL": "Webhook URL",
|
||||
"PLACEHOLDER": "Please enter your Webhook URL",
|
||||
"PLACEHOLDER": "Enter your Webhook URL",
|
||||
"ERROR": "Please enter a valid URL"
|
||||
},
|
||||
"CHANNEL_DOMAIN": {
|
||||
@@ -143,7 +143,7 @@
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"LABEL": "Phone number",
|
||||
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
|
||||
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
|
||||
},
|
||||
@@ -175,12 +175,12 @@
|
||||
},
|
||||
"API_KEY": {
|
||||
"LABEL": "API Key",
|
||||
"PLACEHOLDER": "Please enter your Bandwidth API Key",
|
||||
"PLACEHOLDER": "Please enter your Bandwith API Key",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"API_SECRET": {
|
||||
"LABEL": "API Secret",
|
||||
"PLACEHOLDER": "Please enter your Bandwidth API Secret",
|
||||
"PLACEHOLDER": "Please enter your Bandwith API Secret",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"APPLICATION_ID": {
|
||||
@@ -239,7 +239,7 @@
|
||||
},
|
||||
"WEBHOOK_VERIFY_TOKEN": {
|
||||
"LABEL": "Webhook Verify Token",
|
||||
"PLACEHOLDER": "Enter a verify token which you want to configure for Facebook webhooks.",
|
||||
"PLACEHOLDER": "Enter a verify token which you want to configure for facebook webhooks.",
|
||||
"ERROR": "Please enter a valid value."
|
||||
},
|
||||
"API_KEY": {
|
||||
@@ -269,7 +269,7 @@
|
||||
},
|
||||
"WEBHOOK_URL": {
|
||||
"LABEL": "Webhook URL",
|
||||
"SUBTITLE": "Configure the URL where you want to receive callbacks on events.",
|
||||
"SUBTITLE": "Configure the URL where you want to recieve callbacks on events.",
|
||||
"PLACEHOLDER": "Webhook URL"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create API Channel",
|
||||
@@ -279,7 +279,7 @@
|
||||
},
|
||||
"EMAIL_CHANNEL": {
|
||||
"TITLE": "Email Channel",
|
||||
"DESC": "Integrate your email inbox.",
|
||||
"DESC": "Integrate you email inbox.",
|
||||
"CHANNEL_NAME": {
|
||||
"LABEL": "Channel Name",
|
||||
"PLACEHOLDER": "Please enter a channel name",
|
||||
@@ -345,7 +345,7 @@
|
||||
"AGENTS": {
|
||||
"TITLE": "Agents",
|
||||
"DESC": "Here you can add agents to manage your newly created inbox. Only these selected agents will have access to your inbox. Agents which are not part of this inbox will not be able to see or respond to messages in this inbox when they login. <br> <b>PS:</b> As an administrator, if you need access to all inboxes, you should add yourself as agent to all inboxes that you create.",
|
||||
"VALIDATION_ERROR": "Add at least one agent to your new Inbox",
|
||||
"VALIDATION_ERROR": "Add atleast one agent to your new Inbox",
|
||||
"PICK_AGENTS": "Pick agents for the inbox"
|
||||
},
|
||||
"DETAILS": {
|
||||
@@ -406,7 +406,7 @@
|
||||
},
|
||||
"SENDER_NAME_SECTION": {
|
||||
"TITLE": "Sender name",
|
||||
"SUB_TEXT": "Select the name shown to your customer when they receive emails from your agents.",
|
||||
"SUB_TEXT": "Select the name shown to the your customer when they receive emails from your agents.",
|
||||
"FOR_EG": "For eg:",
|
||||
"FRIENDLY": {
|
||||
"TITLE": "Friendly",
|
||||
@@ -508,12 +508,12 @@
|
||||
"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_UPDATE_SUBHEADER": "Enter the new API key to be used for the integration with the WhatsApp APIs.",
|
||||
"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": "Update",
|
||||
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
|
||||
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verify Token",
|
||||
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||
},
|
||||
@@ -585,7 +585,7 @@
|
||||
"NOTE_TEXT": "To enable SMTP, please configure IMAP.",
|
||||
"UPDATE": "Update IMAP settings",
|
||||
"TOGGLE_AVAILABILITY": "Enable IMAP configuration for this inbox",
|
||||
"TOGGLE_HELP": "Enabling IMAP will help the user to receive email",
|
||||
"TOGGLE_HELP": "Enabling IMAP will help the user to recieve email",
|
||||
"EDIT": {
|
||||
"SUCCESS_MESSAGE": "IMAP settings updated successfully",
|
||||
"ERROR_MESSAGE": "Unable to update IMAP settings"
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"PASSWORD_UPDATE_SUCCESS": "Your password has been changed successfully",
|
||||
"AFTER_EMAIL_CHANGED": "Your profile has been updated successfully, please login again as your login credentials are changed",
|
||||
"FORM": {
|
||||
"PICTURE": "Profile Picture",
|
||||
"AVATAR": "Profile Image",
|
||||
"ERROR": "Please fix form errors",
|
||||
"REMOVE_IMAGE": "Remove",
|
||||
@@ -57,19 +56,13 @@
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"NOTE": "This token can be used if you are building an API based integration",
|
||||
"COPY": "Copy"
|
||||
"NOTE": "This token can be used if you are building an API based integration"
|
||||
},
|
||||
"AUDIO_NOTIFICATIONS_SECTION": {
|
||||
"TITLE": "Audio Notifications",
|
||||
"NOTE": "Enable audio notifications in dashboard for new messages and conversations.",
|
||||
"ALERT_TYPES": {
|
||||
"NONE": "None",
|
||||
"MINE": "Assigned",
|
||||
"ALL": "All"
|
||||
},
|
||||
"ALERT_TYPE": {
|
||||
"TITLE": "Alert events for conversations:",
|
||||
"TITLE": "Alert events:",
|
||||
"NONE": "None",
|
||||
"ASSIGNED": "Assigned Conversations",
|
||||
"ALL_CONVERSATIONS": "All Conversations"
|
||||
@@ -95,23 +88,6 @@
|
||||
"SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA",
|
||||
"SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA"
|
||||
},
|
||||
"NOTIFICATIONS": {
|
||||
"TITLE": "Notification preferences",
|
||||
"TYPE_TITLE": "Notification type",
|
||||
"EMAIL": "Email",
|
||||
"PUSH": "Push notification",
|
||||
"TYPES": {
|
||||
"CONVERSATION_CREATED": "A new conversation is created",
|
||||
"CONVERSATION_ASSIGNED": "A conversation is assigned to you",
|
||||
"CONVERSATION_MENTION": "You are mentioned in a conversation",
|
||||
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "A new message is created in an assigned conversation",
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "A new message is created in a participating conversation",
|
||||
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
|
||||
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
|
||||
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
|
||||
},
|
||||
"BROWSER_PERMISSION": "Enable push notifications for your browser so you’re able to receive them"
|
||||
},
|
||||
"API": {
|
||||
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
|
||||
"UPDATE_ERROR": "There is an error while updating the preferences, please try again"
|
||||
@@ -344,6 +320,7 @@
|
||||
"GO_TO_REPORTS_SIDEBAR": "Go to Reports sidebar",
|
||||
"MOVE_TO_NEXT_TAB": "Move to next tab in conversation list",
|
||||
"GO_TO_SETTINGS": "Go to Settings",
|
||||
"SWITCH_CONVERSATION_STATUS": "Switch to the next conversation status",
|
||||
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
|
||||
"SWITCH_TO_REPLY": "Switch to Reply",
|
||||
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
|
||||
|
||||
@@ -6,18 +6,6 @@
|
||||
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
|
||||
"LEARN_MORE": "Learn more about SLA",
|
||||
"LOADING": "Fetching SLAs",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to create SLAs",
|
||||
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to advanced features like team management, automations, custom attributes, and more.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "The SLA feature is only available in the paid plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade to a paid plan to access advanced features like audit logs, agent capacity, and more.",
|
||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||
},
|
||||
"LIST": {
|
||||
"404": "There are no SLAs available in this account.",
|
||||
"EMPTY": {
|
||||
@@ -105,4 +93,4 @@
|
||||
"HIDE": "Hide {count} rows"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@
|
||||
},
|
||||
"CONDITION": {
|
||||
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ",
|
||||
"CONTACT_CUSTOM_ATTR_LABEL": "سمة مخصصة لجهة اتصال",
|
||||
"CONVERSATION_CUSTOM_ATTR_LABEL": "سمة مخصصة للمحادثة"
|
||||
"CONTACT_CUSTOM_ATTR_LABEL": "Contact Custom Attributes",
|
||||
"CONVERSATION_CUSTOM_ATTR_LABEL": "Conversation Custom Attributes"
|
||||
},
|
||||
"ACTION": {
|
||||
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ",
|
||||
@@ -111,7 +111,7 @@
|
||||
"UPLOAD_ERROR": "تعذر تحميل المرفق، الرجاء المحاولة مرة أخرى",
|
||||
"LABEL_IDLE": "ارفع المرفق",
|
||||
"LABEL_UPLOADING": "جاري الرفع...",
|
||||
"LABEL_UPLOADED": "تم الرفع بنجاح",
|
||||
"LABEL_UPLOADED": "Successfully Uploaded",
|
||||
"LABEL_UPLOAD_FAILED": "فشل الرفع"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"AGENT_LIST_LOADING": "جاري تحميل الوكلاء",
|
||||
"UPDATE": {
|
||||
"CHANGE_STATUS": "تغيير الحالة",
|
||||
"SNOOZE_UNTIL": "غفوة",
|
||||
"SNOOZE_UNTIL_NEXT_REPLY": "غفوة حتى الرد القادم.",
|
||||
"UPDATE_SUCCESFUL": "تم تحديث حالة المحادثة بنجاح.",
|
||||
"UPDATE_FAILED": "Failed to update conversations. Please try again."
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"CANNED_MGMT": {
|
||||
"HEADER": "الردود السريعة",
|
||||
"HEADER_BTN_TXT": "إضافة رد جاهز",
|
||||
"LOADING": "جاري جلب الردود الجاهزة...",
|
||||
"HEADER_BTN_TXT": "Add canned response",
|
||||
"LOADING": "Fetching canned responses...",
|
||||
"SEARCH_404": "لا توجد عناصر مطابقة لهذا الاستعلام.",
|
||||
"SIDEBAR_TXT": "<p><b>Canned Responses</b> </p><p> Canned Responses are pre-written reply templates that help you quickly respond to a conversation. To insert a canned response during a chat, agents can type a short code preceded by a '/' character. </p><p> You can manage your canned responses from this page or create new ones using the \"Add canned response\" button.</p><p>Open the <a target=\"_blank\" href=\"https://www.chatwoot.com/hc/chatwoot-user-guide-cloud-version/articles/1677501325-how-to-create-saved-reply-templates-with-canned-responses\">Canned Responses handbook</a> in another tab for a helping hand.</p><p>Also, check out the all-new <a href=\"https://www.chatwoot.com/tools/canned-responses-library\" target=\"_blank\">Canned Responses Library</a>.</p>",
|
||||
"LIST": {
|
||||
@@ -10,66 +10,66 @@
|
||||
"TITLE": "إدارة الردود الجاهزة",
|
||||
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to conversations.",
|
||||
"TABLE_HEADER": [
|
||||
"كود مختصر",
|
||||
"Short code",
|
||||
"المحتوى",
|
||||
"الإجراءات"
|
||||
]
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "إضافة رد جاهز",
|
||||
"TITLE": "Add canned response",
|
||||
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to conversations.",
|
||||
"CANCEL_BUTTON_TEXT": "إلغاء",
|
||||
"FORM": {
|
||||
"SHORT_CODE": {
|
||||
"LABEL": "كود مختصر",
|
||||
"PLACEHOLDER": "من فضلك ادخل الكود مختصر.",
|
||||
"ERROR": "الكود مختصر مطلوب."
|
||||
"LABEL": "Short code",
|
||||
"PLACEHOLDER": "Please enter a short code.",
|
||||
"ERROR": "Short Code is required."
|
||||
},
|
||||
"CONTENT": {
|
||||
"LABEL": "رسالة",
|
||||
"PLACEHOLDER": "من فضلك ادخل نص لرسالة التي ترغب في حفظها كقالب لاستخدامها لاحقاُ.",
|
||||
"ERROR": "الرسالة مطلوبة."
|
||||
"PLACEHOLDER": "Please write the message you want to save as a template to use later.",
|
||||
"ERROR": "Message is required."
|
||||
},
|
||||
"SUBMIT": "إرسال"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "تم إضافة الرد المعد مسبقاً بنجاح.",
|
||||
"SUCCESS_MESSAGE": "Canned response added successfully.",
|
||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "تعديل الرد المعد مسبقاً",
|
||||
"TITLE": "Edit canned response",
|
||||
"CANCEL_BUTTON_TEXT": "إلغاء",
|
||||
"FORM": {
|
||||
"SHORT_CODE": {
|
||||
"LABEL": "كود مختصر",
|
||||
"PLACEHOLDER": "من فضلك ادخل الكود مختصر.",
|
||||
"ERROR": "الكود مختصر مطلوب."
|
||||
"LABEL": "Short code",
|
||||
"PLACEHOLDER": "Please enter a shortcode.",
|
||||
"ERROR": "Short code is required."
|
||||
},
|
||||
"CONTENT": {
|
||||
"LABEL": "رسالة",
|
||||
"PLACEHOLDER": "من فضلك ادخل نص لرسالة التي ترغب في حفظها كقالب لاستخدامها لاحقاُ.",
|
||||
"PLACEHOLDER": "Please write the message you want to save as a template to use later.",
|
||||
"ERROR": "الرسالة مطلوبة."
|
||||
},
|
||||
"SUBMIT": "إرسال"
|
||||
},
|
||||
"BUTTON_TEXT": "تعديل",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "تم تحديث الرد المعد مسبقاً بنجاح.",
|
||||
"SUCCESS_MESSAGE": "Canned response is updated successfully.",
|
||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "حذف",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "تم حذف الرد المعد مسبقاً بنجاح.",
|
||||
"SUCCESS_MESSAGE": "Canned response deleted successfully.",
|
||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
||||
},
|
||||
"CONFIRM": {
|
||||
"TITLE": "تأكيد الحذف",
|
||||
"MESSAGE": "هل أنت متأكد من الحذف ",
|
||||
"YES": "نعم, حذف ",
|
||||
"NO": "لا, قم بالابقاء عليه "
|
||||
"YES": "Yes, delete ",
|
||||
"NO": "No, keep "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
},
|
||||
"CHAT_TIME_STAMP": {
|
||||
"CREATED": {
|
||||
"LATEST": "مضاف",
|
||||
"LATEST": "Created",
|
||||
"OLDEST": "تم إنشاؤها في:"
|
||||
},
|
||||
"LAST_ACTIVITY": {
|
||||
@@ -53,10 +53,10 @@
|
||||
},
|
||||
"SORT_ORDER_ITEMS": {
|
||||
"last_activity_at_asc": {
|
||||
"TEXT": "اخر نشاط: آلأقدم أولا"
|
||||
"TEXT": "Last activity: Oldest first"
|
||||
},
|
||||
"last_activity_at_desc": {
|
||||
"TEXT": "اخر نشاط: الأحدث أولا"
|
||||
"TEXT": "Last activity: Newest first"
|
||||
},
|
||||
"created_at_desc": {
|
||||
"TEXT": "Created at: Newest first"
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
"LAST_USER_MESSAGE_AT": {
|
||||
"NAME": "Last user message at",
|
||||
"LABEL": "أخر رسالة"
|
||||
"LABEL": "Last message"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"INITIATED_FROM": "تم البدء من",
|
||||
"INITIATED_AT": "تم البدء في",
|
||||
"IP_ADDRESS": "عنوان IP",
|
||||
"CREATED_AT_LABEL": "أضيفت",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "رسالة جديدة",
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "لا توجد محادثات سابقة مرتبطة بجهة الاتصال هذه.",
|
||||
@@ -39,17 +39,16 @@
|
||||
},
|
||||
"MERGE_CONTACT": "دمج جهة الاتصال",
|
||||
"CONTACT_ACTIONS": "إجراءات جهات الاتصال",
|
||||
"MUTE_CONTACT": "احجب جهة الاتصال",
|
||||
"UNMUTE_CONTACT": "إلغاء حجب جهة الاتصال",
|
||||
"MUTE_CONTACT": "Block Contact",
|
||||
"UNMUTE_CONTACT": "Unblock Contact",
|
||||
"MUTED_SUCCESS": "This contact is blocked successfully. You will not be notified of any future conversations.",
|
||||
"UNMUTED_SUCCESS": "تم إلغاء حجب جهة الاتصال بنجاح.",
|
||||
"UNMUTED_SUCCESS": "This contact is unblocked successfully.",
|
||||
"SEND_TRANSCRIPT": "إرسال النص",
|
||||
"EDIT_LABEL": "تعديل",
|
||||
"SIDEBAR_SECTIONS": {
|
||||
"CUSTOM_ATTRIBUTES": "سمات مخصصة",
|
||||
"CONTACT_LABELS": "تصنفيات جهات الاتصال",
|
||||
"PREVIOUS_CONVERSATIONS": "المحادثات السابقة",
|
||||
"NO_RECORDS_FOUND": "لم يتم العثور على سمات"
|
||||
"PREVIOUS_CONVERSATIONS": "المحادثات السابقة"
|
||||
}
|
||||
},
|
||||
"EDIT_CONTACT": {
|
||||
@@ -76,17 +75,16 @@
|
||||
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى"
|
||||
},
|
||||
"EXPORT_CONTACTS": {
|
||||
"BUTTON_LABEL": "تصدير",
|
||||
"TITLE": "تصدير جهات الاتصال",
|
||||
"DESC": "تصدير جهات الاتصال إلى ملف CSV.",
|
||||
"BUTTON_LABEL": "Export",
|
||||
"TITLE": "Export Contacts",
|
||||
"DESC": "Export contacts to a CSV file.",
|
||||
"SUCCESS_MESSAGE": "Export is in progress, You will be notified via email when export file is ready to dowanlod.",
|
||||
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى",
|
||||
"CONFIRM": {
|
||||
"TITLE": "تصدير جهات الاتصال",
|
||||
"TITLE": "Export Contacts",
|
||||
"MESSAGE": "Are you sure you want to export all contacts?",
|
||||
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
|
||||
"YES": "نعم, قم بالتصدير",
|
||||
"NO": "نعم, إلغاء"
|
||||
"YES": "Yes, Export",
|
||||
"NO": "No, Cancel"
|
||||
}
|
||||
},
|
||||
"DELETE_NOTE": {
|
||||
@@ -150,15 +148,15 @@
|
||||
"LABEL": "اسم الشركة"
|
||||
},
|
||||
"COUNTRY": {
|
||||
"PLACEHOLDER": "إدخال اسم الدولة",
|
||||
"PLACEHOLDER": "Enter the country name",
|
||||
"LABEL": "اسم الدولة",
|
||||
"SELECT_PLACEHOLDER": "اختر",
|
||||
"REMOVE": "حذف",
|
||||
"SELECT_COUNTRY": "أختر الدولة"
|
||||
"SELECT_COUNTRY": "Select Country"
|
||||
},
|
||||
"CITY": {
|
||||
"PLACEHOLDER": "إدخال اسم المدينة",
|
||||
"LABEL": "اسم المدينة"
|
||||
"PLACEHOLDER": "Enter the city name",
|
||||
"LABEL": "City Name"
|
||||
},
|
||||
"SOCIAL_PROFILES": {
|
||||
"FACEBOOK": {
|
||||
@@ -199,7 +197,7 @@
|
||||
},
|
||||
"INBOX": {
|
||||
"LABEL": "صندوق الوارد",
|
||||
"PLACEHOLDER": "اختر صندوق المصدر",
|
||||
"PLACEHOLDER": "Choose source inbox",
|
||||
"ERROR": "حدد صندوق الوارد"
|
||||
},
|
||||
"SUBJECT": {
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
"PRIVATE_NOTE": "إضافة ملاحظة خاصة",
|
||||
"SEND": "إرسال",
|
||||
"CREATE": "إضافة ملاحظة",
|
||||
"INSERT_READ_MORE": "اقرأ المزيد",
|
||||
"INSERT_READ_MORE": "Read more",
|
||||
"DISMISS_REPLY": "Dismiss reply",
|
||||
"REPLYING_TO": "Replying to:",
|
||||
"TIP_FORMAT_ICON": "عرض محرر النصوص",
|
||||
@@ -280,7 +280,6 @@
|
||||
},
|
||||
"CONVERSATION_CUSTOM_ATTRIBUTES": {
|
||||
"ADD_BUTTON_TEXT": "إنشاء سمة جديدة",
|
||||
"NO_RECORDS_FOUND": "لم يتم العثور على سمات",
|
||||
"UPDATE": {
|
||||
"SUCCESS": "تم تحديث السمة المخصصة بنجاح",
|
||||
"ERROR": "غير قادر على تحديث السمة. الرجاء المحاولة مرة أخرى لاحقاً"
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
"TITLE": "قيم محادثتك",
|
||||
"PLACEHOLDER": "أخبرنا المزيد...",
|
||||
"RATINGS": {
|
||||
"POOR": "😞 سيئ",
|
||||
"FAIR": "مقبول😑",
|
||||
"AVERAGE": "😐 متوسط",
|
||||
"GOOD": "😀 جيد",
|
||||
"EXCELLENT": "😍 ممتاز"
|
||||
"POOR": "😞 Poor",
|
||||
"FAIR": "😑 Fair",
|
||||
"AVERAGE": "😐 Average",
|
||||
"GOOD": "😀 Good",
|
||||
"EXCELLENT": "😍 Excellent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"DATE_PICKER": {
|
||||
"APPLY_BUTTON": "تطبيق",
|
||||
"CLEAR_BUTTON": "Clear",
|
||||
"DATE_RANGE_INPUT": {
|
||||
"START": "Start Date",
|
||||
"END": "End Date"
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"TITLE": "DATE RANGE",
|
||||
"LAST_7_DAYS": "آخر 7 أيام",
|
||||
"LAST_30_DAYS": "آخر 30 يوماً",
|
||||
"LAST_3_MONTHS": "آخر 3 أشهر",
|
||||
"LAST_6_MONTHS": "آخر 6 أشهر",
|
||||
"LAST_YEAR": "العام الماضي",
|
||||
"CUSTOM_RANGE": "تحديد نطاق المدة"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"GENERAL": {
|
||||
"SHOWING_RESULTS": "عرض{firstIndex}-{lastIndex} من {totalCount} إجماليي العناصر"
|
||||
"SHOWING_RESULTS": "Showing {firstIndex}-{lastIndex} of {totalCount} items"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "إعدادات الحساب",
|
||||
"SUBMIT": "تحديث الإعدادات",
|
||||
"BACK": "العودة",
|
||||
"DISMISS": "تجاهل",
|
||||
"DISMISS": "Dismiss",
|
||||
"UPDATE": {
|
||||
"ERROR": "تعذر تحديث الإعدادات، الرجاء المحاولة مرة أخرى!",
|
||||
"SUCCESS": "تم تحديث إعدادات الحساب بنجاح"
|
||||
@@ -52,7 +52,7 @@
|
||||
"LEARN_MORE": "اعرف المزيد",
|
||||
"PAYMENT_PENDING": "Your payment is pending. Please update your payment information to continue using Chatwoot",
|
||||
"LIMITS_UPGRADE": "Your account has exceeded the usage limits, please upgrade your plan to continue using Chatwoot",
|
||||
"OPEN_BILLING": "فتح الفواتير"
|
||||
"OPEN_BILLING": "Open billing"
|
||||
},
|
||||
"FORMS": {
|
||||
"MULTISELECT": {
|
||||
@@ -107,17 +107,16 @@
|
||||
"GENERAL": "عام",
|
||||
"REPORTS": "التقارير",
|
||||
"CONVERSATION": "المحادثات",
|
||||
"BULK_ACTIONS": "Bulk Actions",
|
||||
"CHANGE_ASSIGNEE": "تغيير المحال إليه",
|
||||
"CHANGE_PRIORITY": "تغيير الأولوية",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"CHANGE_TEAM": "تغيير الفريق",
|
||||
"SNOOZE_CONVERSATION": "تأجيل المحادثة",
|
||||
"ADD_LABEL": "إضافة تسمية إلى المحادثة",
|
||||
"REMOVE_LABEL": "إزالة التسمية من المحادثة",
|
||||
"SETTINGS": "الإعدادات",
|
||||
"AI_ASSIST": "AI Assist",
|
||||
"APPEARANCE": "مظهر",
|
||||
"SNOOZE_NOTIFICATION": "تأجيل التنبيهات"
|
||||
"APPEARANCE": "Appearance",
|
||||
"SNOOZE_NOTIFICATION": "Snooze Notification"
|
||||
},
|
||||
"COMMANDS": {
|
||||
"GO_TO_CONVERSATION_DASHBOARD": "الذهاب إلى لوحة المحادثة",
|
||||
@@ -140,7 +139,7 @@
|
||||
"ADD_LABELS_TO_CONVERSATION": "إضافة تسمية إلى المحادثة",
|
||||
"ASSIGN_AN_AGENT": "تعيين وكيل",
|
||||
"AI_ASSIST": "AI Assist",
|
||||
"ASSIGN_PRIORITY": "تعيين الأولوية",
|
||||
"ASSIGN_PRIORITY": "Assign priority",
|
||||
"ASSIGN_A_TEAM": "تعيين فريق",
|
||||
"MUTE_CONVERSATION": "كتم المحادثة",
|
||||
"UNMUTE_CONVERSATION": "إلغاء كتم المحادثة",
|
||||
@@ -152,21 +151,21 @@
|
||||
"UNTIL_NEXT_REPLY": "حتى الرد القادم",
|
||||
"UNTIL_NEXT_WEEK": "حتى الأسبوع القادم",
|
||||
"UNTIL_TOMORROW": "حتى الغد",
|
||||
"UNTIL_NEXT_MONTH": "حتي الشهر القادم",
|
||||
"AN_HOUR_FROM_NOW": "حتي ساعة من الأن",
|
||||
"CUSTOM": "مخصص...",
|
||||
"CHANGE_APPEARANCE": "تغيير المظهر",
|
||||
"UNTIL_NEXT_MONTH": "Until next month",
|
||||
"AN_HOUR_FROM_NOW": "Until an hour from now",
|
||||
"CUSTOM": "Custom...",
|
||||
"CHANGE_APPEARANCE": "Change Appearance",
|
||||
"LIGHT_MODE": "Light",
|
||||
"DARK_MODE": "مظلم",
|
||||
"SYSTEM_MODE": "نظام",
|
||||
"SNOOZE_NOTIFICATION": "تأجيل التنبيهات"
|
||||
"DARK_MODE": "Dark",
|
||||
"SYSTEM_MODE": "System",
|
||||
"SNOOZE_NOTIFICATION": "Snooze Notification"
|
||||
}
|
||||
},
|
||||
"DASHBOARD_APPS": {
|
||||
"LOADING_MESSAGE": "تحميل تطبيق لوحة التحكم..."
|
||||
},
|
||||
"COMMON": {
|
||||
"OR": "أو",
|
||||
"OR": "Or",
|
||||
"CLICK_HERE": "اضغط هنا"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"body": "إضافة موظفين إلى صندوق الوارد الخاص بقناة التواصل التي تم إنشاؤها."
|
||||
},
|
||||
{
|
||||
"title": "Voilà!",
|
||||
"title": "مرحى!",
|
||||
"route": "settings_inbox_finish",
|
||||
"body": "أصبح كل شيء جاهزاً الآن!"
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
"CHOOSE_PLACEHOLDER": "اختر صفحة من القائمة",
|
||||
"INBOX_NAME": "اسم صندوق الوارد لقناة التواصل",
|
||||
"ADD_NAME": "قم بتعيين اسم لصندوق الوارد الخاص بقناتك الجديدة",
|
||||
"PICK_NAME": "Pick a Name for your Inbox",
|
||||
"PICK_NAME": "اختر اسم لقناة التواصل الخاصة بك",
|
||||
"PICK_A_VALUE": "اختر قيمة"
|
||||
},
|
||||
"TWITTER": {
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"CHANNEL_WEBHOOK_URL": {
|
||||
"LABEL": "رابط Webhook",
|
||||
"PLACEHOLDER": "Please enter your Webhook URL",
|
||||
"PLACEHOLDER": "أدخل رابط Webhook",
|
||||
"ERROR": "الرجاء إدخال عنوان URL صالح"
|
||||
},
|
||||
"CHANNEL_DOMAIN": {
|
||||
@@ -112,14 +112,14 @@
|
||||
"ERROR": "هذا الحقل مطلوب"
|
||||
},
|
||||
"API_KEY": {
|
||||
"USE_API_KEY": "استخدم مفتاح مصادقة API",
|
||||
"LABEL": "مفتاح API SID",
|
||||
"PLACEHOLDER": "الرجاء إدخال معرف مفتاح واجهة برمجة التطبيقات API الخاص بك",
|
||||
"USE_API_KEY": "Use API Key Authentication",
|
||||
"LABEL": "API Key SID",
|
||||
"PLACEHOLDER": "Please enter your API Key SID",
|
||||
"ERROR": "هذا الحقل مطلوب"
|
||||
},
|
||||
"API_KEY_SECRET": {
|
||||
"LABEL": "مفتاح سر API",
|
||||
"PLACEHOLDER": "الرجاء إدخال سر مفتاح API الخاص بك",
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Please enter your API Key Secret",
|
||||
"ERROR": "هذا الحقل مطلوب"
|
||||
},
|
||||
"MESSAGING_SERVICE_SID": {
|
||||
@@ -175,12 +175,12 @@
|
||||
},
|
||||
"API_KEY": {
|
||||
"LABEL": "مفتاح API",
|
||||
"PLACEHOLDER": "Please enter your Bandwidth API Key",
|
||||
"PLACEHOLDER": "الرجاء إدخال مفتاح API الخاص بك",
|
||||
"ERROR": "هذا الحقل مطلوب"
|
||||
},
|
||||
"API_SECRET": {
|
||||
"LABEL": "سرية API",
|
||||
"PLACEHOLDER": "Please enter your Bandwidth API Secret",
|
||||
"PLACEHOLDER": "الرجاء إدخال مفتاح API الخاص بك",
|
||||
"ERROR": "هذا الحقل مطلوب"
|
||||
},
|
||||
"APPLICATION_ID": {
|
||||
@@ -239,7 +239,7 @@
|
||||
},
|
||||
"WEBHOOK_VERIFY_TOKEN": {
|
||||
"LABEL": "رمز التحقق من Webhook",
|
||||
"PLACEHOLDER": "Enter a verify token which you want to configure for Facebook webhooks.",
|
||||
"PLACEHOLDER": "أدخل رمز التحقق الذي تريد إعداده لفيسبوك على شبكة الويب.",
|
||||
"ERROR": "الرجاء إدخال اسم صالح."
|
||||
},
|
||||
"API_KEY": {
|
||||
@@ -269,7 +269,7 @@
|
||||
},
|
||||
"WEBHOOK_URL": {
|
||||
"LABEL": "رابط Webhook",
|
||||
"SUBTITLE": "Configure the URL where you want to receive callbacks on events.",
|
||||
"SUBTITLE": "تكوين عنوان URL حيث تريد تلقي ردود المكالمات على الأحداث.",
|
||||
"PLACEHOLDER": "رابط Webhook"
|
||||
},
|
||||
"SUBMIT_BUTTON": "إنشاء قناة API",
|
||||
@@ -279,7 +279,7 @@
|
||||
},
|
||||
"EMAIL_CHANNEL": {
|
||||
"TITLE": "قناة البريد الالكتروني",
|
||||
"DESC": "Integrate your email inbox.",
|
||||
"DESC": "ربط البريد الإلكتروني الخاص بك.",
|
||||
"CHANNEL_NAME": {
|
||||
"LABEL": "اسم القناة",
|
||||
"PLACEHOLDER": "الرجاء إدخال اسم القناة",
|
||||
@@ -345,7 +345,7 @@
|
||||
"AGENTS": {
|
||||
"TITLE": "موظف الدعم",
|
||||
"DESC": "هنا يمكنك إضافة موظفين لإدارة صندوق الوارد الخاص بقناة تواصلك التي تم إنشاؤها حديثاً. الموظفين الذين يتم تحديدهم هنا هم فقط من يمكنهم الوصول إلى صندوق الوارد الخاص بتلك القناة. الموظفين الذين ليسوا جزءاً من صندوق الوارد هذا لن يكونوا قادرين على رؤية أو الرد على الرسائل في قناة التواصل هذه عند تسجيل الدخول. <br> <b>ملحوظة:</b> كمسؤول، إذا كنت بحاجة إلى الوصول إلى جميع صناديق الوارد، يجب عليك إضافة نفسك كموظف لجميع صناديق الوارد الخاصة بقنوات التواصل التي تنشئها.",
|
||||
"VALIDATION_ERROR": "Add at least one agent to your new Inbox",
|
||||
"VALIDATION_ERROR": "إضافة وكيل واحد على الأقل إلى علبة الوارد الجديدة",
|
||||
"PICK_AGENTS": "اختر وكلاء لصندوق الوارد"
|
||||
},
|
||||
"DETAILS": {
|
||||
@@ -357,7 +357,7 @@
|
||||
"DESC": "لقد تم بنجاح ربط صفحة فيسبوك الخاصة بك مع Chatwoot. في المرة القادمة التي يرسل فيها العملاء رسالة إلى صفحتك، ستظهر المحادثة تلقائيًا على صندوق الوارد الخاص بك هنا.<br>نحن نزودك أيضًا بالكود النصي لصندوق دردشة الماسنجر والذي يمكنك إضافته بسهولة إلى الموقع الخاص بك لاستقبال الرسائل من الزوار كذلك. بمجرد أن يتم ذلك على موقع الويب الخاص بك، يمكن للعملاء مراسلتك من موقع الويب الخاص بك بدون الحاجة لأي أدوات خارجية وستظهر المحادثة هنا على Chatwoot.<br>رائع، أليس كذلك؟ نحن بالتأكيد نحاول أن نكون الأفضل :)"
|
||||
},
|
||||
"EMAIL_PROVIDER": {
|
||||
"TITLE": "حدد مزود البريد الإلكتروني الخاص بك",
|
||||
"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": {
|
||||
@@ -406,7 +406,7 @@
|
||||
},
|
||||
"SENDER_NAME_SECTION": {
|
||||
"TITLE": "Sender name",
|
||||
"SUB_TEXT": "Select the name shown to your customer when they receive emails from your agents.",
|
||||
"SUB_TEXT": "Select the name shown to the your customer when they receive emails from your agents.",
|
||||
"FOR_EG": "For eg:",
|
||||
"FRIENDLY": {
|
||||
"TITLE": "Friendly",
|
||||
@@ -508,7 +508,7 @@
|
||||
"ALLOW_MESSAGES_AFTER_RESOLVED": "السماح بالرسائل بعد حل المحادثة",
|
||||
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "السماح للمستخدمين النهائيين بإرسال رسائل حتى بعد تسوية المحادثة.",
|
||||
"WHATSAPP_SECTION_SUBHEADER": "يتم استخدام مفتاح API هذا للتكامل مع واتسب APIs.",
|
||||
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the new API key to be used for the integration with the WhatsApp APIs.",
|
||||
"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",
|
||||
@@ -585,7 +585,7 @@
|
||||
"NOTE_TEXT": "لتمكين SMTP ، الرجاء تكوين IMAP.",
|
||||
"UPDATE": "تحديث الإعدادات",
|
||||
"TOGGLE_AVAILABILITY": "تمكين تكوين IMAP لهذا البريد الوارد",
|
||||
"TOGGLE_HELP": "Enabling IMAP will help the user to receive email",
|
||||
"TOGGLE_HELP": "تمكين IMAP سيساعد المستخدم على تلقي البريد الإلكتروني",
|
||||
"EDIT": {
|
||||
"SUCCESS_MESSAGE": "تم تحديث إعدادات IMAP بنجاح",
|
||||
"ERROR_MESSAGE": "غير قادر على تحديث إعدادات IMAP"
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
|
||||
"BUTTONS": {
|
||||
"NEED_HELP": "تحتاج مساعدة؟",
|
||||
"DISMISS": "تجاهل",
|
||||
"DISMISS": "Dismiss",
|
||||
"FINISH": "Finish Setup"
|
||||
},
|
||||
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"DISMISS": "Dismiss suggestion"
|
||||
},
|
||||
"POWERED_BY": "Chatwoot AI",
|
||||
"DISMISS": "تجاهل",
|
||||
"DISMISS": "Dismiss",
|
||||
"ADD_SELECTED_LABELS": "Add selected labels",
|
||||
"ADD_SELECTED_LABEL": "Add selected label",
|
||||
"ADD_ALL_LABELS": "Add all labels"
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
"UNAUTH": "اسم المستخدم / كلمة المرور غير صحيحة. الرجاء المحاولة مرة أخرى"
|
||||
},
|
||||
"OAUTH": {
|
||||
"GOOGLE_LOGIN": "تسجيل الدخول بواسطة جوجل",
|
||||
"BUSINESS_ACCOUNTS_ONLY": "الرجاء استخدام عنوان البريد الإلكتروني الخاص بشركتك لتسجيل الدخول",
|
||||
"NO_ACCOUNT_FOUND": "لم نتمكن من العثور على حساب لعنوان البريد الإلكتروني الخاص بك."
|
||||
"GOOGLE_LOGIN": "Login with Google",
|
||||
"BUSINESS_ACCOUNTS_ONLY": "Please use your company email address to login",
|
||||
"NO_ACCOUNT_FOUND": "We couldn't find an account for your email address."
|
||||
},
|
||||
"FORGOT_PASSWORD": "نسيت كلمة المرور؟",
|
||||
"CREATE_NEW_ACCOUNT": "إنشاء حساب جديد",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
|
||||
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
|
||||
"BOT_LABEL": "رد آلي",
|
||||
"READ_MORE": "اقرأ المزيد",
|
||||
"READ_MORE": "Read more",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "من",
|
||||
"EMAIL": "البريد الإلكتروني"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"SET_NEW_PASSWORD": {
|
||||
"TITLE": "تعيين كلمة مرور جديدة",
|
||||
"TITLE": "Set new password",
|
||||
"PASSWORD": {
|
||||
"LABEL": "كلمة المرور",
|
||||
"PLACEHOLDER": "كلمة المرور",
|
||||
"ERROR": "كلمة المرور قصيرة جداً."
|
||||
"ERROR": "Password is too short."
|
||||
},
|
||||
"CONFIRM_PASSWORD": {
|
||||
"LABEL": "تأكيد كلمة المرور",
|
||||
"LABEL": "Confirm password",
|
||||
"PLACEHOLDER": "تأكيد كلمة المرور",
|
||||
"ERROR": "كلمة المرور غير متطابقة."
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"PASSWORD_UPDATE_SUCCESS": "تم تغيير كلمة المرور بنجاح",
|
||||
"AFTER_EMAIL_CHANGED": "تم تحديث ملفك الشخصي بنجاح، الرجاء تسجيل الدخول مرة أخرى حيث أنه قد تم تغيير بيانات تسجيل الدخول الخاصة بك",
|
||||
"FORM": {
|
||||
"PICTURE": "Profile Picture",
|
||||
"AVATAR": "صورة الملف الشخصي",
|
||||
"ERROR": "الرجاء إصلاح الأخطاء في النموذج",
|
||||
"REMOVE_IMAGE": "حذف",
|
||||
@@ -57,19 +56,13 @@
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "رمز المصادقة",
|
||||
"NOTE": "يمكن استخدام هذا رمز المصادقة إذا كنت تبني تطبيقات API للتكامل مع Chatwoot",
|
||||
"COPY": "نسخ"
|
||||
"NOTE": "يمكن استخدام هذا رمز المصادقة إذا كنت تبني تطبيقات API للتكامل مع Chatwoot"
|
||||
},
|
||||
"AUDIO_NOTIFICATIONS_SECTION": {
|
||||
"TITLE": "الإشعارات الصوتية",
|
||||
"NOTE": "تمكين التنبيهات الصوتية في لوحة التحكم للرسائل والمحادثات الجديدة.",
|
||||
"ALERT_TYPES": {
|
||||
"NONE": "لا شيء",
|
||||
"MINE": "Assigned",
|
||||
"ALL": "الكل"
|
||||
},
|
||||
"ALERT_TYPE": {
|
||||
"TITLE": "Alert events for conversations:",
|
||||
"TITLE": "أحداث التنبيه:",
|
||||
"NONE": "لا شيء",
|
||||
"ASSIGNED": "المحادثات المسندة",
|
||||
"ALL_CONVERSATIONS": "كل المحادثات"
|
||||
@@ -95,23 +88,6 @@
|
||||
"SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA",
|
||||
"SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA"
|
||||
},
|
||||
"NOTIFICATIONS": {
|
||||
"TITLE": "Notification preferences",
|
||||
"TYPE_TITLE": "Notification type",
|
||||
"EMAIL": "البريد الإلكتروني",
|
||||
"PUSH": "الإشعارات الفورية",
|
||||
"TYPES": {
|
||||
"CONVERSATION_CREATED": "A new conversation is created",
|
||||
"CONVERSATION_ASSIGNED": "A conversation is assigned to you",
|
||||
"CONVERSATION_MENTION": "You are mentioned in a conversation",
|
||||
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "A new message is created in an assigned conversation",
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "A new message is created in a participating conversation",
|
||||
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
|
||||
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
|
||||
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
|
||||
},
|
||||
"BROWSER_PERMISSION": "Enable push notifications for your browser so you’re able to receive them"
|
||||
},
|
||||
"API": {
|
||||
"UPDATE_SUCCESS": "يتم تحديث إعدادات الإشعارات بنجاح",
|
||||
"UPDATE_ERROR": "حدث خطأ أثناء تحديث الإعدادات، الرجاء المحاولة مرة أخرى"
|
||||
@@ -182,7 +158,7 @@
|
||||
"SELECTOR_SUBTITLE": "اختر حساباً من القائمة التالية",
|
||||
"PROFILE_SETTINGS": "إعدادات الملف الشخصي",
|
||||
"KEYBOARD_SHORTCUTS": "اختصارات لوحة المفاتيح",
|
||||
"APPEARANCE": "تغيير المظهر",
|
||||
"APPEARANCE": "Change Appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "وحدة تحكم المدير المتميز",
|
||||
"LOGOUT": "تسجيل الخروج"
|
||||
},
|
||||
@@ -344,6 +320,7 @@
|
||||
"GO_TO_REPORTS_SIDEBAR": "الذهاب إلى شريط التقارير الجانبي",
|
||||
"MOVE_TO_NEXT_TAB": "نقل إلى علامة التبويب التالية في قائمة المحادثات",
|
||||
"GO_TO_SETTINGS": "انتقل إلى الإعدادات",
|
||||
"SWITCH_CONVERSATION_STATUS": "التبديل إلى حالة المحادثة التالية",
|
||||
"SWITCH_TO_PRIVATE_NOTE": "التبديل إلى الملاحظة الخاصة",
|
||||
"SWITCH_TO_REPLY": "التبديل إلى الرد",
|
||||
"TOGGLE_SNOOZE_DROPDOWN": "تبديل القائمة المنسدلة"
|
||||
|
||||
@@ -6,18 +6,6 @@
|
||||
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
|
||||
"LEARN_MORE": "Learn more about SLA",
|
||||
"LOADING": "Fetching SLAs",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to create SLAs",
|
||||
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to advanced features like team management, automations, custom attributes, and more.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "The SLA feature is only available in the paid plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade to a paid plan to access advanced features like audit logs, agent capacity, and more.",
|
||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||
},
|
||||
"LIST": {
|
||||
"404": "There are no SLAs available in this account.",
|
||||
"EMPTY": {
|
||||
@@ -105,4 +93,4 @@
|
||||
"HIDE": "Hide {count} rows"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"AGENT_LIST_LOADING": "Loading agents",
|
||||
"UPDATE": {
|
||||
"CHANGE_STATUS": "Change status",
|
||||
"SNOOZE_UNTIL": "Snooze",
|
||||
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply.",
|
||||
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
|
||||
"UPDATE_FAILED": "Failed to update conversations. Please try again."
|
||||
},
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
"SIDEBAR_SECTIONS": {
|
||||
"CUSTOM_ATTRIBUTES": "Персонализирани атрибути",
|
||||
"CONTACT_LABELS": "Етикети на контакта",
|
||||
"PREVIOUS_CONVERSATIONS": "Предишни разговори",
|
||||
"NO_RECORDS_FOUND": "Няма намерени атрибути"
|
||||
"PREVIOUS_CONVERSATIONS": "Предишни разговори"
|
||||
}
|
||||
},
|
||||
"EDIT_CONTACT": {
|
||||
@@ -84,7 +83,6 @@
|
||||
"CONFIRM": {
|
||||
"TITLE": "Export Contacts",
|
||||
"MESSAGE": "Are you sure you want to export all contacts?",
|
||||
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
|
||||
"YES": "Yes, Export",
|
||||
"NO": "No, Cancel"
|
||||
}
|
||||
|
||||
@@ -280,7 +280,6 @@
|
||||
},
|
||||
"CONVERSATION_CUSTOM_ATTRIBUTES": {
|
||||
"ADD_BUTTON_TEXT": "Create attribute",
|
||||
"NO_RECORDS_FOUND": "Няма намерени атрибути",
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Атрибута е обновен успешно",
|
||||
"ERROR": "Атрибута не бе обновен. Моля, опитайте отново по-късно"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"DATE_PICKER": {
|
||||
"APPLY_BUTTON": "Apply",
|
||||
"CLEAR_BUTTON": "Clear",
|
||||
"DATE_RANGE_INPUT": {
|
||||
"START": "Start Date",
|
||||
"END": "End Date"
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"TITLE": "DATE RANGE",
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
"LAST_YEAR": "Last year",
|
||||
"CUSTOM_RANGE": "Custom date range"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,6 @@
|
||||
"GENERAL": "General",
|
||||
"REPORTS": "Reports",
|
||||
"CONVERSATION": "Разговор",
|
||||
"BULK_ACTIONS": "Bulk Actions",
|
||||
"CHANGE_ASSIGNEE": "Change Assignee",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"CHANGE_TEAM": "Change Team",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"body": "Add agents to the created inbox."
|
||||
},
|
||||
{
|
||||
"title": "Voilà!",
|
||||
"title": "Voila!",
|
||||
"route": "settings_inbox_finish",
|
||||
"body": "You are all set to go!"
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
"CHOOSE_PLACEHOLDER": "Select a page from the list",
|
||||
"INBOX_NAME": "Inbox Name",
|
||||
"ADD_NAME": "Add a name for your inbox",
|
||||
"PICK_NAME": "Pick a Name for your Inbox",
|
||||
"PICK_NAME": "Pick A Name Your Inbox",
|
||||
"PICK_A_VALUE": "Pick a value"
|
||||
},
|
||||
"TWITTER": {
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"CHANNEL_WEBHOOK_URL": {
|
||||
"LABEL": "Webhook URL",
|
||||
"PLACEHOLDER": "Please enter your Webhook URL",
|
||||
"PLACEHOLDER": "Enter your Webhook URL",
|
||||
"ERROR": "Please enter a valid URL"
|
||||
},
|
||||
"CHANNEL_DOMAIN": {
|
||||
@@ -143,7 +143,7 @@
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Телефон",
|
||||
"LABEL": "Phone number",
|
||||
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
|
||||
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
|
||||
},
|
||||
@@ -175,12 +175,12 @@
|
||||
},
|
||||
"API_KEY": {
|
||||
"LABEL": "API Key",
|
||||
"PLACEHOLDER": "Please enter your Bandwidth API Key",
|
||||
"PLACEHOLDER": "Please enter your Bandwith API Key",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"API_SECRET": {
|
||||
"LABEL": "API Secret",
|
||||
"PLACEHOLDER": "Please enter your Bandwidth API Secret",
|
||||
"PLACEHOLDER": "Please enter your Bandwith API Secret",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"APPLICATION_ID": {
|
||||
@@ -223,7 +223,7 @@
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Телефон",
|
||||
"LABEL": "Phone number",
|
||||
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
|
||||
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
|
||||
},
|
||||
@@ -239,7 +239,7 @@
|
||||
},
|
||||
"WEBHOOK_VERIFY_TOKEN": {
|
||||
"LABEL": "Webhook Verify Token",
|
||||
"PLACEHOLDER": "Enter a verify token which you want to configure for Facebook webhooks.",
|
||||
"PLACEHOLDER": "Enter a verify token which you want to configure for facebook webhooks.",
|
||||
"ERROR": "Please enter a valid value."
|
||||
},
|
||||
"API_KEY": {
|
||||
@@ -269,7 +269,7 @@
|
||||
},
|
||||
"WEBHOOK_URL": {
|
||||
"LABEL": "Webhook URL",
|
||||
"SUBTITLE": "Configure the URL where you want to receive callbacks on events.",
|
||||
"SUBTITLE": "Configure the URL where you want to recieve callbacks on events.",
|
||||
"PLACEHOLDER": "Webhook URL"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create API Channel",
|
||||
@@ -279,7 +279,7 @@
|
||||
},
|
||||
"EMAIL_CHANNEL": {
|
||||
"TITLE": "Email Channel",
|
||||
"DESC": "Integrate your email inbox.",
|
||||
"DESC": "Integrate you email inbox.",
|
||||
"CHANNEL_NAME": {
|
||||
"LABEL": "Channel Name",
|
||||
"PLACEHOLDER": "Please enter a channel name",
|
||||
@@ -345,7 +345,7 @@
|
||||
"AGENTS": {
|
||||
"TITLE": "Агенти",
|
||||
"DESC": "Here you can add agents to manage your newly created inbox. Only these selected agents will have access to your inbox. Agents which are not part of this inbox will not be able to see or respond to messages in this inbox when they login. <br> <b>PS:</b> As an administrator, if you need access to all inboxes, you should add yourself as agent to all inboxes that you create.",
|
||||
"VALIDATION_ERROR": "Add at least one agent to your new Inbox",
|
||||
"VALIDATION_ERROR": "Add atleast one agent to your new Inbox",
|
||||
"PICK_AGENTS": "Pick agents for the inbox"
|
||||
},
|
||||
"DETAILS": {
|
||||
@@ -406,7 +406,7 @@
|
||||
},
|
||||
"SENDER_NAME_SECTION": {
|
||||
"TITLE": "Sender name",
|
||||
"SUB_TEXT": "Select the name shown to your customer when they receive emails from your agents.",
|
||||
"SUB_TEXT": "Select the name shown to the your customer when they receive emails from your agents.",
|
||||
"FOR_EG": "For eg:",
|
||||
"FRIENDLY": {
|
||||
"TITLE": "Friendly",
|
||||
@@ -508,12 +508,12 @@
|
||||
"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_UPDATE_SUBHEADER": "Enter the new API key to be used for the integration with the WhatsApp APIs.",
|
||||
"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": "Обновяване",
|
||||
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
|
||||
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verify Token",
|
||||
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||
},
|
||||
@@ -585,7 +585,7 @@
|
||||
"NOTE_TEXT": "To enable SMTP, please configure IMAP.",
|
||||
"UPDATE": "Update IMAP settings",
|
||||
"TOGGLE_AVAILABILITY": "Enable IMAP configuration for this inbox",
|
||||
"TOGGLE_HELP": "Enabling IMAP will help the user to receive email",
|
||||
"TOGGLE_HELP": "Enabling IMAP will help the user to recieve email",
|
||||
"EDIT": {
|
||||
"SUCCESS_MESSAGE": "IMAP settings updated successfully",
|
||||
"ERROR_MESSAGE": "Unable to update IMAP settings"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user