Compare commits
134
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5761120fa8 | ||
|
|
7066f66fa7 | ||
|
|
191b8a64fe | ||
|
|
ea78020f2f | ||
|
|
dc1a41b136 | ||
|
|
28e7a5d228 | ||
|
|
40830046e8 | ||
|
|
d05c953eef | ||
|
|
4a770fdea7 | ||
|
|
239b1e2bf1 | ||
|
|
f963e00731 | ||
|
|
155a5b9947 | ||
|
|
26a75b250d | ||
|
|
20b84ee6a1 | ||
|
|
f1f14312fe | ||
|
|
212d7caab1 | ||
|
|
9cda16d0e8 | ||
|
|
a8acbb7427 | ||
|
|
1176e5eb8a | ||
|
|
996325f35b | ||
|
|
4f8ce7b597 | ||
|
|
f8e631a00c | ||
|
|
54bf33083e | ||
|
|
533f9f7fe4 | ||
|
|
82e5fc413e | ||
|
|
93daaea19b | ||
|
|
e6a49b5800 | ||
|
|
595e6e79f0 | ||
|
|
93d8157a55 | ||
|
|
6bea2cbc4a | ||
|
|
afb7e67795 | ||
|
|
bc813b71dd | ||
|
|
9ac4a1eb30 | ||
|
|
9bb455ec2d | ||
|
|
c410fe333a | ||
|
|
5e807c5875 | ||
|
|
9bed57c7d1 | ||
|
|
5c5381c0a9 | ||
|
|
138630b4ae | ||
|
|
9d0de04f7c | ||
|
|
2c3337b117 | ||
|
|
237358af24 | ||
|
|
35dfff0a5b | ||
|
|
86b2896333 | ||
|
|
e8a27bea4b | ||
|
|
0d465362ac | ||
|
|
e383e9259b | ||
|
|
2c34dca347 | ||
|
|
69b9123963 | ||
|
|
cac9fe1880 | ||
|
|
06ac7c829b | ||
|
|
9985f1c143 | ||
|
|
1ee6a8fe90 | ||
|
|
6eb4fa41ff | ||
|
|
8b91f0ffac | ||
|
|
d2aa19579e | ||
|
|
2e79a32db7 | ||
|
|
a86e236d19 | ||
|
|
c755978352 | ||
|
|
23ca6d56f9 | ||
|
|
429ec7194f | ||
|
|
c99d9f9557 | ||
|
|
9ff5978d6e | ||
|
|
cd28f401ba | ||
|
|
879a244f93 | ||
|
|
f64f2138db | ||
|
|
48f2e58e59 | ||
|
|
c715e396f0 | ||
|
|
c8fac08e0b | ||
|
|
7fd220c177 | ||
|
|
b3b76d00ff | ||
|
|
2f2ae88cba | ||
|
|
2b7989f52b | ||
|
|
d7314079c9 | ||
|
|
1017903ee1 | ||
|
|
d25e7fd54e | ||
|
|
7e89e3a34a | ||
|
|
39eaed7766 | ||
|
|
b1a4907ede | ||
|
|
a01d81a7e1 | ||
|
|
117d5301b4 | ||
|
|
aae6081d73 | ||
|
|
d03924b846 | ||
|
|
b333d0c986 | ||
|
|
9f3d155822 | ||
|
|
9203b097d2 | ||
|
|
12cb97db5f | ||
|
|
09971fd613 | ||
|
|
ca9d108741 | ||
|
|
f28533bc47 | ||
|
|
68f82c11a5 | ||
|
|
abc27fa791 | ||
|
|
d93a8d05bc | ||
|
|
373f5f5b64 | ||
|
|
ae7df60a75 | ||
|
|
412d750b6a | ||
|
|
f1a77ba934 | ||
|
|
ffc6364690 | ||
|
|
3a7633b564 | ||
|
|
91d1d44aa9 | ||
|
|
afc97faa8b | ||
|
|
1d0930ef63 | ||
|
|
776ea6d39a | ||
|
|
2c85098698 | ||
|
|
6bd0e074dc | ||
|
|
123fc73394 | ||
|
|
88cef88d80 | ||
|
|
8e79cf72e1 | ||
|
|
bfaca851f1 | ||
|
|
5618b5ebd3 | ||
|
|
4807052c37 | ||
|
|
44b1047b90 | ||
|
|
9c6c19c3e5 | ||
|
|
2764338453 | ||
|
|
4f0fcfcbcd | ||
|
|
fdbaf98c3b | ||
|
|
03bbd048a7 | ||
|
|
d481b9fbcf | ||
|
|
68dedc37ba | ||
|
|
958c0d7946 | ||
|
|
9553329092 | ||
|
|
d01f2063d0 | ||
|
|
590e4e9c1c | ||
|
|
0e903d2365 | ||
|
|
d6ce1ceeeb | ||
|
|
e8f56d0e56 | ||
|
|
b8dae07c0f | ||
|
|
e3f4be97c0 | ||
|
|
1f4d096804 | ||
|
|
1bcc438b21 | ||
|
|
8b3d13a920 | ||
|
|
dcdd58bae5 | ||
|
|
de1fb6ba4e | ||
|
|
18c5c47836 |
@@ -157,7 +157,7 @@ jobs:
|
||||
- run:
|
||||
name: Code Climate Test Coverage
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json buildreports/lcov.info
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
- persist_to_workspace:
|
||||
root: coverage
|
||||
|
||||
+14
-1
@@ -173,6 +173,9 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
## LogRocket
|
||||
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
|
||||
|
||||
# MICROSOFT CLARITY
|
||||
# MS_CLARITY_TOKEN=xxxxxxxxx
|
||||
|
||||
## Scout
|
||||
## https://scoutapm.com/docs/ruby/configuration
|
||||
# SCOUT_KEY=YOURKEY
|
||||
@@ -219,10 +222,20 @@ STRIPE_WEBHOOK_SECRET=
|
||||
# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true.
|
||||
DIRECT_UPLOADS_ENABLED=
|
||||
|
||||
#MS OAUTH creds
|
||||
# MS OAUTH creds
|
||||
AZURE_APP_ID=
|
||||
AZURE_APP_SECRET=
|
||||
|
||||
## MS Azure Tenant ID
|
||||
# Set the following id to the id of your Azure 'tenant'.
|
||||
# This will enable single tenant applications to work.
|
||||
# If the following id is set, Chatwoot will use the Microsoft Graph API
|
||||
# to send and receive emails, as that seems to be required for single
|
||||
# tenant applications.
|
||||
#
|
||||
# https://learn.microsoft.com/en-us/azure/active-directory/fundamentals/active-directory-how-to-find-tenant
|
||||
AZURE_TENANT_ID=
|
||||
|
||||
## Advanced configurations
|
||||
## Change these values to fine tune performance
|
||||
# control the concurrency setting of sidekiq
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# ref: https://github.com/amannn/action-semantic-pull-request
|
||||
# ensure PR title is in semantic format
|
||||
|
||||
name: "Lint PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Run Size Limit Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Run asset compile
|
||||
run: bundle exec rake assets:precompile
|
||||
|
||||
- name: Size Check
|
||||
run: yarn run size
|
||||
@@ -74,3 +74,5 @@ yalc.lock
|
||||
/yarn-error.log
|
||||
yarn-debug.log*
|
||||
.yarn-integrity
|
||||
|
||||
/storybook-static
|
||||
@@ -4,7 +4,7 @@ ruby '3.2.2'
|
||||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
gem 'rails', '~> 7'
|
||||
gem 'rails', '~> 7.0.5.1'
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
@@ -75,6 +75,7 @@ gem 'jwt'
|
||||
gem 'pundit'
|
||||
# super admin
|
||||
gem 'administrate'
|
||||
gem 'administrate-field-active_storage'
|
||||
|
||||
##--- gems for pubsub service ---##
|
||||
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
|
||||
|
||||
+75
-70
@@ -33,70 +33,70 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.4.3)
|
||||
actionpack (= 7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
actioncable (7.0.5.1)
|
||||
actionpack (= 7.0.5.1)
|
||||
activesupport (= 7.0.5.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.4.3)
|
||||
actionpack (= 7.0.4.3)
|
||||
activejob (= 7.0.4.3)
|
||||
activerecord (= 7.0.4.3)
|
||||
activestorage (= 7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
actionmailbox (7.0.5.1)
|
||||
actionpack (= 7.0.5.1)
|
||||
activejob (= 7.0.5.1)
|
||||
activerecord (= 7.0.5.1)
|
||||
activestorage (= 7.0.5.1)
|
||||
activesupport (= 7.0.5.1)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.4.3)
|
||||
actionpack (= 7.0.4.3)
|
||||
actionview (= 7.0.4.3)
|
||||
activejob (= 7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
actionmailer (7.0.5.1)
|
||||
actionpack (= 7.0.5.1)
|
||||
actionview (= 7.0.5.1)
|
||||
activejob (= 7.0.5.1)
|
||||
activesupport (= 7.0.5.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.4.3)
|
||||
actionview (= 7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
rack (~> 2.0, >= 2.2.0)
|
||||
actionpack (7.0.5.1)
|
||||
actionview (= 7.0.5.1)
|
||||
activesupport (= 7.0.5.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.4.3)
|
||||
actionpack (= 7.0.4.3)
|
||||
activerecord (= 7.0.4.3)
|
||||
activestorage (= 7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
actiontext (7.0.5.1)
|
||||
actionpack (= 7.0.5.1)
|
||||
activerecord (= 7.0.5.1)
|
||||
activestorage (= 7.0.5.1)
|
||||
activesupport (= 7.0.5.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
actionview (7.0.5.1)
|
||||
activesupport (= 7.0.5.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.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
activejob (7.0.5.1)
|
||||
activesupport (= 7.0.5.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
activerecord (7.0.4.3)
|
||||
activemodel (= 7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
activemodel (7.0.5.1)
|
||||
activesupport (= 7.0.5.1)
|
||||
activerecord (7.0.5.1)
|
||||
activemodel (= 7.0.5.1)
|
||||
activesupport (= 7.0.5.1)
|
||||
activerecord-import (1.4.1)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.0.4.3)
|
||||
actionpack (= 7.0.4.3)
|
||||
activejob (= 7.0.4.3)
|
||||
activerecord (= 7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
activestorage (7.0.5.1)
|
||||
actionpack (= 7.0.5.1)
|
||||
activejob (= 7.0.5.1)
|
||||
activerecord (= 7.0.5.1)
|
||||
activesupport (= 7.0.5.1)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.4.3)
|
||||
activesupport (7.0.5.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@@ -113,6 +113,9 @@ GEM
|
||||
kaminari (>= 1.0)
|
||||
sassc-rails (~> 2.1)
|
||||
selectize-rails (~> 0.6)
|
||||
administrate-field-active_storage (0.4.2)
|
||||
administrate (>= 0.2.2)
|
||||
rails (>= 7.0)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
@@ -353,7 +356,7 @@ GEM
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.13.0)
|
||||
i18n (1.14.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
@@ -420,9 +423,9 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.20.0)
|
||||
loofah (2.21.3)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
@@ -462,14 +465,14 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (8.16.0)
|
||||
nio4r (2.5.9)
|
||||
nokogiri (1.14.3)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
nokogiri (1.15.2)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.14.3-arm64-darwin)
|
||||
nokogiri (1.15.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.14.3-x86_64-darwin)
|
||||
nokogiri (1.15.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.14.3-x86_64-linux)
|
||||
nokogiri (1.15.2-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -522,7 +525,7 @@ GEM
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.6.2)
|
||||
racc (1.7.0)
|
||||
rack (2.2.7)
|
||||
rack-attack (6.6.1)
|
||||
rack (>= 1.0, < 3)
|
||||
@@ -537,28 +540,29 @@ GEM
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rails (7.0.4.3)
|
||||
actioncable (= 7.0.4.3)
|
||||
actionmailbox (= 7.0.4.3)
|
||||
actionmailer (= 7.0.4.3)
|
||||
actionpack (= 7.0.4.3)
|
||||
actiontext (= 7.0.4.3)
|
||||
actionview (= 7.0.4.3)
|
||||
activejob (= 7.0.4.3)
|
||||
activemodel (= 7.0.4.3)
|
||||
activerecord (= 7.0.4.3)
|
||||
activestorage (= 7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
rails (7.0.5.1)
|
||||
actioncable (= 7.0.5.1)
|
||||
actionmailbox (= 7.0.5.1)
|
||||
actionmailer (= 7.0.5.1)
|
||||
actionpack (= 7.0.5.1)
|
||||
actiontext (= 7.0.5.1)
|
||||
actionview (= 7.0.5.1)
|
||||
activejob (= 7.0.5.1)
|
||||
activemodel (= 7.0.5.1)
|
||||
activerecord (= 7.0.5.1)
|
||||
activestorage (= 7.0.5.1)
|
||||
activesupport (= 7.0.5.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.4.3)
|
||||
railties (= 7.0.5.1)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.5.0)
|
||||
loofah (~> 2.19, >= 2.19.1)
|
||||
railties (7.0.4.3)
|
||||
actionpack (= 7.0.4.3)
|
||||
activesupport (= 7.0.4.3)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.5.1)
|
||||
actionpack (= 7.0.5.1)
|
||||
activesupport (= 7.0.5.1)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
@@ -722,8 +726,8 @@ GEM
|
||||
stripe (8.5.0)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.2.1)
|
||||
tilt (2.1.0)
|
||||
thor (1.2.2)
|
||||
tilt (2.2.0)
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
@@ -784,8 +788,8 @@ GEM
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
arm64-darwin-22
|
||||
arm64-darwin-21
|
||||
arm64-darwin-22
|
||||
ruby
|
||||
x86_64-darwin-18
|
||||
x86_64-darwin-20
|
||||
@@ -798,6 +802,7 @@ DEPENDENCIES
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate
|
||||
administrate-field-active_storage
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.3)
|
||||
@@ -870,7 +875,7 @@ DEPENDENCIES
|
||||
rack-cors
|
||||
rack-mini-profiler
|
||||
rack-timeout
|
||||
rails (~> 7)
|
||||
rails (~> 7.0.5.1)
|
||||
redis
|
||||
redis-namespace
|
||||
responders
|
||||
@@ -916,4 +921,4 @@ RUBY VERSION
|
||||
ruby 3.2.2p185
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.10
|
||||
2.4.6
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//= link_tree ../images
|
||||
//= link administrate/application.css
|
||||
//= link administrate/application.js
|
||||
//= link administrate-field-active_storage/application.css
|
||||
//= link dashboardChart.js
|
||||
//= link secretField.js
|
||||
|
||||
@@ -11,7 +11,7 @@ function prepareData(data) {
|
||||
|
||||
function getChartOptions() {
|
||||
var fontFamily =
|
||||
'Inter,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
return {
|
||||
responsive: true,
|
||||
legend: { labels: { fontFamily } },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Typography
|
||||
$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
||||
$base-font-family: PlusJakarta, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
||||
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif !default;
|
||||
$heading-font-family: $base-font-family !default;
|
||||
|
||||
@@ -63,9 +63,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.where(
|
||||
@conversation ||= Conversation.where(conversation_params).find_by(
|
||||
"additional_attributes ->> 'type' = 'instagram_direct_message'"
|
||||
).find_by(conversation_params) || build_conversation
|
||||
) || build_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
@@ -96,6 +96,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: { type: 'instagram_direct_message' }
|
||||
|
||||
@@ -48,17 +48,22 @@ class Messages::MessageBuilder
|
||||
def process_emails
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
cc_emails = []
|
||||
cc_emails = @params[:cc_emails].gsub(/\s+/, '').split(',') if @params[:cc_emails].present?
|
||||
cc_emails = process_email_string(@params[:cc_emails])
|
||||
bcc_emails = process_email_string(@params[:bcc_emails])
|
||||
to_emails = process_email_string(@params[:to_emails])
|
||||
|
||||
bcc_emails = []
|
||||
bcc_emails = @params[:bcc_emails].gsub(/\s+/, '').split(',') if @params[:bcc_emails].present?
|
||||
|
||||
all_email_addresses = cc_emails + bcc_emails
|
||||
all_email_addresses = cc_emails + bcc_emails + to_emails
|
||||
validate_email_addresses(all_email_addresses)
|
||||
|
||||
@message.content_attributes[:cc_emails] = cc_emails
|
||||
@message.content_attributes[:bcc_emails] = bcc_emails
|
||||
@message.content_attributes[:to_emails] = to_emails
|
||||
end
|
||||
|
||||
def process_email_string(email_string)
|
||||
return [] if email_string.blank?
|
||||
|
||||
email_string.gsub(/\s+/, '').split(',')
|
||||
end
|
||||
|
||||
def validate_email_addresses(all_emails)
|
||||
|
||||
@@ -96,10 +96,9 @@ class V2::ReportBuilder
|
||||
|
||||
def conversations
|
||||
@open_conversations = scope.conversations.where(account_id: @account.id).open
|
||||
first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
|
||||
metric = {
|
||||
open: @open_conversations.count,
|
||||
unattended: @open_conversations.count - first_response_count
|
||||
unattended: @open_conversations.unattended.count
|
||||
}
|
||||
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
|
||||
metric
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController
|
||||
include HmacConcern
|
||||
before_action :ensure_inbox, only: [:create]
|
||||
|
||||
def create
|
||||
@contact_inbox = ContactInboxBuilder.new(
|
||||
contact: @contact,
|
||||
inbox: @inbox,
|
||||
source_id: params[:source_id]
|
||||
source_id: params[:source_id],
|
||||
hmac_verified: hmac_verified?
|
||||
).perform
|
||||
end
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def export
|
||||
column_names = params['column_names']
|
||||
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
|
||||
head :ok, message: I18n.t('errors.contacts.export.success')
|
||||
end
|
||||
|
||||
# returns online contacts
|
||||
def active
|
||||
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :conversation
|
||||
|
||||
private
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
|
||||
include Events::Types
|
||||
include DateRangeHelper
|
||||
include HmacConcern
|
||||
|
||||
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
|
||||
before_action :inbox, :contact, :contact_inbox, only: [:create]
|
||||
@@ -104,9 +105,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def set_conversation_status
|
||||
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
|
||||
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
|
||||
# status = params[:status] == 'bot' ? 'pending' : params[:status]
|
||||
@conversation.status = params[:status]
|
||||
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
||||
end
|
||||
@@ -152,7 +150,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
ContactInboxBuilder.new(
|
||||
contact: @contact,
|
||||
inbox: @inbox,
|
||||
source_id: params[:source_id]
|
||||
source_id: params[:source_id],
|
||||
hmac_verified: hmac_verified?
|
||||
).perform
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_custom_filters, except: [:create]
|
||||
before_action :fetch_custom_filter, only: [:show, :update, :destroy]
|
||||
DEFAULT_FILTER_TYPE = 'conversation'.freeze
|
||||
@@ -11,6 +12,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
|
||||
@custom_filter = current_user.custom_filters.create!(
|
||||
permitted_payload.merge(account_id: Current.account.id)
|
||||
)
|
||||
render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid?
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -44,26 +44,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def update
|
||||
@inbox.update!(permitted_params.except(:channel))
|
||||
update_inbox_working_hours
|
||||
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
||||
|
||||
# Inbox update doesn't necessarily need channel attributes
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
if @inbox.inbox_type == 'Email'
|
||||
begin
|
||||
validate_email_channel(channel_attributes)
|
||||
rescue StandardError => e
|
||||
render json: { message: e }, status: :unprocessable_entity and return
|
||||
end
|
||||
@inbox.channel.reauthorized!
|
||||
end
|
||||
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
update_channel_feature_flags
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
update_channel if channel_update_required?
|
||||
end
|
||||
|
||||
def agent_bot
|
||||
@@ -82,7 +63,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
::DeleteObjectJob.perform_later(@inbox) if @inbox.present?
|
||||
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
@@ -103,6 +84,35 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
end
|
||||
|
||||
def update_channel
|
||||
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
validate_and_update_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
|
||||
|
||||
reauthorize_and_update_channel(channel_attributes)
|
||||
update_channel_feature_flags
|
||||
end
|
||||
|
||||
def channel_update_required?
|
||||
permitted_params(get_channel_attributes(@inbox.channel_type))[:channel].present?
|
||||
end
|
||||
|
||||
def validate_and_update_email_channel(channel_attributes)
|
||||
validate_email_channel(channel_attributes)
|
||||
rescue StandardError => e
|
||||
render json: { message: e }, status: :unprocessable_entity and return
|
||||
end
|
||||
|
||||
def reauthorize_and_update_channel(channel_attributes)
|
||||
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
end
|
||||
|
||||
def update_channel_feature_flags
|
||||
return unless @inbox.web_widget?
|
||||
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
|
||||
|
||||
@@ -4,13 +4,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
|
||||
def create
|
||||
email = params[:authorization][:email]
|
||||
redirect_url = microsoft_client.auth_code.authorize_url(
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
|
||||
prompt: 'consent'
|
||||
}
|
||||
)
|
||||
redirect_url = microsoft_client.auth_code.authorize_url(auth_params)
|
||||
if redirect_url
|
||||
email = email.downcase
|
||||
::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
|
||||
@@ -25,4 +19,31 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
|
||||
# SMTP, Pop and IMAP are being deprecated by Outlook.
|
||||
# https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online
|
||||
#
|
||||
# As such, Microsoft has made it a real pain to use them.
|
||||
# If AZURE_TENANT_ID is set, we will use the MS Graph API instead.
|
||||
def auth_params
|
||||
return graph_auth_params if ENV.fetch('AZURE_TENANT_ID', false)
|
||||
|
||||
standard_auth_params
|
||||
end
|
||||
|
||||
def standard_auth_params
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
|
||||
prompt: 'consent'
|
||||
}
|
||||
end
|
||||
|
||||
def graph_auth_params
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: 'offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/Mail.Send openid profile',
|
||||
prompt: 'consent'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,6 +37,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
|
||||
render json: { cache_keys: get_cache_keys }, status: :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
@message.update!(submitted_email: contact_email)
|
||||
ContactIdentifyAction.new(
|
||||
contact: @contact,
|
||||
params: { email: contact_email, name: contact_name }
|
||||
params: { email: contact_email, name: contact_name },
|
||||
retain_original_contact_name: true
|
||||
).perform
|
||||
else
|
||||
@message.update!(message_update_params[:message])
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module HmacConcern
|
||||
def hmac_verified?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:hmac_verified]).present?
|
||||
end
|
||||
end
|
||||
@@ -5,8 +5,8 @@ module MicrosoftConcern
|
||||
::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',
|
||||
token_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
|
||||
authorize_url: "https://login.microsoftonline.com/#{azure_tenant_id}/oauth2/v2.0/authorize",
|
||||
token_url: "https://login.microsoftonline.com/#{azure_tenant_id}/oauth2/v2.0/token"
|
||||
})
|
||||
end
|
||||
|
||||
@@ -19,4 +19,8 @@ module MicrosoftConcern
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
def azure_tenant_id
|
||||
MicrosoftGraphAuth.azure_tenant_id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ class DashboardController < ActionController::Base
|
||||
|
||||
def set_global_config
|
||||
@global_config = GlobalConfig.get(
|
||||
'LOGO', 'LOGO_THUMBNAIL',
|
||||
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
|
||||
'INSTALLATION_NAME',
|
||||
'WIDGET_BRAND_URL', 'TERMS_URL',
|
||||
'PRIVACY_URL',
|
||||
|
||||
@@ -37,3 +37,5 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|
||||
|
||||
@@ -43,8 +43,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
end
|
||||
|
||||
def render_article_content(content)
|
||||
# rubocop:disable Rails/OutputSafety
|
||||
CommonMarker.render_html(content).html_safe
|
||||
# rubocop:enable Rails/OutputSafety
|
||||
ChatwootMarkdownRenderer.new(content).render_article
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
def set_locale(&)
|
||||
switch_locale_with_portal(&) if params[:locale].present?
|
||||
switch_locale_with_article(&) if params[:article_slug].present?
|
||||
|
||||
yield
|
||||
end
|
||||
|
||||
def switch_locale_with_portal(&)
|
||||
|
||||
@@ -50,6 +50,13 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
|
||||
# rubocop:enable Rails/I18nLocaleTexts
|
||||
end
|
||||
|
||||
def reset_cache
|
||||
requested_resource.reset_cache_keys
|
||||
# rubocop:disable Rails/I18nLocaleTexts
|
||||
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Cache keys cleared')
|
||||
# rubocop:enable Rails/I18nLocaleTexts
|
||||
end
|
||||
|
||||
def destroy
|
||||
account = Account.find(params[:id])
|
||||
|
||||
|
||||
@@ -41,4 +41,14 @@ class SuperAdmin::AgentBotsController < SuperAdmin::ApplicationController
|
||||
|
||||
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
|
||||
# for more information
|
||||
|
||||
def destroy_avatar
|
||||
avatar = requested_resource.avatar
|
||||
avatar.purge
|
||||
redirect_back(fallback_location: super_admin_agent_bots_path)
|
||||
end
|
||||
|
||||
def scoped_resource
|
||||
resource_class.with_attached_avatar
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,17 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
||||
sha
|
||||
postgres_status
|
||||
redis_metrics
|
||||
chatwoot_edition
|
||||
end
|
||||
|
||||
def chatwoot_edition
|
||||
@metrics['Chatwoot edition'] = if ChatwootApp.enterprise?
|
||||
'Enterprise'
|
||||
elsif ChatwootApp.custom?
|
||||
'Custom'
|
||||
else
|
||||
'Community'
|
||||
end
|
||||
end
|
||||
|
||||
def chatwoot_version
|
||||
|
||||
@@ -45,6 +45,17 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
|
||||
# empty values into nil values. It uses other APIs such as `resource_class`
|
||||
# and `dashboard`:
|
||||
#
|
||||
|
||||
def destroy_avatar
|
||||
avatar = requested_resource.avatar
|
||||
avatar.purge
|
||||
redirect_back(fallback_location: super_admin_users_path)
|
||||
end
|
||||
|
||||
def scoped_resource
|
||||
resource_class.with_attached_avatar
|
||||
end
|
||||
|
||||
def resource_params
|
||||
permitted_params = super
|
||||
permitted_params.delete(:password) if permitted_params[:password].blank?
|
||||
|
||||
@@ -10,6 +10,11 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
||||
ATTRIBUTE_TYPES = {
|
||||
access_token: Field::HasOne,
|
||||
avatar_url: AvatarField,
|
||||
avatar: Field::ActiveStorage.with_options(
|
||||
destroy_url: proc do |_namespace, _resource, attachment|
|
||||
[:avatar_super_admin_agent_bot, { attachment_id: attachment.id }]
|
||||
end
|
||||
),
|
||||
id: Field::Number,
|
||||
name: Field::String,
|
||||
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
|
||||
@@ -36,6 +41,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
||||
# an array of attributes that will be displayed on the model's show page.
|
||||
SHOW_PAGE_ATTRIBUTES = %i[
|
||||
id
|
||||
avatar_url
|
||||
account
|
||||
name
|
||||
description
|
||||
@@ -47,6 +53,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
||||
# on the model's form (`new` and `edit`) pages.
|
||||
FORM_ATTRIBUTES = %i[
|
||||
name
|
||||
avatar
|
||||
account
|
||||
description
|
||||
outgoing_url
|
||||
|
||||
@@ -11,6 +11,11 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
account_users: Field::HasMany,
|
||||
id: Field::Number,
|
||||
avatar_url: AvatarField,
|
||||
avatar: Field::ActiveStorage.with_options(
|
||||
destroy_url: proc do |_namespace, _resource, attachment|
|
||||
[:avatar_super_admin_user, { attachment_id: attachment.id }]
|
||||
end
|
||||
),
|
||||
provider: Field::String,
|
||||
uid: Field::String,
|
||||
password: Field::Password,
|
||||
@@ -69,6 +74,7 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
# on the model's form (`new` and `edit`) pages.
|
||||
FORM_ATTRIBUTES = %i[
|
||||
name
|
||||
avatar
|
||||
display_name
|
||||
email
|
||||
password
|
||||
|
||||
@@ -6,7 +6,8 @@ class ConversationFinder
|
||||
latest: 'latest',
|
||||
sort_on_created_at: 'sort_on_created_at',
|
||||
last_user_message_at: 'last_user_message_at',
|
||||
sort_on_priority: 'sort_on_priority'
|
||||
sort_on_priority: 'sort_on_priority',
|
||||
sort_on_waiting_since: 'sort_on_waiting_since'
|
||||
}.with_indifferent_access
|
||||
|
||||
# assumptions
|
||||
@@ -29,7 +30,7 @@ class ConversationFinder
|
||||
def perform
|
||||
set_up
|
||||
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
mine_count, unassigned_count, all_count, unread_count = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
filter_by_assignee_type
|
||||
@@ -40,7 +41,8 @@ class ConversationFinder
|
||||
mine_count: mine_count,
|
||||
assigned_count: assigned_count,
|
||||
unassigned_count: unassigned_count,
|
||||
all_count: all_count
|
||||
all_count: all_count,
|
||||
unread_count: unread_count
|
||||
}
|
||||
}
|
||||
end
|
||||
@@ -102,7 +104,7 @@ class ConversationFinder
|
||||
when 'participating'
|
||||
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
|
||||
when 'unattended'
|
||||
@conversations = @conversations.where(first_reply_created_at: nil)
|
||||
@conversations = @conversations.unattended
|
||||
end
|
||||
@conversations
|
||||
end
|
||||
@@ -146,7 +148,10 @@ class ConversationFinder
|
||||
[
|
||||
@conversations.assigned_to(current_user).count,
|
||||
@conversations.unassigned.count,
|
||||
@conversations.count
|
||||
@conversations.count,
|
||||
@conversations.joins(:messages)
|
||||
.where('conversations.agent_last_seen_at IS NULL OR messages.created_at > conversations.agent_last_seen_at')
|
||||
.distinct.count
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
module BillingHelper
|
||||
private
|
||||
|
||||
def default_plan?(account)
|
||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
||||
default_plan = installation_config&.value&.first
|
||||
|
||||
# Return false if not plans are configured, so that no checks are enforced
|
||||
return false if default_plan.blank?
|
||||
|
||||
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name']
|
||||
end
|
||||
|
||||
def conversations_this_month(account)
|
||||
account.conversations.where('created_at > ?', 30.days.ago).count
|
||||
end
|
||||
|
||||
def non_web_inboxes(account)
|
||||
account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count
|
||||
end
|
||||
end
|
||||
@@ -7,8 +7,6 @@ module MessageFormatHelper
|
||||
end
|
||||
|
||||
def render_message_content(message_content)
|
||||
# rubocop:disable Rails/OutputSafety
|
||||
CommonMarker.render_html(message_content).html_safe
|
||||
# rubocop:enable Rails/OutputSafety
|
||||
ChatwootMarkdownRenderer.new(message_content).render_message
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,7 +29,9 @@ module ReportHelper
|
||||
end
|
||||
|
||||
def resolutions_count
|
||||
(get_grouped_values scope.conversations.where(account_id: account.id).resolved).count
|
||||
object_scope = scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
|
||||
conversations: { status: :resolved }).distinct
|
||||
(get_grouped_values object_scope).count
|
||||
end
|
||||
|
||||
def avg_first_response_time
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
:class="{ 'app-rtl--wrapper': isRTLView }"
|
||||
>
|
||||
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<template v-if="!accountUIFlags.isFetchingItem && currentAccountId">
|
||||
<payment-pending-banner />
|
||||
<upgrade-banner />
|
||||
</template>
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
</transition>
|
||||
@@ -25,6 +29,8 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import UpgradeBanner from './components/app/UpgradeBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import WootSnackbarBox from './components/SnackbarContainer';
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
@@ -41,7 +47,9 @@ export default {
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
UpgradeBanner,
|
||||
},
|
||||
|
||||
mixins: [rtlMixin],
|
||||
@@ -59,6 +67,7 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
globalConfig: 'globalConfig/get',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
hasAccounts() {
|
||||
@@ -115,11 +124,6 @@ export default {
|
||||
|
||||
<style lang="scss">
|
||||
@import './assets/scss/app';
|
||||
.update-banner {
|
||||
height: var(--space-larger);
|
||||
align-items: center;
|
||||
font-size: var(--font-size-small) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||
|
||||
@@ -75,6 +75,10 @@ class ContactAPI extends ApiClient {
|
||||
destroyAvatar(contactId) {
|
||||
return axios.delete(`${this.url}/${contactId}/avatar`);
|
||||
}
|
||||
|
||||
exportContacts() {
|
||||
return axios.get(`${this.url}/export`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactAPI();
|
||||
|
||||
@@ -35,10 +35,10 @@ class CSATReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({ from, to, user_ids, inbox_id, team_id } = {}) {
|
||||
getMetrics({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
|
||||
// no ratings for metrics
|
||||
return axios.get(`${this.url}/metrics`, {
|
||||
params: { since: from, until: to, user_ids, inbox_id, team_id },
|
||||
params: { since: from, until: to, user_ids, inbox_id, team_id, rating },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ class EnterpriseAccountAPI extends ApiClient {
|
||||
subscription() {
|
||||
return axios.post(`${this.url}subscription`);
|
||||
}
|
||||
|
||||
getLimits() {
|
||||
return axios.get(`${this.url}limits`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
|
||||
@@ -127,6 +127,10 @@ class ConversationApi extends ApiClient {
|
||||
user_ids: userIds,
|
||||
});
|
||||
}
|
||||
|
||||
getAllAttachments(conversationId) {
|
||||
return axios.get(`${this.url}/${conversationId}/attachments`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
||||
@@ -10,6 +10,7 @@ export const buildCreatePayload = ({
|
||||
files,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
templateParams,
|
||||
}) => {
|
||||
let payload;
|
||||
@@ -25,6 +26,9 @@ export const buildCreatePayload = ({
|
||||
payload.append('echo_id', echoId);
|
||||
payload.append('cc_emails', ccEmails);
|
||||
payload.append('bcc_emails', bccEmails);
|
||||
if (toEmails) {
|
||||
payload.append('to_emails', toEmails);
|
||||
}
|
||||
} else {
|
||||
payload = {
|
||||
content: message,
|
||||
@@ -33,6 +37,7 @@ export const buildCreatePayload = ({
|
||||
content_attributes: contentAttributes,
|
||||
cc_emails: ccEmails,
|
||||
bcc_emails: bccEmails,
|
||||
to_emails: toEmails,
|
||||
template_params: templateParams,
|
||||
};
|
||||
}
|
||||
@@ -53,6 +58,7 @@ class MessageApi extends ApiClient {
|
||||
files,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
templateParams,
|
||||
}) {
|
||||
return axios({
|
||||
@@ -66,6 +72,7 @@ class MessageApi extends ApiClient {
|
||||
files,
|
||||
ccEmails,
|
||||
bccEmails,
|
||||
toEmails,
|
||||
templateParams,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -210,5 +210,12 @@ describe('#ConversationAPI', () => {
|
||||
{ params: { page: payload.page } }
|
||||
);
|
||||
});
|
||||
|
||||
it('#getAllAttachments', () => {
|
||||
conversationAPI.getAllAttachments(1);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/1/attachments'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
border-radius: var(--border-radius-normal);
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
height: 4.0rem;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.mx-input:disabled,
|
||||
@@ -35,3 +35,32 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-datepicker-inline {
|
||||
width: 100%;
|
||||
|
||||
.mx-calendar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cell.disabled {
|
||||
background-color: var(--s-25);
|
||||
color: var(--s-200);
|
||||
}
|
||||
|
||||
.mx-time-item.disabled {
|
||||
background-color: var(--s-25);
|
||||
}
|
||||
|
||||
.today {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.mx-datepicker-main {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mx-time-header {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ $black: #000;
|
||||
$white: #fff;
|
||||
$body-background: $white;
|
||||
$body-font-color: $color-body;
|
||||
$body-font-family: 'Inter',
|
||||
$body-font-family: 'PlusJakarta',
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
@@ -133,7 +133,7 @@ $header-styles: (small: ("h1": ("font-size": 24),
|
||||
$header-text-rendering: optimizeLegibility;
|
||||
$small-font-size: 80%;
|
||||
$header-small-font-color: $medium-gray;
|
||||
$paragraph-lineheight: 1.45;
|
||||
$paragraph-lineheight: 1.65;
|
||||
$paragraph-margin-bottom: var(--space-small);
|
||||
$paragraph-text-rendering: optimizeLegibility;
|
||||
$code-color: $black;
|
||||
|
||||
@@ -29,4 +29,5 @@ a {
|
||||
|
||||
p {
|
||||
font-size: $font-size-small;
|
||||
word-spacing: .12em;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'shared/assets/fonts/plus-jakarta';
|
||||
@import 'shared/assets/stylesheets/animations';
|
||||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'shared/assets/fonts/plus-jakarta';
|
||||
@import 'shared/assets/stylesheets/animations';
|
||||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'shared/assets/fonts/plus-jakarta';
|
||||
@import '../variables';
|
||||
@import '~shared/assets/stylesheets/ionicons';
|
||||
|
||||
@@ -29,13 +29,13 @@
|
||||
}
|
||||
|
||||
.modal-image {
|
||||
max-height: 90%;
|
||||
max-width: 90%;
|
||||
max-height: 76vh;
|
||||
max-width: 76vw;
|
||||
}
|
||||
|
||||
.modal-video {
|
||||
max-height: 75vh;
|
||||
max-width: 100%;
|
||||
max-height: 76vh;
|
||||
max-width: 76vw;
|
||||
}
|
||||
|
||||
&::before {
|
||||
@@ -53,16 +53,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.video {
|
||||
.modal-container {
|
||||
width: auto;
|
||||
|
||||
.modal--close {
|
||||
z-index: var(--z-index-low);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.conversations-list-wrap {
|
||||
@@ -400,4 +390,3 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,14 @@
|
||||
/>
|
||||
</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"
|
||||
@@ -168,8 +176,11 @@
|
||||
v-if="showAdvancedFilters"
|
||||
:initial-filter-types="advancedFilterTypes"
|
||||
:initial-applied-filters="appliedFilter"
|
||||
:active-folder-name="activeFolderName"
|
||||
:on-close="closeAdvanceFiltersModal"
|
||||
:is-folder-view="hasActiveFolders"
|
||||
@applyFilter="onApplyFilter"
|
||||
@updateFolder="onUpdateSavedFilter"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
@@ -193,6 +204,9 @@ import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCust
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import filterMixin from 'shared/mixins/filterMixin';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import countries from 'shared/constants/countries';
|
||||
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||
|
||||
import {
|
||||
hasPressedAltAndJKey,
|
||||
@@ -289,6 +303,11 @@ export default {
|
||||
appliedFilters: 'getAppliedConversationFilters',
|
||||
folders: 'customViews/getCustomViews',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
agentList: 'agents/getAgents',
|
||||
teamsList: 'teams/getTeams',
|
||||
inboxesList: 'inboxes/getInboxes',
|
||||
campaigns: 'campaigns/getAllCampaigns',
|
||||
labels: 'labels/getLabels',
|
||||
}),
|
||||
hasAppliedFilters() {
|
||||
return this.appliedFilters.length !== 0;
|
||||
@@ -451,6 +470,9 @@ export default {
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
activeFolderName() {
|
||||
return this.activeFolder?.name;
|
||||
},
|
||||
activeTeam() {
|
||||
if (this.teamId) {
|
||||
return this.$store.getters['teams/getTeam'](this.teamId);
|
||||
@@ -483,9 +505,7 @@ export default {
|
||||
this.resetAndFetchData();
|
||||
},
|
||||
activeFolder() {
|
||||
if (!this.hasAppliedFilters) {
|
||||
this.resetAndFetchData();
|
||||
}
|
||||
this.resetAndFetchData();
|
||||
},
|
||||
chatLists() {
|
||||
this.chatsOnView = this.conversationList;
|
||||
@@ -496,6 +516,10 @@ export default {
|
||||
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
|
||||
this.resetAndFetchData();
|
||||
|
||||
if (this.hasActiveFolders) {
|
||||
this.$store.dispatch('campaigns/get');
|
||||
}
|
||||
|
||||
bus.$on('fetch_conversation_stats', () => {
|
||||
this.$store.dispatch('conversationStats/get', this.conversationFilters);
|
||||
});
|
||||
@@ -508,6 +532,15 @@ export default {
|
||||
this.$store.dispatch('emptyAllConversations');
|
||||
this.fetchFilteredConversations(payload);
|
||||
},
|
||||
onUpdateSavedFilter(payload, folderName) {
|
||||
const payloadData = {
|
||||
...this.activeFolder,
|
||||
name: folderName,
|
||||
query: filterQueryGenerator(payload),
|
||||
};
|
||||
this.$store.dispatch('customViews/update', payloadData);
|
||||
this.closeAdvanceFiltersModal();
|
||||
},
|
||||
onClickOpenAddFoldersModal() {
|
||||
this.showAddFoldersModal = true;
|
||||
},
|
||||
@@ -521,15 +554,70 @@ export default {
|
||||
this.showDeleteFoldersModal = false;
|
||||
},
|
||||
onToggleAdvanceFiltersModal() {
|
||||
if (!this.hasAppliedFilters) {
|
||||
if (!this.hasAppliedFilters && !this.hasActiveFolders) {
|
||||
this.initializeExistingFilterToModal();
|
||||
}
|
||||
if (this.hasActiveFolders) {
|
||||
this.initializeFolderToFilterModal(this.activeFolder);
|
||||
}
|
||||
this.showAdvancedFilters = true;
|
||||
},
|
||||
closeAdvanceFiltersModal() {
|
||||
this.showAdvancedFilters = false;
|
||||
this.appliedFilter = [];
|
||||
},
|
||||
setParamsForEditFolderModal() {
|
||||
// Here we are setting the params for edit folder modal to show the existing values.
|
||||
|
||||
// For agent, team, inboxes,and campaigns we get only the id's from the query.
|
||||
// So we are mapping the id's to the actual values.
|
||||
|
||||
// For labels we get the name of the label from the query.
|
||||
// If we delete the label from the label list then we will not be able to show the label name.
|
||||
|
||||
// For custom attributes we get only attribute key.
|
||||
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
|
||||
const params = {
|
||||
agents: this.agentList,
|
||||
teams: this.teamsList,
|
||||
inboxes: this.inboxesList,
|
||||
labels: this.labels,
|
||||
campaigns: this.campaigns,
|
||||
languages: languages,
|
||||
countries: countries,
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: this.$store.getters[
|
||||
'attributes/getAttributesByModel'
|
||||
]('conversation_attribute'),
|
||||
};
|
||||
return params;
|
||||
},
|
||||
initializeFolderToFilterModal(activeFolder) {
|
||||
// Here we are setting the params for edit folder modal.
|
||||
// To show the existing values. when we click on edit folder button.
|
||||
|
||||
// Here we get the query from the active folder.
|
||||
// And we are mapping the query to the actual values.
|
||||
// To show in the edit folder modal by the help of generateValuesForEditCustomViews helper.
|
||||
const query = activeFolder?.query?.payload;
|
||||
if (!Array.isArray(query)) return;
|
||||
|
||||
this.appliedFilter.push(
|
||||
...query.map(filter => ({
|
||||
attribute_key: filter.attribute_key,
|
||||
attribute_model: filter.attribute_model,
|
||||
filter_operator: filter.filter_operator,
|
||||
values: Array.isArray(filter.values)
|
||||
? generateValuesForEditCustomViews(
|
||||
filter,
|
||||
this.setParamsForEditFolderModal()
|
||||
)
|
||||
: [],
|
||||
query_operator: filter.query_operator,
|
||||
custom_attribute_type: filter.custom_attribute_type,
|
||||
}))
|
||||
);
|
||||
},
|
||||
getKeyboardListenerParams() {
|
||||
const allConversations = this.$refs.activeConversation.querySelectorAll(
|
||||
'div.conversations-list div.conversation'
|
||||
@@ -575,6 +663,7 @@ export default {
|
||||
}
|
||||
},
|
||||
resetAndFetchData() {
|
||||
this.appliedFilter = [];
|
||||
this.resetBulkActions();
|
||||
this.$store.dispatch('conversationPage/reset');
|
||||
this.$store.dispatch('emptyAllConversations');
|
||||
@@ -587,7 +676,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.fetchConversations();
|
||||
this.appliedFilter = [];
|
||||
},
|
||||
fetchConversations() {
|
||||
this.$store
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import format from 'date-fns/format';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { required, url } from 'vuelidate/lib/validators';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
|
||||
@@ -143,15 +143,20 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
formattedValue() {
|
||||
displayValue() {
|
||||
if (this.isAttributeTypeDate) {
|
||||
return format(new Date(this.value || new Date()), DATE_FORMAT);
|
||||
return new Date(this.value || new Date()).toLocaleDateString();
|
||||
}
|
||||
if (this.isAttributeTypeCheckbox) {
|
||||
return this.value === 'false' ? false : this.value;
|
||||
}
|
||||
return this.value;
|
||||
},
|
||||
formattedValue() {
|
||||
return this.isAttributeTypeDate
|
||||
? format(this.value ? new Date(this.value) : new Date(), DATE_FORMAT)
|
||||
: this.value;
|
||||
},
|
||||
listOptions() {
|
||||
return this.values.map((value, index) => ({
|
||||
id: index + 1,
|
||||
@@ -192,17 +197,11 @@ export default {
|
||||
}
|
||||
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
|
||||
},
|
||||
displayValue() {
|
||||
if (this.attributeType === 'date') {
|
||||
return format(new Date(this.editedValue), 'dd-MM-yyyy');
|
||||
}
|
||||
return this.editedValue;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value() {
|
||||
this.isEditing = false;
|
||||
this.editedValue = this.value;
|
||||
this.editedValue = this.formattedValue;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -249,9 +248,8 @@ export default {
|
||||
onUpdate() {
|
||||
const updatedValue =
|
||||
this.attributeType === 'date'
|
||||
? format(new Date(this.editedValue), DATE_FORMAT)
|
||||
? parseISO(this.editedValue)
|
||||
: this.editedValue;
|
||||
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="column">
|
||||
<woot-modal-header :header-title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')" />
|
||||
<form class="row modal-content" @submit.prevent="chooseTime">
|
||||
<date-picker
|
||||
v-model="snoozeTime"
|
||||
type="datetime"
|
||||
inline
|
||||
:lang="lang"
|
||||
:disabled-date="disabledDate"
|
||||
:disabled-time="disabledTime"
|
||||
:popup-style="{ width: '100%' }"
|
||||
/>
|
||||
<div class="modal-footer justify-content-end w-full">
|
||||
<woot-button variant="clear" @click.prevent="onClose">
|
||||
{{ this.$t('CONVERSATION.CUSTOM_SNOOZE.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button>
|
||||
{{ this.$t('CONVERSATION.CUSTOM_SNOOZE.APPLY') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DatePicker from 'vue2-datepicker';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DatePicker,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
snoozeTime: null,
|
||||
lang: {
|
||||
days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
yearFormat: 'YYYY',
|
||||
monthFormat: 'MMMM',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
chooseTime() {
|
||||
this.$emit('choose-time', this.snoozeTime);
|
||||
},
|
||||
disabledDate(date) {
|
||||
// Disable all the previous dates
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return date < yesterday;
|
||||
},
|
||||
disabledTime(date) {
|
||||
// Allow only time after 1 hour
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() + 1);
|
||||
return date < now;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-footer {
|
||||
padding: var(--space-two);
|
||||
}
|
||||
.modal-content {
|
||||
padding: var(--space-small) var(--space-two) var(--space-zero);
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@
|
||||
>
|
||||
<div :class="modalContainerClassName" @click.stop>
|
||||
<woot-button
|
||||
v-if="showCloseButton"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
variant="clear"
|
||||
@@ -28,6 +29,10 @@ export default {
|
||||
default: true,
|
||||
},
|
||||
show: Boolean,
|
||||
showCloseButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<banner
|
||||
v-if="shouldShowBanner"
|
||||
color-scheme="alert"
|
||||
:banner-message="bannerMessage"
|
||||
:action-button-label="actionButtonMessage"
|
||||
has-action-button
|
||||
@click="routeToBilling"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import adminMixin from 'dashboard/mixins/isAdmin';
|
||||
import accountMixin from 'dashboard/mixins/account';
|
||||
|
||||
const EMPTY_SUBSCRIPTION_INFO = {
|
||||
status: null,
|
||||
endsOn: null,
|
||||
};
|
||||
|
||||
export default {
|
||||
components: { Banner },
|
||||
mixins: [adminMixin, accountMixin],
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
getAccount: 'accounts/getAccount',
|
||||
}),
|
||||
bannerMessage() {
|
||||
return this.$t('GENERAL_SETTINGS.PAYMENT_PENDING');
|
||||
},
|
||||
actionButtonMessage() {
|
||||
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
|
||||
},
|
||||
shouldShowBanner() {
|
||||
if (!this.isOnChatwootCloud) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.isAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isPaymentPending();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
routeToBilling() {
|
||||
this.$router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: this.accountId },
|
||||
});
|
||||
},
|
||||
isPaymentPending() {
|
||||
const { status, endsOn } = this.getSubscriptionInfo();
|
||||
|
||||
if (status && endsOn) {
|
||||
const now = new Date();
|
||||
if (status === 'past_due' && endsOn < now) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
getSubscriptionInfo() {
|
||||
const account = this.getAccount(this.accountId);
|
||||
if (!account) return EMPTY_SUBSCRIPTION_INFO;
|
||||
|
||||
const { custom_attributes: subscription } = account;
|
||||
if (!subscription) return EMPTY_SUBSCRIPTION_INFO;
|
||||
|
||||
const {
|
||||
subscription_status: status,
|
||||
subscription_ends_on: endsOn,
|
||||
} = subscription;
|
||||
|
||||
return { status, endsOn: new Date(endsOn) };
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<banner
|
||||
v-if="shouldShowBanner"
|
||||
class="update-banner"
|
||||
color-scheme="primary"
|
||||
:banner-message="bannerMessage"
|
||||
href-link="https://github.com/chatwoot/chatwoot/releases"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<banner
|
||||
v-if="shouldShowBanner"
|
||||
color-scheme="alert"
|
||||
:banner-message="bannerMessage"
|
||||
:action-button-label="actionButtonMessage"
|
||||
has-action-button
|
||||
@click="routeToBilling"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import adminMixin from 'dashboard/mixins/isAdmin';
|
||||
import accountMixin from 'dashboard/mixins/account';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
export default {
|
||||
components: { Banner },
|
||||
mixins: [adminMixin, accountMixin],
|
||||
data() {
|
||||
return { conversationMeta: {} };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
getAccount: 'accounts/getAccount',
|
||||
}),
|
||||
bannerMessage() {
|
||||
return this.$t('GENERAL_SETTINGS.LIMITS_UPGRADE');
|
||||
},
|
||||
actionButtonMessage() {
|
||||
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
|
||||
},
|
||||
shouldShowBanner() {
|
||||
if (!this.isOnChatwootCloud) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isTrialAccount()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isLimitExceeded();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.isOnChatwootCloud) {
|
||||
this.fetchLimits();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchLimits() {
|
||||
this.$store.dispatch('accounts/limits');
|
||||
},
|
||||
routeToBilling() {
|
||||
this.$router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: this.accountId },
|
||||
});
|
||||
},
|
||||
isTrialAccount() {
|
||||
// check if account is less than 15 days old
|
||||
const account = this.getAccount(this.accountId);
|
||||
if (!account) return false;
|
||||
|
||||
const createdAt = new Date(account.created_at);
|
||||
|
||||
const diffDays = differenceInDays(new Date(), createdAt);
|
||||
|
||||
return diffDays <= 15;
|
||||
},
|
||||
isLimitExceeded() {
|
||||
const account = this.getAccount(this.accountId);
|
||||
if (!account) return false;
|
||||
|
||||
const { limits } = account;
|
||||
if (!limits) return false;
|
||||
|
||||
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
|
||||
return this.testLimit(conversation) || this.testLimit(nonWebInboxes);
|
||||
},
|
||||
testLimit({ allowed, consumed }) {
|
||||
return consumed > allowed;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<kbd class="hotkey">
|
||||
<slot />
|
||||
</kbd>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
kbd.hotkey {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
justify-content: center;
|
||||
padding: var(--space-micro);
|
||||
min-width: var(--space-normal);
|
||||
font-size: var(--font-size-micro);
|
||||
border-radius: var(--space-smaller);
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
text-transform: uppercase;
|
||||
color: var(--color-body);
|
||||
letter-spacing: var(--space-micro);
|
||||
line-height: var(--font-size-micro);
|
||||
}
|
||||
</style>
|
||||
@@ -59,72 +59,45 @@
|
||||
>
|
||||
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="snooze"
|
||||
@click="() => openSnoozeModal()"
|
||||
>
|
||||
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
|
||||
<woot-dropdown-divider v-if="isOpen" />
|
||||
<woot-dropdown-sub-menu
|
||||
v-if="isOpen"
|
||||
:title="this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.TITLE')"
|
||||
>
|
||||
<woot-dropdown-item>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="send-clock"
|
||||
@click="() => toggleStatus(STATUS_TYPE.SNOOZED, null)"
|
||||
>
|
||||
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.NEXT_REPLY') }}
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-item>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="dual-screen-clock"
|
||||
@click="
|
||||
() => toggleStatus(STATUS_TYPE.SNOOZED, snoozeTimes.tomorrow)
|
||||
"
|
||||
>
|
||||
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.TOMORROW') }}
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-item>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="calendar-clock"
|
||||
@click="
|
||||
() => toggleStatus(STATUS_TYPE.SNOOZED, snoozeTimes.nextWeek)
|
||||
"
|
||||
>
|
||||
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.NEXT_WEEK') }}
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
</woot-dropdown-sub-menu>
|
||||
</woot-dropdown-menu>
|
||||
</div>
|
||||
<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 { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin.js';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import {
|
||||
hasPressedAltAndEKey,
|
||||
hasPressedCommandPlusAltAndEKey,
|
||||
hasPressedAltAndMKey,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
@@ -137,16 +110,16 @@ export default {
|
||||
components: {
|
||||
WootDropdownItem,
|
||||
WootDropdownMenu,
|
||||
WootDropdownSubMenu,
|
||||
WootDropdownDivider,
|
||||
CustomSnoozeModal,
|
||||
},
|
||||
mixins: [clickaway, alertMixin, eventListenerMixins, snoozeTimesMixin],
|
||||
mixins: [clickaway, alertMixin, eventListenerMixins],
|
||||
props: { conversationId: { type: [String, Number], required: true } },
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
showActionsDropdown: false,
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
showCustomSnoozeModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -218,10 +191,26 @@ export default {
|
||||
}
|
||||
},
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
this.toggleStatus(
|
||||
this.STATUS_TYPE.SNOOZED,
|
||||
this.snoozeTimes[snoozeType] || null
|
||||
);
|
||||
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);
|
||||
@@ -252,6 +241,10 @@ export default {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
openSnoozeModal() {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'snooze_conversation' });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -270,5 +263,9 @@ export default {
|
||||
right: 0;
|
||||
max-width: 20rem;
|
||||
min-width: 15.6rem;
|
||||
|
||||
.dropdown-menu__item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,26 +11,29 @@
|
||||
{{ hrefLinkText }}
|
||||
</a>
|
||||
</span>
|
||||
<woot-button
|
||||
v-if="hasActionButton"
|
||||
size="small"
|
||||
variant="link"
|
||||
icon="arrow-right"
|
||||
color-scheme="primary"
|
||||
class-names="banner-action__button"
|
||||
@click="onClick"
|
||||
>
|
||||
{{ actionButtonLabel }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-if="hasCloseButton"
|
||||
size="small"
|
||||
variant="link"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss-circle"
|
||||
class-names="banner-action__button"
|
||||
@click="onClickClose"
|
||||
/>
|
||||
<div class="actions">
|
||||
<woot-button
|
||||
v-if="hasActionButton"
|
||||
size="tiny"
|
||||
icon="arrow-right"
|
||||
:variant="actionButtonVariant"
|
||||
color-scheme="primary"
|
||||
class-names="banner-action__button"
|
||||
@click="onClick"
|
||||
>
|
||||
{{ actionButtonLabel }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-if="hasCloseButton"
|
||||
size="tiny"
|
||||
:color-scheme="colorScheme"
|
||||
icon="dismiss-circle"
|
||||
class-names="banner-action__button"
|
||||
@click="onClickClose"
|
||||
>
|
||||
{{ $t('GENERAL_SETTINGS.DISMISS') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +56,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
actionButtonVariant: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
actionButtonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
@@ -68,7 +75,12 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bannerClasses() {
|
||||
return [this.colorScheme];
|
||||
const classList = [this.colorScheme];
|
||||
|
||||
if (this.hasActionButton || this.hasCloseButton) {
|
||||
classList.push('has-button');
|
||||
}
|
||||
return classList;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@@ -84,17 +96,26 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.banner {
|
||||
--x-padding: var(--space-normal);
|
||||
--y-padding: var(--space-slab);
|
||||
|
||||
display: flex;
|
||||
gap: var(--x-padding);
|
||||
color: var(--white);
|
||||
font-size: var(--font-size-mini);
|
||||
padding: var(--space-slab) var(--space-normal);
|
||||
padding: var(--y-padding) var(--x-padding);
|
||||
justify-content: center;
|
||||
position: sticky;
|
||||
|
||||
&.primary {
|
||||
background: var(--w-500);
|
||||
.banner-action__button {
|
||||
background: var(--w-600);
|
||||
border: none;
|
||||
color: var(--white);
|
||||
|
||||
&:hover {
|
||||
background: var(--w-800);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +128,16 @@ export default {
|
||||
}
|
||||
|
||||
&.alert {
|
||||
background: var(--r-400);
|
||||
background: var(--r-500);
|
||||
.banner-action__button {
|
||||
background: var(--r-700);
|
||||
border: none;
|
||||
color: var(--white);
|
||||
|
||||
&:hover {
|
||||
background: var(--r-800);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@@ -133,8 +163,6 @@ export default {
|
||||
}
|
||||
|
||||
.banner-action__button {
|
||||
margin: 0 var(--space-smaller);
|
||||
|
||||
::v-deep .button__content {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -144,5 +172,11 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-smaller);
|
||||
right: var(--y-padding);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
v-if="showSearchDropdownLabel"
|
||||
:account-labels="allLabels"
|
||||
:selected-labels="selectedLabels"
|
||||
:allow-creation="isAdmin"
|
||||
@add="addItem"
|
||||
@remove="removeItem"
|
||||
/>
|
||||
@@ -30,8 +31,15 @@
|
||||
|
||||
<script>
|
||||
import AddLabel from 'shared/components/ui/dropdown/AddLabel';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import LabelDropdown from 'shared/components/ui/label/LabelDropdown';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import adminMixin from 'dashboard/mixins/isAdmin';
|
||||
import {
|
||||
buildHotKeys,
|
||||
isEscape,
|
||||
isActiveElementTypeable,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -39,7 +47,7 @@ export default {
|
||||
LabelDropdown,
|
||||
},
|
||||
|
||||
mixins: [clickaway],
|
||||
mixins: [clickaway, adminMixin, eventListenerMixins],
|
||||
|
||||
props: {
|
||||
allLabels: {
|
||||
@@ -80,6 +88,18 @@ export default {
|
||||
closeDropdownLabel() {
|
||||
this.showSearchDropdownLabel = false;
|
||||
},
|
||||
|
||||
handleKeyEvents(e) {
|
||||
const keyPattern = buildHotKeys(e);
|
||||
|
||||
if (keyPattern === 'l' && !isActiveElementTypeable(e)) {
|
||||
this.toggleLabels();
|
||||
e.preventDefault();
|
||||
} else if (isEscape(e) && this.showSearchDropdownLabel) {
|
||||
this.closeDropdownLabel();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -45,6 +45,14 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
collection() {
|
||||
this.renderChart(this.collection, {
|
||||
...chartOptions,
|
||||
...this.chartOptions,
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.renderChart(this.collection, {
|
||||
...chartOptions,
|
||||
|
||||
+79
-11
@@ -1,9 +1,21 @@
|
||||
<template>
|
||||
<div class="column">
|
||||
<woot-modal-header :header-title="$t('FILTER.TITLE')">
|
||||
<p>{{ $t('FILTER.SUBTITLE') }}</p>
|
||||
<woot-modal-header :header-title="filterModalHeaderTitle">
|
||||
<p>{{ filterModalSubTitle }}</p>
|
||||
</woot-modal-header>
|
||||
<div class="row modal-content">
|
||||
<div class="column modal-content">
|
||||
<div v-if="isFolderView" class="columns">
|
||||
<label class="input-label" :class="{ error: !activeFolderNewName }">
|
||||
{{ $t('FILTER.FOLDER_LABEL') }}
|
||||
<input v-model="activeFolderNewName" type="text" class="name-input" />
|
||||
<span v-if="!activeFolderNewName" class="message">
|
||||
{{ $t('FILTER.EMPTY_VALUE_ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<label class="input-label">
|
||||
{{ $t('FILTER.FOLDER_QUERY_LABEL') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="medium-12 columns filters-wrap">
|
||||
<filter-input-box
|
||||
v-for="(filter, i) in appliedFilters"
|
||||
@@ -42,7 +54,14 @@
|
||||
<woot-button class="button clear" @click.prevent="onClose">
|
||||
{{ $t('FILTER.CANCEL_BUTTON_LABEL') }}
|
||||
</woot-button>
|
||||
<woot-button @click="submitFilterQuery">
|
||||
<woot-button
|
||||
v-if="isFolderView"
|
||||
:disabled="!activeFolderNewName"
|
||||
@click="updateSavedCustomViews"
|
||||
>
|
||||
{{ $t('FILTER.UPDATE_BUTTON_LABEL') }}
|
||||
</woot-button>
|
||||
<woot-button v-else @click="submitFilterQuery">
|
||||
{{ $t('FILTER.SUBMIT_BUTTON_LABEL') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
@@ -81,6 +100,14 @@ export default {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activeFolderName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isFolderView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
appliedFilters: {
|
||||
@@ -107,6 +134,7 @@ export default {
|
||||
return {
|
||||
show: true,
|
||||
appliedFilters: this.initialAppliedFilters,
|
||||
activeFolderNewName: this.activeFolderName,
|
||||
filterTypes: this.initialFilterTypes,
|
||||
filterAttributeGroups,
|
||||
filterGroups: [],
|
||||
@@ -119,6 +147,16 @@ export default {
|
||||
...mapGetters({
|
||||
getAppliedConversationFilters: 'getAppliedConversationFilters',
|
||||
}),
|
||||
filterModalHeaderTitle() {
|
||||
return !this.isFolderView
|
||||
? this.$t('FILTER.TITLE')
|
||||
: this.$t('FILTER.EDIT_CUSTOM_FILTER');
|
||||
},
|
||||
filterModalSubTitle() {
|
||||
return !this.isFolderView
|
||||
? this.$t('FILTER.SUBTITLE')
|
||||
: this.$t('FILTER.CUSTOM_VIEWS_SUBTITLE');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setFilterAttributes();
|
||||
@@ -126,7 +164,7 @@ export default {
|
||||
if (this.getAppliedConversationFilters.length) {
|
||||
this.appliedFilters = [];
|
||||
this.appliedFilters = [...this.getAppliedConversationFilters];
|
||||
} else {
|
||||
} else if (!this.isFolderView) {
|
||||
this.appliedFilters.push({
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
@@ -177,11 +215,11 @@ export default {
|
||||
if (key === 'created_at' || key === 'last_activity_at')
|
||||
if (operator === 'days_before') return 'plain_text';
|
||||
const type = this.filterTypes.find(filter => filter.attributeKey === key);
|
||||
return type.inputType;
|
||||
return type?.inputType;
|
||||
},
|
||||
getOperators(key) {
|
||||
const type = this.filterTypes.find(filter => filter.attributeKey === key);
|
||||
return type.filterOperators;
|
||||
return type?.filterOperators;
|
||||
},
|
||||
getDropdownValues(type) {
|
||||
const statusFilters = this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
|
||||
@@ -267,11 +305,30 @@ export default {
|
||||
}
|
||||
},
|
||||
appendNewFilter() {
|
||||
this.appliedFilters.push({
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
if (this.isFolderView) {
|
||||
this.setQueryOperatorOnLastQuery();
|
||||
} else {
|
||||
this.appliedFilters.push({
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
});
|
||||
}
|
||||
},
|
||||
setQueryOperatorOnLastQuery() {
|
||||
const lastItemIndex = this.appliedFilters.length - 1;
|
||||
this.appliedFilters[lastItemIndex] = {
|
||||
...this.appliedFilters[lastItemIndex],
|
||||
query_operator: 'and',
|
||||
};
|
||||
this.$nextTick(() => {
|
||||
this.appliedFilters.push({
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
});
|
||||
});
|
||||
},
|
||||
removeFilter(index) {
|
||||
@@ -296,6 +353,9 @@ export default {
|
||||
})),
|
||||
});
|
||||
},
|
||||
updateSavedCustomViews() {
|
||||
this.$emit('updateFolder', this.appliedFilters, this.activeFolderNewName);
|
||||
},
|
||||
resetFilter(index, currentFilter) {
|
||||
this.appliedFilters[index].filter_operator = this.filterTypes.find(
|
||||
filter => filter.attributeKey === currentFilter.attribute_key
|
||||
@@ -322,4 +382,12 @@ export default {
|
||||
.filter-actions {
|
||||
margin-top: var(--space-normal);
|
||||
}
|
||||
|
||||
.input-label {
|
||||
margin-bottom: var(--space-smaller);
|
||||
|
||||
.name-input {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,6 @@ import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
|
||||
import { mapGetters } from 'vuex';
|
||||
import agentMixin from '../../../mixins/agentMixin.js';
|
||||
import BackButton from '../BackButton';
|
||||
import differenceInHours from 'date-fns/differenceInHours';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName';
|
||||
@@ -65,6 +64,8 @@ import MoreActions from './MoreActions';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { conversationReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BackButton,
|
||||
@@ -125,17 +126,9 @@ export default {
|
||||
snoozedDisplayText() {
|
||||
const { snoozed_until: snoozedUntil } = this.currentChat;
|
||||
if (snoozedUntil) {
|
||||
// When the snooze is applied, it schedules the unsnooze event to next day/week 9AM.
|
||||
// By that logic if the time difference is less than or equal to 24 + 9 hours we can consider it tomorrow.
|
||||
const MAX_TIME_DIFFERENCE = 33;
|
||||
const isSnoozedUntilTomorrow =
|
||||
differenceInHours(new Date(snoozedUntil), new Date()) <=
|
||||
MAX_TIME_DIFFERENCE;
|
||||
return this.$t(
|
||||
isSnoozedUntilTomorrow
|
||||
? 'CONVERSATION.HEADER.SNOOZED_UNTIL_TOMORROW'
|
||||
: 'CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_WEEK'
|
||||
);
|
||||
return `${this.$t(
|
||||
'CONVERSATION.HEADER.SNOOZED_UNTIL'
|
||||
)} ${conversationReopenTime(snoozedUntil)}`;
|
||||
}
|
||||
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
|
||||
},
|
||||
|
||||
@@ -25,10 +25,14 @@
|
||||
<blockquote v-if="storyReply" class="story-reply-quote">
|
||||
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
|
||||
<bubble-image
|
||||
v-if="!hasStoryError"
|
||||
v-if="!hasImgStoryError && storyUrl"
|
||||
:url="storyUrl"
|
||||
@error="onStoryLoadError"
|
||||
/>
|
||||
<bubble-video
|
||||
v-else-if="hasImgStoryError && storyUrl"
|
||||
:url="storyUrl"
|
||||
/>
|
||||
</blockquote>
|
||||
<bubble-text
|
||||
v-if="data.content"
|
||||
@@ -48,23 +52,12 @@
|
||||
{{ $t('CONVERSATION.UPLOADING_ATTACHMENTS') }}
|
||||
</span>
|
||||
<div v-if="!isPending && hasAttachments">
|
||||
<div v-for="attachment in data.attachments" :key="attachment.id">
|
||||
<bubble-image
|
||||
v-if="attachment.file_type === 'image' && !hasImageError"
|
||||
:url="attachment.data_url"
|
||||
<div v-for="attachment in attachments" :key="attachment.id">
|
||||
<bubble-image-audio-video
|
||||
v-if="isAttachmentImageVideoAudio(attachment.file_type)"
|
||||
:attachment="attachment"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="attachment.file_type === 'audio'"
|
||||
controls
|
||||
class="skip-context-menu"
|
||||
>
|
||||
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
|
||||
</audio>
|
||||
<bubble-video
|
||||
v-else-if="attachment.file_type === 'video'"
|
||||
:url="attachment.data_url"
|
||||
/>
|
||||
<bubble-location
|
||||
v-else-if="attachment.file_type === 'location'"
|
||||
:latitude="attachment.coordinates_lat"
|
||||
@@ -140,11 +133,12 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import BubbleActions from './bubble/Actions';
|
||||
import BubbleFile from './bubble/File';
|
||||
import BubbleImage from './bubble/Image';
|
||||
import BubbleVideo from './bubble/Video';
|
||||
import BubbleImageAudioVideo from './bubble/ImageAudioVideo';
|
||||
import BubbleIntegration from './bubble/Integration.vue';
|
||||
import BubbleLocation from './bubble/Location';
|
||||
import BubbleMailHead from './bubble/MailHead';
|
||||
import BubbleText from './bubble/Text';
|
||||
import BubbleVideo from './bubble/Video.vue';
|
||||
import BubbleContact from './bubble/Contact';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
|
||||
@@ -161,11 +155,12 @@ export default {
|
||||
BubbleActions,
|
||||
BubbleFile,
|
||||
BubbleImage,
|
||||
BubbleVideo,
|
||||
BubbleImageAudioVideo,
|
||||
BubbleIntegration,
|
||||
BubbleLocation,
|
||||
BubbleMailHead,
|
||||
BubbleText,
|
||||
BubbleVideo,
|
||||
BubbleContact,
|
||||
ContextMenu,
|
||||
Spinner,
|
||||
@@ -200,10 +195,18 @@ export default {
|
||||
hasImageError: false,
|
||||
contextMenuPosition: {},
|
||||
showBackgroundHighlight: false,
|
||||
hasStoryError: false,
|
||||
hasImgStoryError: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
attachments() {
|
||||
// Here it is used to get sender and created_at for each attachment
|
||||
return this.data?.attachments.map(attachment => ({
|
||||
...attachment,
|
||||
sender: this.data.sender || {},
|
||||
created_at: this.data.created_at || '',
|
||||
}));
|
||||
},
|
||||
shouldRenderMessage() {
|
||||
return (
|
||||
this.hasAttachments ||
|
||||
@@ -429,12 +432,12 @@ export default {
|
||||
watch: {
|
||||
data() {
|
||||
this.hasImageError = false;
|
||||
this.hasStoryError = false;
|
||||
this.hasImgStoryError = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.hasImageError = false;
|
||||
this.hasStoryError = false;
|
||||
this.hasImgStoryError = false;
|
||||
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
|
||||
this.setupHighlightTimer();
|
||||
},
|
||||
@@ -443,6 +446,9 @@ export default {
|
||||
clearTimeout(this.higlightTimeout);
|
||||
},
|
||||
methods: {
|
||||
isAttachmentImageVideoAudio(fileType) {
|
||||
return ['image', 'audio', 'video'].includes(fileType);
|
||||
},
|
||||
hasMediaAttachment(type) {
|
||||
if (this.hasAttachments && this.data.attachments.length > 0) {
|
||||
const { attachments = [{}] } = this.data;
|
||||
@@ -464,7 +470,7 @@ export default {
|
||||
this.hasImageError = true;
|
||||
},
|
||||
onStoryLoadError() {
|
||||
this.hasStoryError = true;
|
||||
this.hasImgStoryError = true;
|
||||
},
|
||||
openContextMenu(e) {
|
||||
const shouldSkipContextMenu =
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
v-for="message in getReadMessages"
|
||||
:key="message.id"
|
||||
class="message--read ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
@@ -56,6 +57,7 @@
|
||||
v-for="message in getUnReadMessages"
|
||||
:key="message.id"
|
||||
class="message--unread ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
@@ -277,6 +279,7 @@ export default {
|
||||
if (newChat.id === oldChat.id) {
|
||||
return;
|
||||
}
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.selectedTweetId = null;
|
||||
},
|
||||
},
|
||||
@@ -288,6 +291,7 @@ export default {
|
||||
|
||||
mounted() {
|
||||
this.addScrollListener();
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
@@ -296,6 +300,9 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchAllAttachmentsFromCurrentChat() {
|
||||
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
|
||||
},
|
||||
removeBusListeners() {
|
||||
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||
bus.$off(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="reply-box" :class="replyBoxClass">
|
||||
<banner
|
||||
v-if="showSelfAssignBanner"
|
||||
action-button-variant="link"
|
||||
color-scheme="secondary"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
:has-action-button="true"
|
||||
@@ -34,6 +35,7 @@
|
||||
v-if="showReplyHead"
|
||||
:cc-emails.sync="ccEmails"
|
||||
:bcc-emails.sync="bccEmails"
|
||||
:to-emails.sync="toEmails"
|
||||
/>
|
||||
<woot-audio-recorder
|
||||
v-if="showAudioRecorderEditor"
|
||||
@@ -237,6 +239,7 @@ export default {
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
toEmails: '',
|
||||
doAutoSaveDraft: () => {},
|
||||
showWhatsAppTemplatesModal: false,
|
||||
updateEditorSelectionWith: '',
|
||||
@@ -501,7 +504,7 @@ export default {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
if (this.isAWhatsAppChannel || this.isAPIInbox) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
return AUDIO_FORMATS.WAV;
|
||||
@@ -527,7 +530,7 @@ export default {
|
||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||
}
|
||||
|
||||
this.setCCEmailFromLastChat();
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
},
|
||||
conversationIdByRoute(conversationId, oldConversationId) {
|
||||
if (conversationId !== oldConversationId) {
|
||||
@@ -561,7 +564,7 @@ export default {
|
||||
// working even if input/textarea is focussed.
|
||||
document.addEventListener('paste', this.onPaste);
|
||||
document.addEventListener('keydown', this.handleKeyEvents);
|
||||
this.setCCEmailFromLastChat();
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
this.doAutoSaveDraft = debounce(
|
||||
() => {
|
||||
this.saveDraft(this.conversationIdByRoute, this.replyType);
|
||||
@@ -843,6 +846,7 @@ export default {
|
||||
clearEmailField() {
|
||||
this.ccEmails = '';
|
||||
this.bccEmails = '';
|
||||
this.toEmails = '';
|
||||
},
|
||||
toggleEmojiPicker() {
|
||||
this.showEmojiPicker = !this.showEmojiPicker;
|
||||
@@ -1053,22 +1057,56 @@ export default {
|
||||
messagePayload.bccEmails = this.bccEmails;
|
||||
}
|
||||
|
||||
if (this.toEmails && !this.isOnPrivateNote) {
|
||||
messagePayload.toEmails = this.toEmails;
|
||||
}
|
||||
|
||||
return messagePayload;
|
||||
},
|
||||
setCcEmails(value) {
|
||||
this.bccEmails = value.bccEmails;
|
||||
this.ccEmails = value.ccEmails;
|
||||
},
|
||||
setCCEmailFromLastChat() {
|
||||
if (this.lastEmail) {
|
||||
const {
|
||||
content_attributes: { email: emailAttributes = {} },
|
||||
} = this.lastEmail;
|
||||
const cc = emailAttributes.cc || [];
|
||||
const bcc = emailAttributes.bcc || [];
|
||||
this.ccEmails = cc.join(', ');
|
||||
this.bccEmails = bcc.join(', ');
|
||||
setCCAndToEmailsFromLastChat() {
|
||||
if (!this.lastEmail) return;
|
||||
|
||||
const {
|
||||
content_attributes: { email: emailAttributes = {} },
|
||||
} = this.lastEmail;
|
||||
|
||||
// Retrieve the email of the current conversation's sender
|
||||
const conversationContact = this.currentChat?.meta?.sender?.email || '';
|
||||
let cc = [...emailAttributes.cc] || [];
|
||||
let to = [];
|
||||
|
||||
// there might be a situation where the current conversation will include a message from a third person,
|
||||
// and the current conversation contact is in CC.
|
||||
// This is an edge-case, reported here: CW-1511 [ONLY FOR INTERNAL REFERENCE]
|
||||
// So we remove the current conversation contact's email from the CC list if present
|
||||
if (cc.includes(conversationContact)) {
|
||||
cc = cc.filter(email => email !== conversationContact);
|
||||
}
|
||||
|
||||
// If the last incoming message sender is different from the conversation contact, add them to the "to"
|
||||
// and add the conversation contact to the CC
|
||||
if (!emailAttributes.from.includes(conversationContact)) {
|
||||
to.push(...emailAttributes.from);
|
||||
cc.push(conversationContact);
|
||||
}
|
||||
|
||||
// Remove the conversation contact's email from the BCC list if present
|
||||
let bcc = (emailAttributes.bcc || []).filter(
|
||||
email => email !== conversationContact
|
||||
);
|
||||
|
||||
// Ensure only unique email addresses are in the CC list
|
||||
bcc = [...new Set(bcc)];
|
||||
cc = [...new Set(cc)];
|
||||
to = [...new Set(to)];
|
||||
|
||||
this.ccEmails = cc.join(', ');
|
||||
this.bccEmails = bcc.join(', ');
|
||||
this.toEmails = to.join(', ');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="toEmails">
|
||||
<div class="input-group small" :class="{ error: $v.toEmailsVal.$error }">
|
||||
<label class="input-group-label">
|
||||
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.TO') }}
|
||||
</label>
|
||||
<div class="input-group-field">
|
||||
<woot-input
|
||||
v-model.trim="$v.toEmailsVal.$model"
|
||||
type="text"
|
||||
:class="{ error: $v.toEmailsVal.$error }"
|
||||
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group-wrap">
|
||||
<div class="input-group small" :class="{ error: $v.ccEmailsVal.$error }">
|
||||
<label class="input-group-label">
|
||||
@@ -53,6 +69,7 @@
|
||||
|
||||
<script>
|
||||
import { validEmailsByComma } from './helpers/emailHeadHelper';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
ccEmails: {
|
||||
@@ -63,12 +80,17 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
toEmails: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showBcc: false,
|
||||
ccEmailsVal: '',
|
||||
bccEmailsVal: '',
|
||||
toEmailsVal: '',
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -82,10 +104,16 @@ export default {
|
||||
this.ccEmailsVal = newVal;
|
||||
}
|
||||
},
|
||||
toEmails(newVal) {
|
||||
if (newVal !== this.toEmailsVal) {
|
||||
this.toEmailsVal = newVal;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.ccEmailsVal = this.ccEmails;
|
||||
this.bccEmailsVal = this.bccEmails;
|
||||
this.toEmailsVal = this.toEmails;
|
||||
},
|
||||
validations: {
|
||||
ccEmailsVal: {
|
||||
@@ -98,6 +126,11 @@ export default {
|
||||
return validEmailsByComma(value);
|
||||
},
|
||||
},
|
||||
toEmailsVal: {
|
||||
hasValidEmails(value) {
|
||||
return validEmailsByComma(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleAddBcc() {
|
||||
@@ -107,6 +140,7 @@ export default {
|
||||
this.$v.$touch();
|
||||
this.$emit('update:bccEmails', this.bccEmailsVal);
|
||||
this.$emit('update:ccEmails', this.ccEmailsVal);
|
||||
this.$emit('update:toEmails', this.toEmailsVal);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="message-text__wrap" :class="attachmentTypeClasses">
|
||||
<img
|
||||
v-if="isImage && !isImageError"
|
||||
:src="attachment.data_url"
|
||||
@click="onClick"
|
||||
@error="onImgError()"
|
||||
/>
|
||||
<video
|
||||
v-if="isVideo"
|
||||
:src="attachment.data_url"
|
||||
muted
|
||||
playsInline
|
||||
@click="onClick"
|
||||
/>
|
||||
<audio v-else-if="isAudio" controls class="skip-context-menu">
|
||||
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
|
||||
</audio>
|
||||
<gallery-view
|
||||
v-if="show"
|
||||
:show.sync="show"
|
||||
:attachment="attachment"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
@error="onImgError()"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
import GalleryView from '../components/GalleryView';
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
IMAGE: 'image',
|
||||
VIDEO: 'video',
|
||||
AUDIO: 'audio',
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GalleryView,
|
||||
},
|
||||
props: {
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: false,
|
||||
isImageError: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChatAttachments: 'getSelectedChatAttachments',
|
||||
}),
|
||||
isImage() {
|
||||
return this.attachment.file_type === ALLOWED_FILE_TYPES.IMAGE;
|
||||
},
|
||||
isVideo() {
|
||||
return this.attachment.file_type === ALLOWED_FILE_TYPES.VIDEO;
|
||||
},
|
||||
isAudio() {
|
||||
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
|
||||
},
|
||||
attachmentTypeClasses() {
|
||||
return {
|
||||
image: this.isImage,
|
||||
video: this.isVideo,
|
||||
};
|
||||
},
|
||||
filteredCurrentChatAttachments() {
|
||||
const attachments = this.currentChatAttachments.filter(attachment =>
|
||||
['image', 'video', 'audio'].includes(attachment.file_type)
|
||||
);
|
||||
return attachments;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
attachment() {
|
||||
this.isImageError = false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.show = false;
|
||||
},
|
||||
onClick(e) {
|
||||
if (hasPressedCommand(e)) {
|
||||
window.open(this.attachment.data_url, '_blank');
|
||||
return;
|
||||
}
|
||||
this.show = true;
|
||||
},
|
||||
onImgError() {
|
||||
this.isImageError = true;
|
||||
this.$emit('error');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<woot-modal
|
||||
full-width
|
||||
:show.sync="show"
|
||||
:show-close-button="false"
|
||||
:on-close="onClose"
|
||||
>
|
||||
<div v-on-clickaway="onClose" class="gallery-modal--wrap" @click="onClose">
|
||||
<div class="gallery-modal--header">
|
||||
<div class="header-info--wrap" @click.stop>
|
||||
<thumbnail
|
||||
:username="senderDetails.name"
|
||||
:src="senderDetails.avatar"
|
||||
/>
|
||||
<div class="header-info">
|
||||
<h3 class="sub-block-title sender-name">
|
||||
<span class="text-truncate">{{ senderDetails.name }}</span>
|
||||
</h3>
|
||||
<span class="time-stamp text-truncate">{{ readableTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-name--header text-block-title">
|
||||
<span class="text-truncate">
|
||||
{{ fileNameFromDataUrl }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-actions" @click.stop>
|
||||
<woot-button
|
||||
size="large"
|
||||
color-scheme="secondary"
|
||||
variant="clear"
|
||||
icon="arrow-download"
|
||||
@click="onClickDownload"
|
||||
/>
|
||||
<woot-button
|
||||
size="large"
|
||||
color-scheme="secondary"
|
||||
variant="clear"
|
||||
icon="dismiss"
|
||||
@click="onClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery-modal--body">
|
||||
<div class="attachment-toggle--button">
|
||||
<woot-button
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
size="large"
|
||||
variant="smooth"
|
||||
color-scheme="primary"
|
||||
icon="chevron-left"
|
||||
:disabled="activeImageIndex === 0"
|
||||
@click.stop="
|
||||
onClickChangeAttachment(
|
||||
allAttachments[activeImageIndex - 1],
|
||||
activeImageIndex - 1
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="attachments-viewer">
|
||||
<div class="attachment-view">
|
||||
<img
|
||||
v-if="isImage"
|
||||
:key="activeAttachment.message_id"
|
||||
:src="activeAttachment.data_url"
|
||||
class="modal-image skip-context-menu"
|
||||
@click.stop
|
||||
/>
|
||||
<video
|
||||
v-if="isVideo"
|
||||
:key="activeAttachment.message_id"
|
||||
:src="activeAttachment.data_url"
|
||||
controls
|
||||
playsInline
|
||||
class="modal-video skip-context-menu"
|
||||
@click.stop
|
||||
/>
|
||||
<audio
|
||||
v-if="isAudio"
|
||||
:key="activeAttachment.message_id"
|
||||
controls
|
||||
class="skip-context-menu"
|
||||
@click.stop
|
||||
>
|
||||
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
<div class="attachment-toggle--button">
|
||||
<woot-button
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
size="large"
|
||||
variant="smooth"
|
||||
color-scheme="primary"
|
||||
:disabled="activeImageIndex === allAttachments.length - 1"
|
||||
icon="chevron-right"
|
||||
@click.stop="
|
||||
onClickChangeAttachment(
|
||||
allAttachments[activeImageIndex + 1],
|
||||
activeImageIndex + 1
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery-modal--footer">
|
||||
<div class="header-count text-block-title">
|
||||
<span class="count">
|
||||
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { mapGetters } from 'vuex';
|
||||
import {
|
||||
isEscape,
|
||||
hasPressedArrowLeftKey,
|
||||
hasPressedArrowRightKey,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import timeMixin from 'dashboard/mixins/time';
|
||||
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
IMAGE: 'image',
|
||||
VIDEO: 'video',
|
||||
AUDIO: 'audio',
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
},
|
||||
mixins: [eventListenerMixins, clickaway, timeMixin],
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
allAttachments: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeAttachment: {},
|
||||
activeFileType: '',
|
||||
activeImageIndex:
|
||||
this.allAttachments.findIndex(
|
||||
attachment => attachment.message_id === this.attachment.message_id
|
||||
) || 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentUser: 'getCurrentUser',
|
||||
}),
|
||||
hasMoreThanOneAttachment() {
|
||||
return this.allAttachments.length > 1;
|
||||
},
|
||||
readableTime() {
|
||||
if (!this.activeAttachment.created_at) return '';
|
||||
const time = this.messageTimestamp(
|
||||
this.activeAttachment.created_at,
|
||||
'LLL d yyyy, h:mm a'
|
||||
);
|
||||
return time || '';
|
||||
},
|
||||
isImage() {
|
||||
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
|
||||
},
|
||||
isVideo() {
|
||||
return this.activeFileType === ALLOWED_FILE_TYPES.VIDEO;
|
||||
},
|
||||
isAudio() {
|
||||
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
|
||||
},
|
||||
senderDetails() {
|
||||
const { name, available_name: availableName, avatar_url, thumbnail, id } =
|
||||
this.activeAttachment?.sender || this.attachment?.sender;
|
||||
const currentUserID = this.currentUser?.id;
|
||||
return {
|
||||
name: currentUserID === id ? 'You' : name || availableName || '',
|
||||
avatar: thumbnail || avatar_url || '',
|
||||
};
|
||||
},
|
||||
fileNameFromDataUrl() {
|
||||
const { data_url: dataUrl } = this.activeAttachment;
|
||||
if (!dataUrl) return '';
|
||||
const fileName = dataUrl?.split('/').pop();
|
||||
return fileName || '';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setImageAndVideoSrc(this.attachment);
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
onClickChangeAttachment(attachment, index) {
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
this.activeImageIndex = index;
|
||||
this.setImageAndVideoSrc(attachment);
|
||||
},
|
||||
setImageAndVideoSrc(attachment) {
|
||||
const { file_type: type } = attachment;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
this.activeAttachment = attachment;
|
||||
this.activeFileType = type;
|
||||
},
|
||||
onKeyDownHandler(e) {
|
||||
if (isEscape(e)) {
|
||||
this.onClose();
|
||||
} else if (hasPressedArrowLeftKey(e)) {
|
||||
this.onClickChangeAttachment(
|
||||
this.allAttachments[this.activeImageIndex - 1],
|
||||
this.activeImageIndex - 1
|
||||
);
|
||||
} else if (hasPressedArrowRightKey(e)) {
|
||||
this.onClickChangeAttachment(
|
||||
this.allAttachments[this.activeImageIndex + 1],
|
||||
this.activeImageIndex + 1
|
||||
);
|
||||
}
|
||||
},
|
||||
onClickDownload() {
|
||||
const { file_type: type, data_url: url } = this.activeAttachment;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `attachment.${type}`;
|
||||
link.click();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.gallery-modal--wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: inherit;
|
||||
width: inherit;
|
||||
|
||||
.gallery-modal--header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: var(--space-jumbo);
|
||||
justify-content: space-between;
|
||||
padding: var(--space-small) var(--space-medium);
|
||||
width: 100%;
|
||||
|
||||
.header-info--wrap {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
min-width: var(--space-giga);
|
||||
|
||||
.header-info {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin-left: var(--space-small);
|
||||
|
||||
.sender-name {
|
||||
display: inline-block;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.time-stamp {
|
||||
color: var(--s-400);
|
||||
font-size: var(--font-size-mini);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-name--header {
|
||||
align-items: center;
|
||||
color: var(--s-700);
|
||||
display: flex;
|
||||
font-weight: var(--font-weight-bold);
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
padding: var(--space-smaller);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-small);
|
||||
justify-content: flex-end;
|
||||
min-width: var(--space-giga);
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-modal--body {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gallery-modal--footer {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: var(--space-jumbo);
|
||||
justify-content: center;
|
||||
padding: var(--space-small) var(--space-medium);
|
||||
width: 100%;
|
||||
|
||||
.header-count {
|
||||
align-items: center;
|
||||
border-radius: var(--border-radius-small);
|
||||
background-color: var(--s-25);
|
||||
color: var(--s-600);
|
||||
display: flex;
|
||||
font-weight: var(--font-weight-bold);
|
||||
justify-content: center;
|
||||
min-width: 8rem;
|
||||
padding: var(--space-smaller);
|
||||
}
|
||||
}
|
||||
|
||||
.attachments-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.attachment-view {
|
||||
img {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
video {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-toggle--button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: var(--space-mega);
|
||||
width: var(--space-mega);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,14 +15,13 @@
|
||||
@click="toggleStatus(option.key, null)"
|
||||
/>
|
||||
</template>
|
||||
<menu-item-with-submenu :option="snoozeMenuConfig">
|
||||
<menu-item
|
||||
v-for="(option, i) in snoozeMenuConfig.options"
|
||||
:key="i"
|
||||
:option="option"
|
||||
@click="snoozeConversation(option.snoozedUntil)"
|
||||
/>
|
||||
</menu-item-with-submenu>
|
||||
<menu-item
|
||||
v-if="show(snoozeOption.key)"
|
||||
:option="snoozeOption"
|
||||
variant="icon"
|
||||
@click="snoozeConversation()"
|
||||
/>
|
||||
|
||||
<menu-item-with-submenu :option="priorityConfig">
|
||||
<menu-item
|
||||
v-for="(option, i) in priorityConfig.options"
|
||||
@@ -78,7 +77,7 @@
|
||||
import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
|
||||
import agentMixin from 'dashboard/mixins/agentMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
export default {
|
||||
@@ -87,7 +86,7 @@ export default {
|
||||
MenuItemWithSubmenu,
|
||||
AgentLoadingPlaceholder,
|
||||
},
|
||||
mixins: [snoozeTimesMixin],
|
||||
mixins: [agentMixin],
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
@@ -130,27 +129,10 @@ export default {
|
||||
icon: 'arrow-redo',
|
||||
},
|
||||
],
|
||||
snoozeMenuConfig: {
|
||||
key: 'snooze',
|
||||
snoozeOption: {
|
||||
key: wootConstants.STATUS_TYPE.SNOOZED,
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TITLE'),
|
||||
icon: 'snooze',
|
||||
options: [
|
||||
{
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.NEXT_REPLY'),
|
||||
key: 'next-reply',
|
||||
snoozedUntil: null,
|
||||
},
|
||||
{
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TOMORROW'),
|
||||
key: 'tomorrow',
|
||||
snoozedUntil: 'tomorrow',
|
||||
},
|
||||
{
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.NEXT_WEEK'),
|
||||
key: 'next-week',
|
||||
snoozedUntil: 'nextWeek',
|
||||
},
|
||||
],
|
||||
},
|
||||
priorityConfig: {
|
||||
key: 'priority',
|
||||
@@ -202,6 +184,16 @@ export default {
|
||||
teams: 'teams/getTeams',
|
||||
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
}),
|
||||
filteredAgentOnAvailability() {
|
||||
const agents = this.$store.getters[
|
||||
'inboxAssignableAgents/getAssignableAgents'
|
||||
](this.inboxId);
|
||||
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
|
||||
const filteredAgents = this.sortedAgentsByAvailability(
|
||||
agentsByUpdatedPresence
|
||||
);
|
||||
return filteredAgents;
|
||||
},
|
||||
assignableAgents() {
|
||||
return [
|
||||
{
|
||||
@@ -212,9 +204,7 @@ export default {
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
},
|
||||
...this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
|
||||
this.inboxId
|
||||
),
|
||||
...this.filteredAgentOnAvailability,
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -225,12 +215,9 @@ export default {
|
||||
toggleStatus(status, snoozedUntil) {
|
||||
this.$emit('update-conversation', status, snoozedUntil);
|
||||
},
|
||||
snoozeConversation(snoozedUntil) {
|
||||
this.$emit(
|
||||
'update-conversation',
|
||||
this.STATUS_TYPE.SNOOZED,
|
||||
this.snoozeTimes[snoozedUntil] || null
|
||||
);
|
||||
snoozeConversation() {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'snooze_conversation' });
|
||||
},
|
||||
assignPriority(priority) {
|
||||
this.$emit('assign-priority', priority);
|
||||
@@ -246,6 +233,7 @@ export default {
|
||||
...(type === 'icon' && { icon: option.icon }),
|
||||
...(type === 'label' && { color: option.color }),
|
||||
...(type === 'agent' && { thumbnail: option.thumbnail }),
|
||||
...(type === 'agent' && { status: option.availability_status }),
|
||||
...(type === 'text' && { label: option.label }),
|
||||
...(type === 'label' && { label: option.title }),
|
||||
...(type === 'agent' && { label: option.name }),
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
v-if="variant === 'agent'"
|
||||
:username="option.label"
|
||||
:src="option.thumbnail"
|
||||
:status="option.status"
|
||||
size="20px"
|
||||
class="agent-thumbnail"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ export default {
|
||||
LATEST: 'latest',
|
||||
CREATED_AT: 'sort_on_created_at',
|
||||
PRIORITY: 'sort_on_priority',
|
||||
WATIING_SINCE: 'waiting_since',
|
||||
},
|
||||
ARTICLE_STATUS_TYPES: {
|
||||
DRAFT: 0,
|
||||
@@ -30,5 +31,15 @@ export default {
|
||||
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
|
||||
SMALL_SCREEN_BREAKPOINT: 1024,
|
||||
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
|
||||
SNOOZE_OPTIONS: {
|
||||
UNTIL_NEXT_REPLY: 'until_next_reply',
|
||||
AN_HOUR_FROM_NOW: 'an_hour_from_now',
|
||||
UNTIL_TOMORROW: 'until_tomorrow',
|
||||
UNTIL_NEXT_WEEK: 'until_next_week',
|
||||
UNTIL_NEXT_MONTH: 'until_next_month',
|
||||
UNTIL_CUSTOM_TIME: 'until_custom_time',
|
||||
},
|
||||
EXAMPLE_URL: 'https://example.com',
|
||||
EXAMPLE_WEBHOOK_URL: 'https://example/api/webhook',
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
|
||||
@@ -111,6 +111,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
} = data;
|
||||
DashboardAudioNotificationHelper.onNewMessage(data);
|
||||
this.app.$store.dispatch('addMessage', data);
|
||||
this.app.$store.dispatch('updateUnreadCount');
|
||||
this.app.$store.dispatch('updateConversationLastActivity', {
|
||||
lastActivityAt,
|
||||
conversationId,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
const roleMapping = {
|
||||
0: 'agent',
|
||||
1: 'administrator',
|
||||
};
|
||||
|
||||
const availabilityMapping = {
|
||||
0: 'online',
|
||||
1: 'offline',
|
||||
2: 'busy',
|
||||
};
|
||||
|
||||
const translationKeys = {
|
||||
'automationrule:create': `AUDIT_LOGS.AUTOMATION_RULE.ADD`,
|
||||
'automationrule:update': `AUDIT_LOGS.AUTOMATION_RULE.EDIT`,
|
||||
'automationrule:destroy': `AUDIT_LOGS.AUTOMATION_RULE.DELETE`,
|
||||
'webhook:create': `AUDIT_LOGS.WEBHOOK.ADD`,
|
||||
'webhook:update': `AUDIT_LOGS.WEBHOOK.EDIT`,
|
||||
'webhook:destroy': `AUDIT_LOGS.WEBHOOK.DELETE`,
|
||||
'inbox:create': `AUDIT_LOGS.INBOX.ADD`,
|
||||
'inbox:update': `AUDIT_LOGS.INBOX.EDIT`,
|
||||
'inbox:destroy': `AUDIT_LOGS.INBOX.DELETE`,
|
||||
'user:sign_in': `AUDIT_LOGS.USER_ACTION.SIGN_IN`,
|
||||
'user:sign_out': `AUDIT_LOGS.USER_ACTION.SIGN_OUT`,
|
||||
'team:create': `AUDIT_LOGS.TEAM.ADD`,
|
||||
'team:update': `AUDIT_LOGS.TEAM.EDIT`,
|
||||
'team:destroy': `AUDIT_LOGS.TEAM.DELETE`,
|
||||
'macro:create': `AUDIT_LOGS.MACRO.ADD`,
|
||||
'macro:update': `AUDIT_LOGS.MACRO.EDIT`,
|
||||
'macro:destroy': `AUDIT_LOGS.MACRO.DELETE`,
|
||||
'accountuser:create': `AUDIT_LOGS.ACCOUNT_USER.ADD`,
|
||||
'accountuser:update:self': `AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF`,
|
||||
'accountuser:update:other': `AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER`,
|
||||
};
|
||||
|
||||
function extractAttrChange(attrChange) {
|
||||
if (Array.isArray(attrChange)) {
|
||||
return attrChange[attrChange.length - 1];
|
||||
}
|
||||
return attrChange;
|
||||
}
|
||||
|
||||
export function extractChangedAccountUserValues(auditedChanges) {
|
||||
let changes = [];
|
||||
let values = [];
|
||||
|
||||
// Check roles
|
||||
if (auditedChanges.role && auditedChanges.role.length) {
|
||||
changes.push('role');
|
||||
values.push(roleMapping[extractAttrChange(auditedChanges.role)]);
|
||||
}
|
||||
|
||||
// Check availability
|
||||
if (auditedChanges.availability && auditedChanges.availability.length) {
|
||||
changes.push('availability');
|
||||
values.push(
|
||||
availabilityMapping[extractAttrChange(auditedChanges.availability)]
|
||||
);
|
||||
}
|
||||
|
||||
return { changes, values };
|
||||
}
|
||||
|
||||
function getAgentName(userId, agentList) {
|
||||
if (userId === null) {
|
||||
return 'System';
|
||||
}
|
||||
|
||||
const agentName = agentList.find(agent => agent.id === userId)?.name;
|
||||
|
||||
// If agent does not exist(removed/deleted), return userId
|
||||
return agentName || userId;
|
||||
}
|
||||
|
||||
function handleAccountUserCreate(auditLogItem, translationPayload, agentList) {
|
||||
translationPayload.invitee = getAgentName(
|
||||
auditLogItem.audited_changes.user_id,
|
||||
agentList
|
||||
);
|
||||
|
||||
const roleKey = auditLogItem.audited_changes.role;
|
||||
translationPayload.role = roleMapping[roleKey] || 'unknown'; // 'unknown' as a fallback in case an unrecognized key is provided
|
||||
|
||||
return translationPayload;
|
||||
}
|
||||
|
||||
function handleAccountUserUpdate(auditLogItem, translationPayload, agentList) {
|
||||
if (auditLogItem.user_id !== auditLogItem.auditable.user_id) {
|
||||
translationPayload.user = getAgentName(
|
||||
auditLogItem.auditable.user_id,
|
||||
agentList
|
||||
);
|
||||
}
|
||||
|
||||
const accountUserChanges = extractChangedAccountUserValues(
|
||||
auditLogItem.audited_changes
|
||||
);
|
||||
if (accountUserChanges) {
|
||||
translationPayload.attributes = accountUserChanges.changes;
|
||||
translationPayload.values = accountUserChanges.values;
|
||||
}
|
||||
return translationPayload;
|
||||
}
|
||||
|
||||
export function generateTranslationPayload(auditLogItem, agentList) {
|
||||
let translationPayload = {
|
||||
agentName: getAgentName(auditLogItem.user_id, agentList),
|
||||
id: auditLogItem.auditable_id,
|
||||
};
|
||||
|
||||
const auditableType = auditLogItem.auditable_type.toLowerCase();
|
||||
const action = auditLogItem.action.toLowerCase();
|
||||
|
||||
if (auditableType === 'accountuser') {
|
||||
if (action === 'create') {
|
||||
translationPayload = handleAccountUserCreate(
|
||||
auditLogItem,
|
||||
translationPayload,
|
||||
agentList
|
||||
);
|
||||
}
|
||||
|
||||
if (action === 'update') {
|
||||
translationPayload = handleAccountUserUpdate(
|
||||
auditLogItem,
|
||||
translationPayload,
|
||||
agentList
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return translationPayload;
|
||||
}
|
||||
|
||||
export const generateLogActionKey = auditLogItem => {
|
||||
const auditableType = auditLogItem.auditable_type.toLowerCase();
|
||||
const action = auditLogItem.action.toLowerCase();
|
||||
let logActionKey = `${auditableType}:${action}`;
|
||||
|
||||
if (auditableType === 'accountuser' && action === 'update') {
|
||||
logActionKey +=
|
||||
auditLogItem.user_id === auditLogItem.auditable.user_id
|
||||
? ':self'
|
||||
: ':other';
|
||||
}
|
||||
|
||||
return translationKeys[logActionKey] || '';
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
export const getInputType = (key, operator, filterTypes) => {
|
||||
if (key === 'created_at' || key === 'last_activity_at')
|
||||
if (operator === 'days_before') return 'plain_text';
|
||||
const type = filterTypes.find(filter => filter.attributeKey === key);
|
||||
return type?.inputType;
|
||||
};
|
||||
|
||||
export const generateCustomAttributesInputType = type => {
|
||||
const filterInputTypes = {
|
||||
text: 'string',
|
||||
number: 'string',
|
||||
date: 'string',
|
||||
checkbox: 'multi_select',
|
||||
list: 'multi_select',
|
||||
link: 'string',
|
||||
};
|
||||
return filterInputTypes[type];
|
||||
};
|
||||
|
||||
export const getAttributeInputType = (key, allCustomAttributes) => {
|
||||
const customAttribute = allCustomAttributes.find(
|
||||
attr => attr.attribute_key === key
|
||||
);
|
||||
const { attribute_display_type } = customAttribute;
|
||||
const filterInputTypes = generateCustomAttributesInputType(
|
||||
attribute_display_type
|
||||
);
|
||||
return filterInputTypes;
|
||||
};
|
||||
|
||||
export const getValuesName = (values, list, idKey, nameKey) => {
|
||||
const item = list?.find(v => v[idKey] === values[0]);
|
||||
return {
|
||||
id: values[0],
|
||||
name: item ? item[nameKey] : values[0],
|
||||
};
|
||||
};
|
||||
|
||||
const getValuesForLabels = (values, labels) => {
|
||||
const selectedLabels = labels.filter(label => values.includes(label.title));
|
||||
return selectedLabels.map(({ title }) => ({
|
||||
id: title,
|
||||
name: title,
|
||||
}));
|
||||
};
|
||||
|
||||
const getValuesForLanguages = (values, languages) => {
|
||||
const selectedLanguages = languages.filter(language =>
|
||||
values.includes(language.id)
|
||||
);
|
||||
return selectedLanguages.map(({ id, name }) => ({
|
||||
id: id.toLowerCase(),
|
||||
name: name,
|
||||
}));
|
||||
};
|
||||
|
||||
const getValuesForCountries = (values, countries) => {
|
||||
const selectedCountries = countries.filter(country =>
|
||||
values.includes(country.id)
|
||||
);
|
||||
return selectedCountries.map(({ id, name }) => ({
|
||||
id: id,
|
||||
name: name,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getValuesForFilter = (filter, params) => {
|
||||
const { attribute_key, values } = filter;
|
||||
const {
|
||||
languages,
|
||||
countries,
|
||||
agents,
|
||||
inboxes,
|
||||
teams,
|
||||
campaigns,
|
||||
labels,
|
||||
} = params;
|
||||
switch (attribute_key) {
|
||||
case 'assignee_id':
|
||||
return getValuesName(values, agents, 'id', 'name');
|
||||
case 'inbox_id':
|
||||
return getValuesName(values, inboxes, 'id', 'name');
|
||||
case 'team_id':
|
||||
return getValuesName(values, teams, 'id', 'name');
|
||||
case 'campaign_id':
|
||||
return getValuesName(values, campaigns, 'id', 'title');
|
||||
case 'labels': {
|
||||
return getValuesForLabels(values, labels);
|
||||
}
|
||||
case 'browser_language': {
|
||||
return getValuesForLanguages(values, languages);
|
||||
}
|
||||
case 'country_code': {
|
||||
return getValuesForCountries(values, countries);
|
||||
}
|
||||
default:
|
||||
return { id: values[0], name: values[0] };
|
||||
}
|
||||
};
|
||||
|
||||
export const generateValuesForEditCustomViews = (filter, params) => {
|
||||
const { attribute_key, filter_operator, values } = filter;
|
||||
const { filterTypes, allCustomAttributes } = params;
|
||||
const inboxType = getInputType(attribute_key, filter_operator, filterTypes);
|
||||
|
||||
if (inboxType === undefined) {
|
||||
const filterInputTypes = getAttributeInputType(
|
||||
attribute_key,
|
||||
allCustomAttributes
|
||||
);
|
||||
return filterInputTypes === 'string'
|
||||
? values[0].toString()
|
||||
: { id: values[0], name: values[0] };
|
||||
}
|
||||
|
||||
return inboxType === 'multi_select' || inboxType === 'search_select'
|
||||
? getValuesForFilter(filter, params)
|
||||
: values[0].toString();
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
const setArrayValues = item => {
|
||||
return item.values[0]?.id ? item.values.map(val => val.id) : item.values;
|
||||
};
|
||||
const generatePayload = data => {
|
||||
// Make a copy of data to avoid vue data reactivity issues
|
||||
const filters = JSON.parse(JSON.stringify(data));
|
||||
let payload = filters.map(item => {
|
||||
if (Array.isArray(item.values)) {
|
||||
item.values = item.values.map(val => val.id);
|
||||
item.values = setArrayValues(item);
|
||||
} else if (typeof item.values === 'object') {
|
||||
item.values = [item.values.id];
|
||||
} else if (!item.values) {
|
||||
|
||||
@@ -18,18 +18,18 @@ export const initializeAnalyticsEvents = () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
window.bus.$on(ANALYTICS_RESET, () => {});
|
||||
};
|
||||
|
||||
const initializeAudioAlerts = user => {
|
||||
// InitializeAudioNotifications
|
||||
const { ui_settings: uiSettings } = user || {};
|
||||
const {
|
||||
always_play_audio_alert: alwaysPlayAudioAlert,
|
||||
enable_audio_alerts: audioAlertType,
|
||||
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
|
||||
notification_tone: audioAlertTone,
|
||||
} = uiSettings;
|
||||
// UI Settings can be undefined initally as we don't send the
|
||||
// entire payload for the user during the signup process.
|
||||
} = uiSettings || {};
|
||||
|
||||
DashboardAudioNotificationHelper.setInstanceValues({
|
||||
currentUserId: user.id,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
getUnixTime,
|
||||
format,
|
||||
add,
|
||||
startOfWeek,
|
||||
addWeeks,
|
||||
startOfMonth,
|
||||
isMonday,
|
||||
isToday,
|
||||
setHours,
|
||||
setMinutes,
|
||||
setSeconds,
|
||||
} from 'date-fns';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
||||
|
||||
export const findStartOfNextWeek = currentDate => {
|
||||
const startOfNextWeek = startOfWeek(addWeeks(currentDate, 1));
|
||||
return isMonday(startOfNextWeek)
|
||||
? startOfNextWeek
|
||||
: add(startOfNextWeek, {
|
||||
days: (8 - startOfNextWeek.getDay()) % 7,
|
||||
});
|
||||
};
|
||||
|
||||
export const findStartOfNextMonth = currentDate => {
|
||||
const startOfNextMonth = startOfMonth(add(currentDate, { months: 1 }));
|
||||
return isMonday(startOfNextMonth)
|
||||
? startOfNextMonth
|
||||
: add(startOfNextMonth, {
|
||||
days: (8 - startOfNextMonth.getDay()) % 7,
|
||||
});
|
||||
};
|
||||
|
||||
export const findNextDay = currentDate => {
|
||||
return add(currentDate, { days: 1 });
|
||||
};
|
||||
|
||||
export const setHoursToNine = date => {
|
||||
return setSeconds(setMinutes(setHours(date, 9), 0), 0);
|
||||
};
|
||||
|
||||
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
|
||||
let parsedDate = null;
|
||||
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
|
||||
parsedDate = add(currentDate, { hours: 1 });
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
|
||||
parsedDate = setHoursToNine(findNextDay(currentDate));
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
|
||||
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
|
||||
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
|
||||
}
|
||||
|
||||
return parsedDate ? getUnixTime(parsedDate) : null;
|
||||
};
|
||||
export const conversationReopenTime = snoozedUntil => {
|
||||
if (!snoozedUntil) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(snoozedUntil);
|
||||
|
||||
if (isToday(date)) {
|
||||
return format(date, 'h.mmaaa');
|
||||
}
|
||||
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
extractChangedAccountUserValues,
|
||||
generateTranslationPayload,
|
||||
generateLogActionKey,
|
||||
} from '../auditlogHelper'; // import the functions
|
||||
|
||||
describe('Helper functions', () => {
|
||||
const agentList = [
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
{ id: 2, name: 'Agent 2' },
|
||||
{ id: 3, name: 'Agent 3' },
|
||||
];
|
||||
|
||||
describe('extractChangedAccountUserValues', () => {
|
||||
it('should correctly extract values when role is changed', () => {
|
||||
const changes = {
|
||||
role: [0, 1],
|
||||
};
|
||||
const {
|
||||
changes: extractedChanges,
|
||||
values,
|
||||
} = extractChangedAccountUserValues(changes);
|
||||
expect(extractedChanges).toEqual(['role']);
|
||||
expect(values).toEqual(['administrator']);
|
||||
});
|
||||
|
||||
it('should correctly extract values when availability is changed', () => {
|
||||
const changes = {
|
||||
availability: [0, 2],
|
||||
};
|
||||
const {
|
||||
changes: extractedChanges,
|
||||
values,
|
||||
} = extractChangedAccountUserValues(changes);
|
||||
expect(extractedChanges).toEqual(['availability']);
|
||||
expect(values).toEqual(['busy']);
|
||||
});
|
||||
|
||||
it('should correctly extract values when both are changed', () => {
|
||||
const changes = {
|
||||
role: [1, 0],
|
||||
availability: [1, 2],
|
||||
};
|
||||
const {
|
||||
changes: extractedChanges,
|
||||
values,
|
||||
} = extractChangedAccountUserValues(changes);
|
||||
expect(extractedChanges).toEqual(['role', 'availability']);
|
||||
expect(values).toEqual(['agent', 'busy']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTranslationPayload', () => {
|
||||
it('should handle AccountUser create', () => {
|
||||
const auditLogItem = {
|
||||
auditable_type: 'AccountUser',
|
||||
action: 'create',
|
||||
user_id: 1,
|
||||
auditable_id: 123,
|
||||
audited_changes: {
|
||||
user_id: 2,
|
||||
role: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const payload = generateTranslationPayload(auditLogItem, agentList);
|
||||
expect(payload).toEqual({
|
||||
agentName: 'Agent 1',
|
||||
id: 123,
|
||||
invitee: 'Agent 2',
|
||||
role: 'administrator',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle AccountUser update', () => {
|
||||
const auditLogItem = {
|
||||
auditable_type: 'AccountUser',
|
||||
action: 'update',
|
||||
user_id: 1,
|
||||
auditable_id: 123,
|
||||
audited_changes: {
|
||||
user_id: 2,
|
||||
role: [1, 0],
|
||||
availability: [0, 2],
|
||||
},
|
||||
auditable: {
|
||||
user_id: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const payload = generateTranslationPayload(auditLogItem, agentList);
|
||||
expect(payload).toEqual({
|
||||
agentName: 'Agent 1',
|
||||
id: 123,
|
||||
user: 'Agent 3',
|
||||
attributes: ['role', 'availability'],
|
||||
values: ['agent', 'busy'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle generic case like Team create', () => {
|
||||
const auditLogItem = {
|
||||
auditable_type: 'Team',
|
||||
action: 'create',
|
||||
user_id: 1,
|
||||
auditable_id: 456,
|
||||
};
|
||||
|
||||
const payload = generateTranslationPayload(auditLogItem, agentList);
|
||||
expect(payload).toEqual({
|
||||
agentName: 'Agent 1',
|
||||
id: 456,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateLogActionKey', () => {
|
||||
it('should generate correct action key when user updates self', () => {
|
||||
const auditLogItem = {
|
||||
auditable_type: 'AccountUser',
|
||||
action: 'update',
|
||||
user_id: 1,
|
||||
auditable: {
|
||||
user_id: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const logActionKey = generateLogActionKey(auditLogItem);
|
||||
expect(logActionKey).toEqual('AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF');
|
||||
});
|
||||
|
||||
it('should generate correct action key when user updates other agent', () => {
|
||||
const auditLogItem = {
|
||||
auditable_type: 'AccountUser',
|
||||
action: 'update',
|
||||
user_id: 1,
|
||||
auditable: {
|
||||
user_id: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const logActionKey = generateLogActionKey(auditLogItem);
|
||||
expect(logActionKey).toEqual('AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
getAttributeInputType,
|
||||
getInputType,
|
||||
getValuesName,
|
||||
getValuesForFilter,
|
||||
generateValuesForEditCustomViews,
|
||||
generateCustomAttributesInputType,
|
||||
} from '../customViewsHelper';
|
||||
import advancedFilterTypes from 'dashboard/components/widgets/conversation/advancedFilterItems/index';
|
||||
|
||||
describe('customViewsHelper', () => {
|
||||
describe('#getInputType', () => {
|
||||
it('should return plain_text if key is created_at or last_activity_at and operator is days_before', () => {
|
||||
const filterTypes = [
|
||||
{ attributeKey: 'created_at', inputType: 'date' },
|
||||
{ attributeKey: 'last_activity_at', inputType: 'date' },
|
||||
];
|
||||
expect(getInputType('created_at', 'days_before', filterTypes)).toEqual(
|
||||
'plain_text'
|
||||
);
|
||||
expect(
|
||||
getInputType('last_activity_at', 'days_before', filterTypes)
|
||||
).toEqual('plain_text');
|
||||
});
|
||||
|
||||
it('should return inputType if key is not created_at or last_activity_at', () => {
|
||||
const filterTypes = [
|
||||
{ attributeKey: 'created_at', inputType: 'date' },
|
||||
{ attributeKey: 'last_activity_at', inputType: 'date' },
|
||||
{ attributeKey: 'test', inputType: 'string' },
|
||||
];
|
||||
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
|
||||
'string'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return undefined if key is not created_at or last_activity_at and inputType is not present', () => {
|
||||
const filterTypes = [
|
||||
{ attributeKey: 'created_at', inputType: 'date' },
|
||||
{ attributeKey: 'last_activity_at', inputType: 'date' },
|
||||
{ attributeKey: 'test', inputType: 'string' },
|
||||
];
|
||||
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
|
||||
'string'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getAttributeInputType', () => {
|
||||
it('should return multi_select if attribute_display_type is checkbox or list', () => {
|
||||
const allCustomAttributes = [
|
||||
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
|
||||
{ attribute_key: 'test2', attribute_display_type: 'list' },
|
||||
];
|
||||
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
|
||||
'multi_select'
|
||||
);
|
||||
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
|
||||
'multi_select'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return string if attribute_display_type is text, number, date or link', () => {
|
||||
const allCustomAttributes = [
|
||||
{ attribute_key: 'test', attribute_display_type: 'text' },
|
||||
{ attribute_key: 'test2', attribute_display_type: 'number' },
|
||||
{ attribute_key: 'test3', attribute_display_type: 'date' },
|
||||
{ attribute_key: 'test4', attribute_display_type: 'link' },
|
||||
];
|
||||
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
|
||||
'string'
|
||||
);
|
||||
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
|
||||
'string'
|
||||
);
|
||||
expect(getAttributeInputType('test3', allCustomAttributes)).toEqual(
|
||||
'string'
|
||||
);
|
||||
expect(getAttributeInputType('test4', allCustomAttributes)).toEqual(
|
||||
'string'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getValuesName', () => {
|
||||
it('should return id and name if item is present', () => {
|
||||
const list = [{ id: 1, name: 'test' }];
|
||||
const idKey = 'id';
|
||||
const nameKey = 'name';
|
||||
const values = [1];
|
||||
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
|
||||
id: 1,
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return id and value if item is not present', () => {
|
||||
const list = [{ id: 1, name: 'test' }];
|
||||
const idKey = 'id';
|
||||
const nameKey = 'name';
|
||||
const values = [2];
|
||||
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
|
||||
id: 2,
|
||||
name: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getValuesForFilter', () => {
|
||||
it('should return id and name if attribute_key is assignee_id', () => {
|
||||
const filter = { attribute_key: 'assignee_id', values: [1] };
|
||||
const params = { agents: [{ id: 1, name: 'test' }] };
|
||||
expect(getValuesForFilter(filter, params)).toEqual({
|
||||
id: 1,
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return id and name if attribute_key is inbox_id', () => {
|
||||
const filter = { attribute_key: 'inbox_id', values: [1] };
|
||||
const params = { inboxes: [{ id: 1, name: 'test' }] };
|
||||
expect(getValuesForFilter(filter, params)).toEqual({
|
||||
id: 1,
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return id and name if attribute_key is team_id', () => {
|
||||
const filter = { attribute_key: 'team_id', values: [1] };
|
||||
const params = { teams: [{ id: 1, name: 'test' }] };
|
||||
expect(getValuesForFilter(filter, params)).toEqual({
|
||||
id: 1,
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return id and title if attribute_key is campaign_id', () => {
|
||||
const filter = { attribute_key: 'campaign_id', values: [1] };
|
||||
const params = { campaigns: [{ id: 1, title: 'test' }] };
|
||||
expect(getValuesForFilter(filter, params)).toEqual({
|
||||
id: 1,
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return id and title if attribute_key is labels', () => {
|
||||
const filter = { attribute_key: 'labels', values: ['test'] };
|
||||
const params = { labels: [{ title: 'test' }] };
|
||||
expect(getValuesForFilter(filter, params)).toEqual([
|
||||
{ id: 'test', name: 'test' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return id and name if attribute_key is browser_language', () => {
|
||||
const filter = { attribute_key: 'browser_language', values: ['en'] };
|
||||
const params = { languages: [{ id: 'en', name: 'English' }] };
|
||||
expect(getValuesForFilter(filter, params)).toEqual([
|
||||
{ id: 'en', name: 'English' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return id and name if attribute_key is country_code', () => {
|
||||
const filter = { attribute_key: 'country_code', values: ['IN'] };
|
||||
const params = { countries: [{ id: 'IN', name: 'India' }] };
|
||||
expect(getValuesForFilter(filter, params)).toEqual([
|
||||
{ id: 'IN', name: 'India' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return id and name if attribute_key is not present', () => {
|
||||
const filter = { attribute_key: 'test', values: [1] };
|
||||
const params = {};
|
||||
expect(getValuesForFilter(filter, params)).toEqual({
|
||||
id: 1,
|
||||
name: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#generateValuesForEditCustomViews', () => {
|
||||
it('should return id and name if inboxType is multi_select or search_select', () => {
|
||||
const filter = {
|
||||
attribute_key: 'assignee_id',
|
||||
filter_operator: 'and',
|
||||
values: [1],
|
||||
};
|
||||
const params = {
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: [],
|
||||
agents: [{ id: 1, name: 'test' }],
|
||||
};
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
|
||||
id: 1,
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return id and name if inboxType is not multi_select or search_select', () => {
|
||||
const filter = {
|
||||
attribute_key: 'assignee_id',
|
||||
filter_operator: 'and',
|
||||
values: [1],
|
||||
};
|
||||
const params = {
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: [],
|
||||
agents: [{ id: 1, name: 'test' }],
|
||||
};
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
|
||||
id: 1,
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return id and name if inboxType is undefined', () => {
|
||||
const filter = {
|
||||
attribute_key: 'test2',
|
||||
filter_operator: 'and',
|
||||
values: [1],
|
||||
};
|
||||
const params = {
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: [
|
||||
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
|
||||
{ attribute_key: 'test2', attribute_display_type: 'list' },
|
||||
],
|
||||
};
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
|
||||
id: 1,
|
||||
name: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return value as string if filterInputTypes is string', () => {
|
||||
const filter = {
|
||||
attribute_key: 'test',
|
||||
filter_operator: 'and',
|
||||
values: [1],
|
||||
};
|
||||
const params = {
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: [
|
||||
{ attribute_key: 'test', attribute_display_type: 'date' },
|
||||
{ attribute_key: 'test2', attribute_display_type: 'list' },
|
||||
],
|
||||
};
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#generateCustomAttributesInputType', () => {
|
||||
it('should return string if type is text', () => {
|
||||
expect(generateCustomAttributesInputType('text')).toEqual('string');
|
||||
});
|
||||
|
||||
it('should return string if type is number', () => {
|
||||
expect(generateCustomAttributesInputType('number')).toEqual('string');
|
||||
});
|
||||
|
||||
it('should return string if type is date', () => {
|
||||
expect(generateCustomAttributesInputType('date')).toEqual('string');
|
||||
});
|
||||
|
||||
it('should return multi_select if type is checkbox', () => {
|
||||
expect(generateCustomAttributesInputType('checkbox')).toEqual(
|
||||
'multi_select'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return multi_select if type is list', () => {
|
||||
expect(generateCustomAttributesInputType('list')).toEqual('multi_select');
|
||||
});
|
||||
|
||||
it('should return string if type is link', () => {
|
||||
expect(generateCustomAttributesInputType('link')).toEqual('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
findSnoozeTime,
|
||||
conversationReopenTime,
|
||||
findStartOfNextWeek,
|
||||
findStartOfNextMonth,
|
||||
findNextDay,
|
||||
setHoursToNine,
|
||||
} from '../snoozeHelpers';
|
||||
|
||||
describe('#Snooze Helpers', () => {
|
||||
describe('findStartOfNextWeek', () => {
|
||||
it('should return first working day of next week if a date is passed', () => {
|
||||
const today = new Date('06/16/2023');
|
||||
const startOfNextWeek = new Date('06/19/2023');
|
||||
expect(findStartOfNextWeek(today)).toEqual(startOfNextWeek);
|
||||
});
|
||||
it('should return first working day of next week if a date is passed', () => {
|
||||
const today = new Date('06/03/2023');
|
||||
const startOfNextWeek = new Date('06/05/2023');
|
||||
expect(findStartOfNextWeek(today)).toEqual(startOfNextWeek);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findStartOfNextMonth', () => {
|
||||
it('should return first working day of next month if a valid date is passed', () => {
|
||||
const today = new Date('06/21/2023');
|
||||
const startOfNextMonth = new Date('07/03/2023');
|
||||
expect(findStartOfNextMonth(today)).toEqual(startOfNextMonth);
|
||||
});
|
||||
it('should return first working day of next month if a valid date is passed', () => {
|
||||
const today = new Date('02/28/2023');
|
||||
const startOfNextMonth = new Date('03/06/2023');
|
||||
expect(findStartOfNextMonth(today)).toEqual(startOfNextMonth);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setHoursToNine', () => {
|
||||
it('should return date with 9.00AM time', () => {
|
||||
const nextDay = new Date('06/17/2023');
|
||||
nextDay.setHours(9, 0, 0, 0);
|
||||
expect(setHoursToNine(nextDay)).toEqual(nextDay);
|
||||
});
|
||||
it('should return date with 9.00AM time if date with 10am is passes', () => {
|
||||
const nextDay = new Date('06/17/2023 10:00:00');
|
||||
nextDay.setHours(9, 0, 0, 0);
|
||||
expect(setHoursToNine(nextDay)).toEqual(nextDay);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSnoozeTime', () => {
|
||||
it('should return nil if until_next_reply is passed', () => {
|
||||
expect(findSnoozeTime('until_next_reply')).toEqual(null);
|
||||
});
|
||||
|
||||
it('should return next hour time stamp if an_hour_from_now is passed', () => {
|
||||
const nextHour = new Date();
|
||||
nextHour.setHours(nextHour.getHours() + 1);
|
||||
expect(findSnoozeTime('an_hour_from_now')).toBeCloseTo(
|
||||
Math.floor(nextHour.getTime() / 1000)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return next day 9.00AM time stamp until_tomorrow is passed', () => {
|
||||
const today = new Date('06/16/2023');
|
||||
const nextDay = new Date('06/17/2023');
|
||||
nextDay.setHours(9, 0, 0, 0);
|
||||
expect(findSnoozeTime('until_tomorrow', today)).toBeCloseTo(
|
||||
nextDay.getTime() / 1000
|
||||
);
|
||||
});
|
||||
|
||||
it('should return next week monday 9.00AM time stamp if until_next_week is passed', () => {
|
||||
const today = new Date('06/16/2023');
|
||||
const startOfNextWeek = new Date('06/19/2023');
|
||||
startOfNextWeek.setHours(9, 0, 0, 0);
|
||||
expect(findSnoozeTime('until_next_week', today)).toBeCloseTo(
|
||||
startOfNextWeek.getTime() / 1000
|
||||
);
|
||||
});
|
||||
|
||||
it('should return next month 9.00AM time stamp if until_next_month is passed', () => {
|
||||
const today = new Date('06/21/2023');
|
||||
const startOfNextMonth = new Date('07/03/2023');
|
||||
startOfNextMonth.setHours(9, 0, 0, 0);
|
||||
expect(findSnoozeTime('until_next_month', today)).toBeCloseTo(
|
||||
startOfNextMonth.getTime() / 1000
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversationReopenTime', () => {
|
||||
it('should return nil if snoozedUntil is nil', () => {
|
||||
expect(conversationReopenTime(null)).toEqual(null);
|
||||
});
|
||||
|
||||
it('should return formatted date if snoozedUntil is not nil', () => {
|
||||
expect(conversationReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
|
||||
'7 Jun, 9.00am'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextDay', () => {
|
||||
it('should return next day', () => {
|
||||
const today = new Date('06/16/2023');
|
||||
const nextDay = new Date('06/17/2023');
|
||||
expect(findNextDay(today)).toEqual(nextDay);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import vi from './locale/vi';
|
||||
import zh_CN from './locale/zh_CN';
|
||||
import zh_TW from './locale/zh_TW';
|
||||
import is from './locale/is';
|
||||
import lt from './locale/lt';
|
||||
|
||||
export default {
|
||||
ar,
|
||||
@@ -74,4 +75,5 @@ export default {
|
||||
zh_CN,
|
||||
zh_TW,
|
||||
is,
|
||||
lt,
|
||||
};
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
"FILTER": {
|
||||
"TITLE": "تصفية المحادثات",
|
||||
"SUBTITLE": "إضافة فلاتر أدناه واضغط على 'إرسال' لتصفية المحادثات.",
|
||||
"ADD_NEW_FILTER": "إضافة فلتر",
|
||||
"FILTER_DELETE_ERROR": "يجب ان يكون لديك فلتر واحد على الاقل",
|
||||
"SUBMIT_BUTTON_LABEL": "تطبيق الفلاتر",
|
||||
"EDIT_CUSTOM_FILTER": "Edit Folder",
|
||||
"CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your folder.",
|
||||
"ADD_NEW_FILTER": "إضافة عامل تصفية",
|
||||
"FILTER_DELETE_ERROR": "يجب أن يكون لديك عامل تصفية واحد على الأقل للحفظ",
|
||||
"SUBMIT_BUTTON_LABEL": "تطبيق عامل التصفية",
|
||||
"UPDATE_BUTTON_LABEL": "Update folder",
|
||||
"CANCEL_BUTTON_LABEL": "إلغاء",
|
||||
"CLEAR_BUTTON_LABEL": "مسح الفلاتر",
|
||||
"CLEAR_BUTTON_LABEL": "مسح عامل التصفية",
|
||||
"EMPTY_VALUE_ERROR": "القيمة مطلوبة",
|
||||
"TOOLTIP_LABEL": "تصفية المحادثة",
|
||||
"FOLDER_LABEL": "Folder Name",
|
||||
"FOLDER_QUERY_LABEL": "Folder Query",
|
||||
"TOOLTIP_LABEL": "تصفية المحادثات",
|
||||
"QUERY_DROPDOWN_LABELS": {
|
||||
"AND": "و",
|
||||
"OR": "أو"
|
||||
@@ -23,7 +28,7 @@
|
||||
"is_greater_than": "هو أكبر من",
|
||||
"is_less_than": "هو أقل من",
|
||||
"days_before": "قبل x أيام",
|
||||
"starts_with": "Starts with"
|
||||
"starts_with": "يبدأ بـ"
|
||||
},
|
||||
"ATTRIBUTE_LABELS": {
|
||||
"TRUE": "صحيح",
|
||||
@@ -38,7 +43,7 @@
|
||||
"CAMPAIGN_NAME": "اسم الحملة",
|
||||
"LABELS": "الوسوم",
|
||||
"BROWSER_LANGUAGE": "لغة المتصفح",
|
||||
"PRIORITY": "Priority",
|
||||
"PRIORITY": "الأولوية",
|
||||
"COUNTRY_NAME": "اسم الدولة",
|
||||
"REFERER_LINK": "رابط المرجع",
|
||||
"CUSTOM_ATTRIBUTE_LIST": "القائمة",
|
||||
@@ -71,6 +76,9 @@
|
||||
"ERROR_MESSAGE": "خطأ أثناء إنشاء طريقة عرض مخصصة"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
"EDIT_BUTTON": "Edit folder"
|
||||
},
|
||||
"DELETE": {
|
||||
"DELETE_BUTTON": "حذف الفلتر",
|
||||
"MODAL": {
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
},
|
||||
"AGENT_AVAILABILITY": {
|
||||
"LABEL": "التوفر",
|
||||
"PLACEHOLDER": "Please select an availability status",
|
||||
"ERROR": "Availability is required"
|
||||
"PLACEHOLDER": "الرجاء تحديد حالة التوفر",
|
||||
"ERROR": "حالة التوفر مطلوبة"
|
||||
},
|
||||
"SUBMIT": "تعديل حساب الموظف"
|
||||
},
|
||||
@@ -111,7 +111,7 @@
|
||||
"PLACEHOLDER": {
|
||||
"AGENT": "البحث عن وكلاء",
|
||||
"TEAM": "البحث عن فريق",
|
||||
"INPUT": "Search for agents"
|
||||
"INPUT": "البحث عن ممثلى الخدمة"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,41 @@
|
||||
"TABLE_HEADER": [
|
||||
"User",
|
||||
"Action",
|
||||
"عنوان IP",
|
||||
"Time"
|
||||
"عنوان IP"
|
||||
]
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "AuditLogs retrieved successfully",
|
||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
||||
},
|
||||
"AUTOMATION_RULE": {
|
||||
"ADD": "%{agentName} created a new automation rule (#%{id})",
|
||||
"EDIT": "%{agentName} updated an automation rule (#%{id})",
|
||||
"DELETE": "%{agentName} deleted an automation rule (#%{id})"
|
||||
},
|
||||
"INBOX": {
|
||||
"ADD": "%{agentName} created a new inbox (#%{id})",
|
||||
"EDIT": "%{agentName} updated an inbox (#%{id})",
|
||||
"DELETE": "%{agentName} deleted an inbox (#%{id})"
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"ADD": "%{agentName} created a new webhook (#%{id})",
|
||||
"EDIT": "%{agentName} updated a webhook (#%{id})",
|
||||
"DELETE": "%{agentName} deleted a webhook (#%{id})"
|
||||
},
|
||||
"USER_ACTION": {
|
||||
"SIGN_IN": "%{agentName} signed in",
|
||||
"SIGN_OUT": "%{agentName} signed out"
|
||||
},
|
||||
"TEAM": {
|
||||
"ADD": "%{agentName} created a new team (#%{id})",
|
||||
"EDIT": "%{agentName} updated a team (#%{id})",
|
||||
"DELETE": "%{agentName} deleted a team (#%{id})"
|
||||
},
|
||||
"MACRO": {
|
||||
"ADD": "%{agentName} created a new macro (#%{id})",
|
||||
"EDIT": "%{agentName} updated a macro (#%{id})",
|
||||
"DELETE": "%{agentName} deleted a macro (#%{id})"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +36,23 @@
|
||||
}
|
||||
},
|
||||
"VIEW_FILTER": "عرض",
|
||||
"SORT_TOOLTIP_LABEL": "Sort conversations",
|
||||
"SORT_TOOLTIP_LABEL": "ترتيب المحادثات",
|
||||
"CHAT_SORT": {
|
||||
"STATUS": "الحالة",
|
||||
"ORDER_BY": "Order by"
|
||||
"ORDER_BY": "الترتيب بواسطة"
|
||||
},
|
||||
"CHAT_SORT_FILTER_ITEMS": {
|
||||
"latest": {
|
||||
"TEXT": "Last activity"
|
||||
"TEXT": "النشاط الأخير"
|
||||
},
|
||||
"sort_on_created_at": {
|
||||
"TEXT": "تم إنشاؤها في"
|
||||
},
|
||||
"sort_on_priority": {
|
||||
"TEXT": "الأولوية"
|
||||
},
|
||||
"sort_on_waiting_since": {
|
||||
"TEXT": "Pending Response"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
@@ -70,12 +76,12 @@
|
||||
}
|
||||
},
|
||||
"CHAT_SORT_BY_FILTER": {
|
||||
"TITLE": "Sort conversation",
|
||||
"TITLE": "ترتيب المحادثات",
|
||||
"DROPDOWN_TITLE": "ترتيب حسب",
|
||||
"ITEMS": {
|
||||
"LATEST": {
|
||||
"NAME": "Last activity at",
|
||||
"LABEL": "Last activity"
|
||||
"NAME": "النشاط الأخير في",
|
||||
"LABEL": "النشاط الأخير"
|
||||
},
|
||||
"CREATED_AT": {
|
||||
"NAME": "تم إنشاؤها في",
|
||||
|
||||
@@ -73,6 +73,13 @@
|
||||
"SUCCESS_MESSAGE": "تم حفظ جهة الاتصال بنجاح",
|
||||
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى"
|
||||
},
|
||||
"EXPORT_CONTACTS": {
|
||||
"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": "حدث خطأ، الرجاء المحاولة مرة أخرى"
|
||||
},
|
||||
"DELETE_NOTE": {
|
||||
"CONFIRM": {
|
||||
"TITLE": "تأكيد الحذف",
|
||||
@@ -120,7 +127,7 @@
|
||||
"PHONE_NUMBER": {
|
||||
"PLACEHOLDER": "أدخل رقم الهاتف الخاص بجهة الاتصال",
|
||||
"LABEL": "رقم الهاتف",
|
||||
"HELP": "Phone number should be of E.164 format eg: +1415555555 [+][country code][area code][local phone number]. You can select the dial code from the dropdown.",
|
||||
"HELP": "يجب ان يحتوى رقم الهاتف على كود دولتك تسبقها علامة +\nمثال: +20101243567",
|
||||
"ERROR": "يجب ان تكون خانة رقم الهاتف إما فارغة او مكتملة مع رمز الدولة",
|
||||
"DIAL_CODE_ERROR": "Please select a dial code from the list",
|
||||
"DUPLICATE": "رقم الهاتف هذا مستخدم لجهة اتصال أخرى."
|
||||
@@ -182,7 +189,7 @@
|
||||
"LABEL": "إلى"
|
||||
},
|
||||
"INBOX": {
|
||||
"LABEL": "Via Inbox",
|
||||
"LABEL": "صندوق الوارد",
|
||||
"PLACEHOLDER": "Choose source inbox",
|
||||
"ERROR": "حدد صندوق الوارد"
|
||||
},
|
||||
@@ -211,6 +218,7 @@
|
||||
"FILTER_CONTACTS": "فلترة",
|
||||
"FILTER_CONTACTS_SAVE": "حفظ الفلتر",
|
||||
"FILTER_CONTACTS_DELETE": "حذف الفلتر",
|
||||
"FILTER_CONTACTS_EDIT": "Edit segment",
|
||||
"LIST": {
|
||||
"LOADING_MESSAGE": "جاري تحميل جهات الاتصال...",
|
||||
"404": "لا توجد جهات اتصال تطابق بحثك 🔍",
|
||||
|
||||
@@ -1,50 +1,55 @@
|
||||
{
|
||||
"CONTACTS_FILTER": {
|
||||
"TITLE": "تصفية جهات الاتصال",
|
||||
"SUBTITLE": "إضافة فلاتر أدناه واضغط على 'إرسال' لتصفية جهات الاتصال.",
|
||||
"ADD_NEW_FILTER": "إضافة فلتر",
|
||||
"CLEAR_ALL_FILTERS": "مسح جميع الفلاتر",
|
||||
"FILTER_DELETE_ERROR": "يجب ان يكون لديك فلتر واحد على الاقل",
|
||||
"SUBMIT_BUTTON_LABEL": "إرسال",
|
||||
"CANCEL_BUTTON_LABEL": "إلغاء",
|
||||
"CLEAR_BUTTON_LABEL": "مسح الفلاتر",
|
||||
"EMPTY_VALUE_ERROR": "القيمة مطلوبة",
|
||||
"TOOLTIP_LABEL": "تصفية جهات الاتصال",
|
||||
"QUERY_DROPDOWN_LABELS": {
|
||||
"AND": "و",
|
||||
"OR": "أو"
|
||||
},
|
||||
"OPERATOR_LABELS": {
|
||||
"equal_to": "يساوي",
|
||||
"not_equal_to": "لا يساوي",
|
||||
"contains": "يحتوي",
|
||||
"does_not_contain": "لا يحتوي",
|
||||
"is_present": "موجود",
|
||||
"is_not_present": "غير موجود",
|
||||
"is_greater_than": "هو أكبر من",
|
||||
"is_lesser_than": "هو أقل من",
|
||||
"days_before": "قبل x أيام"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"NAME": "الاسم",
|
||||
"EMAIL": "البريد الإلكتروني",
|
||||
"PHONE_NUMBER": "رقم الهاتف",
|
||||
"IDENTIFIER": "المعرف",
|
||||
"CITY": "المدينة",
|
||||
"COUNTRY": "الدولة",
|
||||
"CUSTOM_ATTRIBUTE_LIST": "القائمة",
|
||||
"CUSTOM_ATTRIBUTE_TEXT": "النص",
|
||||
"CUSTOM_ATTRIBUTE_NUMBER": "العدد",
|
||||
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع",
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"LAST_ACTIVITY": "آخر نشاط",
|
||||
"REFERER_LINK": "رابط المرجع"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "الفلاتر القياسية",
|
||||
"ADDITIONAL_FILTERS": "فلاتر إضافية",
|
||||
"CUSTOM_ATTRIBUTES": "سمات مخصصة"
|
||||
}
|
||||
"CONTACTS_FILTER": {
|
||||
"TITLE": "تصفية جهات الاتصال",
|
||||
"SUBTITLE": "إضافة فلاتر أدناه واضغط على 'إرسال' لتصفية جهات الاتصال.",
|
||||
"EDIT_CUSTOM_SEGMENT": "Edit Segment",
|
||||
"CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your segment.",
|
||||
"ADD_NEW_FILTER": "إضافة فلتر",
|
||||
"CLEAR_ALL_FILTERS": "مسح جميع الفلاتر",
|
||||
"FILTER_DELETE_ERROR": "يجب ان يكون لديك فلتر واحد على الاقل",
|
||||
"SUBMIT_BUTTON_LABEL": "إرسال",
|
||||
"UPDATE_BUTTON_LABEL": "Update Segment",
|
||||
"CANCEL_BUTTON_LABEL": "إلغاء",
|
||||
"CLEAR_BUTTON_LABEL": "مسح الفلاتر",
|
||||
"EMPTY_VALUE_ERROR": "القيمة مطلوبة",
|
||||
"SEGMENT_LABEL": "Segment Name",
|
||||
"SEGMENT_QUERY_LABEL": "Segment Query",
|
||||
"TOOLTIP_LABEL": "تصفية جهات الاتصال",
|
||||
"QUERY_DROPDOWN_LABELS": {
|
||||
"AND": "و",
|
||||
"OR": "أو"
|
||||
},
|
||||
"OPERATOR_LABELS": {
|
||||
"equal_to": "يساوي",
|
||||
"not_equal_to": "لا يساوي",
|
||||
"contains": "يحتوي",
|
||||
"does_not_contain": "لا يحتوي",
|
||||
"is_present": "موجود",
|
||||
"is_not_present": "غير موجود",
|
||||
"is_greater_than": "هو أكبر من",
|
||||
"is_lesser_than": "هو أقل من",
|
||||
"days_before": "قبل x أيام"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"NAME": "الاسم",
|
||||
"EMAIL": "البريد الإلكتروني",
|
||||
"PHONE_NUMBER": "رقم الهاتف",
|
||||
"IDENTIFIER": "المعرف",
|
||||
"CITY": "المدينة",
|
||||
"COUNTRY": "الدولة",
|
||||
"CUSTOM_ATTRIBUTE_LIST": "القائمة",
|
||||
"CUSTOM_ATTRIBUTE_TEXT": "النص",
|
||||
"CUSTOM_ATTRIBUTE_NUMBER": "العدد",
|
||||
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع",
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"LAST_ACTIVITY": "آخر نشاط",
|
||||
"REFERER_LINK": "رابط المرجع"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "الفلاتر القياسية",
|
||||
"ADDITIONAL_FILTERS": "فلاتر إضافية",
|
||||
"CUSTOM_ATTRIBUTES": "سمات مخصصة"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,14 @@
|
||||
"OPEN": "المزيد",
|
||||
"CLOSE": "أغلق",
|
||||
"DETAILS": "التفاصيل",
|
||||
"SNOOZED_UNTIL": "Snoozed until",
|
||||
"SNOOZED_UNTIL_TOMORROW": "غفوة حتى الغد",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "غفوة حتى الأسبوع القادم",
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "غفوة حتى الرد التالي"
|
||||
},
|
||||
"RESOLVE_DROPDOWN": {
|
||||
"MARK_PENDING": "تحديد كمعلق",
|
||||
"SNOOZE_UNTIL": "غفوة",
|
||||
"SNOOZE": {
|
||||
"TITLE": "تأجيل حتى",
|
||||
"NEXT_REPLY": "الرد القادم",
|
||||
@@ -67,8 +69,13 @@
|
||||
"NEXT_WEEK": "الأسبوع المقبل"
|
||||
}
|
||||
},
|
||||
"CUSTOM_SNOOZE": {
|
||||
"TITLE": "تأجيل حتى",
|
||||
"APPLY": "غفوة",
|
||||
"CANCEL": "إلغاء"
|
||||
},
|
||||
"PRIORITY": {
|
||||
"TITLE": "Priority",
|
||||
"TITLE": "الأولوية",
|
||||
"OPTIONS": {
|
||||
"NONE": "لا شيء",
|
||||
"URGENT": "Urgent",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"CSAT": {
|
||||
"TITLE": "قيم محادثتك",
|
||||
"PLACEHOLDER": "أخبرنا المزيد..."
|
||||
"PLACEHOLDER": "أخبرنا المزيد...",
|
||||
"RATINGS": {
|
||||
"POOR": "😞 Poor",
|
||||
"FAIR": "😑 Fair",
|
||||
"AVERAGE": "😐 Average",
|
||||
"GOOD": "😀 Good",
|
||||
"EXCELLENT": "😍 Excellent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,10 @@
|
||||
}
|
||||
},
|
||||
"UPDATE_CHATWOOT": "يتوفر تحديث %{latestChatwootVersion} لـ Chatwoot. الرجاء التحديث.",
|
||||
"LEARN_MORE": "اعرف المزيد"
|
||||
"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"
|
||||
},
|
||||
"FORMS": {
|
||||
"MULTISELECT": {
|
||||
@@ -103,6 +106,7 @@
|
||||
"CHANGE_ASSIGNEE": "تغيير المحال إليه",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"CHANGE_TEAM": "تغيير الفريق",
|
||||
"SNOOZE_CONVERSATION": "تأجيل المحادثة",
|
||||
"ADD_LABEL": "إضافة تسمية إلى المحادثة",
|
||||
"REMOVE_LABEL": "إزالة التسمية من المحادثة",
|
||||
"SETTINGS": "الإعدادات"
|
||||
@@ -138,7 +142,10 @@
|
||||
"SNOOZE_CONVERSATION": "تأجيل المحادثة",
|
||||
"UNTIL_NEXT_REPLY": "حتى الرد القادم",
|
||||
"UNTIL_NEXT_WEEK": "حتى الأسبوع القادم",
|
||||
"UNTIL_TOMORROW": "حتى الغد"
|
||||
"UNTIL_TOMORROW": "حتى الغد",
|
||||
"UNTIL_NEXT_MONTH": "Until next month",
|
||||
"AN_HOUR_FROM_NOW": "Until an hour from now",
|
||||
"CUSTOM": "Custom..."
|
||||
}
|
||||
},
|
||||
"DASHBOARD_APPS": {
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
"DELETE": "حذف المقال"
|
||||
}
|
||||
},
|
||||
"ARTICLE_SEARCH_RESULT": {
|
||||
"UNCATEGORIZED": "Uncategorized",
|
||||
"INSERT_ARTICLE": "Insert",
|
||||
"NO_RESULT": "No articles found",
|
||||
"COPY_LINK": "Copy article link to clipboard",
|
||||
"OPEN_LINK": "Open article in new tab",
|
||||
"PREVIEW_LINK": "Preview article"
|
||||
},
|
||||
"PORTAL": {
|
||||
"HEADER": "الصفحات",
|
||||
"DEFAULT": "افتراضي",
|
||||
|
||||
@@ -535,7 +535,6 @@
|
||||
"UPDATE": "تحديث إعدادات ساعات العمل",
|
||||
"TOGGLE_AVAILABILITY": "تمكين توافر العمل لهذا البريد الوارد",
|
||||
"UNAVAILABLE_MESSAGE_LABEL": "رسالة غير متاح للزائرين",
|
||||
"UNAVAILABLE_MESSAGE_DEFAULT": "نحن غير متوفرين في هذه اللحظة. اترك رسالة سنرد عليها بمجرد عودتنا.",
|
||||
"TOGGLE_HELP": "تمكين توفر العمل سيظهر الساعات المتاحة على أداة الدردشة المباشرة حتى لو كان جميع الوكلاء غير متصلين بالإنترنت. خارج الساعات المتاحة يمكن تحذير الزوار برسالة ونموذج ما قبل الدردشة.",
|
||||
"DAY": {
|
||||
"ENABLE": "تمكين التوفر لهذا اليوم",
|
||||
|
||||
@@ -87,6 +87,8 @@
|
||||
},
|
||||
"OPEN_AI": {
|
||||
"TITLE": "Improve With AI",
|
||||
"SUMMARY_TITLE": "Summary with AI",
|
||||
"REPLY_TITLE": "Reply suggestion with AI",
|
||||
"SUBTITLE": "An improved reply will be generated using AI, based on your current draft.",
|
||||
"TONE": {
|
||||
"TITLE": "Tone",
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
"TITLE": "تسجيل الدخول إلى Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "البريد الإلكتروني",
|
||||
"PLACEHOLDER": "example@companyname.com"
|
||||
"PLACEHOLDER": "مثال: someone@example.com"
|
||||
},
|
||||
"PASSWORD": {
|
||||
"LABEL": "كلمة المرور",
|
||||
"PLACEHOLDER": "كلمة المرور"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Login successful",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again.",
|
||||
"UNAUTH": "Username or password is incorrect. Please try again."
|
||||
"SUCCESS_MESSAGE": "تم تسجيل الدخول بنجاح",
|
||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً",
|
||||
"UNAUTH": "اسم المستخدم / كلمة المرور غير صحيحة. الرجاء المحاولة مرة أخرى"
|
||||
},
|
||||
"OAUTH": {
|
||||
"GOOGLE_LOGIN": "Login with Google",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"LOADING_CHART": "تحميل بيانات الرسم البياني...",
|
||||
"NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.",
|
||||
"DOWNLOAD_AGENT_REPORTS": "تنزيل تقارير الوكيل",
|
||||
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
|
||||
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "المحادثات",
|
||||
@@ -34,6 +36,14 @@
|
||||
"DESC": "(الإجمالي)"
|
||||
}
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"LAST_7_DAYS": "آخر 7 أيام",
|
||||
"LAST_30_DAYS": "آخر 30 يوماً",
|
||||
"LAST_3_MONTHS": "آخر 3 أشهر",
|
||||
"LAST_6_MONTHS": "آخر 6 أشهر",
|
||||
"LAST_YEAR": "العام الماضي",
|
||||
"CUSTOM_DATE_RANGE": "تحديد نطاق المدة"
|
||||
},
|
||||
"DATE_RANGE": [
|
||||
{
|
||||
"id": 0,
|
||||
@@ -66,6 +76,12 @@
|
||||
},
|
||||
"GROUP_BY_FILTER_DROPDOWN_LABEL": "تجميع بواسطة",
|
||||
"DURATION_FILTER_LABEL": "المدة",
|
||||
"GROUPING_OPTIONS": {
|
||||
"DAY": "اليوم",
|
||||
"WEEK": "الأسبوع",
|
||||
"MONTH": "الشهر",
|
||||
"YEAR": "السنة"
|
||||
},
|
||||
"GROUP_BY_DAY_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -388,6 +404,7 @@
|
||||
"HEADER": "تقارير CSAT",
|
||||
"NO_RECORDS": "لا توجد ردود متوفرة على الدراسة الاستقصائية CSAT.",
|
||||
"DOWNLOAD": "تحميل تقرير رضاء خدمة العملاء",
|
||||
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
|
||||
"FILTERS": {
|
||||
"AGENTS": {
|
||||
"PLACEHOLDER": "اختر الوكلاء"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "تم إرسال رابط إعادة تعيين كلمة المرور إلى بريدك الإلكتروني.",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
||||
},
|
||||
"SUBMIT": "إرسال"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user