Merge branch 'release/2.17.0'
This commit is contained in:
commit
de1fb6ba4e
@ -7,7 +7,7 @@ defaults: &defaults
|
|||||||
working_directory: ~/build
|
working_directory: ~/build
|
||||||
docker:
|
docker:
|
||||||
# specify the version you desire here
|
# specify the version you desire here
|
||||||
- image: cimg/ruby:3.1.3-browsers
|
- image: cimg/ruby:3.2.2-browsers
|
||||||
|
|
||||||
# Specify service dependencies here if necessary
|
# Specify service dependencies here if necessary
|
||||||
# CircleCI maintains a library of pre-built images
|
# CircleCI maintains a library of pre-built images
|
||||||
|
|||||||
@ -30,7 +30,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
|||||||
npm
|
npm
|
||||||
|
|
||||||
# Install rbenv and ruby
|
# Install rbenv and ruby
|
||||||
ARG RUBY_VERSION="3.1.3"
|
ARG RUBY_VERSION="3.2.2"
|
||||||
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
|
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
|
||||||
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
|
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
|
||||||
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
|
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
|
||||||
|
|||||||
78
.github/ISSUE_TEMPLATE/bug_report.md
vendored
78
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: 'Bug'
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See the error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
|
|
||||||
Share a clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Browser logs**
|
|
||||||
|
|
||||||
Share the browser logs to debug the issue further.
|
|
||||||
|
|
||||||
**Server logs**
|
|
||||||
|
|
||||||
Share the server logs to debug the issue further.
|
|
||||||
|
|
||||||
**Environment**
|
|
||||||
|
|
||||||
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
|
|
||||||
|
|
||||||
- [ ] app.chatwoot.com (Chatwoot Cloud)
|
|
||||||
- [ ] Self-hosted
|
|
||||||
- - [ ] Linux VM
|
|
||||||
- - [ ] Docker
|
|
||||||
- - [ ] Kubernetes
|
|
||||||
- - [ ] Heroku
|
|
||||||
- - [ ] Other (Please specify)
|
|
||||||
|
|
||||||
|
|
||||||
**Desktop (please complete the following information)** (If applicable)
|
|
||||||
- OS: [e.g. Linux, Windows, MacOS]
|
|
||||||
- Browser [e.g. chrome, firefox, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information)** (If applicable)
|
|
||||||
- Device: [e.g. iPhone6, Pixel7]
|
|
||||||
- OS: [e.g. iOS8.1]
|
|
||||||
- Browser [e.g. stock browser, firefox, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Docker** (If applicable)
|
|
||||||
|
|
||||||
Please share the output of the following.
|
|
||||||
- `docker version`
|
|
||||||
- `docker info`
|
|
||||||
- `docker-compose version`
|
|
||||||
|
|
||||||
**Cloud Provider** (If applicable)
|
|
||||||
- [ ] AWS
|
|
||||||
- [ ] GCP
|
|
||||||
- [ ] Azure
|
|
||||||
- [ ] DigitalOcean
|
|
||||||
- [ ] Others
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
|
|
||||||
Add any other context about the problem here.
|
|
||||||
78
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
name: 🐞 Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
labels: 'Bug'
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: A concise description of what you expected to happen along with screenshots if applicable.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: To Reproduce
|
||||||
|
description: Steps to reproduce the behavior.
|
||||||
|
placeholder: |
|
||||||
|
1. In this environment...
|
||||||
|
2. With this config...
|
||||||
|
3. Run '...'
|
||||||
|
4. See error...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: A concise description of what you expected to happen.
|
||||||
|
- type: dropdown
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
|
||||||
|
options:
|
||||||
|
- app.chatwoot.com
|
||||||
|
- Linux VM
|
||||||
|
- Docker
|
||||||
|
- Kubernetes
|
||||||
|
- Heroku
|
||||||
|
- Other [please specify in the description]
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: provider
|
||||||
|
attributes:
|
||||||
|
label: Cloud Provider
|
||||||
|
description:
|
||||||
|
options:
|
||||||
|
- AWS
|
||||||
|
- GCP
|
||||||
|
- Azure
|
||||||
|
- DigitalOcean
|
||||||
|
- Other [please specify in the description]
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
description: Describe the platform you are using
|
||||||
|
options:
|
||||||
|
- Browser
|
||||||
|
- Mobile
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Operating system
|
||||||
|
description: The operating system and the version you are using.
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Browser and version
|
||||||
|
description: The name of the browser and version you are using.
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Docker (if applicable)
|
||||||
|
description: |
|
||||||
|
Please share the output of the following.
|
||||||
|
- `docker version`
|
||||||
|
- `docker info`
|
||||||
|
- `docker-compose version`
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context about the problem here.
|
||||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Report a security issue
|
||||||
|
url: https://www.chatwoot.com/docs/contributing-guide/security-reports/
|
||||||
|
about: Guidelines and steps to report a security vulnerability. Please report security vulnerabilities here.
|
||||||
|
- name: Product Documentation
|
||||||
|
url: https://www.chatwoot.com/help-center
|
||||||
|
about: If you have questions, are confused, or just want to understand our product better, please check out our documentation.
|
||||||
20
.github/ISSUE_TEMPLATE/enhancement_request.md
vendored
20
.github/ISSUE_TEMPLATE/enhancement_request.md
vendored
@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: Enhancement request
|
|
||||||
about: Suggest any enhancements for this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your enhancement request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the enhancement request here.
|
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
28
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
28
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
name: 🧙 Feature request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
labels: 'feature-request'
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Is your feature or enhancement related to a problem? Please describe.
|
||||||
|
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the solution you'd like
|
||||||
|
description: A clear and concise description of what you want to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe alternatives you've considered
|
||||||
|
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context or screenshots about the feature request here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -67,3 +67,10 @@ test/cypress/videos/*
|
|||||||
# yalc for local testing
|
# yalc for local testing
|
||||||
.yalc
|
.yalc
|
||||||
yalc.lock
|
yalc.lock
|
||||||
|
|
||||||
|
/public/packs
|
||||||
|
/public/packs-test
|
||||||
|
/node_modules
|
||||||
|
/yarn-error.log
|
||||||
|
yarn-debug.log*
|
||||||
|
.yarn-integrity
|
||||||
|
|||||||
37
.rubocop.yml
37
.rubocop.yml
@ -71,6 +71,10 @@ Rails/ApplicationController:
|
|||||||
- 'app/controllers/platform_controller.rb'
|
- 'app/controllers/platform_controller.rb'
|
||||||
- 'app/controllers/public_controller.rb'
|
- 'app/controllers/public_controller.rb'
|
||||||
- 'app/controllers/survey/responses_controller.rb'
|
- 'app/controllers/survey/responses_controller.rb'
|
||||||
|
Rails/FindEach:
|
||||||
|
Enabled: true
|
||||||
|
Include:
|
||||||
|
- 'app/**/*.rb'
|
||||||
Rails/CompactBlank:
|
Rails/CompactBlank:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
Rails/EnvironmentVariableAccess:
|
Rails/EnvironmentVariableAccess:
|
||||||
@ -160,6 +164,39 @@ Performance/CollectionLiteralInLoop:
|
|||||||
- 'db/migrate/20210315101919_enable_email_channel.rb'
|
- 'db/migrate/20210315101919_enable_email_channel.rb'
|
||||||
RSpec/NamedSubject:
|
RSpec/NamedSubject:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
Style/RedundantConstantBase:
|
||||||
|
Enabled: false
|
||||||
|
Rails/RootPathnameMethods:
|
||||||
|
Enabled: false
|
||||||
|
RSpec/Rails/MinitestAssertions:
|
||||||
|
Enabled: false
|
||||||
|
RSpec/Rails/InferredSpecType:
|
||||||
|
Enabled: false
|
||||||
|
RSpec/IndexedLet:
|
||||||
|
Enabled: false
|
||||||
|
RSpec/MatchArray:
|
||||||
|
Enabled: false
|
||||||
|
Rails/ResponseParsedBody:
|
||||||
|
Enabled: false
|
||||||
|
RSpec/FactoryBot/ConsistentParenthesesStyle:
|
||||||
|
Enabled: false
|
||||||
|
Rails/ThreeStateBooleanColumn:
|
||||||
|
Enabled: false
|
||||||
|
Rails/Pluck:
|
||||||
|
Enabled: false
|
||||||
|
Rails/TopLevelHashWithIndifferentAccess:
|
||||||
|
Enabled: false
|
||||||
|
Rails/ActionOrder:
|
||||||
|
Enabled: false
|
||||||
|
Style/ArrayIntersect:
|
||||||
|
Enabled: false
|
||||||
|
RSpec/NoExpectationExample:
|
||||||
|
Enabled: false
|
||||||
|
Style/RedundantReturn:
|
||||||
|
Enabled: false
|
||||||
|
Rails/I18nLocaleTexts:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
# we should bring this down
|
# we should bring this down
|
||||||
RSpec/MultipleMemoizedHelpers:
|
RSpec/MultipleMemoizedHelpers:
|
||||||
Max: 14
|
Max: 14
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
3.1.3
|
3.2.2
|
||||||
|
|||||||
1
.slugignore
Normal file
1
.slugignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
/spec
|
||||||
49
Gemfile
49
Gemfile
@ -1,10 +1,10 @@
|
|||||||
source 'https://rubygems.org'
|
source 'https://rubygems.org'
|
||||||
|
|
||||||
ruby '3.1.3'
|
ruby '3.2.2'
|
||||||
|
|
||||||
##-- base gems for rails --##
|
##-- base gems for rails --##
|
||||||
gem 'rack-cors', require: 'rack/cors'
|
gem 'rack-cors', require: 'rack/cors'
|
||||||
gem 'rails', '~> 6.1', '>= 6.1.7.3'
|
gem 'rails', '~> 7'
|
||||||
# Reduces boot times through caching; required in config/boot.rb
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
gem 'bootsnap', require: false
|
gem 'bootsnap', require: false
|
||||||
|
|
||||||
@ -36,19 +36,19 @@ gem 'json_schemer'
|
|||||||
# Rack middleware for blocking & throttling abusive requests
|
# Rack middleware for blocking & throttling abusive requests
|
||||||
gem 'rack-attack'
|
gem 'rack-attack'
|
||||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||||
gem 'down', '~> 5.0'
|
gem 'down'
|
||||||
# authentication type to fetch and send mail over oauth2.0
|
# authentication type to fetch and send mail over oauth2.0
|
||||||
gem 'gmail_xoauth'
|
gem 'gmail_xoauth'
|
||||||
# Prevent CSV injection
|
# Prevent CSV injection
|
||||||
gem 'csv-safe'
|
gem 'csv-safe'
|
||||||
# Support message translation
|
|
||||||
gem 'google-cloud-translate'
|
|
||||||
|
|
||||||
##-- for active storage --##
|
##-- for active storage --##
|
||||||
gem 'aws-sdk-s3', require: false
|
gem 'aws-sdk-s3', require: false
|
||||||
gem 'azure-storage-blob', require: false
|
# original gem isn't maintained actively
|
||||||
|
# we wanted updated version of faraday which is a dependency for slack-ruby-client
|
||||||
|
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
|
||||||
gem 'google-cloud-storage', require: false
|
gem 'google-cloud-storage', require: false
|
||||||
gem 'image_processing', '~> 1.12.2'
|
gem 'image_processing'
|
||||||
|
|
||||||
##-- gems for database --#
|
##-- gems for database --#
|
||||||
gem 'groupdate'
|
gem 'groupdate'
|
||||||
@ -62,13 +62,13 @@ gem 'activerecord-import'
|
|||||||
gem 'dotenv-rails'
|
gem 'dotenv-rails'
|
||||||
gem 'foreman'
|
gem 'foreman'
|
||||||
gem 'puma'
|
gem 'puma'
|
||||||
gem 'webpacker', '~> 5.4', '>= 5.4.3'
|
gem 'webpacker'
|
||||||
# metrics on heroku
|
# metrics on heroku
|
||||||
gem 'barnes'
|
gem 'barnes'
|
||||||
|
|
||||||
##--- gems for authentication & authorization ---##
|
##--- gems for authentication & authorization ---##
|
||||||
gem 'devise'
|
gem 'devise'
|
||||||
gem 'devise-secure_password', '~> 2.0', git: 'https://github.com/chatwoot/devise-secure_password'
|
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||||
gem 'devise_token_auth'
|
gem 'devise_token_auth'
|
||||||
# authorization
|
# authorization
|
||||||
gem 'jwt'
|
gem 'jwt'
|
||||||
@ -81,7 +81,6 @@ gem 'administrate'
|
|||||||
gem 'wisper', '2.0.0'
|
gem 'wisper', '2.0.0'
|
||||||
|
|
||||||
##--- gems for channels ---##
|
##--- gems for channels ---##
|
||||||
# TODO: bump up gem to 2.0
|
|
||||||
gem 'facebook-messenger'
|
gem 'facebook-messenger'
|
||||||
gem 'line-bot-api'
|
gem 'line-bot-api'
|
||||||
gem 'twilio-ruby', '~> 5.66'
|
gem 'twilio-ruby', '~> 5.66'
|
||||||
@ -91,9 +90,14 @@ gem 'twitty', '~> 0.1.5'
|
|||||||
# facebook client
|
# facebook client
|
||||||
gem 'koala'
|
gem 'koala'
|
||||||
# slack client
|
# slack client
|
||||||
gem 'slack-ruby-client'
|
gem 'slack-ruby-client', '~> 2.0.0'
|
||||||
# for dialogflow integrations
|
# for dialogflow integrations
|
||||||
gem 'google-cloud-dialogflow'
|
gem 'google-cloud-dialogflow-v2'
|
||||||
|
gem 'grpc'
|
||||||
|
# Translate integrations
|
||||||
|
# 'google-cloud-translate' gem depends on faraday 2.0 version
|
||||||
|
# this dependency breaks the slack-ruby-client gem
|
||||||
|
gem 'google-cloud-translate-v3'
|
||||||
|
|
||||||
##-- apm and error monitoring ---#
|
##-- apm and error monitoring ---#
|
||||||
# loaded only when environment variables are set.
|
# loaded only when environment variables are set.
|
||||||
@ -108,9 +112,9 @@ gem 'sentry-ruby', require: false
|
|||||||
gem 'sentry-sidekiq', require: false
|
gem 'sentry-sidekiq', require: false
|
||||||
|
|
||||||
##-- background job processing --##
|
##-- background job processing --##
|
||||||
gem 'sidekiq', '~> 6.4.2'
|
gem 'sidekiq'
|
||||||
# We want cron jobs
|
# We want cron jobs
|
||||||
gem 'sidekiq-cron', '~> 1.6', '>= 1.6.0'
|
gem 'sidekiq-cron'
|
||||||
|
|
||||||
##-- Push notification service --##
|
##-- Push notification service --##
|
||||||
gem 'fcm'
|
gem 'fcm'
|
||||||
@ -129,7 +133,10 @@ gem 'procore-sift'
|
|||||||
|
|
||||||
# parse email
|
# parse email
|
||||||
gem 'email_reply_trimmer'
|
gem 'email_reply_trimmer'
|
||||||
gem 'html2text'
|
|
||||||
|
# TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10.
|
||||||
|
# and this gem hasn't been updated for a while.
|
||||||
|
gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot'
|
||||||
|
|
||||||
# to calculate working hours
|
# to calculate working hours
|
||||||
gem 'working_hours'
|
gem 'working_hours'
|
||||||
@ -144,18 +151,13 @@ gem 'stripe'
|
|||||||
## to populate db with sample data
|
## to populate db with sample data
|
||||||
gem 'faker'
|
gem 'faker'
|
||||||
|
|
||||||
# Can remove this in rails 7
|
|
||||||
gem 'net-imap', require: false
|
|
||||||
gem 'net-pop', require: false
|
|
||||||
gem 'net-smtp', require: false
|
|
||||||
|
|
||||||
# Include logrange conditionally in intializer using env variable
|
# Include logrange conditionally in intializer using env variable
|
||||||
gem 'lograge', '~> 0.12.0', require: false
|
gem 'lograge', '~> 0.12.0', require: false
|
||||||
|
|
||||||
# worked with microsoft refresh token
|
# worked with microsoft refresh token
|
||||||
gem 'omniauth-oauth2'
|
gem 'omniauth-oauth2'
|
||||||
|
|
||||||
gem 'audited', '~> 5.2'
|
gem 'audited', '~> 5.3'
|
||||||
|
|
||||||
# need for google auth
|
# need for google auth
|
||||||
gem 'omniauth'
|
gem 'omniauth'
|
||||||
@ -174,6 +176,7 @@ group :development do
|
|||||||
gem 'annotate'
|
gem 'annotate'
|
||||||
gem 'bullet'
|
gem 'bullet'
|
||||||
gem 'letter_opener'
|
gem 'letter_opener'
|
||||||
|
gem 'scss_lint', require: false
|
||||||
gem 'web-console'
|
gem 'web-console'
|
||||||
|
|
||||||
# used in swagger build
|
# used in swagger build
|
||||||
@ -189,7 +192,7 @@ end
|
|||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
# Cypress in rails.
|
# Cypress in rails.
|
||||||
gem 'cypress-on-rails', '~> 1.13', '>= 1.13.1'
|
gem 'cypress-on-rails'
|
||||||
# fast cleaning of database
|
# fast cleaning of database
|
||||||
gem 'database_cleaner'
|
gem 'database_cleaner'
|
||||||
# mock http calls
|
# mock http calls
|
||||||
@ -211,7 +214,7 @@ group :development, :test do
|
|||||||
gem 'mock_redis'
|
gem 'mock_redis'
|
||||||
gem 'pry-rails'
|
gem 'pry-rails'
|
||||||
gem 'rspec_junit_formatter'
|
gem 'rspec_junit_formatter'
|
||||||
gem 'rspec-rails', '~> 5.0.3'
|
gem 'rspec-rails'
|
||||||
gem 'rubocop', require: false
|
gem 'rubocop', require: false
|
||||||
gem 'rubocop-performance', require: false
|
gem 'rubocop-performance', require: false
|
||||||
gem 'rubocop-rails', require: false
|
gem 'rubocop-rails', require: false
|
||||||
|
|||||||
663
Gemfile.lock
663
Gemfile.lock
File diff suppressed because it is too large
Load Diff
38
Makefile
Normal file
38
Makefile
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Variables
|
||||||
|
APP_NAME := chatwoot
|
||||||
|
RAILS_ENV ?= development
|
||||||
|
|
||||||
|
# Targets
|
||||||
|
setup:
|
||||||
|
gem install bundler
|
||||||
|
bundle install
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
db_create:
|
||||||
|
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
|
||||||
|
|
||||||
|
db_migrate:
|
||||||
|
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:migrate
|
||||||
|
|
||||||
|
db_seed:
|
||||||
|
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed
|
||||||
|
|
||||||
|
db:
|
||||||
|
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare
|
||||||
|
|
||||||
|
console:
|
||||||
|
RAILS_ENV=$(RAILS_ENV) bundle exec rails console
|
||||||
|
|
||||||
|
server:
|
||||||
|
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
|
||||||
|
|
||||||
|
burn:
|
||||||
|
bundle && yarn
|
||||||
|
|
||||||
|
run:
|
||||||
|
overmind start -f Procfile.dev
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
||||||
|
|
||||||
|
.PHONY: setup db_create db_migrate db_seed db console server burn docker run
|
||||||
@ -63,28 +63,42 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def conversation
|
def conversation
|
||||||
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
|
@conversation ||= Conversation.where(
|
||||||
|
"additional_attributes ->> 'type' = 'instagram_direct_message'"
|
||||||
|
).find_by(conversation_params) || build_conversation
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_content
|
def message_content
|
||||||
@messaging[:message][:text]
|
@messaging[:message][:text]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def story_reply_attributes
|
||||||
|
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
|
||||||
|
end
|
||||||
|
|
||||||
def build_message
|
def build_message
|
||||||
return if @outgoing_echo && already_sent_from_chatwoot?
|
return if @outgoing_echo && already_sent_from_chatwoot?
|
||||||
return if message_content.blank? && all_unsupported_files?
|
return if message_content.blank? && all_unsupported_files?
|
||||||
|
|
||||||
@message = conversation.messages.create!(message_params)
|
@message = conversation.messages.create!(message_params)
|
||||||
|
save_story_id
|
||||||
|
|
||||||
attachments.each do |attachment|
|
attachments.each do |attachment|
|
||||||
process_attachment(attachment)
|
process_attachment(attachment)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def save_story_id
|
||||||
|
return if story_reply_attributes.blank?
|
||||||
|
|
||||||
|
@message.save_story_info(story_reply_attributes)
|
||||||
|
end
|
||||||
|
|
||||||
def build_conversation
|
def build_conversation
|
||||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||||
Conversation.create!(conversation_params.merge(
|
Conversation.create!(conversation_params.merge(
|
||||||
contact_inbox_id: @contact_inbox.id
|
contact_inbox_id: @contact_inbox.id,
|
||||||
|
additional_attributes: { type: 'instagram_direct_message' }
|
||||||
))
|
))
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -92,10 +106,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||||||
{
|
{
|
||||||
account_id: @inbox.account_id,
|
account_id: @inbox.account_id,
|
||||||
inbox_id: @inbox.id,
|
inbox_id: @inbox.id,
|
||||||
contact_id: contact.id,
|
contact_id: contact.id
|
||||||
additional_attributes: {
|
|
||||||
type: 'instagram_direct_message'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -49,10 +49,10 @@ class Messages::MessageBuilder
|
|||||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||||
|
|
||||||
cc_emails = []
|
cc_emails = []
|
||||||
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails].present?
|
cc_emails = @params[:cc_emails].gsub(/\s+/, '').split(',') if @params[:cc_emails].present?
|
||||||
|
|
||||||
bcc_emails = []
|
bcc_emails = []
|
||||||
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails].present?
|
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
|
||||||
validate_email_addresses(all_email_addresses)
|
validate_email_addresses(all_email_addresses)
|
||||||
|
|||||||
@ -22,6 +22,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
@conversations_count = result[:count]
|
@conversations_count = result[:count]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def attachments
|
||||||
|
@attachments = @conversation.attachments
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
|
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
|
||||||
@ -64,13 +68,14 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
|
assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def toggle_priority
|
||||||
|
@conversation.toggle_priority(params[:priority])
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
def toggle_typing_status
|
def toggle_typing_status
|
||||||
case params[:typing_status]
|
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
|
||||||
when 'on'
|
typing_status_manager.toggle_typing_status
|
||||||
trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private])
|
|
||||||
when 'off'
|
|
||||||
trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private])
|
|
||||||
end
|
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -111,11 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
@conversation.update_assignee(@agent)
|
@conversation.update_assignee(@agent)
|
||||||
end
|
end
|
||||||
|
|
||||||
def trigger_typing_event(event, is_private)
|
|
||||||
user = current_user.presence || @resource
|
|
||||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversation
|
def conversation
|
||||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
|
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
|
||||||
authorize @conversation.inbox, :show?
|
authorize @conversation.inbox, :show?
|
||||||
|
|||||||
@ -114,10 +114,13 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
def inbox_attributes
|
def inbox_attributes
|
||||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||||
:lock_to_single_conversation]
|
:lock_to_single_conversation, :portal_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
def permitted_params(channel_attributes = [])
|
||||||
|
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
||||||
|
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
||||||
|
|
||||||
params.permit(
|
params.permit(
|
||||||
*inbox_attributes,
|
*inbox_attributes,
|
||||||
channel: [:type, *channel_attributes]
|
channel: [:type, *channel_attributes]
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_hook, only: [:update, :destroy]
|
before_action :fetch_hook, except: [:create]
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@ -10,6 +10,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
|||||||
@hook.update!(permitted_params.slice(:status, :settings))
|
@hook.update!(permitted_params.slice(:status, :settings))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def process_event
|
||||||
|
render json: { message: @hook.process_event(params[:event]) }
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@hook.destroy!
|
@hook.destroy!
|
||||||
head :ok
|
head :ok
|
||||||
|
|||||||
@ -30,7 +30,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@portal.update!(portal_params) if params[:portal].present?
|
@portal.update!(portal_params) if params[:portal].present?
|
||||||
# @portal.custom_domain = parsed_custom_domain
|
# @portal.custom_domain = parsed_custom_domain
|
||||||
process_attached_logo
|
process_attached_logo if params[:blob_id].present?
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error e
|
Rails.logger.error e
|
||||||
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
|
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
|
||||||
|
|||||||
@ -48,7 +48,8 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
def message_finder_params
|
def message_finder_params
|
||||||
{
|
{
|
||||||
filter_internal_messages: true,
|
filter_internal_messages: true,
|
||||||
before: permitted_params[:before]
|
before: permitted_params[:before],
|
||||||
|
after: permitted_params[:after]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -62,7 +63,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
# timestamp parameter is used in create conversation method
|
# timestamp parameter is used in create conversation method
|
||||||
params.permit(:id, :before, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_message
|
def set_message
|
||||||
|
|||||||
@ -72,14 +72,16 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
def current_summary_params
|
def current_summary_params
|
||||||
common_params.merge({
|
common_params.merge({
|
||||||
since: range[:current][:since],
|
since: range[:current][:since],
|
||||||
until: range[:current][:until]
|
until: range[:current][:until],
|
||||||
|
timezone_offset: params[:timezone_offset]
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
def previous_summary_params
|
def previous_summary_params
|
||||||
common_params.merge({
|
common_params.merge({
|
||||||
since: range[:previous][:since],
|
since: range[:previous][:since],
|
||||||
until: range[:previous][:until]
|
until: range[:previous][:until],
|
||||||
|
timezone_offset: params[:timezone_offset]
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ class ApiController < ApplicationController
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
render json: { version: Chatwoot.config[:version],
|
render json: { version: Chatwoot.config[:version],
|
||||||
timestamp: Time.now.utc.to_formatted_s(:db),
|
timestamp: Time.now.utc.to_fs(:db),
|
||||||
queue_services: redis_status,
|
queue_services: redis_status,
|
||||||
data_services: postgres_status }
|
data_services: postgres_status }
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :set_category, except: [:index]
|
before_action :set_category, except: [:index, :show]
|
||||||
before_action :set_article, only: [:show]
|
before_action :set_article, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_article
|
def set_article
|
||||||
@article = @category.articles.find(permitted_params[:id])
|
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||||
@article.increment_view_count
|
@article.increment_view_count
|
||||||
@parsed_content = render_article_content(@article.content)
|
@parsed_content = render_article_content(@article.content)
|
||||||
end
|
end
|
||||||
@ -39,7 +39,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:slug, :category_slug, :locale, :id)
|
params.permit(:slug, :category_slug, :locale, :id, :article_slug)
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_article_content(content)
|
def render_article_content(content)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
|
|
||||||
def set_locale(&)
|
def set_locale(&)
|
||||||
switch_locale_with_portal(&) if params[:locale].present?
|
switch_locale_with_portal(&) if params[:locale].present?
|
||||||
|
switch_locale_with_article(&) if params[:article_slug].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def switch_locale_with_portal(&)
|
def switch_locale_with_portal(&)
|
||||||
@ -19,4 +20,16 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
|
|
||||||
I18n.with_locale(@locale, &)
|
I18n.with_locale(@locale, &)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def switch_locale_with_article(&)
|
||||||
|
article = Article.find_by(slug: params[:article_slug])
|
||||||
|
|
||||||
|
@locale = if article.category.present?
|
||||||
|
article.category.locale
|
||||||
|
else
|
||||||
|
'en'
|
||||||
|
end
|
||||||
|
|
||||||
|
I18n.with_locale(@locale, &)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -41,7 +41,7 @@ class WidgetsController < ActionController::Base
|
|||||||
source_id: @auth_token_params[:source_id]
|
source_id: @auth_token_params[:source_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
@contact = @contact_inbox ? @contact_inbox.contact : nil
|
@contact = @contact_inbox&.contact
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_contact
|
def build_contact
|
||||||
|
|||||||
@ -1,2 +1,5 @@
|
|||||||
class AccountDrop < BaseDrop
|
class AccountDrop < BaseDrop
|
||||||
|
def name
|
||||||
|
@obj.try(:name)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -5,7 +5,8 @@ class ConversationFinder
|
|||||||
SORT_OPTIONS = {
|
SORT_OPTIONS = {
|
||||||
latest: 'latest',
|
latest: 'latest',
|
||||||
sort_on_created_at: 'sort_on_created_at',
|
sort_on_created_at: 'sort_on_created_at',
|
||||||
last_user_message_at: 'last_user_message_at'
|
last_user_message_at: 'last_user_message_at',
|
||||||
|
sort_on_priority: 'sort_on_priority'
|
||||||
}.with_indifferent_access
|
}.with_indifferent_access
|
||||||
|
|
||||||
# assumptions
|
# assumptions
|
||||||
@ -53,9 +54,10 @@ class ConversationFinder
|
|||||||
|
|
||||||
find_all_conversations
|
find_all_conversations
|
||||||
filter_by_status unless params[:q]
|
filter_by_status unless params[:q]
|
||||||
filter_by_team if @team
|
filter_by_team
|
||||||
filter_by_labels if params[:labels]
|
filter_by_labels
|
||||||
filter_by_query if params[:q]
|
filter_by_query
|
||||||
|
filter_by_source_id
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_inboxes
|
def set_inboxes
|
||||||
@ -106,6 +108,8 @@ class ConversationFinder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def filter_by_query
|
def filter_by_query
|
||||||
|
return unless params[:q]
|
||||||
|
|
||||||
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
|
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
|
||||||
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
|
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
|
||||||
.where(messages: { message_type: allowed_message_types }).includes(:messages)
|
.where(messages: { message_type: allowed_message_types }).includes(:messages)
|
||||||
@ -120,13 +124,24 @@ class ConversationFinder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def filter_by_team
|
def filter_by_team
|
||||||
|
return unless @team
|
||||||
|
|
||||||
@conversations = @conversations.where(team: @team)
|
@conversations = @conversations.where(team: @team)
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_by_labels
|
def filter_by_labels
|
||||||
|
return unless params[:labels]
|
||||||
|
|
||||||
@conversations = @conversations.tagged_with(params[:labels], any: true)
|
@conversations = @conversations.tagged_with(params[:labels], any: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def filter_by_source_id
|
||||||
|
return unless params[:source_id]
|
||||||
|
|
||||||
|
@conversations = @conversations.joins(:contact_inbox)
|
||||||
|
@conversations = @conversations.where(contact_inboxes: { source_id: params[:source_id] })
|
||||||
|
end
|
||||||
|
|
||||||
def set_count_for_all_conversations
|
def set_count_for_all_conversations
|
||||||
[
|
[
|
||||||
@conversations.assigned_to(current_user).count,
|
@conversations.assigned_to(current_user).count,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ module DateRangeHelper
|
|||||||
def range
|
def range
|
||||||
return if params[:since].blank? || params[:until].blank?
|
return if params[:since].blank? || params[:until].blank?
|
||||||
|
|
||||||
parse_date_time(params[:since])..parse_date_time(params[:until])
|
parse_date_time(params[:since])...parse_date_time(params[:until])
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_date_time(datetime)
|
def parse_date_time(datetime)
|
||||||
|
|||||||
@ -10,4 +10,26 @@ module EmailHelper
|
|||||||
def normalize_email_with_plus_addressing(email)
|
def normalize_email_with_plus_addressing(email)
|
||||||
"#{email.split('@').first.split('+').first}@#{email.split('@').last}".downcase
|
"#{email.split('@').first.split('+').first}@#{email.split('@').last}".downcase
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parse_email_variables(conversation, email)
|
||||||
|
case email
|
||||||
|
when modified_liquid_content(email)
|
||||||
|
template = Liquid::Template.parse(modified_liquid_content(email))
|
||||||
|
template.render(message_drops(conversation))
|
||||||
|
when URI::MailTo::EMAIL_REGEXP
|
||||||
|
email
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def modified_liquid_content(email)
|
||||||
|
# This regex is used to match the code blocks in the content
|
||||||
|
# We don't want to process liquid in code blocks
|
||||||
|
email.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_drops(conversation)
|
||||||
|
{
|
||||||
|
'contact' => ContactDrop.new(conversation.contact)
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class ConversationApi extends ApiClient {
|
|||||||
labels,
|
labels,
|
||||||
teamId,
|
teamId,
|
||||||
conversationType,
|
conversationType,
|
||||||
|
sortBy,
|
||||||
}) {
|
}) {
|
||||||
return axios.get(this.url, {
|
return axios.get(this.url, {
|
||||||
params: {
|
params: {
|
||||||
@ -24,6 +25,7 @@ class ConversationApi extends ApiClient {
|
|||||||
page,
|
page,
|
||||||
labels,
|
labels,
|
||||||
conversation_type: conversationType,
|
conversation_type: conversationType,
|
||||||
|
sort_by: sortBy,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -52,6 +54,12 @@ class ConversationApi extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
togglePriority({ conversationId, priority }) {
|
||||||
|
return axios.post(`${this.url}/${conversationId}/toggle_priority`, {
|
||||||
|
priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
assignAgent({ conversationId, agentId }) {
|
assignAgent({ conversationId, agentId }) {
|
||||||
return axios.post(
|
return axios.post(
|
||||||
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
|
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
|
||||||
|
|||||||
31
app/javascript/dashboard/api/integrations/openapi.js
Normal file
31
app/javascript/dashboard/api/integrations/openapi.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/* global axios */
|
||||||
|
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class OpenAIAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('integrations', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
|
||||||
|
let data = {
|
||||||
|
tone,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'reply_suggestion' || type === 'summarize') {
|
||||||
|
data = {
|
||||||
|
conversation_display_id: conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
|
||||||
|
event: {
|
||||||
|
name: type,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new OpenAIAPI();
|
||||||
@ -40,6 +40,7 @@ class ReportsAPI extends ApiClient {
|
|||||||
id,
|
id,
|
||||||
group_by: groupBy,
|
group_by: groupBy,
|
||||||
business_hours: businessHours,
|
business_hours: businessHours,
|
||||||
|
timezone_offset: getTimeOffset(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,30 @@ class SearchAPI extends ApiClient {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contacts({ q }) {
|
||||||
|
return axios.get(`${this.url}/contacts`, {
|
||||||
|
params: {
|
||||||
|
q,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
conversations({ q }) {
|
||||||
|
return axios.get(`${this.url}/conversations`, {
|
||||||
|
params: {
|
||||||
|
q,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
messages({ q }) {
|
||||||
|
return axios.get(`${this.url}/messages`, {
|
||||||
|
params: {
|
||||||
|
q,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new SearchAPI();
|
export default new SearchAPI();
|
||||||
|
|||||||
@ -42,9 +42,13 @@ describe('#Reports API', () => {
|
|||||||
'/api/v2/reports/summary',
|
'/api/v2/reports/summary',
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
|
business_hours: undefined,
|
||||||
|
group_by: undefined,
|
||||||
|
id: undefined,
|
||||||
since: 1621103400,
|
since: 1621103400,
|
||||||
until: 1621621800,
|
timezone_offset: -0,
|
||||||
type: 'account',
|
type: 'account',
|
||||||
|
until: 1621621800,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -254,7 +254,7 @@ $button-sizes: (tiny: var(--font-size-micro),
|
|||||||
default: var(--font-size-small),
|
default: var(--font-size-small),
|
||||||
large: var(--font-size-medium));
|
large: var(--font-size-medium));
|
||||||
$button-palette: $foundation-palette;
|
$button-palette: $foundation-palette;
|
||||||
$button-opacity-disabled: 0.25;
|
$button-opacity-disabled: 0.4;
|
||||||
$button-background-hover-lightness: -20%;
|
$button-background-hover-lightness: -20%;
|
||||||
$button-hollow-hover-lightness: -50%;
|
$button-hollow-hover-lightness: -50%;
|
||||||
$button-transition: background-color 0.25s ease-out,
|
$button-transition: background-color 0.25s ease-out,
|
||||||
|
|||||||
@ -85,7 +85,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings.back-button {
|
.header-section.back-button {
|
||||||
direction: initial;
|
direction: initial;
|
||||||
margin-left: var(--space-normal);
|
margin-left: var(--space-normal);
|
||||||
margin-right: var(--space-smaller);
|
margin-right: var(--space-smaller);
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
.multiselect--active {
|
.multiselect--active {
|
||||||
>.multiselect__tags {
|
>.multiselect__tags {
|
||||||
border-color: $color-woot;
|
border-color: var(--w-500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,16 +75,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.multiselect__option--selected {
|
&.multiselect__option--selected {
|
||||||
background: var(--w-400);
|
background: var(--w-75);
|
||||||
color: var(--white);
|
|
||||||
|
|
||||||
&.multiselect__option--highlight:hover {
|
&.multiselect__option--highlight:hover {
|
||||||
background: var(--w-600);
|
background: var(--w-75);
|
||||||
color: var(--white);
|
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--white);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after:hover {
|
&::after:hover {
|
||||||
@ -196,6 +193,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
max-height: 3.8rem;
|
||||||
padding: var(--space-smaller) var(--space-micro);
|
padding: var(--space-smaller) var(--space-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -95,10 +95,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 var(--space-normal);
|
padding: 0 var(--space-normal);
|
||||||
|
|
||||||
.page-title {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-box {
|
.content-box {
|
||||||
|
|||||||
@ -11,15 +11,23 @@
|
|||||||
class="chat-list__top"
|
class="chat-list__top"
|
||||||
:class="{ filter__applied: hasAppliedFiltersOrActiveFolders }"
|
:class="{ filter__applied: hasAppliedFiltersOrActiveFolders }"
|
||||||
>
|
>
|
||||||
<h1 class="page-title text-truncate" :title="pageTitle">
|
<div class="flex-center chat-list__title">
|
||||||
{{ pageTitle }}
|
<h1
|
||||||
</h1>
|
class="page-sub-title text-truncate margin-bottom-0"
|
||||||
|
:title="pageTitle"
|
||||||
<div class="filter--actions">
|
>
|
||||||
<chat-filter
|
{{ pageTitle }}
|
||||||
|
</h1>
|
||||||
|
<span
|
||||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||||
@statusFilterChange="updateStatusType"
|
class="conversation--status-pill"
|
||||||
/>
|
>
|
||||||
|
{{
|
||||||
|
this.$t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="filter--actions">
|
||||||
<div v-if="hasAppliedFilters && !hasActiveFolders">
|
<div v-if="hasAppliedFilters && !hasActiveFolders">
|
||||||
<woot-button
|
<woot-button
|
||||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
||||||
@ -48,7 +56,6 @@
|
|||||||
@click="onClickOpenDeleteFoldersModal"
|
@click="onClickOpenDeleteFoldersModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<woot-button
|
<woot-button
|
||||||
v-else
|
v-else
|
||||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||||
@ -58,6 +65,10 @@
|
|||||||
size="tiny"
|
size="tiny"
|
||||||
@click="onToggleAdvanceFiltersModal"
|
@click="onToggleAdvanceFiltersModal"
|
||||||
/>
|
/>
|
||||||
|
<conversation-basic-filter
|
||||||
|
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||||
|
@changeFilter="onBasicFilterChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -125,6 +136,7 @@
|
|||||||
@update-conversation-status="toggleConversationStatus"
|
@update-conversation-status="toggleConversationStatus"
|
||||||
@context-menu-toggle="onContextMenuToggle"
|
@context-menu-toggle="onContextMenuToggle"
|
||||||
@mark-as-unread="markAsUnread"
|
@mark-as-unread="markAsUnread"
|
||||||
|
@assign-priority="assignPriority"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="chatListLoading" class="text-center">
|
<div v-if="chatListLoading" class="text-center">
|
||||||
@ -166,8 +178,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
|
|
||||||
import ChatFilter from './widgets/conversation/ChatFilter';
|
|
||||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
|
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
|
||||||
|
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter';
|
||||||
import ChatTypeTabs from './widgets/ChatTypeTabs';
|
import ChatTypeTabs from './widgets/ChatTypeTabs';
|
||||||
import ConversationCard from './widgets/conversation/ConversationCard';
|
import ConversationCard from './widgets/conversation/ConversationCard';
|
||||||
import timeMixin from '../mixins/time';
|
import timeMixin from '../mixins/time';
|
||||||
@ -191,16 +203,17 @@ import {
|
|||||||
isOnMentionsView,
|
isOnMentionsView,
|
||||||
isOnUnattendedView,
|
isOnUnattendedView,
|
||||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||||
|
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
AddCustomViews,
|
AddCustomViews,
|
||||||
ChatTypeTabs,
|
ChatTypeTabs,
|
||||||
ConversationCard,
|
ConversationCard,
|
||||||
ChatFilter,
|
|
||||||
ConversationAdvancedFilter,
|
ConversationAdvancedFilter,
|
||||||
DeleteCustomViews,
|
DeleteCustomViews,
|
||||||
ConversationBulkActions,
|
ConversationBulkActions,
|
||||||
|
ConversationBasicFilter,
|
||||||
},
|
},
|
||||||
mixins: [
|
mixins: [
|
||||||
timeMixin,
|
timeMixin,
|
||||||
@ -243,11 +256,15 @@ export default {
|
|||||||
return {
|
return {
|
||||||
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
|
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
|
||||||
activeStatus: wootConstants.STATUS_TYPE.OPEN,
|
activeStatus: wootConstants.STATUS_TYPE.OPEN,
|
||||||
|
activeSortBy: wootConstants.SORT_BY_TYPE.LATEST,
|
||||||
showAdvancedFilters: false,
|
showAdvancedFilters: false,
|
||||||
advancedFilterTypes: advancedFilterTypes.map(filter => ({
|
advancedFilterTypes: advancedFilterTypes.map(filter => ({
|
||||||
...filter,
|
...filter,
|
||||||
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
||||||
})),
|
})),
|
||||||
|
// chatsOnView is to store the chats that are currently visible on the screen,
|
||||||
|
// which mirrors the conversationList.
|
||||||
|
chatsOnView: [],
|
||||||
foldersQuery: {},
|
foldersQuery: {},
|
||||||
showAddFoldersModal: false,
|
showAddFoldersModal: false,
|
||||||
showDeleteFoldersModal: false,
|
showDeleteFoldersModal: false,
|
||||||
@ -347,18 +364,40 @@ export default {
|
|||||||
this.currentPageFilterKey
|
this.currentPageFilterKey
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
activeAssigneeTabCount() {
|
||||||
|
const { activeAssigneeTab } = this;
|
||||||
|
const count = this.assigneeTabItems.find(
|
||||||
|
item => item.key === activeAssigneeTab
|
||||||
|
).count;
|
||||||
|
return count;
|
||||||
|
},
|
||||||
conversationFilters() {
|
conversationFilters() {
|
||||||
return {
|
return {
|
||||||
inboxId: this.conversationInbox ? this.conversationInbox : undefined,
|
inboxId: this.conversationInbox ? this.conversationInbox : undefined,
|
||||||
assigneeType: this.activeAssigneeTab,
|
assigneeType: this.activeAssigneeTab,
|
||||||
status: this.activeStatus,
|
status: this.activeStatus,
|
||||||
page: this.currentPage + 1,
|
sortBy: this.activeSortBy,
|
||||||
|
page: this.conversationListPagination,
|
||||||
labels: this.label ? [this.label] : undefined,
|
labels: this.label ? [this.label] : undefined,
|
||||||
teamId: this.teamId || undefined,
|
teamId: this.teamId || undefined,
|
||||||
conversationType: this.conversationType || undefined,
|
conversationType: this.conversationType || undefined,
|
||||||
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
|
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
conversationListPagination() {
|
||||||
|
const conversationsPerPage = 25;
|
||||||
|
const isNoFiltersOrFoldersAndChatListNotEmpty =
|
||||||
|
!this.hasAppliedFiltersOrActiveFolders && this.chatsOnView !== [];
|
||||||
|
const isUnderPerPage =
|
||||||
|
this.chatsOnView.length < conversationsPerPage &&
|
||||||
|
this.activeAssigneeTabCount < conversationsPerPage &&
|
||||||
|
this.activeAssigneeTabCount > this.chatsOnView.length;
|
||||||
|
|
||||||
|
if (isNoFiltersOrFoldersAndChatListNotEmpty && isUnderPerPage) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return this.currentPage + 1;
|
||||||
|
},
|
||||||
pageTitle() {
|
pageTitle() {
|
||||||
if (this.hasAppliedFilters) {
|
if (this.hasAppliedFilters) {
|
||||||
return this.$t('CHAT_LIST.TAB_HEADING');
|
return this.$t('CHAT_LIST.TAB_HEADING');
|
||||||
@ -400,7 +439,6 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
conversationList = [...this.chatLists];
|
conversationList = [...this.chatLists];
|
||||||
}
|
}
|
||||||
|
|
||||||
return conversationList;
|
return conversationList;
|
||||||
},
|
},
|
||||||
activeFolder() {
|
activeFolder() {
|
||||||
@ -449,9 +487,13 @@ export default {
|
|||||||
this.resetAndFetchData();
|
this.resetAndFetchData();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
chatLists() {
|
||||||
|
this.chatsOnView = this.conversationList;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$store.dispatch('setChatFilter', this.activeStatus);
|
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
|
||||||
|
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
|
||||||
this.resetAndFetchData();
|
this.resetAndFetchData();
|
||||||
|
|
||||||
bus.$on('fetch_conversation_stats', () => {
|
bus.$on('fetch_conversation_stats', () => {
|
||||||
@ -597,11 +639,13 @@ export default {
|
|||||||
this.selectedConversations = [];
|
this.selectedConversations = [];
|
||||||
this.selectedInboxes = [];
|
this.selectedInboxes = [];
|
||||||
},
|
},
|
||||||
updateStatusType(index) {
|
onBasicFilterChange(value, type) {
|
||||||
if (this.activeStatus !== index) {
|
if (type === 'status') {
|
||||||
this.activeStatus = index;
|
this.activeStatus = value;
|
||||||
this.resetAndFetchData();
|
} else {
|
||||||
|
this.activeSortBy = value;
|
||||||
}
|
}
|
||||||
|
this.resetAndFetchData();
|
||||||
},
|
},
|
||||||
openLastSavedItemInFolder() {
|
openLastSavedItemInFolder() {
|
||||||
const lastItemOfFolder = this.folders[this.folders.length - 1];
|
const lastItemOfFolder = this.folders[this.folders.length - 1];
|
||||||
@ -670,6 +714,26 @@ export default {
|
|||||||
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
|
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async assignPriority(priority, conversationId = null) {
|
||||||
|
this.$store.dispatch('setCurrentChatPriority', {
|
||||||
|
priority,
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
this.$store
|
||||||
|
.dispatch('assignPriority', { conversationId, priority })
|
||||||
|
.then(() => {
|
||||||
|
this.$track(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
|
||||||
|
newValue: priority,
|
||||||
|
from: 'Context menu',
|
||||||
|
});
|
||||||
|
this.showAlert(
|
||||||
|
this.$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
|
||||||
|
priority,
|
||||||
|
conversationId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
async markAsUnread(conversationId) {
|
async markAsUnread(conversationId) {
|
||||||
try {
|
try {
|
||||||
await this.$store.dispatch('markMessagesUnread', {
|
await this.$store.dispatch('markMessagesUnread', {
|
||||||
@ -830,11 +894,15 @@ export default {
|
|||||||
&.list--full-width {
|
&.list--full-width {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-sub-title {
|
||||||
|
font-size: var(--font-size-two);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.filter--actions {
|
.filter--actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-micro);
|
gap: var(--space-smaller);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter__applied {
|
.filter__applied {
|
||||||
@ -851,4 +919,19 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversation--status-pill {
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
color: var(--color-medium-gray);
|
||||||
|
font-size: var(--font-size-micro);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
margin: var(--space-micro) var(--space-small) 0;
|
||||||
|
padding: var(--space-smaller);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list__title {
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -5,7 +5,9 @@
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
</p>
|
</p>
|
||||||
<p class="sub-head">
|
<p class="sub-head">
|
||||||
{{ subTitle }}
|
<slot name="subTitle">
|
||||||
|
{{ subTitle }}
|
||||||
|
</slot>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="note">
|
<p v-if="note">
|
||||||
<span class="note">{{ $t('INBOX_MGMT.NOTE') }}</span>
|
<span class="note">{{ $t('INBOX_MGMT.NOTE') }}</span>
|
||||||
|
|||||||
@ -157,6 +157,7 @@ const settings = accountId => ({
|
|||||||
hasSubMenu: false,
|
hasSubMenu: false,
|
||||||
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
|
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
|
||||||
toStateName: 'auditlogs_list',
|
toStateName: 'auditlogs_list',
|
||||||
|
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
|
||||||
beta: true,
|
beta: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -6,7 +6,11 @@
|
|||||||
:disabled="isDisabled || isLoading"
|
:disabled="isDisabled || isLoading"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<spinner v-if="isLoading" size="small" />
|
<spinner
|
||||||
|
v-if="isLoading"
|
||||||
|
size="small"
|
||||||
|
:color-scheme="showDarkSpinner ? 'dark' : ''"
|
||||||
|
/>
|
||||||
<emoji-or-icon
|
<emoji-or-icon
|
||||||
v-else-if="icon || emoji"
|
v-else-if="icon || emoji"
|
||||||
class="icon"
|
class="icon"
|
||||||
@ -108,6 +112,14 @@ export default {
|
|||||||
return 16;
|
return 16;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
showDarkSpinner() {
|
||||||
|
return (
|
||||||
|
this.colorScheme === 'secondary' ||
|
||||||
|
this.variant === 'clear' ||
|
||||||
|
this.variant === 'link' ||
|
||||||
|
this.variant === 'hollow'
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleClick(evt) {
|
handleClick(evt) {
|
||||||
|
|||||||
@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="isAIIntegrationEnabled" class="position-relative">
|
||||||
|
<div v-if="!message">
|
||||||
|
<woot-button
|
||||||
|
v-if="isPrivateNote"
|
||||||
|
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.SUMMARY_TITLE')"
|
||||||
|
icon="book-pulse"
|
||||||
|
color-scheme="secondary"
|
||||||
|
variant="smooth"
|
||||||
|
size="small"
|
||||||
|
:is-loading="uiFlags.summarize"
|
||||||
|
@click="processEvent('summarize')"
|
||||||
|
/>
|
||||||
|
<woot-button
|
||||||
|
v-else
|
||||||
|
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_TITLE')"
|
||||||
|
icon="wand"
|
||||||
|
color-scheme="secondary"
|
||||||
|
variant="smooth"
|
||||||
|
size="small"
|
||||||
|
:is-loading="uiFlags.reply_suggestion"
|
||||||
|
@click="processEvent('reply_suggestion')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<woot-button
|
||||||
|
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
|
||||||
|
icon="text-grammar-wand"
|
||||||
|
color-scheme="secondary"
|
||||||
|
variant="smooth"
|
||||||
|
size="small"
|
||||||
|
@click="toggleDropdown"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="showDropdown"
|
||||||
|
v-on-clickaway="closeDropdown"
|
||||||
|
class="dropdown-pane dropdown-pane--open ai-modal"
|
||||||
|
>
|
||||||
|
<h4 class="sub-block-title margin-top-1">
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }}
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }}
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }}
|
||||||
|
</label>
|
||||||
|
<div class="tone__item">
|
||||||
|
<select v-model="activeTone" class="status--filter small">
|
||||||
|
<option v-for="tone in tones" :key="tone.key" :value="tone.key">
|
||||||
|
{{ tone.value }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer flex-container align-right">
|
||||||
|
<woot-button variant="clear" size="small" @click="closeDropdown">
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }}
|
||||||
|
</woot-button>
|
||||||
|
<woot-button
|
||||||
|
:is-loading="uiFlags.rephrase"
|
||||||
|
size="small"
|
||||||
|
@click="processEvent('rephrase')"
|
||||||
|
>
|
||||||
|
{{ buttonText }}
|
||||||
|
</woot-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { mixin as clickaway } from 'vue-clickaway';
|
||||||
|
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||||
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
|
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [alertMixin, clickaway],
|
||||||
|
props: {
|
||||||
|
conversationId: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
isPrivateNote: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
uiFlags: {
|
||||||
|
rephrase: false,
|
||||||
|
reply_suggestion: false,
|
||||||
|
summarize: false,
|
||||||
|
},
|
||||||
|
showDropdown: false,
|
||||||
|
activeTone: 'professional',
|
||||||
|
tones: [
|
||||||
|
{
|
||||||
|
key: 'professional',
|
||||||
|
value: this.$t(
|
||||||
|
'INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.PROFESSIONAL'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'friendly',
|
||||||
|
value: this.$t('INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.FRIENDLY'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({ appIntegrations: 'integrations/getAppIntegrations' }),
|
||||||
|
isAIIntegrationEnabled() {
|
||||||
|
return this.appIntegrations.find(
|
||||||
|
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||||
|
);
|
||||||
|
},
|
||||||
|
hookId() {
|
||||||
|
return this.appIntegrations.find(
|
||||||
|
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||||
|
).hooks[0].id;
|
||||||
|
},
|
||||||
|
buttonText() {
|
||||||
|
return this.uiFlags.isRephrasing
|
||||||
|
? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING')
|
||||||
|
: this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (!this.appIntegrations.length) {
|
||||||
|
this.$store.dispatch('integrations/get');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleDropdown() {
|
||||||
|
this.showDropdown = !this.showDropdown;
|
||||||
|
},
|
||||||
|
closeDropdown() {
|
||||||
|
this.showDropdown = false;
|
||||||
|
},
|
||||||
|
async recordAnalytics({ type, tone }) {
|
||||||
|
const event = OPEN_AI_EVENTS[type.toUpperCase()];
|
||||||
|
if (event) {
|
||||||
|
this.$track(event, {
|
||||||
|
type,
|
||||||
|
tone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async processEvent(type = 'rephrase') {
|
||||||
|
this.uiFlags[type] = true;
|
||||||
|
try {
|
||||||
|
const result = await OpenAPI.processEvent({
|
||||||
|
hookId: this.hookId,
|
||||||
|
type,
|
||||||
|
content: this.message,
|
||||||
|
tone: this.activeTone,
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
data: { message: generatedMessage },
|
||||||
|
} = result;
|
||||||
|
this.$emit('replace-text', generatedMessage || this.message);
|
||||||
|
this.closeDropdown();
|
||||||
|
this.recordAnalytics({ type, tone: this.activeTone });
|
||||||
|
} catch (error) {
|
||||||
|
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
|
||||||
|
} finally {
|
||||||
|
this.uiFlags[type] = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.ai-modal {
|
||||||
|
width: 400px;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: var(--space-normal);
|
||||||
|
bottom: 34px;
|
||||||
|
position: absolute;
|
||||||
|
span {
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--s-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
margin-bottom: var(--space-smaller);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--filter {
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
height: var(--space-large);
|
||||||
|
padding: 0 var(--space-medium) 0 var(--space-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
gap: var(--space-smaller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="settings back-button" @click.capture="goBack">
|
<button class="header-section back-button" @click.capture="goBack">
|
||||||
<fluent-icon icon="chevron-left" />
|
<fluent-icon icon="chevron-left" />
|
||||||
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
|
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard-app--container">
|
<div v-if="hasOpenedAtleastOnce" class="dashboard-app--container">
|
||||||
<div
|
<div
|
||||||
v-for="(configItem, index) in config"
|
v-for="(configItem, index) in config"
|
||||||
:key="index"
|
:key="index"
|
||||||
@ -35,9 +35,14 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
|
isVisible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
hasOpenedAtleastOnce: false,
|
||||||
iframeLoading: true,
|
iframeLoading: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -57,6 +62,13 @@ export default {
|
|||||||
return { id, name, email };
|
return { id, name, email };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
isVisible() {
|
||||||
|
if (this.isVisible) {
|
||||||
|
this.hasOpenedAtleastOnce = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
window.onmessage = e => {
|
window.onmessage = e => {
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
import InboxDropdownItem from './InboxDropdownItem';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Components/DropDowns/InboxDropdownItem',
|
||||||
|
component: InboxDropdownItem,
|
||||||
|
argTypes: {
|
||||||
|
name: {
|
||||||
|
defaultValue: 'My new inbox',
|
||||||
|
control: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
inboxIdentifier: {
|
||||||
|
defaultValue: 'nithin@mail.com',
|
||||||
|
control: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
channelType: {
|
||||||
|
defaultValue: 'email',
|
||||||
|
control: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Template = (args, { argTypes }) => ({
|
||||||
|
props: Object.keys(argTypes),
|
||||||
|
components: { InboxDropdownItem },
|
||||||
|
template: '<inbox-dropdown-item v-bind="$props" ></inbox-dropdown-item>',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Banner = Template.bind({});
|
||||||
|
Banner.args = {};
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div class="option-item--inbox">
|
||||||
|
<span class="badge--icon">
|
||||||
|
<fluent-icon :icon="computedInboxIcon" size="14" />
|
||||||
|
</span>
|
||||||
|
<div class="option__user-data">
|
||||||
|
<h5 class="option__title">
|
||||||
|
{{ name }}
|
||||||
|
</h5>
|
||||||
|
<p class="option__body text-truncate" :title="inboxIdentifier">
|
||||||
|
{{ inboxIdentifier || computedInboxType }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
getInboxClassByType,
|
||||||
|
getReadableInboxByType,
|
||||||
|
} from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {},
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
inboxIdentifier: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
channelType: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
computedInboxIcon() {
|
||||||
|
if (!this.channelType) return 'chat';
|
||||||
|
const classByType = getInboxClassByType(
|
||||||
|
this.channelType,
|
||||||
|
this.inboxIdentifier
|
||||||
|
);
|
||||||
|
return classByType;
|
||||||
|
},
|
||||||
|
computedInboxType() {
|
||||||
|
if (!this.channelType) return 'chat';
|
||||||
|
const classByType = getReadableInboxByType(
|
||||||
|
this.channelType,
|
||||||
|
this.inboxIdentifier
|
||||||
|
);
|
||||||
|
return classByType;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.option-item--inbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 3.8rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 var(--space-smaller);
|
||||||
|
}
|
||||||
|
.badge--icon {
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
margin-right: var(--space-smaller);
|
||||||
|
background: var(--s-25);
|
||||||
|
padding: var(--space-micro);
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--space-medium);
|
||||||
|
height: var(--space-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option__user-data {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: var(--space-smaller);
|
||||||
|
margin-right: var(--space-smaller);
|
||||||
|
}
|
||||||
|
.option__body {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--s-600);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
line-height: 1.3;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.option__title {
|
||||||
|
line-height: 1.1;
|
||||||
|
font-size: var(--font-size-mini);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -13,15 +13,19 @@ import videojs from 'video.js';
|
|||||||
import alertMixin from '../../../../shared/mixins/alertMixin';
|
import alertMixin from '../../../../shared/mixins/alertMixin';
|
||||||
|
|
||||||
import Recorder from 'opus-recorder';
|
import Recorder from 'opus-recorder';
|
||||||
|
|
||||||
|
// Workers to record Audio .ogg and .wav
|
||||||
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
|
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
|
||||||
import waveWorker from 'opus-recorder/dist/waveWorker.min';
|
import waveWorker from 'opus-recorder/dist/waveWorker.min';
|
||||||
|
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
|
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
|
||||||
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
|
|
||||||
|
|
||||||
|
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
|
||||||
import 'videojs-record/dist/videojs.record.js';
|
import 'videojs-record/dist/videojs.record.js';
|
||||||
import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
|
|
||||||
|
import OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
|
||||||
|
|
||||||
import { format, addSeconds } from 'date-fns';
|
import { format, addSeconds } from 'date-fns';
|
||||||
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
||||||
|
|
||||||
@ -33,7 +37,7 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
audioRecordFormat: {
|
audioRecordFormat: {
|
||||||
type: String,
|
type: String,
|
||||||
default: AUDIO_FORMATS.WEBM,
|
default: AUDIO_FORMATS.WAV,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@ -79,24 +83,18 @@ export default {
|
|||||||
maxLength: 900,
|
maxLength: 900,
|
||||||
timeSlice: 1000,
|
timeSlice: 1000,
|
||||||
maxFileSize: 15 * 1024 * 1024,
|
maxFileSize: 15 * 1024 * 1024,
|
||||||
...(this.audioRecordFormat === AUDIO_FORMATS.WEBM && {
|
displayMilliseconds: false,
|
||||||
monitorGain: 0,
|
audioChannels: 1,
|
||||||
recordingGain: 1,
|
audioSampleRate: 48000,
|
||||||
numberOfChannels: 1,
|
audioBitRate: 128,
|
||||||
encoderSampleRate: 16000,
|
audioEngine: 'opus-recorder',
|
||||||
originalSampleRateOverride: 16000,
|
...(this.audioRecordFormat === AUDIO_FORMATS.WAV && {
|
||||||
streamPages: true,
|
audioMimeType: 'audio/wav',
|
||||||
maxFramesPerPage: 1,
|
audioWorkerURL: waveWorker,
|
||||||
encoderFrameSize: 1,
|
|
||||||
encoderPath: waveWorker,
|
|
||||||
}),
|
}),
|
||||||
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
|
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
|
||||||
displayMilliseconds: false,
|
audioMimeType: 'audio/ogg',
|
||||||
audioEngine: 'opus-recorder',
|
|
||||||
audioWorkerURL: encoderWorker,
|
audioWorkerURL: encoderWorker,
|
||||||
audioChannels: 1,
|
|
||||||
audioSampleRate: 48000,
|
|
||||||
audioBitRate: 128,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -134,6 +132,11 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
deviceReady() {
|
deviceReady() {
|
||||||
|
if (this.player.record().engine instanceof OpusRecorderEngine) {
|
||||||
|
if (this.audioRecordFormat === AUDIO_FORMATS.WAV) {
|
||||||
|
this.player.record().engine.audioType = 'audio/wav';
|
||||||
|
}
|
||||||
|
}
|
||||||
this.player.record().start();
|
this.player.record().start();
|
||||||
},
|
},
|
||||||
startRecord() {
|
startRecord() {
|
||||||
|
|||||||
@ -49,7 +49,7 @@ import {
|
|||||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||||
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
|
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
const createState = (content, placeholder, plugins = []) => {
|
const createState = (content, placeholder, plugins = []) => {
|
||||||
|
|||||||
@ -38,12 +38,12 @@
|
|||||||
</file-upload>
|
</file-upload>
|
||||||
<woot-button
|
<woot-button
|
||||||
v-if="showAudioRecorderButton"
|
v-if="showAudioRecorderButton"
|
||||||
|
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||||
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
|
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
|
||||||
emoji="🎤"
|
emoji="🎤"
|
||||||
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
|
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
|
||||||
variant="smooth"
|
variant="smooth"
|
||||||
size="small"
|
size="small"
|
||||||
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
|
||||||
@click="toggleAudioRecorder"
|
@click="toggleAudioRecorder"
|
||||||
/>
|
/>
|
||||||
<woot-button
|
<woot-button
|
||||||
@ -91,6 +91,12 @@
|
|||||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||||
:conversation-id="conversationId"
|
:conversation-id="conversationId"
|
||||||
/>
|
/>
|
||||||
|
<AIAssistanceButton
|
||||||
|
:conversation-id="conversationId"
|
||||||
|
:is-private-note="isOnPrivateNote"
|
||||||
|
:message="message"
|
||||||
|
@replace-text="replaceText"
|
||||||
|
/>
|
||||||
<transition name="modal-fade">
|
<transition name="modal-fade">
|
||||||
<div
|
<div
|
||||||
v-show="$refs.upload && $refs.upload.dropActive"
|
v-show="$refs.upload && $refs.upload.dropActive"
|
||||||
@ -129,12 +135,13 @@ import {
|
|||||||
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
||||||
} from 'shared/constants/messages';
|
} from 'shared/constants/messages';
|
||||||
import VideoCallButton from '../VideoCallButton';
|
import VideoCallButton from '../VideoCallButton';
|
||||||
|
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||||
import { REPLY_EDITOR_MODES } from './constants';
|
import { REPLY_EDITOR_MODES } from './constants';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ReplyBottomPanel',
|
name: 'ReplyBottomPanel',
|
||||||
components: { FileUpload, VideoCallButton },
|
components: { FileUpload, VideoCallButton, AIAssistanceButton },
|
||||||
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin],
|
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin],
|
||||||
props: {
|
props: {
|
||||||
mode: {
|
mode: {
|
||||||
@ -217,6 +224,10 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
@ -303,6 +314,9 @@ export default {
|
|||||||
send_with_signature: !this.sendWithSignature,
|
send_with_signature: !this.sendWithSignature,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
replaceText(text) {
|
||||||
|
this.$emit('replace-text', text);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -38,8 +38,8 @@ export default {
|
|||||||
this.onTabChange();
|
this.onTabChange();
|
||||||
},
|
},
|
||||||
onTabChange() {
|
onTabChange() {
|
||||||
this.$store.dispatch('setChatFilter', this.activeStatus);
|
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
|
||||||
this.$emit('statusFilterChange', this.activeStatus);
|
this.$emit('onChangeFilter', this.activeStatus);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="position-relative">
|
||||||
|
<woot-button
|
||||||
|
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
|
||||||
|
variant="smooth"
|
||||||
|
size="tiny"
|
||||||
|
color-scheme="secondary"
|
||||||
|
class="selector-button"
|
||||||
|
icon="sort-icon"
|
||||||
|
@click="toggleDropdown"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="showActionsDropdown"
|
||||||
|
v-on-clickaway="closeDropdown"
|
||||||
|
class="dropdown-pane dropdown-pane--open basic-filter"
|
||||||
|
>
|
||||||
|
<div class="filter__item">
|
||||||
|
<span>{{ this.$t('CHAT_LIST.CHAT_SORT.STATUS') }}</span>
|
||||||
|
<filter-item
|
||||||
|
type="status"
|
||||||
|
:selected-value="chatStatus"
|
||||||
|
:items="chatStatusItems"
|
||||||
|
path-prefix="CHAT_LIST.CHAT_STATUS_FILTER_ITEMS"
|
||||||
|
@onChangeFilter="onChangeFilter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="filter__item">
|
||||||
|
<span>{{ this.$t('CHAT_LIST.CHAT_SORT.ORDER_BY') }}</span>
|
||||||
|
<filter-item
|
||||||
|
type="sort"
|
||||||
|
:selected-value="chatSortFilter"
|
||||||
|
:items="chatSortItems"
|
||||||
|
path-prefix="CHAT_LIST.CHAT_SORT_FILTER_ITEMS"
|
||||||
|
@onChangeFilter="onChangeFilter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { mixin as clickaway } from 'vue-clickaway';
|
||||||
|
import FilterItem from './FilterItem';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
FilterItem,
|
||||||
|
},
|
||||||
|
mixins: [clickaway],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showActionsDropdown: false,
|
||||||
|
chatStatusItems: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS'),
|
||||||
|
chatSortItems: this.$t('CHAT_LIST.CHAT_SORT_FILTER_ITEMS'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
chatStatusFilter: 'getChatStatusFilter',
|
||||||
|
chatSortFilter: 'getChatSortFilter',
|
||||||
|
}),
|
||||||
|
chatStatus() {
|
||||||
|
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
|
||||||
|
},
|
||||||
|
sortFilter() {
|
||||||
|
return this.chatSortFilter || wootConstants.SORT_BY_TYPE.LATEST;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onTabChange(value) {
|
||||||
|
this.$emit('changeFilter', value);
|
||||||
|
this.closeDropdown();
|
||||||
|
},
|
||||||
|
toggleDropdown() {
|
||||||
|
this.showActionsDropdown = !this.showActionsDropdown;
|
||||||
|
},
|
||||||
|
closeDropdown() {
|
||||||
|
this.showActionsDropdown = false;
|
||||||
|
},
|
||||||
|
onChangeFilter(type, value) {
|
||||||
|
this.$emit('changeFilter', type, value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.basic-filter {
|
||||||
|
margin-top: var(--space-smaller);
|
||||||
|
padding: var(--space-normal);
|
||||||
|
right: 0;
|
||||||
|
width: 21rem;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter__item {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-top: var(--space-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: var(--font-size-mini);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-right: var(--space-smaller);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
margin-left: var(--space-smaller);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -23,7 +23,7 @@
|
|||||||
:show-badge="false"
|
:show-badge="false"
|
||||||
/>
|
/>
|
||||||
</woot-tabs>
|
</woot-tabs>
|
||||||
<div v-if="!activeIndex" class="messages-and-sidebar">
|
<div v-show="!activeIndex" class="messages-and-sidebar">
|
||||||
<messages-view
|
<messages-view
|
||||||
v-if="currentChat.id"
|
v-if="currentChat.id"
|
||||||
:inbox-id="inboxId"
|
:inbox-id="inboxId"
|
||||||
@ -41,9 +41,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dashboard-app-frame
|
<dashboard-app-frame
|
||||||
v-else
|
v-for="(dashboardApp, index) in dashboardApps"
|
||||||
:key="currentChat.id + '-' + activeIndex"
|
v-show="activeIndex - 1 === index"
|
||||||
:config="dashboardApps[activeIndex - 1].content"
|
:key="currentChat.id + '-' + dashboardApp.id"
|
||||||
|
:is-visible="activeIndex - 1 === index"
|
||||||
|
:config="dashboardApps[index].content"
|
||||||
:current-chat="currentChat"
|
:current-chat="currentChat"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -112,6 +114,7 @@ export default {
|
|||||||
},
|
},
|
||||||
'currentChat.id'() {
|
'currentChat.id'() {
|
||||||
this.fetchLabels();
|
this.fetchLabels();
|
||||||
|
this.activeIndex = 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@ -33,13 +33,16 @@
|
|||||||
<div class="conversation--details columns">
|
<div class="conversation--details columns">
|
||||||
<div class="conversation--metadata">
|
<div class="conversation--metadata">
|
||||||
<inbox-name v-if="showInboxName" :inbox="inbox" />
|
<inbox-name v-if="showInboxName" :inbox="inbox" />
|
||||||
<span
|
<div class="conversation-metadata-attributes">
|
||||||
v-if="showAssignee && assignee.name"
|
<span
|
||||||
class="label assignee-label text-truncate"
|
v-if="showAssignee && assignee.name"
|
||||||
>
|
class="label assignee-label text-truncate"
|
||||||
<fluent-icon icon="person" size="12" />
|
>
|
||||||
{{ assignee.name }}
|
<fluent-icon icon="person" size="12" />
|
||||||
</span>
|
{{ assignee.name }}
|
||||||
|
</span>
|
||||||
|
<priority-mark :priority="chat.priority" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="conversation--user">
|
<h4 class="conversation--user">
|
||||||
{{ currentContact.name }}
|
{{ currentContact.name }}
|
||||||
@ -106,12 +109,14 @@
|
|||||||
<conversation-context-menu
|
<conversation-context-menu
|
||||||
:status="chat.status"
|
:status="chat.status"
|
||||||
:inbox-id="inbox.id"
|
:inbox-id="inbox.id"
|
||||||
|
:priority="chat.priority"
|
||||||
:has-unread-messages="hasUnread"
|
:has-unread-messages="hasUnread"
|
||||||
@update-conversation="onUpdateConversation"
|
@update-conversation="onUpdateConversation"
|
||||||
@assign-agent="onAssignAgent"
|
@assign-agent="onAssignAgent"
|
||||||
@assign-label="onAssignLabel"
|
@assign-label="onAssignLabel"
|
||||||
@assign-team="onAssignTeam"
|
@assign-team="onAssignTeam"
|
||||||
@mark-as-unread="markAsUnread"
|
@mark-as-unread="markAsUnread"
|
||||||
|
@assign-priority="assignPriority"
|
||||||
/>
|
/>
|
||||||
</woot-context-menu>
|
</woot-context-menu>
|
||||||
</div>
|
</div>
|
||||||
@ -131,6 +136,7 @@ import ConversationContextMenu from './contextMenu/Index.vue';
|
|||||||
import alertMixin from 'shared/mixins/alertMixin';
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
import TimeAgo from 'dashboard/components/ui/TimeAgo';
|
import TimeAgo from 'dashboard/components/ui/TimeAgo';
|
||||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||||
|
import PriorityMark from './PriorityMark.vue';
|
||||||
const ATTACHMENT_ICONS = {
|
const ATTACHMENT_ICONS = {
|
||||||
image: 'image',
|
image: 'image',
|
||||||
audio: 'headphones-sound-wave',
|
audio: 'headphones-sound-wave',
|
||||||
@ -147,6 +153,7 @@ export default {
|
|||||||
Thumbnail,
|
Thumbnail,
|
||||||
ConversationContextMenu,
|
ConversationContextMenu,
|
||||||
TimeAgo,
|
TimeAgo,
|
||||||
|
PriorityMark,
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [
|
mixins: [
|
||||||
@ -370,6 +377,10 @@ export default {
|
|||||||
this.$emit('mark-as-unread', this.chat.id);
|
this.$emit('mark-as-unread', this.chat.id);
|
||||||
this.closeContextMenu();
|
this.closeContextMenu();
|
||||||
},
|
},
|
||||||
|
async assignPriority(priority) {
|
||||||
|
this.$emit('assign-priority', priority, this.chat.id);
|
||||||
|
this.closeContextMenu();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@ -429,9 +440,14 @@ export default {
|
|||||||
padding: var(--space-micro) 0 var(--space-micro) 0;
|
padding: var(--space-micro) 0 var(--space-micro) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversation-metadata-attributes {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-small);
|
||||||
|
margin-left: var(--space-small);
|
||||||
|
}
|
||||||
|
|
||||||
.assignee-label {
|
.assignee-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin-left: var(--space-small);
|
|
||||||
max-width: 50%;
|
max-width: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<select v-model="activeValue" class="status--filter" @change="onTabChange()">
|
||||||
|
<option v-for="(value, status) in items" :key="status" :value="status">
|
||||||
|
{{ $t(`${pathPrefix}.${status}.TEXT`) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
selectedValue: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
pathPrefix: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
activeValue: this.selectedValue,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onTabChange() {
|
||||||
|
if (this.type === 'status') {
|
||||||
|
this.$store.dispatch('setChatStatusFilter', this.activeValue);
|
||||||
|
} else {
|
||||||
|
this.$store.dispatch('setChatSortFilter', this.activeValue);
|
||||||
|
}
|
||||||
|
this.$emit('onChangeFilter', this.activeValue, this.type);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.status--filter {
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-size: var(--font-size-mini);
|
||||||
|
height: var(--space-medium);
|
||||||
|
margin: 0 var(--space-smaller);
|
||||||
|
padding: 0 var(--space-medium) 0 var(--space-small);
|
||||||
|
width: 126px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -22,6 +22,14 @@
|
|||||||
:bcc="emailHeadAttributes.bcc"
|
:bcc="emailHeadAttributes.bcc"
|
||||||
:is-incoming="isIncoming"
|
:is-incoming="isIncoming"
|
||||||
/>
|
/>
|
||||||
|
<blockquote v-if="storyReply" class="story-reply-quote">
|
||||||
|
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
|
||||||
|
<bubble-image
|
||||||
|
v-if="!hasStoryError"
|
||||||
|
:url="storyUrl"
|
||||||
|
@error="onStoryLoadError"
|
||||||
|
/>
|
||||||
|
</blockquote>
|
||||||
<bubble-text
|
<bubble-text
|
||||||
v-if="data.content"
|
v-if="data.content"
|
||||||
:message="message"
|
:message="message"
|
||||||
@ -51,7 +59,7 @@
|
|||||||
controls
|
controls
|
||||||
class="skip-context-menu"
|
class="skip-context-menu"
|
||||||
>
|
>
|
||||||
<source :src="attachment.data_url" />
|
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
|
||||||
</audio>
|
</audio>
|
||||||
<bubble-video
|
<bubble-video
|
||||||
v-else-if="attachment.file_type === 'video'"
|
v-else-if="attachment.file_type === 'video'"
|
||||||
@ -79,7 +87,7 @@
|
|||||||
:sender="data.sender"
|
:sender="data.sender"
|
||||||
:story-sender="storySender"
|
:story-sender="storySender"
|
||||||
:external-error="externalError"
|
:external-error="externalError"
|
||||||
:story-id="storyId"
|
:story-id="`${storyId}`"
|
||||||
:is-a-tweet="isATweet"
|
:is-a-tweet="isATweet"
|
||||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||||
:has-instagram-story="hasInstagramStory"
|
:has-instagram-story="hasInstagramStory"
|
||||||
@ -192,6 +200,7 @@ export default {
|
|||||||
hasImageError: false,
|
hasImageError: false,
|
||||||
contextMenuPosition: {},
|
contextMenuPosition: {},
|
||||||
showBackgroundHighlight: false,
|
showBackgroundHighlight: false,
|
||||||
|
hasStoryError: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -277,6 +286,12 @@ export default {
|
|||||||
storyId() {
|
storyId() {
|
||||||
return this.contentAttributes.story_id || null;
|
return this.contentAttributes.story_id || null;
|
||||||
},
|
},
|
||||||
|
storyUrl() {
|
||||||
|
return this.contentAttributes.story_url || null;
|
||||||
|
},
|
||||||
|
storyReply() {
|
||||||
|
return this.storyUrl && this.hasInstagramStory;
|
||||||
|
},
|
||||||
contentType() {
|
contentType() {
|
||||||
const {
|
const {
|
||||||
data: { content_type: contentType },
|
data: { content_type: contentType },
|
||||||
@ -414,10 +429,12 @@ export default {
|
|||||||
watch: {
|
watch: {
|
||||||
data() {
|
data() {
|
||||||
this.hasImageError = false;
|
this.hasImageError = false;
|
||||||
|
this.hasStoryError = false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.hasImageError = false;
|
this.hasImageError = false;
|
||||||
|
this.hasStoryError = false;
|
||||||
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
|
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
|
||||||
this.setupHighlightTimer();
|
this.setupHighlightTimer();
|
||||||
},
|
},
|
||||||
@ -432,6 +449,9 @@ export default {
|
|||||||
const { file_type: fileType } = attachments[0];
|
const { file_type: fileType } = attachments[0];
|
||||||
return fileType === type && !this.hasImageError;
|
return fileType === type && !this.hasImageError;
|
||||||
}
|
}
|
||||||
|
if (this.storyReply) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
handleContextMenuClick() {
|
handleContextMenuClick() {
|
||||||
@ -443,6 +463,9 @@ export default {
|
|||||||
onImageLoadError() {
|
onImageLoadError() {
|
||||||
this.hasImageError = true;
|
this.hasImageError = true;
|
||||||
},
|
},
|
||||||
|
onStoryLoadError() {
|
||||||
|
this.hasStoryError = true;
|
||||||
|
},
|
||||||
openContextMenu(e) {
|
openContextMenu(e) {
|
||||||
const shouldSkipContextMenu =
|
const shouldSkipContextMenu =
|
||||||
e.target?.classList.contains('skip-context-menu') ||
|
e.target?.classList.contains('skip-context-menu') ||
|
||||||
@ -672,7 +695,6 @@ li.right {
|
|||||||
blockquote {
|
blockquote {
|
||||||
border-left: var(--space-micro) solid var(--s-75);
|
border-left: var(--space-micro) solid var(--s-75);
|
||||||
color: var(--s-800);
|
color: var(--s-800);
|
||||||
padding: var(--space-smaller) var(--space-small);
|
|
||||||
margin: var(--space-smaller) 0;
|
margin: var(--space-smaller) 0;
|
||||||
padding: var(--space-small) var(--space-small) 0 var(--space-normal);
|
padding: var(--space-small) var(--space-small) 0 var(--space-normal);
|
||||||
}
|
}
|
||||||
@ -704,4 +726,11 @@ li.right {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.story-reply-quote {
|
||||||
|
border-left: var(--space-micro) solid var(--s-75);
|
||||||
|
color: var(--s-600);
|
||||||
|
margin: var(--space-small) var(--space-normal) 0;
|
||||||
|
padding: var(--space-small) var(--space-small) 0 var(--space-small);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -93,7 +93,9 @@ import { mapGetters } from 'vuex';
|
|||||||
|
|
||||||
import ReplyBox from './ReplyBox';
|
import ReplyBox from './ReplyBox';
|
||||||
import Message from './Message';
|
import Message from './Message';
|
||||||
import conversationMixin from '../../../mixins/conversations';
|
import conversationMixin, {
|
||||||
|
filterDuplicateSourceMessages,
|
||||||
|
} from '../../../mixins/conversations';
|
||||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||||
import { getTypingUsersText } from '../../../helper/commons';
|
import { getTypingUsersText } from '../../../helper/commons';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
@ -171,24 +173,28 @@ export default {
|
|||||||
|
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
|
|
||||||
getMessages() {
|
getMessages() {
|
||||||
const [chat] = this.allConversations.filter(
|
const messages = this.currentChat.messages || [];
|
||||||
c => c.id === this.currentChat.id
|
if (this.isAWhatsAppChannel) {
|
||||||
);
|
return filterDuplicateSourceMessages(messages);
|
||||||
return chat;
|
}
|
||||||
|
return messages;
|
||||||
},
|
},
|
||||||
getReadMessages() {
|
getReadMessages() {
|
||||||
const chat = this.getMessages;
|
return this.readMessages(
|
||||||
return chat === undefined ? null : this.readMessages(chat);
|
this.getMessages,
|
||||||
|
this.currentChat.agent_last_seen_at
|
||||||
|
);
|
||||||
},
|
},
|
||||||
getUnReadMessages() {
|
getUnReadMessages() {
|
||||||
const chat = this.getMessages;
|
return this.unReadMessages(
|
||||||
return chat === undefined ? null : this.unReadMessages(chat);
|
this.getMessages,
|
||||||
|
this.currentChat.agent_last_seen_at
|
||||||
|
);
|
||||||
},
|
},
|
||||||
shouldShowSpinner() {
|
shouldShowSpinner() {
|
||||||
return (
|
return (
|
||||||
(this.getMessages && this.getMessages.dataFetched === undefined) ||
|
(this.currentChat && this.currentChat.dataFetched === undefined) ||
|
||||||
(!this.listLoadingStatus && this.isLoadingPrevious)
|
(!this.listLoadingStatus && this.isLoadingPrevious)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -208,7 +214,7 @@ export default {
|
|||||||
|
|
||||||
selectedTweet() {
|
selectedTweet() {
|
||||||
if (this.selectedTweetId) {
|
if (this.selectedTweetId) {
|
||||||
const { messages = [] } = this.getMessages;
|
const { messages = [] } = this.currentChat;
|
||||||
const [selectedMessage] = messages.filter(
|
const [selectedMessage] = messages.filter(
|
||||||
message => message.id === this.selectedTweetId
|
message => message.id === this.selectedTweetId
|
||||||
);
|
);
|
||||||
@ -360,7 +366,7 @@ export default {
|
|||||||
async fetchPreviousMessages(scrollTop = 0) {
|
async fetchPreviousMessages(scrollTop = 0) {
|
||||||
this.setScrollParams();
|
this.setScrollParams();
|
||||||
const shouldLoadMoreMessages =
|
const shouldLoadMoreMessages =
|
||||||
this.getMessages.dataFetched === true &&
|
this.currentChat.dataFetched === true &&
|
||||||
!this.listLoadingStatus &&
|
!this.listLoadingStatus &&
|
||||||
!this.isLoadingPrevious;
|
!this.isLoadingPrevious;
|
||||||
|
|
||||||
@ -373,7 +379,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
await this.$store.dispatch('fetchPreviousMessages', {
|
await this.$store.dispatch('fetchPreviousMessages', {
|
||||||
conversationId: this.currentChat.id,
|
conversationId: this.currentChat.id,
|
||||||
before: this.getMessages.messages[0].id,
|
before: this.currentChat.messages[0].id,
|
||||||
});
|
});
|
||||||
const heightDifference =
|
const heightDifference =
|
||||||
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
|
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-if="priority"
|
||||||
|
v-tooltip="{
|
||||||
|
content: tooltipText,
|
||||||
|
delay: { show: 1500, hide: 0 },
|
||||||
|
hideOnClick: true,
|
||||||
|
}"
|
||||||
|
class="conversation-priority-mark"
|
||||||
|
:class="{ urgent: priority === CONVERSATION_PRIORITY.URGENT }"
|
||||||
|
>
|
||||||
|
<fluent-icon
|
||||||
|
:icon="`priority-${priority.toLowerCase()}`"
|
||||||
|
size="14"
|
||||||
|
view-box="0 0 14 14"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PriorityMark',
|
||||||
|
props: {
|
||||||
|
priority: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
validate: value =>
|
||||||
|
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
CONVERSATION_PRIORITY,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
tooltipText() {
|
||||||
|
return this.$t(
|
||||||
|
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.conversation-priority-mark {
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--s-50);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
color: var(--s-600);
|
||||||
|
display: inline-flex;
|
||||||
|
width: var(--space-snug);
|
||||||
|
height: var(--space-snug);
|
||||||
|
|
||||||
|
&.urgent {
|
||||||
|
background: var(--r-50);
|
||||||
|
color: var(--r-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -120,8 +120,10 @@
|
|||||||
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
||||||
:toggle-audio-recorder="toggleAudioRecorder"
|
:toggle-audio-recorder="toggleAudioRecorder"
|
||||||
:toggle-emoji-picker="toggleEmojiPicker"
|
:toggle-emoji-picker="toggleEmojiPicker"
|
||||||
|
:message="message"
|
||||||
@selectWhatsappTemplate="openWhatsappTemplateModal"
|
@selectWhatsappTemplate="openWhatsappTemplateModal"
|
||||||
@toggle-editor="toggleRichContentEditor"
|
@toggle-editor="toggleRichContentEditor"
|
||||||
|
@replace-text="replaceText"
|
||||||
/>
|
/>
|
||||||
<whatsapp-templates
|
<whatsapp-templates
|
||||||
:inbox-id="inbox.id"
|
:inbox-id="inbox.id"
|
||||||
@ -162,11 +164,11 @@ import {
|
|||||||
AUDIO_FORMATS,
|
AUDIO_FORMATS,
|
||||||
} from 'shared/constants/messages';
|
} from 'shared/constants/messages';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
|
|
||||||
import {
|
import {
|
||||||
getMessageVariables,
|
getMessageVariables,
|
||||||
getUndefinedVariablesInMessage,
|
getUndefinedVariablesInMessage,
|
||||||
} from 'dashboard/helper/messageHelper';
|
replaceVariablesInMessage,
|
||||||
|
} from '@chatwoot/utils';
|
||||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||||
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
|
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
|
||||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||||
@ -499,10 +501,10 @@ export default {
|
|||||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||||
},
|
},
|
||||||
audioRecordFormat() {
|
audioRecordFormat() {
|
||||||
if (this.isAWebWidgetInbox) {
|
if (this.isAWhatsAppChannel) {
|
||||||
return AUDIO_FORMATS.WEBM;
|
return AUDIO_FORMATS.OGG;
|
||||||
}
|
}
|
||||||
return AUDIO_FORMATS.OGG;
|
return AUDIO_FORMATS.WAV;
|
||||||
},
|
},
|
||||||
messageVariables() {
|
messageVariables() {
|
||||||
const variables = getMessageVariables({
|
const variables = getMessageVariables({
|
||||||
|
|||||||
@ -195,7 +195,7 @@ export default {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const { storySender, storyId } = this;
|
const { storySender, storyId } = this;
|
||||||
return `https://www.instagram.com/stories/${storySender}/${storyId}`;
|
return `https://www.instagram.com/stories/direct/${storySender}_${storyId}`;
|
||||||
},
|
},
|
||||||
showStatusIndicators() {
|
showStatusIndicators() {
|
||||||
if ((this.isOutgoing || this.isTemplate) && !this.isPrivate) {
|
if ((this.isOutgoing || this.isTemplate) && !this.isPrivate) {
|
||||||
|
|||||||
@ -23,6 +23,14 @@
|
|||||||
@click="snoozeConversation(option.snoozedUntil)"
|
@click="snoozeConversation(option.snoozedUntil)"
|
||||||
/>
|
/>
|
||||||
</menu-item-with-submenu>
|
</menu-item-with-submenu>
|
||||||
|
<menu-item-with-submenu :option="priorityConfig">
|
||||||
|
<menu-item
|
||||||
|
v-for="(option, i) in priorityConfig.options"
|
||||||
|
:key="i"
|
||||||
|
:option="option"
|
||||||
|
@click="assignPriority(option.key)"
|
||||||
|
/>
|
||||||
|
</menu-item-with-submenu>
|
||||||
<menu-item-with-submenu
|
<menu-item-with-submenu
|
||||||
:option="labelMenuConfig"
|
:option="labelMenuConfig"
|
||||||
:sub-menu-available="!!labels.length"
|
:sub-menu-available="!!labels.length"
|
||||||
@ -93,6 +101,10 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
priority: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -140,6 +152,33 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
priorityConfig: {
|
||||||
|
key: 'priority',
|
||||||
|
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
|
||||||
|
icon: 'warning',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
|
||||||
|
key: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
|
||||||
|
key: 'urgent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
|
||||||
|
key: 'high',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
|
||||||
|
key: 'medium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
|
||||||
|
key: 'low',
|
||||||
|
},
|
||||||
|
].filter(item => item.key !== this.priority),
|
||||||
|
},
|
||||||
labelMenuConfig: {
|
labelMenuConfig: {
|
||||||
key: 'label',
|
key: 'label',
|
||||||
icon: 'tag',
|
icon: 'tag',
|
||||||
@ -193,6 +232,9 @@ export default {
|
|||||||
this.snoozeTimes[snoozedUntil] || null
|
this.snoozeTimes[snoozedUntil] || null
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
assignPriority(priority) {
|
||||||
|
this.$emit('assign-priority', priority);
|
||||||
|
},
|
||||||
show(key) {
|
show(key) {
|
||||||
// If the conversation status is same as the action, then don't display the option
|
// If the conversation status is same as the action, then don't display the option
|
||||||
// i.e.: Don't show an option to resolve if the conversation is already resolved.
|
// i.e.: Don't show an option to resolve if the conversation is already resolved.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
|
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
|
||||||
<div class="triangle" :style="cssVars">
|
<div class="triangle">
|
||||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||||
<path
|
<path
|
||||||
d="M20 12l-8-8-12 12"
|
d="M20 12l-8-8-12 12"
|
||||||
@ -104,14 +104,13 @@ import { mapGetters } from 'vuex';
|
|||||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||||
import Spinner from 'shared/components/Spinner';
|
import Spinner from 'shared/components/Spinner';
|
||||||
import { mixin as clickaway } from 'vue-clickaway';
|
import { mixin as clickaway } from 'vue-clickaway';
|
||||||
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Thumbnail,
|
Thumbnail,
|
||||||
Spinner,
|
Spinner,
|
||||||
},
|
},
|
||||||
mixins: [clickaway, bulkActionsMixin],
|
mixins: [clickaway],
|
||||||
props: {
|
props: {
|
||||||
selectedInboxes: {
|
selectedInboxes: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@ -240,7 +239,7 @@ export default {
|
|||||||
display: block;
|
display: block;
|
||||||
z-index: var(--z-index-one);
|
z-index: var(--z-index-one);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(var(--space-slab) * -1);
|
top: var(--space-minus-slab);
|
||||||
right: var(--triangle-position);
|
right: var(--triangle-position);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
<transition name="popover-animation">
|
<transition name="popover-animation">
|
||||||
<label-actions
|
<label-actions
|
||||||
v-if="showLabelActions"
|
v-if="showLabelActions"
|
||||||
triangle-position="8.5"
|
class="label-actions-box"
|
||||||
@assign="assignLabels"
|
@assign="assignLabels"
|
||||||
@close="showLabelActions = false"
|
@close="showLabelActions = false"
|
||||||
/>
|
/>
|
||||||
@ -66,12 +66,12 @@
|
|||||||
<transition name="popover-animation">
|
<transition name="popover-animation">
|
||||||
<update-actions
|
<update-actions
|
||||||
v-if="showUpdateActions"
|
v-if="showUpdateActions"
|
||||||
|
class="update-actions-box"
|
||||||
:selected-inboxes="selectedInboxes"
|
:selected-inboxes="selectedInboxes"
|
||||||
:conversation-count="conversations.length"
|
:conversation-count="conversations.length"
|
||||||
:show-resolve="!showResolvedAction"
|
:show-resolve="!showResolvedAction"
|
||||||
:show-reopen="!showOpenAction"
|
:show-reopen="!showOpenAction"
|
||||||
:show-snooze="!showSnoozedAction"
|
:show-snooze="!showSnoozedAction"
|
||||||
triangle-position="5.6"
|
|
||||||
@update="updateConversations"
|
@update="updateConversations"
|
||||||
@close="showUpdateActions = false"
|
@close="showUpdateActions = false"
|
||||||
/>
|
/>
|
||||||
@ -79,9 +79,9 @@
|
|||||||
<transition name="popover-animation">
|
<transition name="popover-animation">
|
||||||
<agent-selector
|
<agent-selector
|
||||||
v-if="showAgentsList"
|
v-if="showAgentsList"
|
||||||
|
class="agent-actions-box"
|
||||||
:selected-inboxes="selectedInboxes"
|
:selected-inboxes="selectedInboxes"
|
||||||
:conversation-count="conversations.length"
|
:conversation-count="conversations.length"
|
||||||
triangle-position="2.8"
|
|
||||||
@select="submit"
|
@select="submit"
|
||||||
@close="showAgentsList = false"
|
@close="showAgentsList = false"
|
||||||
/>
|
/>
|
||||||
@ -89,7 +89,7 @@
|
|||||||
<transition name="popover-animation">
|
<transition name="popover-animation">
|
||||||
<team-actions
|
<team-actions
|
||||||
v-if="showTeamsList"
|
v-if="showTeamsList"
|
||||||
triangle-position="0.2"
|
class="team-actions-box"
|
||||||
@assign-team="assignTeam"
|
@assign-team="assignTeam"
|
||||||
@close="showTeamsList = false"
|
@close="showTeamsList = false"
|
||||||
/>
|
/>
|
||||||
@ -247,4 +247,17 @@ export default {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label-actions-box {
|
||||||
|
--triangle-position: 8.5rem;
|
||||||
|
}
|
||||||
|
.update-actions-box {
|
||||||
|
--triangle-position: 5.6rem;
|
||||||
|
}
|
||||||
|
.agent-actions-box {
|
||||||
|
--triangle-position: 2.8rem;
|
||||||
|
}
|
||||||
|
.team-actions-box {
|
||||||
|
--triangle-position: 0.2rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-on-clickaway="onClose" class="labels-container">
|
<div v-on-clickaway="onClose" class="labels-container">
|
||||||
<div class="triangle" :style="cssVars">
|
<div class="triangle">
|
||||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||||
<path
|
<path
|
||||||
d="M20 12l-8-8-12 12"
|
d="M20 12l-8-8-12 12"
|
||||||
@ -75,10 +75,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mixin as clickaway } from 'vue-clickaway';
|
import { mixin as clickaway } from 'vue-clickaway';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [clickaway, bulkActionsMixin],
|
mixins: [clickaway],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
query: '',
|
query: '',
|
||||||
@ -207,7 +206,7 @@ export default {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--triangle-position);
|
right: var(--triangle-position);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
top: calc(var(--space-slab) * -1);
|
top: var(--space-minus-slab);
|
||||||
z-index: var(--z-index-one);
|
z-index: var(--z-index-one);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-on-clickaway="onClose" class="bulk-action__teams">
|
<div v-on-clickaway="onClose" class="bulk-action__teams">
|
||||||
<div class="triangle" :style="cssVars">
|
<div class="triangle">
|
||||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||||
<path
|
<path
|
||||||
d="M20 12l-8-8-12 12"
|
d="M20 12l-8-8-12 12"
|
||||||
@ -59,9 +59,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mixin as clickaway } from 'vue-clickaway';
|
import { mixin as clickaway } from 'vue-clickaway';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [clickaway, bulkActionsMixin],
|
mixins: [clickaway],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
query: '',
|
query: '',
|
||||||
@ -141,7 +140,7 @@ export default {
|
|||||||
display: block;
|
display: block;
|
||||||
z-index: var(--z-index-one);
|
z-index: var(--z-index-one);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(var(--space-slab) * -1);
|
top: var(--space-minus-slab);
|
||||||
right: var(--triangle-position);
|
right: var(--triangle-position);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-on-clickaway="onClose" class="actions-container">
|
<div v-on-clickaway="onClose" class="actions-container">
|
||||||
<div class="triangle" :style="cssVars">
|
<div class="triangle">
|
||||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||||
<path
|
<path
|
||||||
d="M20 12l-8-8-12 12"
|
d="M20 12l-8-8-12 12"
|
||||||
@ -45,14 +45,13 @@
|
|||||||
import { mixin as clickaway } from 'vue-clickaway';
|
import { mixin as clickaway } from 'vue-clickaway';
|
||||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||||
import bulkActionsMixin from 'dashboard/mixins/bulkActionsMixin.js';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
WootDropdownItem,
|
WootDropdownItem,
|
||||||
WootDropdownMenu,
|
WootDropdownMenu,
|
||||||
},
|
},
|
||||||
mixins: [clickaway, bulkActionsMixin],
|
mixins: [clickaway],
|
||||||
props: {
|
props: {
|
||||||
selectedInboxes: {
|
selectedInboxes: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@ -156,7 +155,7 @@ export default {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--triangle-position);
|
right: var(--triangle-position);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
top: calc(var(--space-slab) * -1);
|
top: var(--space-minus-slab);
|
||||||
z-index: var(--z-index-one);
|
z-index: var(--z-index-one);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,6 @@ import emailValidator from 'vuelidate/lib/validators/email';
|
|||||||
|
|
||||||
export const validEmailsByComma = value => {
|
export const validEmailsByComma = value => {
|
||||||
if (!value.length) return true;
|
if (!value.length) return true;
|
||||||
const emails = value.split(',');
|
const emails = value.replace(/\s+/g, '').split(',');
|
||||||
return emails.every(email => emailValidator(email));
|
return emails.every(email => emailValidator(email));
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,4 +10,7 @@ describe('#validEmailsByComma', () => {
|
|||||||
it('returns false when one of the email passed is invalid', () => {
|
it('returns false when one of the email passed is invalid', () => {
|
||||||
expect(validEmailsByComma('ni@njan.com,pova.da')).toEqual(false);
|
expect(validEmailsByComma('ni@njan.com,pova.da')).toEqual(false);
|
||||||
});
|
});
|
||||||
|
it('strips spaces between emails before validating', () => {
|
||||||
|
expect(validEmailsByComma('1@test.com , 2@test.com')).toEqual(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,6 +12,11 @@ export default {
|
|||||||
SNOOZED: 'snoozed',
|
SNOOZED: 'snoozed',
|
||||||
ALL: 'all',
|
ALL: 'all',
|
||||||
},
|
},
|
||||||
|
SORT_BY_TYPE: {
|
||||||
|
LATEST: 'latest',
|
||||||
|
CREATED_AT: 'sort_on_created_at',
|
||||||
|
PRIORITY: 'sort_on_priority',
|
||||||
|
},
|
||||||
ARTICLE_STATUS_TYPES: {
|
ARTICLE_STATUS_TYPES: {
|
||||||
DRAFT: 0,
|
DRAFT: 0,
|
||||||
PUBLISH: 1,
|
PUBLISH: 1,
|
||||||
|
|||||||
@ -15,4 +15,5 @@ export const FEATURE_FLAGS = {
|
|||||||
REPORTS: 'reports',
|
REPORTS: 'reports',
|
||||||
TEAM_MANAGEMENT: 'team_management',
|
TEAM_MANAGEMENT: 'team_management',
|
||||||
VOICE_RECORDER: 'voice_recorder',
|
VOICE_RECORDER: 'voice_recorder',
|
||||||
|
AUDIT_LOGS: 'audit_logs',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
|
|||||||
USED_MENTIONS: 'Used mentions',
|
USED_MENTIONS: 'Used mentions',
|
||||||
SEARCH_CONVERSATION: 'Searched conversations',
|
SEARCH_CONVERSATION: 'Searched conversations',
|
||||||
APPLY_FILTER: 'Applied filters in the conversation list',
|
APPLY_FILTER: 'Applied filters in the conversation list',
|
||||||
|
CHANGE_PRIORITY: 'Assigned priority to a conversation',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ACCOUNT_EVENTS = Object.freeze({
|
export const ACCOUNT_EVENTS = Object.freeze({
|
||||||
@ -75,3 +76,9 @@ export const PORTALS_EVENTS = Object.freeze({
|
|||||||
DELETE_ARTICLE: 'Deleted an article',
|
DELETE_ARTICLE: 'Deleted an article',
|
||||||
PREVIEW_ARTICLE: 'Previewed article',
|
PREVIEW_ARTICLE: 'Previewed article',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const OPEN_AI_EVENTS = Object.freeze({
|
||||||
|
SUMMARIZE: 'OpenAI: Used summarize',
|
||||||
|
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
|
||||||
|
REPHRASE: 'OpenAI: Used rephrase',
|
||||||
|
});
|
||||||
|
|||||||
@ -30,6 +30,36 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onReconnect = () => {
|
||||||
|
this.syncActiveConversationMessages();
|
||||||
|
};
|
||||||
|
|
||||||
|
onDisconnected = () => {
|
||||||
|
this.setActiveConversationLastMessageId();
|
||||||
|
};
|
||||||
|
|
||||||
|
setActiveConversationLastMessageId = () => {
|
||||||
|
const {
|
||||||
|
params: { conversation_id },
|
||||||
|
} = this.app.$route;
|
||||||
|
if (conversation_id) {
|
||||||
|
this.app.$store.dispatch('setConversationLastMessageId', {
|
||||||
|
conversationId: Number(conversation_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncActiveConversationMessages = () => {
|
||||||
|
const {
|
||||||
|
params: { conversation_id },
|
||||||
|
} = this.app.$route;
|
||||||
|
if (conversation_id) {
|
||||||
|
this.app.$store.dispatch('syncActiveConversationMessages', {
|
||||||
|
conversationId: Number(conversation_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
isAValidEvent = data => {
|
isAValidEvent = data => {
|
||||||
return this.app.$store.getters.getCurrentAccountId === data.account_id;
|
return this.app.$store.getters.getCurrentAccountId === data.account_id;
|
||||||
};
|
};
|
||||||
@ -75,8 +105,16 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
onLogout = () => AuthAPI.logout();
|
onLogout = () => AuthAPI.logout();
|
||||||
|
|
||||||
onMessageCreated = data => {
|
onMessageCreated = data => {
|
||||||
|
const {
|
||||||
|
conversation: { last_activity_at: lastActivityAt },
|
||||||
|
conversation_id: conversationId,
|
||||||
|
} = data;
|
||||||
DashboardAudioNotificationHelper.onNewMessage(data);
|
DashboardAudioNotificationHelper.onNewMessage(data);
|
||||||
this.app.$store.dispatch('addMessage', data);
|
this.app.$store.dispatch('addMessage', data);
|
||||||
|
this.app.$store.dispatch('updateConversationLastActivity', {
|
||||||
|
lastActivityAt,
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onReload = () => window.location.reload();
|
onReload = () => window.location.reload();
|
||||||
|
|||||||
@ -16,6 +16,29 @@ const MESSAGE_CONDITION_VALUES = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const PRIORITY_CONDITION_VALUES = [
|
||||||
|
{
|
||||||
|
id: 'nil',
|
||||||
|
name: 'None',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'low',
|
||||||
|
name: 'Low',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'medium',
|
||||||
|
name: 'Medium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'high',
|
||||||
|
name: 'High',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'urgent',
|
||||||
|
name: 'Urgent',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const getCustomAttributeInputType = key => {
|
export const getCustomAttributeInputType = key => {
|
||||||
const customAttributeMap = {
|
const customAttributeMap = {
|
||||||
date: 'date',
|
date: 'date',
|
||||||
@ -103,6 +126,7 @@ export const getActionOptions = ({ agents, teams, labels, type }) => {
|
|||||||
assign_team: teams,
|
assign_team: teams,
|
||||||
send_email_to_team: teams,
|
send_email_to_team: teams,
|
||||||
add_label: generateConditionOptions(labels, 'title'),
|
add_label: generateConditionOptions(labels, 'title'),
|
||||||
|
change_priority: PRIORITY_CONDITION_VALUES,
|
||||||
};
|
};
|
||||||
return actionsMap[type];
|
return actionsMap[type];
|
||||||
};
|
};
|
||||||
@ -139,6 +163,7 @@ export const getConditionOptions = ({
|
|||||||
conversation_language: languages,
|
conversation_language: languages,
|
||||||
country_code: countries,
|
country_code: countries,
|
||||||
message_type: MESSAGE_CONDITION_VALUES,
|
message_type: MESSAGE_CONDITION_VALUES,
|
||||||
|
priority: PRIORITY_CONDITION_VALUES,
|
||||||
};
|
};
|
||||||
|
|
||||||
return conditionFilterMaps[type];
|
return conditionFilterMaps[type];
|
||||||
|
|||||||
@ -1,5 +1,55 @@
|
|||||||
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
|
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
|
||||||
|
|
||||||
|
export const getInboxSource = (type, phoneNumber, inbox) => {
|
||||||
|
switch (type) {
|
||||||
|
case INBOX_TYPES.WEB:
|
||||||
|
return inbox.website_url || '';
|
||||||
|
|
||||||
|
case INBOX_TYPES.TWILIO:
|
||||||
|
case INBOX_TYPES.WHATSAPP:
|
||||||
|
return phoneNumber || '';
|
||||||
|
|
||||||
|
case INBOX_TYPES.EMAIL:
|
||||||
|
return inbox.email || '';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const getReadableInboxByType = (type, phoneNumber) => {
|
||||||
|
switch (type) {
|
||||||
|
case INBOX_TYPES.WEB:
|
||||||
|
return 'livechat';
|
||||||
|
|
||||||
|
case INBOX_TYPES.FB:
|
||||||
|
return 'facebook';
|
||||||
|
|
||||||
|
case INBOX_TYPES.TWITTER:
|
||||||
|
return 'twitter';
|
||||||
|
|
||||||
|
case INBOX_TYPES.TWILIO:
|
||||||
|
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
|
||||||
|
|
||||||
|
case INBOX_TYPES.WHATSAPP:
|
||||||
|
return 'whatsapp';
|
||||||
|
|
||||||
|
case INBOX_TYPES.API:
|
||||||
|
return 'api';
|
||||||
|
|
||||||
|
case INBOX_TYPES.EMAIL:
|
||||||
|
return 'email';
|
||||||
|
|
||||||
|
case INBOX_TYPES.TELEGRAM:
|
||||||
|
return 'telegram';
|
||||||
|
|
||||||
|
case INBOX_TYPES.LINE:
|
||||||
|
return 'line';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 'chat';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getInboxClassByType = (type, phoneNumber) => {
|
export const getInboxClassByType = (type, phoneNumber) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case INBOX_TYPES.WEB:
|
case INBOX_TYPES.WEB:
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
const MESSAGE_VARIABLES_REGEX = /{{(.*?)}}/g;
|
|
||||||
export const replaceVariablesInMessage = ({ message, variables }) => {
|
|
||||||
return message.replace(MESSAGE_VARIABLES_REGEX, (match, replace) => {
|
|
||||||
return variables[replace.trim()]
|
|
||||||
? variables[replace.trim().toLowerCase()]
|
|
||||||
: '';
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const capitalizeName = name => {
|
|
||||||
return (name || '').replace(/\b(\w)/g, s => s.toUpperCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
const skipCodeBlocks = str => str.replace(/```(?:.|\n)+?```/g, '');
|
|
||||||
|
|
||||||
export const getFirstName = ({ user }) => {
|
|
||||||
const firstName = user?.name ? user.name.split(' ').shift() : '';
|
|
||||||
return capitalizeName(firstName);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLastName = ({ user }) => {
|
|
||||||
if (user && user.name) {
|
|
||||||
const lastName =
|
|
||||||
user.name.split(' ').length > 1 ? user.name.split(' ').pop() : '';
|
|
||||||
return capitalizeName(lastName);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMessageVariables = ({ conversation }) => {
|
|
||||||
const {
|
|
||||||
meta: { assignee = {}, sender = {} },
|
|
||||||
id,
|
|
||||||
} = conversation;
|
|
||||||
|
|
||||||
return {
|
|
||||||
'contact.name': capitalizeName(sender?.name),
|
|
||||||
'contact.first_name': getFirstName({ user: sender }),
|
|
||||||
'contact.last_name': getLastName({ user: sender }),
|
|
||||||
'contact.email': sender?.email,
|
|
||||||
'contact.phone': sender?.phone_number,
|
|
||||||
'contact.id': sender?.id,
|
|
||||||
'conversation.id': id,
|
|
||||||
'agent.name': capitalizeName(assignee?.name || ''),
|
|
||||||
'agent.first_name': getFirstName({ user: assignee }),
|
|
||||||
'agent.last_name': getLastName({ user: assignee }),
|
|
||||||
'agent.email': assignee?.email ?? '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUndefinedVariablesInMessage = ({ message, variables }) => {
|
|
||||||
const messageWithOutCodeBlocks = skipCodeBlocks(message);
|
|
||||||
const matches = messageWithOutCodeBlocks.match(MESSAGE_VARIABLES_REGEX);
|
|
||||||
if (!matches) return [];
|
|
||||||
|
|
||||||
return matches
|
|
||||||
.map(match => {
|
|
||||||
return match
|
|
||||||
.replace('{{', '')
|
|
||||||
.replace('}}', '')
|
|
||||||
.trim();
|
|
||||||
})
|
|
||||||
.filter(variable => {
|
|
||||||
return !variables[variable];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -8,8 +8,8 @@ export const buildPortalArticleURL = (
|
|||||||
portalSlug,
|
portalSlug,
|
||||||
categorySlug,
|
categorySlug,
|
||||||
locale,
|
locale,
|
||||||
articleId
|
articleSlug
|
||||||
) => {
|
) => {
|
||||||
const portalURL = buildPortalURL(portalSlug);
|
const portalURL = buildPortalURL(portalSlug);
|
||||||
return `${portalURL}/${locale}/${categorySlug}/${articleId}`;
|
return `${portalURL}/articles/${articleSlug}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -57,6 +57,7 @@ export const initializeChatwootEvents = () => {
|
|||||||
window.$chatwoot.setCustomAttributes({
|
window.$chatwoot.setCustomAttributes({
|
||||||
signedUpAt: user.created_at,
|
signedUpAt: user.created_at,
|
||||||
cloudCustomer: 'true',
|
cloudCustomer: 'true',
|
||||||
|
account_id: user.account_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,7 @@ describe('#resolveActionName', () => {
|
|||||||
expect(resolveActionName(MACRO_ACTION_TYPES[1].key)).not.toEqual(
|
expect(resolveActionName(MACRO_ACTION_TYPES[1].key)).not.toEqual(
|
||||||
MACRO_ACTION_TYPES[0].label
|
MACRO_ACTION_TYPES[0].label
|
||||||
);
|
);
|
||||||
|
expect(resolveActionName('change_priority')).toEqual('Change Priority');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,169 +0,0 @@
|
|||||||
import {
|
|
||||||
replaceVariablesInMessage,
|
|
||||||
getFirstName,
|
|
||||||
getLastName,
|
|
||||||
getMessageVariables,
|
|
||||||
getUndefinedVariablesInMessage,
|
|
||||||
capitalizeName,
|
|
||||||
} from '../messageHelper';
|
|
||||||
|
|
||||||
const variables = {
|
|
||||||
'contact.name': 'John Doe',
|
|
||||||
'contact.first_name': 'John',
|
|
||||||
'contact.last_name': 'Doe',
|
|
||||||
'contact.email': 'john.p@example.com',
|
|
||||||
'contact.phone': '1234567890',
|
|
||||||
'conversation.id': 1,
|
|
||||||
'agent.first_name': 'Samuel',
|
|
||||||
'agent.last_name': 'Smith',
|
|
||||||
'agent.email': 'samuel@gmail.com',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('#replaceVariablesInMessage', () => {
|
|
||||||
it('returns the message with variable name', () => {
|
|
||||||
const message =
|
|
||||||
'No issues. Hey {{contact.first_name}}, we will send the reset instructions to your email {{ contact.email}}. The {{ agent.first_name }} {{ agent.last_name }} will take care of everything. Your conversation id is {{ conversation.id }}.';
|
|
||||||
expect(replaceVariablesInMessage({ message, variables })).toBe(
|
|
||||||
'No issues. Hey John, we will send the reset instructions to your email john.p@example.com. The Samuel Smith will take care of everything. Your conversation id is 1.'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the message with variable name having white space', () => {
|
|
||||||
const message = 'hey {{contact.name}} how may I help you?';
|
|
||||||
expect(replaceVariablesInMessage({ message, variables })).toBe(
|
|
||||||
'hey John Doe how may I help you?'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the message with variable email', () => {
|
|
||||||
const message =
|
|
||||||
'No issues. We will send the reset instructions to your email at {{contact.email}}';
|
|
||||||
expect(replaceVariablesInMessage({ message, variables })).toBe(
|
|
||||||
'No issues. We will send the reset instructions to your email at john.p@example.com'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the message with multiple variables', () => {
|
|
||||||
const message =
|
|
||||||
'hey {{ contact.name }}, no issues. We will send the reset instructions to your email at {{contact.email}}';
|
|
||||||
expect(replaceVariablesInMessage({ message, variables })).toBe(
|
|
||||||
'hey John Doe, no issues. We will send the reset instructions to your email at john.p@example.com'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the message if the variable is not present in variables', () => {
|
|
||||||
const message = 'Please dm me at {{contact.twitter}}';
|
|
||||||
expect(replaceVariablesInMessage({ message, variables })).toBe(
|
|
||||||
'Please dm me at '
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#getFirstName', () => {
|
|
||||||
it('returns the first name of the contact', () => {
|
|
||||||
const assignee = { name: 'John Doe' };
|
|
||||||
expect(getFirstName({ user: assignee })).toBe('John');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the first name of the contact with multiple names', () => {
|
|
||||||
const assignee = { name: 'John Doe Smith' };
|
|
||||||
expect(getFirstName({ user: assignee })).toBe('John');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#getLastName', () => {
|
|
||||||
it('returns the last name of the contact', () => {
|
|
||||||
const assignee = { name: 'John Doe' };
|
|
||||||
expect(getLastName({ user: assignee })).toBe('Doe');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the last name of the contact with multiple names', () => {
|
|
||||||
const assignee = { name: 'John Doe Smith' };
|
|
||||||
expect(getLastName({ user: assignee })).toBe('Smith');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#getMessageVariables', () => {
|
|
||||||
it('returns the variables', () => {
|
|
||||||
const conversation = {
|
|
||||||
meta: {
|
|
||||||
assignee: {
|
|
||||||
name: 'samuel Smith',
|
|
||||||
email: 'samuel@example.com',
|
|
||||||
},
|
|
||||||
sender: {
|
|
||||||
name: 'john Doe',
|
|
||||||
email: 'john.doe@gmail.com',
|
|
||||||
phone_number: '1234567890',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
id: 1,
|
|
||||||
};
|
|
||||||
expect(getMessageVariables({ conversation })).toEqual({
|
|
||||||
'contact.name': 'John Doe',
|
|
||||||
'contact.first_name': 'John',
|
|
||||||
'contact.last_name': 'Doe',
|
|
||||||
'contact.email': 'john.doe@gmail.com',
|
|
||||||
'contact.phone': '1234567890',
|
|
||||||
'conversation.id': 1,
|
|
||||||
'agent.name': 'Samuel Smith',
|
|
||||||
'agent.first_name': 'Samuel',
|
|
||||||
'agent.last_name': 'Smith',
|
|
||||||
'agent.email': 'samuel@example.com',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#getUndefinedVariablesInMessage', () => {
|
|
||||||
it('returns the undefined variables', () => {
|
|
||||||
const message = 'Please dm me at {{contact.twitter}}';
|
|
||||||
expect(
|
|
||||||
getUndefinedVariablesInMessage({ message, variables }).length
|
|
||||||
).toEqual(1);
|
|
||||||
expect(getUndefinedVariablesInMessage({ message, variables })).toEqual(
|
|
||||||
expect.arrayContaining(['contact.twitter'])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('skip variables in string with code blocks', () => {
|
|
||||||
const message =
|
|
||||||
'hey {{contact_name}} how are you? ``` code: {{contact_name}} ```';
|
|
||||||
const undefinedVariables = getUndefinedVariablesInMessage({
|
|
||||||
message,
|
|
||||||
variables,
|
|
||||||
});
|
|
||||||
expect(undefinedVariables.length).toEqual(1);
|
|
||||||
expect(undefinedVariables).toEqual(
|
|
||||||
expect.arrayContaining(['contact_name'])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#capitalizeName', () => {
|
|
||||||
it('capitalize name if name is passed', () => {
|
|
||||||
const string = 'john peter';
|
|
||||||
expect(capitalizeName(string)).toBe('John Peter');
|
|
||||||
});
|
|
||||||
it('returns empty string if the name is null', () => {
|
|
||||||
expect(capitalizeName(null)).toBe('');
|
|
||||||
});
|
|
||||||
it('capitalize first name if full name is passed', () => {
|
|
||||||
const string = 'john Doe';
|
|
||||||
expect(capitalizeName(string)).toBe('John Doe');
|
|
||||||
});
|
|
||||||
it('returns empty string if the string is empty', () => {
|
|
||||||
const string = '';
|
|
||||||
expect(capitalizeName(string)).toBe('');
|
|
||||||
});
|
|
||||||
it('capitalize last name if last name is passed', () => {
|
|
||||||
const string = 'john doe';
|
|
||||||
expect(capitalizeName(string)).toBe('John Doe');
|
|
||||||
});
|
|
||||||
it('capitalize first name if first name is passed', () => {
|
|
||||||
const string = 'john';
|
|
||||||
expect(capitalizeName(string)).toBe('John');
|
|
||||||
});
|
|
||||||
it('capitalize last name if last name is passed', () => {
|
|
||||||
const string = 'doe';
|
|
||||||
expect(capitalizeName(string)).toBe('Doe');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -20,9 +20,9 @@ describe('PortalHelper', () => {
|
|||||||
hostURL: 'https://app.chatwoot.com',
|
hostURL: 'https://app.chatwoot.com',
|
||||||
helpCenterURL: 'https://help.chatwoot.com',
|
helpCenterURL: 'https://help.chatwoot.com',
|
||||||
};
|
};
|
||||||
expect(buildPortalArticleURL('handbook', 'culture', 'fr', 1)).toEqual(
|
expect(
|
||||||
'https://help.chatwoot.com/hc/handbook/fr/culture/1'
|
buildPortalArticleURL('handbook', 'culture', 'fr', 'article-slug')
|
||||||
);
|
).toEqual('https://help.chatwoot.com/hc/handbook/articles/article-slug');
|
||||||
window.chatwootConfig = {};
|
window.chatwootConfig = {};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import es from './locale/es';
|
|||||||
import fa from './locale/fa';
|
import fa from './locale/fa';
|
||||||
import fi from './locale/fi';
|
import fi from './locale/fi';
|
||||||
import fr from './locale/fr';
|
import fr from './locale/fr';
|
||||||
|
import he from './locale/he';
|
||||||
import hi from './locale/hi';
|
import hi from './locale/hi';
|
||||||
import hu from './locale/hu';
|
import hu from './locale/hu';
|
||||||
import id from './locale/id';
|
import id from './locale/id';
|
||||||
@ -47,6 +48,7 @@ export default {
|
|||||||
fa,
|
fa,
|
||||||
fi,
|
fi,
|
||||||
fr,
|
fr,
|
||||||
|
he,
|
||||||
hi,
|
hi,
|
||||||
hu,
|
hu,
|
||||||
id,
|
id,
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
"CAMPAIGN_NAME": "اسم الحملة",
|
"CAMPAIGN_NAME": "اسم الحملة",
|
||||||
"LABELS": "الوسوم",
|
"LABELS": "الوسوم",
|
||||||
"BROWSER_LANGUAGE": "لغة المتصفح",
|
"BROWSER_LANGUAGE": "لغة المتصفح",
|
||||||
|
"PRIORITY": "Priority",
|
||||||
"COUNTRY_NAME": "اسم الدولة",
|
"COUNTRY_NAME": "اسم الدولة",
|
||||||
"REFERER_LINK": "رابط المرجع",
|
"REFERER_LINK": "رابط المرجع",
|
||||||
"CUSTOM_ATTRIBUTE_LIST": "القائمة",
|
"CUSTOM_ATTRIBUTE_LIST": "القائمة",
|
||||||
|
|||||||
@ -110,7 +110,8 @@
|
|||||||
},
|
},
|
||||||
"PLACEHOLDER": {
|
"PLACEHOLDER": {
|
||||||
"AGENT": "البحث عن وكلاء",
|
"AGENT": "البحث عن وكلاء",
|
||||||
"TEAM": "البحث عن فريق"
|
"TEAM": "البحث عن فريق",
|
||||||
|
"INPUT": "Search for agents"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
app/javascript/dashboard/i18n/locale/ar/auditLogs.json
Normal file
24
app/javascript/dashboard/i18n/locale/ar/auditLogs.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"AUDIT_LOGS": {
|
||||||
|
"HEADER": "Audit Logs",
|
||||||
|
"HEADER_BTN_TXT": "Add Audit Logs",
|
||||||
|
"LOADING": "Fetching Audit Logs",
|
||||||
|
"SEARCH_404": "لا توجد عناصر مطابقة لهذا الاستعلام",
|
||||||
|
"SIDEBAR_TXT": "<p><b>Audit Logs</b> </p><p> Audit Logs are trails for events and actions in a Chatwoot System. </p>",
|
||||||
|
"LIST": {
|
||||||
|
"404": "There are no Audit Logs available in this account.",
|
||||||
|
"TITLE": "Manage Audit Logs",
|
||||||
|
"DESC": "Audit Logs are trails for events and actions in a Chatwoot System.",
|
||||||
|
"TABLE_HEADER": [
|
||||||
|
"User",
|
||||||
|
"Action",
|
||||||
|
"عنوان IP",
|
||||||
|
"Time"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "AuditLogs retrieved successfully",
|
||||||
|
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,6 +35,20 @@
|
|||||||
"TEXT": "الكل"
|
"TEXT": "الكل"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"VIEW_FILTER": "عرض",
|
||||||
|
"SORT_TOOLTIP_LABEL": "Sort conversations",
|
||||||
|
"CHAT_SORT": {
|
||||||
|
"STATUS": "الحالة",
|
||||||
|
"ORDER_BY": "Order by"
|
||||||
|
},
|
||||||
|
"CHAT_SORT_FILTER_ITEMS": {
|
||||||
|
"latest": {
|
||||||
|
"TEXT": "Last activity"
|
||||||
|
},
|
||||||
|
"sort_on_created_at": {
|
||||||
|
"TEXT": "تم إنشاؤها في"
|
||||||
|
}
|
||||||
|
},
|
||||||
"ATTACHMENTS": {
|
"ATTACHMENTS": {
|
||||||
"image": {
|
"image": {
|
||||||
"CONTENT": "رسالة صورة"
|
"CONTENT": "رسالة صورة"
|
||||||
@ -55,6 +69,24 @@
|
|||||||
"CONTENT": "قام بمشاركة رابط"
|
"CONTENT": "قام بمشاركة رابط"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CHAT_SORT_BY_FILTER": {
|
||||||
|
"TITLE": "Sort conversation",
|
||||||
|
"DROPDOWN_TITLE": "ترتيب حسب",
|
||||||
|
"ITEMS": {
|
||||||
|
"LATEST": {
|
||||||
|
"NAME": "Last activity at",
|
||||||
|
"LABEL": "Last activity"
|
||||||
|
},
|
||||||
|
"CREATED_AT": {
|
||||||
|
"NAME": "تم إنشاؤها في",
|
||||||
|
"LABEL": "تم إنشاؤها في"
|
||||||
|
},
|
||||||
|
"LAST_USER_MESSAGE_AT": {
|
||||||
|
"NAME": "Last user message at",
|
||||||
|
"LABEL": "Last message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"RECEIVED_VIA_EMAIL": "تم تلقيه عبر البريد الإلكتروني",
|
"RECEIVED_VIA_EMAIL": "تم تلقيه عبر البريد الإلكتروني",
|
||||||
"VIEW_TWEET_IN_TWITTER": "عرض التغريدة في تويتر",
|
"VIEW_TWEET_IN_TWITTER": "عرض التغريدة في تويتر",
|
||||||
"REPLY_TO_TWEET": "الرد على هذه التغريدة",
|
"REPLY_TO_TWEET": "الرد على هذه التغريدة",
|
||||||
|
|||||||
@ -120,8 +120,9 @@
|
|||||||
"PHONE_NUMBER": {
|
"PHONE_NUMBER": {
|
||||||
"PLACEHOLDER": "أدخل رقم الهاتف الخاص بجهة الاتصال",
|
"PLACEHOLDER": "أدخل رقم الهاتف الخاص بجهة الاتصال",
|
||||||
"LABEL": "رقم الهاتف",
|
"LABEL": "رقم الهاتف",
|
||||||
"HELP": "يجب ان يحتوى رقم الهاتف على كود دولتك تسبقها علامة +\nمثال: +20101243567",
|
"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.",
|
||||||
"ERROR": "يجب ان تكون خانة رقم الهاتف إما فارغة او مكتملة مع رمز الدولة",
|
"ERROR": "يجب ان تكون خانة رقم الهاتف إما فارغة او مكتملة مع رمز الدولة",
|
||||||
|
"DIAL_CODE_ERROR": "Please select a dial code from the list",
|
||||||
"DUPLICATE": "رقم الهاتف هذا مستخدم لجهة اتصال أخرى."
|
"DUPLICATE": "رقم الهاتف هذا مستخدم لجهة اتصال أخرى."
|
||||||
},
|
},
|
||||||
"LOCATION": {
|
"LOCATION": {
|
||||||
@ -181,7 +182,8 @@
|
|||||||
"LABEL": "إلى"
|
"LABEL": "إلى"
|
||||||
},
|
},
|
||||||
"INBOX": {
|
"INBOX": {
|
||||||
"LABEL": "صندوق الوارد",
|
"LABEL": "Via Inbox",
|
||||||
|
"PLACEHOLDER": "Choose source inbox",
|
||||||
"ERROR": "حدد صندوق الوارد"
|
"ERROR": "حدد صندوق الوارد"
|
||||||
},
|
},
|
||||||
"SUBJECT": {
|
"SUBJECT": {
|
||||||
|
|||||||
@ -37,6 +37,7 @@
|
|||||||
"UNKNOWN_FILE_TYPE": "ملف غير معروف",
|
"UNKNOWN_FILE_TYPE": "ملف غير معروف",
|
||||||
"SAVE_CONTACT": "حفظ",
|
"SAVE_CONTACT": "حفظ",
|
||||||
"UPLOADING_ATTACHMENTS": "جاري تحميل المرفقات...",
|
"UPLOADING_ATTACHMENTS": "جاري تحميل المرفقات...",
|
||||||
|
"REPLIED_TO_STORY": "Replied to your story",
|
||||||
"SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح",
|
"SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح",
|
||||||
"FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى",
|
"FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى",
|
||||||
"NO_RESPONSE": "لا توجد استجابة",
|
"NO_RESPONSE": "لا توجد استجابة",
|
||||||
@ -66,6 +67,23 @@
|
|||||||
"NEXT_WEEK": "الأسبوع المقبل"
|
"NEXT_WEEK": "الأسبوع المقبل"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"PRIORITY": {
|
||||||
|
"TITLE": "Priority",
|
||||||
|
"OPTIONS": {
|
||||||
|
"NONE": "لا شيء",
|
||||||
|
"URGENT": "Urgent",
|
||||||
|
"HIGH": "High",
|
||||||
|
"MEDIUM": "Medium",
|
||||||
|
"LOW": "Low"
|
||||||
|
},
|
||||||
|
"CHANGE_PRIORITY": {
|
||||||
|
"SELECT_PLACEHOLDER": "لا شيء",
|
||||||
|
"INPUT_PLACEHOLDER": "Select priority",
|
||||||
|
"NO_RESULTS": "لم يتم العثور على النتائج",
|
||||||
|
"SUCCESSFUL": "Changed priority of conversation id %{conversationId} to %{priority}",
|
||||||
|
"FAILED": "Couldn't change priority. Please try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"CARD_CONTEXT_MENU": {
|
"CARD_CONTEXT_MENU": {
|
||||||
"PENDING": "تحديد كمعلق",
|
"PENDING": "تحديد كمعلق",
|
||||||
"RESOLVED": "تحديد كمحلولة",
|
"RESOLVED": "تحديد كمحلولة",
|
||||||
|
|||||||
@ -101,6 +101,7 @@
|
|||||||
"REPORTS": "التقارير",
|
"REPORTS": "التقارير",
|
||||||
"CONVERSATION": "المحادثات",
|
"CONVERSATION": "المحادثات",
|
||||||
"CHANGE_ASSIGNEE": "تغيير المحال إليه",
|
"CHANGE_ASSIGNEE": "تغيير المحال إليه",
|
||||||
|
"CHANGE_PRIORITY": "Change Priority",
|
||||||
"CHANGE_TEAM": "تغيير الفريق",
|
"CHANGE_TEAM": "تغيير الفريق",
|
||||||
"ADD_LABEL": "إضافة تسمية إلى المحادثة",
|
"ADD_LABEL": "إضافة تسمية إلى المحادثة",
|
||||||
"REMOVE_LABEL": "إزالة التسمية من المحادثة",
|
"REMOVE_LABEL": "إزالة التسمية من المحادثة",
|
||||||
@ -126,6 +127,7 @@
|
|||||||
"GO_TO_NOTIFICATIONS": "الذهاب إلى الإشعارات",
|
"GO_TO_NOTIFICATIONS": "الذهاب إلى الإشعارات",
|
||||||
"ADD_LABELS_TO_CONVERSATION": "إضافة تسمية إلى المحادثة",
|
"ADD_LABELS_TO_CONVERSATION": "إضافة تسمية إلى المحادثة",
|
||||||
"ASSIGN_AN_AGENT": "تعيين وكيل",
|
"ASSIGN_AN_AGENT": "تعيين وكيل",
|
||||||
|
"ASSIGN_PRIORITY": "Assign priority",
|
||||||
"ASSIGN_A_TEAM": "تعيين فريق",
|
"ASSIGN_A_TEAM": "تعيين فريق",
|
||||||
"MUTE_CONVERSATION": "كتم المحادثة",
|
"MUTE_CONVERSATION": "كتم المحادثة",
|
||||||
"UNMUTE_CONVERSATION": "إلغاء كتم المحادثة",
|
"UNMUTE_CONVERSATION": "إلغاء كتم المحادثة",
|
||||||
|
|||||||
@ -464,6 +464,7 @@
|
|||||||
"AUTO_ASSIGNMENT_SUB_TEXT": "تمكين أو تعطيل الإسناد التلقائي للمحادثات الجديدة إلى الموظفين المضافين إلى قناة التواصل هذه.",
|
"AUTO_ASSIGNMENT_SUB_TEXT": "تمكين أو تعطيل الإسناد التلقائي للمحادثات الجديدة إلى الموظفين المضافين إلى قناة التواصل هذه.",
|
||||||
"HMAC_VERIFICATION": "التحقق من هوية المستخدم",
|
"HMAC_VERIFICATION": "التحقق من هوية المستخدم",
|
||||||
"HMAC_DESCRIPTION": "من أجل التحقق من هوية المستخدم، يمكنك تمرير 'IDer_hash` لكل مستخدم. يمكنك إنشاء تجزئة HMAC sha256 باستخدام \"المعرف\" مع المفتاح المعروض هنا.",
|
"HMAC_DESCRIPTION": "من أجل التحقق من هوية المستخدم، يمكنك تمرير 'IDer_hash` لكل مستخدم. يمكنك إنشاء تجزئة HMAC sha256 باستخدام \"المعرف\" مع المفتاح المعروض هنا.",
|
||||||
|
"HMAC_LINK_TO_DOCS": "You can read more here.",
|
||||||
"HMAC_MANDATORY_VERIFICATION": "فرض التحقق من هوية المستخدم",
|
"HMAC_MANDATORY_VERIFICATION": "فرض التحقق من هوية المستخدم",
|
||||||
"HMAC_MANDATORY_DESCRIPTION": "في حالة التمكين، سيتم رفض الطلبات المفقودة 'IDer_hash'.",
|
"HMAC_MANDATORY_DESCRIPTION": "في حالة التمكين، سيتم رفض الطلبات المفقودة 'IDer_hash'.",
|
||||||
"INBOX_IDENTIFIER": "معرف صندوق الوارد",
|
"INBOX_IDENTIFIER": "معرف صندوق الوارد",
|
||||||
@ -478,8 +479,17 @@
|
|||||||
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
|
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
|
||||||
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
|
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
|
||||||
"WHATSAPP_SECTION_UPDATE_BUTTON": "تحديث",
|
"WHATSAPP_SECTION_UPDATE_BUTTON": "تحديث",
|
||||||
|
"WHATSAPP_WEBHOOK_TITLE": "رمز التحقق من Webhook",
|
||||||
|
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
||||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||||
},
|
},
|
||||||
|
"HELP_CENTER": {
|
||||||
|
"LABEL": "Help Center",
|
||||||
|
"PLACEHOLDER": "Select Help Center",
|
||||||
|
"SELECT_PLACEHOLDER": "Select Help Center",
|
||||||
|
"REMOVE": "Remove Help Center",
|
||||||
|
"SUB_TEXT": "Attach a Help Center with the inbox"
|
||||||
|
},
|
||||||
"AUTO_ASSIGNMENT": {
|
"AUTO_ASSIGNMENT": {
|
||||||
"MAX_ASSIGNMENT_LIMIT": "حد الإسناد التلقائي",
|
"MAX_ASSIGNMENT_LIMIT": "حد الإسناد التلقائي",
|
||||||
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "الرجاء إدخال قيمة أكبر من 0",
|
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "الرجاء إدخال قيمة أكبر من 0",
|
||||||
|
|||||||
@ -85,6 +85,23 @@
|
|||||||
"JOIN_ERROR": "There was an error joining the call, please try again",
|
"JOIN_ERROR": "There was an error joining the call, please try again",
|
||||||
"CREATE_ERROR": "There was an error creating a meeting link, please try again"
|
"CREATE_ERROR": "There was an error creating a meeting link, please try again"
|
||||||
},
|
},
|
||||||
|
"OPEN_AI": {
|
||||||
|
"TITLE": "Improve With AI",
|
||||||
|
"SUBTITLE": "An improved reply will be generated using AI, based on your current draft.",
|
||||||
|
"TONE": {
|
||||||
|
"TITLE": "Tone",
|
||||||
|
"OPTIONS": {
|
||||||
|
"PROFESSIONAL": "Professional",
|
||||||
|
"FRIENDLY": "Friendly"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"BUTTONS": {
|
||||||
|
"GENERATE": "Generate",
|
||||||
|
"GENERATING": "Generating...",
|
||||||
|
"CANCEL": "إلغاء"
|
||||||
|
},
|
||||||
|
"GENERATE_ERROR": "There was an error processing the content, please try again"
|
||||||
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
"BUTTON_TEXT": "حذف",
|
"BUTTON_TEXT": "حذف",
|
||||||
"API": {
|
"API": {
|
||||||
|
|||||||
@ -3,16 +3,16 @@
|
|||||||
"TITLE": "تسجيل الدخول إلى Chatwoot",
|
"TITLE": "تسجيل الدخول إلى Chatwoot",
|
||||||
"EMAIL": {
|
"EMAIL": {
|
||||||
"LABEL": "البريد الإلكتروني",
|
"LABEL": "البريد الإلكتروني",
|
||||||
"PLACEHOLDER": "مثال: someone@example.com"
|
"PLACEHOLDER": "example@companyname.com"
|
||||||
},
|
},
|
||||||
"PASSWORD": {
|
"PASSWORD": {
|
||||||
"LABEL": "كلمة المرور",
|
"LABEL": "كلمة المرور",
|
||||||
"PLACEHOLDER": "كلمة المرور"
|
"PLACEHOLDER": "كلمة المرور"
|
||||||
},
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "تم تسجيل الدخول بنجاح",
|
"SUCCESS_MESSAGE": "Login successful",
|
||||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً",
|
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again.",
|
||||||
"UNAUTH": "اسم المستخدم / كلمة المرور غير صحيحة. الرجاء المحاولة مرة أخرى"
|
"UNAUTH": "Username or password is incorrect. Please try again."
|
||||||
},
|
},
|
||||||
"OAUTH": {
|
"OAUTH": {
|
||||||
"GOOGLE_LOGIN": "Login with Google",
|
"GOOGLE_LOGIN": "Login with Google",
|
||||||
|
|||||||
@ -3,12 +3,12 @@
|
|||||||
"TITLE": "إعادة تعيين كلمة المرور",
|
"TITLE": "إعادة تعيين كلمة المرور",
|
||||||
"EMAIL": {
|
"EMAIL": {
|
||||||
"LABEL": "البريد الإلكتروني",
|
"LABEL": "البريد الإلكتروني",
|
||||||
"PLACEHOLDER": "الرجاء إدخال بريدك الإلكتروني",
|
"PLACEHOLDER": "الرجاء إدخال بريدك الإلكتروني.",
|
||||||
"ERROR": "الرجاء إدخال بريد إلكتروني صالح"
|
"ERROR": "الرجاء إدخال بريد إلكتروني صالح."
|
||||||
},
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "تم إرسال رابط إعادة تعيين كلمة المرور إلى بريدك الإلكتروني",
|
"SUCCESS_MESSAGE": "تم إرسال رابط إعادة تعيين كلمة المرور إلى بريدك الإلكتروني.",
|
||||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
||||||
},
|
},
|
||||||
"SUBMIT": "إرسال"
|
"SUBMIT": "إرسال"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,8 @@
|
|||||||
"EMPTY_STATE": "No %{item} found for query '%{query}'",
|
"EMPTY_STATE": "No %{item} found for query '%{query}'",
|
||||||
"EMPTY_STATE_FULL": "No results found for query '%{query}'",
|
"EMPTY_STATE_FULL": "No results found for query '%{query}'",
|
||||||
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
||||||
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
|
"INPUT_PLACEHOLDER": "Type 3 or more characters to search",
|
||||||
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
|
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
|
||||||
"BOT_LABEL": "رد آلي",
|
"BOT_LABEL": "رد آلي",
|
||||||
"READ_MORE": "Read more",
|
"READ_MORE": "Read more",
|
||||||
"WROTE": "wrote:"
|
"WROTE": "wrote:"
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"SET_NEW_PASSWORD": {
|
"SET_NEW_PASSWORD": {
|
||||||
"TITLE": "تعيين كلمة مرور جديدة",
|
"TITLE": "Set new password",
|
||||||
"PASSWORD": {
|
"PASSWORD": {
|
||||||
"LABEL": "كلمة المرور",
|
"LABEL": "كلمة المرور",
|
||||||
"PLACEHOLDER": "كلمة المرور",
|
"PLACEHOLDER": "كلمة المرور",
|
||||||
"ERROR": "كلمة المرور قصيرة جداً"
|
"ERROR": "Password is too short."
|
||||||
},
|
},
|
||||||
"CONFIRM_PASSWORD": {
|
"CONFIRM_PASSWORD": {
|
||||||
"LABEL": "تأكيد كلمة المرور",
|
"LABEL": "Confirm password",
|
||||||
"PLACEHOLDER": "تأكيد كلمة المرور",
|
"PLACEHOLDER": "تأكيد كلمة المرور",
|
||||||
"ERROR": "كلمة المرور غير متطابقة"
|
"ERROR": "كلمة المرور غير متطابقة."
|
||||||
},
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "تم تغيير كلمة المرور بنجاح",
|
"SUCCESS_MESSAGE": "تم تغيير كلمة المرور بنجاح.",
|
||||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
||||||
},
|
},
|
||||||
"CAPTCHA": {
|
"CAPTCHA": {
|
||||||
"ERROR": "انتهت صلاحية التحقق. الرجاء حل كلمة التحقق مرة أخرى."
|
"ERROR": "انتهت صلاحية التحقق. الرجاء حل كلمة التحقق مرة أخرى."
|
||||||
|
|||||||
@ -203,6 +203,7 @@
|
|||||||
"HOME": "الرئيسية",
|
"HOME": "الرئيسية",
|
||||||
"AGENTS": "موظف الدعم",
|
"AGENTS": "موظف الدعم",
|
||||||
"AGENT_BOTS": "البوتات",
|
"AGENT_BOTS": "البوتات",
|
||||||
|
"AUDIT_LOGS": "Audit Logs",
|
||||||
"INBOXES": "قنوات التواصل",
|
"INBOXES": "قنوات التواصل",
|
||||||
"NOTIFICATIONS": "الإشعارات",
|
"NOTIFICATIONS": "الإشعارات",
|
||||||
"CANNED_RESPONSES": "الردود السريعة",
|
"CANNED_RESPONSES": "الردود السريعة",
|
||||||
|
|||||||
@ -10,33 +10,33 @@
|
|||||||
},
|
},
|
||||||
"COMPANY_NAME": {
|
"COMPANY_NAME": {
|
||||||
"LABEL": "Company name",
|
"LABEL": "Company name",
|
||||||
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
|
"PLACEHOLDER": "Enter your company name. E.g., Wayne Enterprises",
|
||||||
"ERROR": "Company name is too short"
|
"ERROR": "Company name is too short."
|
||||||
},
|
},
|
||||||
"FULL_NAME": {
|
"FULL_NAME": {
|
||||||
"LABEL": "الاسم الكامل",
|
"LABEL": "الاسم الكامل",
|
||||||
"PLACEHOLDER": "أدخل اسمك الكامل. مثال: بروس وين",
|
"PLACEHOLDER": "Enter your full name. E.g., Bruce Wayne",
|
||||||
"ERROR": "الاسم الكامل قصير جداً"
|
"ERROR": "Full name is too short."
|
||||||
},
|
},
|
||||||
"EMAIL": {
|
"EMAIL": {
|
||||||
"LABEL": "البريد الإلكتروني للعمل",
|
"LABEL": "البريد الإلكتروني للعمل",
|
||||||
"PLACEHOLDER": "أدخل عنوان بريدك الإلكتروني للعمل. مثال: bruce@wayne.enterprises",
|
"PLACEHOLDER": "Enter your work email address. E.g., bruce@wayne.enterprises",
|
||||||
"ERROR": "Please enter a valid work email address"
|
"ERROR": "Please enter a valid work email address."
|
||||||
},
|
},
|
||||||
"PASSWORD": {
|
"PASSWORD": {
|
||||||
"LABEL": "كلمة المرور",
|
"LABEL": "كلمة المرور",
|
||||||
"PLACEHOLDER": "كلمة المرور",
|
"PLACEHOLDER": "كلمة المرور",
|
||||||
"ERROR": "كلمة المرور قصيرة جداً",
|
"ERROR": "Password is too short.",
|
||||||
"IS_INVALID_PASSWORD": "يجب أن تحتوي كلمة المرور على الأقل على حرف كبير واحد وحرف صغير واحد ورقم واحد وحرف خاص واحد"
|
"IS_INVALID_PASSWORD": "يجب أن تحتوي كلمة المرور على الأقل على حرف كبير واحد وحرف صغير واحد ورقم واحد وحرف خاص واحد."
|
||||||
},
|
},
|
||||||
"CONFIRM_PASSWORD": {
|
"CONFIRM_PASSWORD": {
|
||||||
"LABEL": "تأكيد كلمة المرور",
|
"LABEL": "Confirm password",
|
||||||
"PLACEHOLDER": "تأكيد كلمة المرور",
|
"PLACEHOLDER": "Confirm password",
|
||||||
"ERROR": "كلمة المرور غير متطابقة"
|
"ERROR": "كلمة المرور غير متطابقة."
|
||||||
},
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "تم التسجيل بنجاح",
|
"SUCCESS_MESSAGE": "تم التسجيل بنجاح",
|
||||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
||||||
},
|
},
|
||||||
"SUBMIT": "إرسال",
|
"SUBMIT": "إرسال",
|
||||||
"HAVE_AN_ACCOUNT": "هل لديك حساب مسبق؟"
|
"HAVE_AN_ACCOUNT": "هل لديك حساب مسبق؟"
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
"CAMPAIGN_NAME": "Име на кампания",
|
"CAMPAIGN_NAME": "Име на кампания",
|
||||||
"LABELS": "Етикети",
|
"LABELS": "Етикети",
|
||||||
"BROWSER_LANGUAGE": "Език на браузъра",
|
"BROWSER_LANGUAGE": "Език на браузъра",
|
||||||
|
"PRIORITY": "Priority",
|
||||||
"COUNTRY_NAME": "Име на държавата",
|
"COUNTRY_NAME": "Име на държавата",
|
||||||
"REFERER_LINK": "Референтна връзка",
|
"REFERER_LINK": "Референтна връзка",
|
||||||
"CUSTOM_ATTRIBUTE_LIST": "List",
|
"CUSTOM_ATTRIBUTE_LIST": "List",
|
||||||
|
|||||||
@ -110,7 +110,8 @@
|
|||||||
},
|
},
|
||||||
"PLACEHOLDER": {
|
"PLACEHOLDER": {
|
||||||
"AGENT": "Търсете агенти",
|
"AGENT": "Търсете агенти",
|
||||||
"TEAM": "Търсете екипи"
|
"TEAM": "Търсете екипи",
|
||||||
|
"INPUT": "Search for agents"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user