Merge branch 'release/3.1.0'

This commit is contained in:
Sojan
2023-09-15 16:46:37 -07:00
497 changed files with 5274 additions and 2166 deletions

View File

@@ -15,9 +15,9 @@ defaults: &defaults
- image: cimg/postgres:15.3
- image: cimg/redis:6.2.6
environment:
- RAILS_LOG_TO_STDOUT: false
- COVERAGE: true
- LOG_LEVEL: warn
- RAILS_LOG_TO_STDOUT: false
- COVERAGE: true
- LOG_LEVEL: warn
parallelism: 4
resource_class: large
@@ -46,7 +46,7 @@ jobs:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm install v16
nvm install v20
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
@@ -64,7 +64,6 @@ jobs:
- ~/.bundle
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
# Only necessary if app uses webpacker or yarn in some other way
- restore_cache:
keys:
@@ -82,7 +81,7 @@ jobs:
- ~/.cache/yarn
- run:
name: Download cc-test-reporter
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
@@ -182,7 +181,7 @@ jobs:
- attach_workspace:
at: ~/build
- run:
name: Download cc-test-reporter
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
@@ -204,4 +203,4 @@ workflows:
- build
- upload-coverage:
requires:
- build
- build

View File

@@ -199,6 +199,8 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## Rack Attack configuration
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true
# RACK_ATTACK_IP_LIMIT=3000
# ENABLE_RACK_ATTACK_WIDGET_API=true
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints

View File

@@ -52,7 +52,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 20
cache: yarn
- name: yarn
run: yarn install
@@ -75,6 +76,8 @@ jobs:
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload rails log folder
uses: actions/upload-artifact@v3

View File

@@ -51,7 +51,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 20
cache: yarn
- name: yarn
run: yarn install

View File

@@ -21,7 +21,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 20
cache: 'yarn'
- name: yarn
run: yarn install
@@ -30,9 +31,11 @@ jobs:
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Run asset compile
run: bundle exec rake assets:precompile
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Size Check
run: yarn run size

2
.nvmrc
View File

@@ -1 +1 @@
16.20.1
20.5.1

View File

@@ -8,15 +8,11 @@ Layout/LineLength:
Max: 150
Metrics/ClassLength:
Max: 125
Max: 175
Exclude:
- 'app/models/conversation.rb'
- 'app/models/contact.rb'
- 'app/mailers/conversation_reply_mailer.rb'
- 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb'
RSpec/ExampleLength:
Max: 25
Style/Documentation:
@@ -73,7 +69,7 @@ Rails/ApplicationController:
- 'app/controllers/survey/responses_controller.rb'
Rails/FindEach:
Enabled: true
Include:
Include:
- 'app/**/*.rb'
Rails/CompactBlank:
Enabled: false
@@ -189,7 +185,6 @@ RSpec/IndexedLet:
RSpec/NamedSubject:
Enabled: false
# we should bring this down
RSpec/MultipleMemoizedHelpers:
Max: 14

12
Gemfile
View File

@@ -4,7 +4,7 @@ ruby '3.2.2'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 7.0.5.1'
gem 'rails', '~> 7.0.8.0'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -108,14 +108,14 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.10.0', require: false
gem 'sentry-rails', '>= 5.11.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.10.0', require: false
gem 'sentry-sidekiq', '>= 5.11.0', require: false
##-- background job processing --##
gem 'sidekiq'
gem 'sidekiq', '>= 7.1.3'
# We want cron jobs
gem 'sidekiq-cron'
gem 'sidekiq-cron', '>= 1.10.1'
##-- Push notification service --##
gem 'fcm'
@@ -188,7 +188,7 @@ group :development do
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
gem 'web-console'
gem 'web-console', '>= 4.2.1'
# used in swagger build
gem 'json_refs'

View File

@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.5.1)
actionpack (= 7.0.5.1)
activesupport (= 7.0.5.1)
actioncable (7.0.8)
actionpack (= 7.0.8)
activesupport (= 7.0.8)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.5.1)
actionpack (= 7.0.5.1)
activejob (= 7.0.5.1)
activerecord (= 7.0.5.1)
activestorage (= 7.0.5.1)
activesupport (= 7.0.5.1)
actionmailbox (7.0.8)
actionpack (= 7.0.8)
activejob (= 7.0.8)
activerecord (= 7.0.8)
activestorage (= 7.0.8)
activesupport (= 7.0.8)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.5.1)
actionpack (= 7.0.5.1)
actionview (= 7.0.5.1)
activejob (= 7.0.5.1)
activesupport (= 7.0.5.1)
actionmailer (7.0.8)
actionpack (= 7.0.8)
actionview (= 7.0.8)
activejob (= 7.0.8)
activesupport (= 7.0.8)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.5.1)
actionview (= 7.0.5.1)
activesupport (= 7.0.5.1)
actionpack (7.0.8)
actionview (= 7.0.8)
activesupport (= 7.0.8)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.5.1)
actionpack (= 7.0.5.1)
activerecord (= 7.0.5.1)
activestorage (= 7.0.5.1)
activesupport (= 7.0.5.1)
actiontext (7.0.8)
actionpack (= 7.0.8)
activerecord (= 7.0.8)
activestorage (= 7.0.8)
activesupport (= 7.0.8)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.5.1)
activesupport (= 7.0.5.1)
actionview (7.0.8)
activesupport (= 7.0.8)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.5.1)
activesupport (= 7.0.5.1)
activejob (7.0.8)
activesupport (= 7.0.8)
globalid (>= 0.3.6)
activemodel (7.0.5.1)
activesupport (= 7.0.5.1)
activerecord (7.0.5.1)
activemodel (= 7.0.5.1)
activesupport (= 7.0.5.1)
activemodel (7.0.8)
activesupport (= 7.0.8)
activerecord (7.0.8)
activemodel (= 7.0.8)
activesupport (= 7.0.8)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.5.1)
actionpack (= 7.0.5.1)
activejob (= 7.0.5.1)
activerecord (= 7.0.5.1)
activesupport (= 7.0.5.1)
activestorage (7.0.8)
actionpack (= 7.0.8)
activejob (= 7.0.8)
activerecord (= 7.0.8)
activesupport (= 7.0.8)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.5.1)
activesupport (7.0.8)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -269,8 +269,8 @@ GEM
grpc (~> 1.36)
geocoder (1.8.1)
gli (2.21.0)
globalid (1.1.0)
activesupport (>= 5.0)
globalid (1.2.1)
activesupport (>= 6.1)
gmail_xoauth (0.4.2)
oauth (>= 0.3.6)
google-apis-core (0.11.0)
@@ -450,9 +450,9 @@ GEM
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.2)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
minitest (5.19.0)
minitest (5.20.0)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -463,7 +463,7 @@ GEM
activerecord (>= 5.2)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.3.6)
net-imap (0.3.7)
date
net-protocol
net-pop (0.1.2)
@@ -478,14 +478,14 @@ GEM
sidekiq
newrelic_rpm (8.16.0)
nio4r (2.5.9)
nokogiri (1.15.3)
nokogiri (1.15.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.15.3-arm64-darwin)
nokogiri (1.15.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.15.3-x86_64-darwin)
nokogiri (1.15.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.15.3-x86_64-linux)
nokogiri (1.15.4-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
@@ -543,7 +543,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (5.0.1)
puma (6.2.2)
puma (6.3.1)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
@@ -563,30 +563,30 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.5.1)
actioncable (= 7.0.5.1)
actionmailbox (= 7.0.5.1)
actionmailer (= 7.0.5.1)
actionpack (= 7.0.5.1)
actiontext (= 7.0.5.1)
actionview (= 7.0.5.1)
activejob (= 7.0.5.1)
activemodel (= 7.0.5.1)
activerecord (= 7.0.5.1)
activestorage (= 7.0.5.1)
activesupport (= 7.0.5.1)
rails (7.0.8)
actioncable (= 7.0.8)
actionmailbox (= 7.0.8)
actionmailer (= 7.0.8)
actionpack (= 7.0.8)
actiontext (= 7.0.8)
actionview (= 7.0.8)
activejob (= 7.0.8)
activemodel (= 7.0.8)
activerecord (= 7.0.8)
activestorage (= 7.0.8)
activesupport (= 7.0.8)
bundler (>= 1.15.0)
railties (= 7.0.5.1)
rails-dom-testing (2.1.1)
railties (= 7.0.8)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.5.1)
actionpack (= 7.0.5.1)
activesupport (= 7.0.5.1)
railties (7.0.8)
actionpack (= 7.0.8)
activesupport (= 7.0.8)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -598,7 +598,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.14.1)
redis-client (0.17.0)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -697,23 +697,23 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.10.0)
sentry-rails (5.11.0)
railties (>= 5.0)
sentry-ruby (~> 5.10.0)
sentry-ruby (5.10.0)
sentry-ruby (~> 5.11.0)
sentry-ruby (5.11.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.10.0)
sentry-ruby (~> 5.10.0)
sentry-sidekiq (5.11.0)
sentry-ruby (~> 5.11.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.1.2)
sidekiq (7.1.3)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.14.0)
sidekiq-cron (1.10.0)
sidekiq-cron (1.10.1)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6)
@@ -786,7 +786,7 @@ GEM
version_gem (1.1.2)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
@@ -805,14 +805,14 @@ GEM
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.8.1)
websocket-driver (0.7.5)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.0)
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.9)
zeitwerk (2.6.11)
PLATFORMS
arm64-darwin-20
@@ -907,7 +907,7 @@ DEPENDENCIES
rack-cors
rack-mini-profiler (>= 3.1.1)
rack-timeout
rails (~> 7.0.5.1)
rails (~> 7.0.8.0)
redis
redis-namespace
responders
@@ -922,12 +922,12 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.10.0)
sentry-rails (>= 5.11.0)
sentry-ruby
sentry-sidekiq (>= 5.10.0)
sentry-sidekiq (>= 5.11.0)
shoulda-matchers
sidekiq
sidekiq-cron
sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.10.1)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.0.0)
spring
@@ -943,7 +943,7 @@ DEPENDENCIES
tzinfo-data
uglifier
valid_email2
web-console
web-console (>= 4.2.1)
web-push
webmock
webpacker

View File

@@ -1,4 +1,4 @@
backend: bin/rails s -p 3000
frontend: bin/webpack-dev-server
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml

View File

@@ -60,6 +60,9 @@
],
"stack": "heroku-20",
"buildpacks": [
{
"url": "heroku/nodejs"
},
{
"url": "heroku/ruby"
}

View File

@@ -29,3 +29,5 @@
@import 'components/pagination';
@import 'components/search';
@import 'components/reports';
@import 'custom_styles';

View File

@@ -0,0 +1,24 @@
// custom styles for the dashboard
.feature-cell {
background: $color-extra-light-blue;
border-radius: 10px;
float: left;
margin-left: 8px;
margin-top: 6px;
padding: 4px 12px;
.icon-container {
margin-right: 4px;
}
.value-container {
margin-left: 6px;
}
}
.feature-container {
max-width: 100rem;
}

View File

@@ -1,7 +1,7 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_article, except: [:index, :create, :attach_file, :reorder]
before_action :fetch_article, except: [:index, :create, :reorder]
before_action :set_current_page, only: [:index]
def index
@@ -36,17 +36,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
head :ok
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:background_image].tempfile,
filename: params[:background_image].original_filename,
content_type: params[:background_image].content_type
)
file_blob.save!
render json: { file_url: url_for(file_blob) }
end
def reorder
Article.update_positions(params[:positions_hash])
head :ok

View File

@@ -20,16 +20,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def update
ActiveRecord::Base.transaction do
automation_rule_update

View File

@@ -18,7 +18,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def index
@contacts_count = resolved_contacts.count
@contacts = fetch_contacts_with_conversation_count(resolved_contacts)
@contacts = fetch_contacts(resolved_contacts)
end
def search
@@ -29,7 +29,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
search: "%#{params[:q].strip}%"
)
@contacts_count = contacts.count
@contacts = fetch_contacts_with_conversation_count(contacts)
@contacts = fetch_contacts(contacts)
end
def import
@@ -63,7 +63,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
result = ::Contacts::FilterService.new(params.permit!, current_user).perform
contacts = result[:contacts]
@contacts_count = result[:count]
@contacts = fetch_contacts_with_conversation_count(contacts)
@contacts = fetch_contacts(contacts)
end
def contactable_inboxes
@@ -125,17 +125,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@current_page = params[:page] || 1
end
def fetch_contacts_with_conversation_count(contacts)
conversation_count_sub_query = 'SELECT COUNT(*) FROM "conversations" WHERE "conversations"."contact_id" = "contacts"."id"'
contacts_with_conversation_count = filtrate(contacts)
.select("contacts.*, (#{conversation_count_sub_query}) as conversations_count")
.group('contacts.id')
.includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE)
def fetch_contacts(contacts)
contacts_with_avatar = filtrate(contacts)
.includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE)
return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
contacts_with_conversation_count
contacts_with_avatar
end
def build_contact_inbox

View File

@@ -39,16 +39,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
head :ok
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def execute
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)

View File

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

View File

@@ -0,0 +1,13 @@
class Api::V1::UploadController < Api::BaseController
def create
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
file_blob.save!
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
end
end

View File

@@ -8,7 +8,7 @@ module EnsureCurrentAccountHelper
def ensure_current_account
account = Account.find(params[:account_id])
ensure_account_is_active?(account)
render_unauthorized('Account is suspended') and return unless account.active?
if current_user
account_accessible_for_user?(account)
@@ -27,8 +27,4 @@ module EnsureCurrentAccountHelper
def account_accessible_for_bot?(account)
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end
def ensure_account_is_active?(account)
render_unauthorized('Account is suspended') unless account.active?
end
end

View File

@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::BaseController < PublicController
before_action :show_plain_layout
around_action :set_locale
after_action :allow_iframe_requests
private
@@ -39,4 +40,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
I18n.with_locale(@locale, &)
end
def allow_iframe_requests
response.headers.delete('X-Frame-Options') if @is_plain_layout_enabled
end
end

View File

@@ -2,7 +2,7 @@
<div
v-if="!authUIFlags.isFetching"
id="app"
class="app-wrapper app-root"
class="app-wrapper h-full flex-grow-0 min-h-0 w-full"
:class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'"
>

View File

@@ -9,14 +9,6 @@ class AutomationsAPI extends ApiClient {
clone(automationId) {
return axios.post(`${this.url}/${automationId}/clone`);
}
attachment(file) {
return axios.post(`${this.url}/attach_file`, file, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
}
export default new AutomationsAPI();

View File

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

View File

@@ -26,7 +26,7 @@ code {
background: $color-background;
border-radius: var(--border-radius-large);
padding: $space-two;
@apply bg-slate-50 dark:bg-slate-600 text-slate-800 dark:text-slate-100;
@apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100;
}
}

View File

@@ -9,87 +9,25 @@ body {
}
.app-wrapper {
@include full-height;
flex-grow: 0;
min-height: 0;
width: 100%;
@apply h-full flex-grow-0 min-h-0 w-full;
.button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
}
}
.banner + .app-wrapper {
// Reduce the height of the dashboard to make room for the banner.
// And causing the top right green-action button to be pushed down when scrolling.
@apply h-[calc(100%-48px)];
.button--fixed-top {
top: 5.6 * $space-one;
@apply top-14;
}
.off-canvas-content {
.button--fixed-top {
top: $space-small;
@apply top-2;
}
}
}
is-closed .app-root {
@include flex;
flex-direction: column;
}
.app-content {
@include flex;
@include full-height;
min-height: 0;
overflow: hidden;
}
.view-box {
@include full-height;
@include space-between-column;
height: 100vh;
margin: 0;
}
.view-panel {
flex-direction: column;
margin: 0;
overflow-y: auto;
padding: $space-normal;
}
.content-box {
overflow: auto;
padding: $space-normal;
}
.back-button {
@include flex;
align-items: center;
color: $color-woot;
cursor: pointer;
font-size: $font-size-default;
font-weight: $font-weight-normal;
margin-right: $space-normal;
&::before {
font-size: $font-size-large;
margin-right: $space-small;
vertical-align: text-bottom;
}
}
.button-spinner {
float: right;
}
.no-items-error-message {
@include flex;
@include full-height;
align-items: center;
flex-direction: column;
justify-content: center;
img {
max-width: $space-mega;
padding: $space-one;
}
}

View File

@@ -1,20 +1,6 @@
.app-rtl--wrapper {
direction: rtl;
.header-section.back-button {
direction: initial;
margin-left: var(--space-normal);
margin-right: var(--space-smaller);
}
// Settings header action button
.button--fixed-top {
left: $space-small;
position: fixed;
right: unset;
top: $space-small;
}
// Woot Tabs
.tabs-title {
&:first-child {

View File

@@ -71,9 +71,3 @@
align-items: center;
display: flex;
}
.button--fixed-top {
position: fixed;
right: var(--space-small);
top: var(--space-small);
}

View File

@@ -1,5 +1,5 @@
.button {
@apply items-center inline-flex h-10 mb-0;
@apply items-center inline-flex h-10 mb-0 gap-2;
.button__content {
@apply w-full;
@@ -14,17 +14,9 @@
@apply px-2 py-0;
}
.icon--emoji + .button__content {
@apply pl-2 rtl:pr-2 rtl:pl-0;
}
.icon--font + .button__content {
@apply pl-2 rtl:pr-2 rtl:pl-0;
}
// @TODDO - Remove after moving all buttons to woot-button
.icon + .button__content {
@apply pl-2 w-auto rtl:pr-2 rtl:pl-0;
@apply w-auto;
}
&.expanded {
@@ -157,18 +149,10 @@
// Sizes
&.tiny {
@apply h-6;
.icon + .button__content {
@apply pl-1 rtl:pr-1 rtl:pl-0;
}
}
&.small {
@apply h-8 pb-1 pt-1;
.icon + .button__content {
@apply pl-1 rtl:pr-1 rtl:pl-0;
}
}
&.large {

View File

@@ -76,13 +76,13 @@ table {
.ve-pagination-goto {
@apply text-slate-600 dark:text-slate-200;
.ve-pagination-goto-input {
@apply bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200;
}
}
.ve-pagination-li {
@apply bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200 border-slate-75 dark:border-slate-700;
}
.ve-pagination-goto-input {
@apply bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200;
}
}

View File

@@ -61,7 +61,7 @@
>
<a
v-if="isAttributeTypeLink"
:href="value"
:href="hrefURL"
target="_blank"
rel="noopener noreferrer"
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1"
@@ -188,6 +188,9 @@ export default {
urlValue() {
return isValidURL(this.value) ? this.value : '---';
},
hrefURL() {
return isValidURL(this.value) ? this.value : '';
},
notAttributeTypeCheckboxAndList() {
return !this.isAttributeTypeCheckbox && !this.isAttributeTypeList;
},

View File

@@ -3,7 +3,7 @@
<div
v-if="show"
v-on-clickaway="onClickAway"
class="left-3 rtl:left-auto rtl:right-3 bottom-16 w-64 absolute z-20 rounded-md shadow-xl bg-white dark:bg-slate-800 py-2 px-2 border border-slate-25 dark:border-slate-700"
class="left-3 rtl:left-auto rtl:right-3 bottom-16 w-64 absolute z-30 rounded-md shadow-xl bg-white dark:bg-slate-800 py-2 px-2 border border-slate-25 dark:border-slate-700"
:class="{ 'block visible': show }"
>
<availability-status />

View File

@@ -46,6 +46,7 @@ import AgentDetails from './AgentDetails';
import NotificationBell from './NotificationBell';
import wootConstants from 'dashboard/constants/globals';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default {
components: {
@@ -99,6 +100,7 @@ export default {
window.$chatwoot.toggle();
},
openNotificationPanel() {
this.$track(ACCOUNT_EVENTS.OPENED_NOTIFICATIONS);
this.$emit('open-notification-panel');
},
},

View File

@@ -1,5 +1,8 @@
<template>
<div class="banner" :class="bannerClasses">
<div
class="banner flex items-center h-12 gap-4 text-white dark:text-white text-xs py-3 px-4 justify-center"
:class="bannerClasses"
>
<span class="banner-message">
{{ bannerMessage }}
<a
@@ -96,8 +99,6 @@ export default {
<style lang="scss" scoped>
.banner {
@apply flex items-center gap-4 text-white dark:text-white text-xs py-3 px-4 justify-center;
&.primary {
@apply bg-woot-500 dark:bg-woot-500;
.banner-action__button {

View File

@@ -61,11 +61,9 @@ export default {
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING');
try {
const file = event.target.files[0];
const formData = new FormData();
formData.append('attachment', file, file.name);
const id = await this.$store.dispatch(
'automations/uploadAttachment',
formData
file
);
this.$emit('input', [id]);
this.uploadState = 'uploaded';

View File

@@ -1,5 +1,8 @@
<template>
<button class="header-section back-button" @click.capture="goBack">
<button
class="header-section flex items-center text-base font-normal mr-4 ml-2 cursor-pointer text-woot-500 dark:text-woot-500"
@click.capture="goBack"
>
<fluent-icon icon="chevron-left" />
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex items-center text-left">
<div class="flex items-center gap-1.5 text-left">
<thumbnail
:src="user.thumbnail"
:size="size"
@@ -7,7 +7,7 @@
:status="user.availability_status"
/>
<h6
class="my-0 mx-2 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
class="my-0 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
{{ user.name }}

View File

@@ -15,6 +15,13 @@
:search-key="variableSearchTerm"
@click="insertVariable"
/>
<input
ref="imageUpload"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
hidden
@change="onFileChange"
/>
<div ref="editor" />
</div>
</template>
@@ -22,7 +29,7 @@
<script>
import {
messageSchema,
wootMessageWriterSetup,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
@@ -37,8 +44,13 @@ import {
import TagAgents from '../conversation/TagAgents';
import CannedResponse from '../conversation/CannedResponse';
import VariableList from '../conversation/VariableList';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
import {
hasPressedEnterAndNotCmdOrShift,
@@ -51,14 +63,27 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import alertMixin from 'shared/mixins/alertMixin';
import { findNodeToInsertImage } from 'dashboard/helper/messageEditorHelper';
import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
const createState = (content, placeholder, plugins = []) => {
const createState = (
content,
placeholder,
plugins = [],
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: wootMessageWriterSetup({
plugins: buildEditor({
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};
@@ -66,7 +91,7 @@ const createState = (content, placeholder, plugins = []) => {
export default {
name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin],
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
props: {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
@@ -78,6 +103,11 @@ export default {
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
},
data() {
return {
@@ -104,6 +134,11 @@ export default {
this.enableCannedResponses && this.showCannedMenu && !this.isPrivate
);
},
editorMenuOptions() {
return this.enabledMenuOptions.length
? this.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
},
plugins() {
if (!this.enableSuggestions) {
return [];
@@ -193,6 +228,12 @@ export default {
}),
];
},
sendWithSignature() {
// this is considered the source of truth, we watch this property
// on change, we toggle the signature in the editor
const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled && this.allowSignature && !this.isPrivate;
},
},
watch: {
showUserMentions(updatedValue) {
@@ -204,20 +245,19 @@ export default {
showVariables(updatedValue) {
this.$emit('toggle-variables-menu', !this.isPrivate && updatedValue);
},
value(newValue = '') {
if (newValue !== this.contentFromEditor) {
this.reloadState();
value(newVal = '') {
if (newVal !== this.contentFromEditor) {
this.reloadState(newVal);
}
},
editorId() {
this.showCannedMenu = false;
this.cannedSearchTerm = '';
this.reloadState();
this.reloadState(this.value);
},
isPrivate() {
this.reloadState();
this.reloadState(this.value);
},
updateSelectionWith(newValue, oldValue) {
if (!this.editorView) {
return null;
@@ -236,20 +276,106 @@ export default {
}
return null;
},
sendWithSignature(newValue) {
// see if the allowSignature flag is true
if (this.allowSignature) {
this.toggleSignatureInEditor(newValue);
}
},
},
created() {
this.state = createState(this.value, this.placeholder, this.plugins);
this.state = createState(
this.value,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.editorMenuOptions
);
},
mounted() {
this.createEditorView();
this.editorView.updateState(this.state);
this.focusEditorInputField();
this.focusEditor(this.value);
},
methods: {
reloadState() {
this.state = createState(this.value, this.placeholder, this.plugins);
reloadState(content = this.value) {
this.state = createState(
content,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.editorMenuOptions
);
this.editorView.updateState(this.state);
this.focusEditorInputField();
this.focusEditor(content);
},
focusEditor(content) {
if (this.isBodyEmpty(content) && this.sendWithSignature) {
// reload state can be called when switching between conversations, or when drafts is loaded
// these drafts can also have a signature, so we need to check if the body is empty
// and handle things accordingly
this.handleEmptyBodyWithSignature();
} else {
// this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
// the position is set to start, because the signature is added at the end of the body
this.focusEditorInputField('end');
}
},
toggleSignatureInEditor(signatureEnabled) {
// The toggleSignatureInEditor gets the new value from the
// watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it.
if (signatureEnabled) {
this.addSignature();
} else {
this.removeSignature();
}
},
addSignature() {
let content = this.value;
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
const contentWasEmpty = this.isBodyEmpty(content);
content = appendSignature(content, this.signature);
// need to reload first, ensuring that the editorView is updated
this.reloadState(content);
if (contentWasEmpty) {
this.handleEmptyBodyWithSignature();
}
},
removeSignature() {
if (!this.signature) return;
let content = this.value;
content = removeSignature(content, this.signature);
// reload the state, ensuring that the editorView is updated
this.reloadState(content);
},
isBodyEmpty(content) {
// if content is undefined, we assume that the body is empty
if (!content) return true;
// if the signature is present, we need to remove it before checking
// note that we don't update the editorView, so this is safe
const bodyWithoutSignature = this.signature
? removeSignature(content, this.signature)
: content;
// trimming should remove all the whitespaces, so we can check the length
return bodyWithoutSignature.trim().length === 0;
},
handleEmptyBodyWithSignature() {
const { schema, tr } = this.state;
// create a paragraph node and
// start a transaction to append it at the end
const paragraph = schema.nodes.paragraph.create();
const paragraphTransaction = tr.insert(0, paragraph);
this.editorView.dispatch(paragraphTransaction);
// Set the focus at the start of the input field
this.focusEditorInputField('start');
},
createEditorView() {
this.editorView = new EditorView(this.$refs.editor, {
@@ -294,9 +420,11 @@ export default {
this.focusEditorInputField();
}
},
focusEditorInputField() {
focusEditorInputField(pos = 'end') {
const { tr } = this.editorView.state;
const selection = Selection.atEnd(tr.doc);
const selection =
pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
@@ -374,6 +502,57 @@ export default {
tr.scrollIntoView();
return false;
},
openFileBrowser() {
this.$refs.imageUpload.click();
},
onFileChange() {
const file = this.$refs.imageUpload.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadImageToStorage(file);
} else {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
{
size: MAXIMUM_FILE_UPLOAD_SIZE,
}
)
);
}
this.$refs.imageUpload.value = '';
},
async uploadImageToStorage(file) {
try {
const { fileUrl } = await uploadFile(file);
if (fileUrl) {
this.onImageInsertInEditor(fileUrl);
}
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
)
);
} catch (error) {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_ERROR'
)
);
}
},
onImageInsertInEditor(fileUrl) {
const { tr } = this.editorView.state;
const insertData = findNodeToInsertImage(this.editorView.state, fileUrl);
if (insertData) {
this.editorView.dispatch(
tr.insert(insertData.pos, insertData.node).scrollIntoView()
);
this.focusEditorInputField();
}
},
emitOnChange() {
this.editorView.updateState(this.state);

View File

@@ -16,7 +16,7 @@
<script>
import {
fullSchema,
wootArticleWriterSetup,
buildEditor,
EditorView,
ArticleMarkdownSerializer,
ArticleMarkdownTransformer,
@@ -33,15 +33,17 @@ const createState = (
content,
placeholder,
plugins = [],
onImageUpload = () => {}
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new ArticleMarkdownTransformer(fullSchema).parse(content),
plugins: wootArticleWriterSetup({
plugins: buildEditor({
schema: fullSchema,
placeholder,
methods,
plugins,
onImageUpload,
enabledMenuOptions,
}),
});
};
@@ -52,6 +54,7 @@ export default {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: { type: String, default: '' },
enabledMenuOptions: { type: Array, default: () => [] },
},
data() {
return {
@@ -83,7 +86,8 @@ export default {
this.value,
this.placeholder,
this.plugins,
this.openFileBrowser
{ onImageUpload: this.openFileBrowser },
this.enabledMenuOptions
);
},
mounted() {
@@ -152,7 +156,8 @@ export default {
this.value,
this.placeholder,
this.plugins,
this.openFileBrowser
{ onImageUpload: this.openFileBrowser },
this.enabledMenuOptions
);
this.editorView.updateState(this.state);
this.focusEditorInputField();

View File

@@ -288,7 +288,7 @@ export default {
}
},
showMessageSignatureButton() {
return !this.isOnPrivateNote && this.isAnEmailChannel;
return !this.isOnPrivateNote;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;

View File

@@ -358,6 +358,7 @@ export default {
applied_filters: this.appliedFilters.map(filter => ({
key: filter.attribute_key,
operator: filter.filter_operator,
query_operator: filter.query_operator,
})),
});
},

View File

@@ -8,7 +8,11 @@
<div
class="flex justify-center items-center mr-4 rtl:mr-0 rtl:ml-4 min-w-0"
>
<back-button v-if="showBackButton" :back-url="backButtonUrl" />
<back-button
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:ml-0 rtl:mr-0 rtl:ml-4"
/>
<Thumbnail
:src="currentContact.thumbnail"
:badge="inboxBadge"

View File

@@ -53,6 +53,9 @@
class="input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
:allow-signature="true"
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -69,6 +72,8 @@
:min-height="4"
:enable-variables="true"
:variables="messageVariables"
:signature="signatureToApply"
:allow-signature="true"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -86,16 +91,10 @@
/>
</div>
<div
v-if="isSignatureEnabledForInbox"
v-tooltip="$t('CONVERSATION.FOOTER.MESSAGE_SIGN_TOOLTIP')"
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
class="message-signature-wrap"
>
<p
v-if="isSignatureAvailable"
v-dompurify-html="formatMessage(messageSignature)"
class="message-signature"
/>
<p v-else class="message-signature">
<p class="message-signature">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
<router-link :to="profilePath">
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
@@ -184,6 +183,12 @@ import wootConstants from 'dashboard/constants/globals';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import rtlMixin from 'shared/mixins/rtlMixin';
import {
appendSignature,
removeSignature,
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
@@ -471,10 +476,10 @@ export default {
);
},
isSignatureEnabledForInbox() {
return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature;
return !this.isPrivate && this.sendWithSignature;
},
isSignatureAvailable() {
return !!this.messageSignature;
return !!this.signatureToApply;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
@@ -514,6 +519,12 @@ export default {
});
return variables;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
},
watch: {
currentChat(conversation) {
@@ -581,6 +592,23 @@ export default {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
if (!this.showRichContentEditor && this.messageSignature) {
// remove the old signature -> extract text from markdown -> attach new signature
let message = removeSignature(this.message, this.messageSignature);
message = extractTextFromMarkdown(message);
message = appendSignature(message, plainTextSignature);
this.message = message;
} else {
this.message = replaceSignature(
this.message,
plainTextSignature,
this.messageSignature
);
}
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
@@ -600,9 +628,22 @@ export default {
getFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
this.message = this.$store.getters['draftMessages/get'](key) || '';
const messageFromStore =
this.$store.getters['draftMessages/get'](key) || '';
// ensure that the message has signature set based on the ui setting
this.message = this.toggleSignatureForDraft(messageFromStore);
}
},
toggleSignatureForDraft(message) {
if (this.isPrivate) {
return message;
}
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
@@ -694,19 +735,14 @@ export default {
return;
}
if (!this.showMentions) {
let newMessage = this.message;
if (this.isSignatureEnabledForInbox && this.messageSignature) {
newMessage += '\n\n' + this.messageSignature;
}
const isOnWhatsApp =
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel;
if (isOnWhatsApp && !this.isPrivate) {
this.sendMessageAsMultipleMessages(newMessage);
this.sendMessageAsMultipleMessages(this.message);
} else {
const messagePayload = this.getMessagePayload(newMessage);
const messagePayload = this.getMessagePayload(this.message);
this.sendMessage(messagePayload);
}
@@ -725,6 +761,15 @@ export default {
this.sendMessage(messagePayload);
});
},
sendMessageAnalyticsData(isPrivate) {
// Analytics data for message signature is enabled or not in channels
return isPrivate
? this.$track(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
: this.$track(CONVERSATION_EVENTS.SENT_MESSAGE, {
channelType: this.channelType,
signatureEnabled: this.sendWithSignature,
});
},
async onSendReply() {
const undefinedVariables = getUndefinedVariablesInMessage({
message: this.message,
@@ -758,6 +803,7 @@ export default {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
bus.$emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
this.sendMessageAnalyticsData(messagePayload.private);
} catch (error) {
const errorMessage =
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
@@ -818,6 +864,10 @@ export default {
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
this.message = appendSignature(this.message, this.signatureToApply);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
},

View File

@@ -95,7 +95,7 @@ export default {
<style scoped lang="scss">
.mention--box {
@apply bg-white text-sm dark:bg-slate-700 rounded-md overflow-auto absolute w-full z-[100] pt-2 px-2 pb-0 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border-t border-solid border-slate-75 dark:border-slate-800;
@apply bg-white text-sm dark:bg-slate-700 rounded-md overflow-auto absolute w-full z-20 pt-2 px-2 pb-0 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border-t border-solid border-slate-75 dark:border-slate-800;
&.with-bottom-border {
@apply border-b-[0.5rem] border-solid border-white dark:border-slate-600;

View File

@@ -79,7 +79,7 @@ export default {
<style scoped lang="scss">
.mention--box {
@apply bg-white dark:bg-slate-700 rounded-md overflow-auto absolute w-full z-[100] pt-2 px-2 pb-0 shadow-md left-0 bottom-full max-h-[9.75rem] border-t border-solid border-slate-75 dark:border-slate-800;
@apply bg-white dark:bg-slate-700 rounded-md overflow-auto absolute w-full z-20 pt-2 px-2 pb-0 shadow-md left-0 bottom-full max-h-[9.75rem] border-t border-solid border-slate-75 dark:border-slate-800;
.dropdown-menu__item:last-child {
@apply pb-1;

View File

@@ -0,0 +1,34 @@
export const MESSAGE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'bulletList',
'orderedList',
'code',
];
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'imageUpload',
];
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'bulletList',
'orderedList',
'h1',
'h2',
'h3',
'imageUpload',
'code',
];

View File

@@ -15,6 +15,9 @@ export const ACCOUNT_EVENTS = Object.freeze({
ADDED_A_CUSTOM_ATTRIBUTE: 'Added a custom attribute',
ADDED_AN_INBOX: 'Added an inbox',
OPEN_MESSAGE_CONTEXT_MENU: 'Opened message context menu',
OPENED_NOTIFICATIONS: 'Opened notifications',
MARK_AS_READ_NOTIFICATIONS: 'Marked notifications as read',
OPEN_CONVERSATION_VIA_NOTIFICATION: 'Opened conversation via notification',
});
export const LABEL_EVENTS = Object.freeze({

View File

@@ -120,9 +120,18 @@ export const generateConditionOptions = (options, key = 'id') => {
});
};
// Add the "None" option to the agent list
export const agentList = agents => [
{
id: 'nil',
name: 'None',
},
...(agents || []),
];
export const getActionOptions = ({ agents, teams, labels, type }) => {
const actionsMap = {
assign_agent: agents,
assign_agent: agentList(agents),
assign_team: teams,
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),

View File

@@ -0,0 +1,135 @@
/**
* The delimiter used to separate the signature from the rest of the body.
* @type {string}
*/
export const SIGNATURE_DELIMITER = '--';
/**
* Trim the signature and remove all " \r" from the signature
* 1. Trim any extra lines or spaces at the start or end of the string
* 2. Converts all \r or \r\n to \f
*/
export function cleanSignature(signature) {
return signature.trim().replace(/\r\n?/g, '\n');
}
/**
* Adds the signature delimiter to the beginning of the signature.
*
* @param {string} signature - The signature to add the delimiter to.
* @returns {string} - The signature with the delimiter added.
*/
function appendDelimiter(signature) {
return `${SIGNATURE_DELIMITER}\n\n${cleanSignature(signature)}`;
}
/**
* Check if there's an unedited signature at the end of the body
* If there is, return the index of the signature, If there isn't, return -1
*
* @param {string} body - The body to search for the signature.
* @param {string} signature - The signature to search for.
* @returns {number} - The index of the last occurrence of the signature in the body, or -1 if not found.
*/
export function findSignatureInBody(body, signature) {
const trimmedBody = body.trimEnd();
const cleanedSignature = cleanSignature(signature);
// check if body ends with signature
if (trimmedBody.endsWith(cleanedSignature)) {
return body.lastIndexOf(cleanedSignature);
}
return -1;
}
/**
* Appends the signature to the body, separated by the signature delimiter.
*
* @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append.
* @returns {string} - The body with the signature appended.
*/
export function appendSignature(body, signature) {
const cleanedSignature = cleanSignature(signature);
// if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) {
return body;
}
return `${body.trimEnd()}\n\n${appendDelimiter(cleanedSignature)}`;
}
/**
* Removes the signature from the body, along with the signature delimiter.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature) {
// this will find the index of the signature if it exists
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
const cleanedSignature = cleanSignature(signature);
const signatureIndex = findSignatureInBody(body, cleanedSignature);
// no need to trim the ends here, because it will simply be removed in the next method
let newBody = body;
// if signature is present, remove it and trim it
// trimming will ensure any spaces or new lines before the signature are removed
// This means we will have the delimiter at the end
if (signatureIndex > -1) {
newBody = newBody.substring(0, signatureIndex).trimEnd();
}
// let's find the delimiter and remove it
const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
if (
delimiterIndex !== -1 &&
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
) {
// if the delimiter is at the end, remove it
newBody = newBody.substring(0, delimiterIndex);
}
// return the value
return newBody;
}
/**
* Replaces the old signature with the new signature.
* If the old signature is not present, it will append the new signature.
*
* @param {string} body - The body to replace the signature in.
* @param {string} oldSignature - The signature to replace.
* @param {string} newSignature - The signature to replace the old signature with.
* @returns {string} - The body with the old signature replaced with the new signature.
*
*/
export function replaceSignature(body, oldSignature, newSignature) {
const withoutSignature = removeSignature(body, oldSignature);
return appendSignature(withoutSignature, newSignature);
}
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns
*/
export function extractTextFromMarkdown(markdown) {
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}

View File

@@ -0,0 +1,40 @@
/**
* Determines the appropriate node and position to insert an image in the editor.
*
* Based on the current editor state and the provided image URL, this function finds out the correct node (either
* a standalone image node or an image wrapped in a paragraph) and its respective position in the editor.
*
* 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it.
* 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped
* in a new paragraph and then inserted.
* 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it.
*
* @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc.
* @param {string} fileUrl - The URL of the image to be inserted into the editor.
* @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created.
* @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image).
* @property {number} pos - The position where the new node should be inserted in the editor.
*/
export const findNodeToInsertImage = (editorState, fileUrl) => {
const { selection, schema } = editorState;
const { nodes } = schema;
const currentNode = selection.$from.node();
const {
type: { name: typeName },
content: { size, content },
} = currentNode;
const imageNode = nodes.image.create({ src: fileUrl });
if (!imageNode) return null;
const isInParagraph = typeName === 'paragraph';
const needsNewLine =
!content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0;
return {
node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode),
pos: selection.from + needsNewLine,
};
};

View File

@@ -49,3 +49,38 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
// Proceed to the route if none of the above conditions are met
return null;
};
export const isAConversationRoute = routeName =>
[
'inbox_conversation',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_inbox',
'conversations_through_label',
'conversations_through_team',
'conversations_through_folders',
'conversation_through_participating',
].includes(routeName);
export const getConversationDashboardRoute = routeName => {
switch (routeName) {
case 'inbox_conversation':
return 'home';
case 'conversation_through_mentions':
return 'conversation_mentions';
case 'conversation_through_unattended':
return 'conversation_unattended';
case 'conversations_through_label':
return 'label_conversations';
case 'conversations_through_team':
return 'team_conversations';
case 'conversations_through_folders':
return 'folder_conversations';
case 'conversation_through_participating':
return 'conversation_participating';
case 'conversation_through_inbox':
return 'inbox_dashboard';
default:
return null;
}
};

View File

@@ -0,0 +1,157 @@
import {
findSignatureInBody,
appendSignature,
removeSignature,
replaceSignature,
extractTextFromMarkdown,
} from '../editorHelper';
const NEW_SIGNATURE = 'This is a new signature';
const DOES_NOT_HAVE_SIGNATURE = {
'no signature': {
body: 'This is a test',
signature: 'This is a signature',
},
'text after signature': {
body: 'This is a test\n\n--\n\nThis is a signature\n\nThis is more text',
signature: 'This is a signature',
},
signature_has_images: {
body: 'This is a test',
signature:
'Testing \n![](http://localhost:3000/rails/active_storage/blobs/redirect/some-hash/image.png)',
},
};
const HAS_SIGNATURE = {
'signature at end': {
body: 'This is a test\n\n--\n\nThis is a signature',
signature: 'This is a signature',
},
'signature at end with spaces and new lines': {
body: 'This is a test\n\n--\n\nThis is a signature \n\n',
signature: 'This is a signature ',
},
'no text before signature': {
body: '\n\n--\n\nThis is a signature',
signature: 'This is a signature',
},
};
describe('findSignatureInBody', () => {
it('returns -1 if there is no signature', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(findSignatureInBody(body, signature)).toBe(-1);
});
});
it('returns the index of the signature if there is one', () => {
Object.keys(HAS_SIGNATURE).forEach(key => {
const { body, signature } = HAS_SIGNATURE[key];
expect(findSignatureInBody(body, signature)).toBeGreaterThan(0);
});
});
});
describe('appendSignature', () => {
it('appends the signature if it is not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(appendSignature(body, signature)).toBe(
`${body}\n\n--\n\n${signature}`
);
});
});
it('does not append signature if already present', () => {
Object.keys(HAS_SIGNATURE).forEach(key => {
const { body, signature } = HAS_SIGNATURE[key];
expect(appendSignature(body, signature)).toBe(body);
});
});
});
describe('removeSignature', () => {
it('does not remove signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(removeSignature(body, signature)).toBe(body);
});
});
it('removes signature if present at the end', () => {
const { body, signature } = HAS_SIGNATURE['signature at end'];
expect(removeSignature(body, signature)).toBe('This is a test\n\n');
});
it('removes signature if present with spaces and new lines', () => {
const { body, signature } = HAS_SIGNATURE[
'signature at end with spaces and new lines'
];
expect(removeSignature(body, signature)).toBe('This is a test\n\n');
});
it('removes signature if present without text before it', () => {
const { body, signature } = HAS_SIGNATURE['no text before signature'];
expect(removeSignature(body, signature)).toBe('\n\n');
});
it('removes just the delimiter if no signature is present', () => {
expect(removeSignature('This is a test\n\n--', 'This is a signature')).toBe(
'This is a test\n\n'
);
});
});
describe('replaceSignature', () => {
it('appends the new signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`${body}\n\n--\n\n${NEW_SIGNATURE}`
);
});
});
it('removes signature if present at the end', () => {
const { body, signature } = HAS_SIGNATURE['signature at end'];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`This is a test\n\n--\n\n${NEW_SIGNATURE}`
);
});
it('removes signature if present with spaces and new lines', () => {
const { body, signature } = HAS_SIGNATURE[
'signature at end with spaces and new lines'
];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`This is a test\n\n--\n\n${NEW_SIGNATURE}`
);
});
it('removes signature if present without text before it', () => {
const { body, signature } = HAS_SIGNATURE['no text before signature'];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`\n\n--\n\n${NEW_SIGNATURE}`
);
});
});
describe('extractTextFromMarkdown', () => {
it('should extract text from markdown and remove all images, code blocks, links, headers, bold, italic, lists etc.', () => {
const markdown = `
# Hello World
This is a **bold** text with a [link](https://example.com).
\`\`\`javascript
const foo = 'bar';
console.log(foo);
\`\`\`
Here's an image: ![alt text](https://example.com/image.png)
- List item 1
- List item 2
*Italic text*
`;
const expected =
"Hello World\nThis is a bold text with a link.\nHere's an image:\nList item 1\nList item 2\nItalic text";
expect(extractTextFromMarkdown(markdown)).toEqual(expected);
});
});

View File

@@ -0,0 +1,100 @@
import { findNodeToInsertImage } from '../messageEditorHelper';
describe('findNodeToInsertImage', () => {
let mockEditorState;
beforeEach(() => {
mockEditorState = {
selection: {
$from: {
node: jest.fn(() => ({})),
},
from: 0,
},
schema: {
nodes: {
image: {
create: jest.fn(attrs => ({ type: { name: 'image' }, attrs })),
},
paragraph: {
create: jest.fn((_, node) => ({
type: { name: 'paragraph' },
content: [node],
})),
},
},
},
};
});
it('should insert image directly into an empty paragraph', () => {
const mockNode = {
type: { name: 'paragraph' },
content: { size: 0, content: [] },
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result).toEqual({
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
pos: 0,
});
});
it('should insert image directly into a paragraph without an image but with other content', () => {
const mockNode = {
type: { name: 'paragraph' },
content: {
size: 1,
content: [
{
type: { name: 'text' },
},
],
},
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
mockEditorState.selection.from = 1;
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result).toEqual({
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
pos: 2, // Because it should insert after the text, on a new line.
});
});
it("should wrap image in a new paragraph when the current node isn't a paragraph", () => {
const mockNode = {
type: { name: 'not-a-paragraph' },
content: { size: 0, content: [] },
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result.node.type.name).toBe('paragraph');
expect(result.node.content[0].type.name).toBe('image');
expect(result.node.content[0].attrs.src).toBe('image-url');
expect(result.pos).toBe(0);
});
it('should insert a new image directly into the paragraph that already contains an image', () => {
const mockNode = {
type: { name: 'paragraph' },
content: {
size: 1,
content: [
{
type: { name: 'image', attrs: { src: 'existing-image-url' } },
},
],
},
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
mockEditorState.selection.from = 1;
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result.node.type.name).toBe('image');
expect(result.node.attrs.src).toBe('image-url');
expect(result.pos).toBe(1);
});
});

View File

@@ -1,6 +1,8 @@
import {
getConversationDashboardRoute,
getCurrentAccount,
getUserRole,
isAConversationRoute,
routeIsAccessibleFor,
validateLoggedInRoutes,
} from '../routeHelpers';
@@ -94,3 +96,41 @@ describe('#validateLoggedInRoutes', () => {
});
});
});
describe('isAConversationRoute', () => {
it('returns true if conversation route name is provided', () => {
expect(isAConversationRoute('inbox_conversation')).toBe(true);
expect(isAConversationRoute('conversation_through_inbox')).toBe(true);
expect(isAConversationRoute('conversations_through_label')).toBe(true);
expect(isAConversationRoute('conversations_through_team')).toBe(true);
expect(isAConversationRoute('dashboard')).toBe(false);
});
});
describe('getConversationDashboardRoute', () => {
it('returns dashboard route for conversation', () => {
expect(getConversationDashboardRoute('inbox_conversation')).toEqual('home');
expect(
getConversationDashboardRoute('conversation_through_mentions')
).toEqual('conversation_mentions');
expect(
getConversationDashboardRoute('conversation_through_unattended')
).toEqual('conversation_unattended');
expect(
getConversationDashboardRoute('conversations_through_label')
).toEqual('label_conversations');
expect(getConversationDashboardRoute('conversations_through_team')).toEqual(
'team_conversations'
);
expect(
getConversationDashboardRoute('conversations_through_folders')
).toEqual('folder_conversations');
expect(
getConversationDashboardRoute('conversation_through_participating')
).toEqual('conversation_participating');
expect(getConversationDashboardRoute('conversation_through_inbox')).toEqual(
'inbox_dashboard'
);
expect(getConversationDashboardRoute('non_existent_route')).toBeNull();
});
});

View File

@@ -0,0 +1,53 @@
import { uploadFile } from '../uploadHelper';
import axios from 'axios';
// Mocking axios using jest-mock-axios
global.axios = axios;
jest.mock('axios');
describe('#Upload Helpers', () => {
afterEach(() => {
// Cleaning up the mock after each test
axios.post.mockReset();
});
it('should send a POST request with correct data', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
const result = await uploadFile(mockFile);
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/upload',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
});
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockError = new Error('Failed to upload');
axios.post.mockRejectedValueOnce(mockError);
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
});
});

View File

@@ -0,0 +1,41 @@
/* global axios */
/**
* Constants and Configuration
*/
// Version for the API endpoint.
const API_VERSION = 'v1';
// Default headers to be used in the axios request.
const HEADERS = {
'Content-Type': 'multipart/form-data',
};
/**
* Uploads a file to the server.
*
* This function sends a POST request to a given API endpoint and uploads the specified file.
* The function uses FormData to wrap the file and axios to send the request.
*
* @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element).
* @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error.
*/
export async function uploadFile(file) {
// Create a new FormData instance.
let formData = new FormData();
// Append the file to the FormData instance under the key 'attachment'.
formData.append('attachment', file);
// Use axios to send a POST request to the upload endpoint.
const { data } = await axios.post(`/api/${API_VERSION}/upload`, formData, {
headers: HEADERS,
});
return {
fileUrl: data.file_url,
blobKey: data.blob_key,
blobId: data.blob_id,
};
}

View File

@@ -26,6 +26,7 @@ import pt_BR from './locale/pt_BR';
import ro from './locale/ro';
import ru from './locale/ru';
import sk from './locale/sk';
import sr from './locale/sr';
import sv from './locale/sv';
import ta from './locale/ta';
import th from './locale/th';
@@ -66,6 +67,7 @@ export default {
ro,
ru,
sk,
sr,
sv,
ta,
th,

View File

@@ -56,6 +56,14 @@
"EDIT": "%{agentName} updated a macro (#%{id})",
"DELETE": "%{agentName} deleted a macro (#%{id})"
},
"INBOX_MEMBER": {
"ADD": "%{agentName} added %{user} to the inbox(#%{inbox_id})",
"REMOVE": "%{agentName} removed %{user} from the inbox(#%{inbox_id})"
},
"TEAM_MEMBER": {
"ADD": "%{agentName} added %{user} to the team(#%{team_id})",
"REMOVE": "%{agentName} removed %{user} from the team(#%{team_id})"
},
"ACCOUNT": {
"EDIT": "%{agentName} updated the account configuration (#%{id})"
}

View File

@@ -71,7 +71,7 @@
"SUBMIT": "Import",
"CANCEL": "Cancel"
},
"SUCCESS_MESSAGE": "Contacts saved successfully",
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"EXPORT_CONTACTS": {

View File

@@ -88,7 +88,8 @@
"UPDATE": "Update",
"BUTTON_TEXT": "Connect channel",
"DESCRIPTION": "Your Slack workspace is now linked with Chatwoot. However, the integration is currently inactive. To activate the integration and connect a channel to Chatwoot, please click the button below.\n\n**Note:** If you are attempting to connect a private channel, add the Chatwoot app to the Slack channel before proceeding with this step.",
"ATTENTION_REQUIRED": "Attention required"
"ATTENTION_REQUIRED": "Attention required",
"EXPIRED": "Your Slack integration has expired. To continue receiving messages on Slack, please delete the integration and connect your workspace again."
},
"UPDATE_ERROR": "There was an error updating the integration, please try again",
"UPDATE_SUCCESS": "The channel is connected successfully",
@@ -124,6 +125,18 @@
"CANCEL": "Cancel"
}
},
"CTA_MODAL": {
"TITLE": "Integrate with OpenAI",
"DESC": "Bring advanced AI features to your dashboard with OpenAI's GPT models. To begin, enter the API key from your OpenAI account.",
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
"BUTTONS": {
"NEED_HELP": "Need help?",
"DISMISS": "Dismiss",
"FINISH": "Finish Setup"
},
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",
"SUCCESS_MESSAGE": "OpenAI integration setup successfully"
},
"TITLE": "Improve With AI",
"SUMMARY_TITLE": "Summary with AI",
"REPLY_TITLE": "Reply suggestion with AI",

View File

@@ -39,7 +39,10 @@
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
"API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",

View File

@@ -56,6 +56,14 @@
"EDIT": "%{agentName} updated a macro (#%{id})",
"DELETE": "%{agentName} deleted a macro (#%{id})"
},
"INBOX_MEMBER": {
"ADD": "%{agentName} added %{user} to the inbox(#%{inbox_id})",
"REMOVE": "%{agentName} removed %{user} from the inbox(#%{inbox_id})"
},
"TEAM_MEMBER": {
"ADD": "%{agentName} added %{user} to the team(#%{team_id})",
"REMOVE": "%{agentName} removed %{user} from the team(#%{team_id})"
},
"ACCOUNT": {
"EDIT": "%{agentName} updated the account configuration (#%{id})"
}

View File

@@ -71,7 +71,7 @@
"SUBMIT": "استيراد",
"CANCEL": "إلغاء"
},
"SUCCESS_MESSAGE": "تم حفظ جهة الاتصال بنجاح",
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى"
},
"EXPORT_CONTACTS": {

View File

@@ -88,7 +88,8 @@
"UPDATE": "تحديث",
"BUTTON_TEXT": "Connect channel",
"DESCRIPTION": "Your Slack workspace is now linked with Chatwoot. However, the integration is currently inactive. To activate the integration and connect a channel to Chatwoot, please click the button below.\n\n**Note:** If you are attempting to connect a private channel, add the Chatwoot app to the Slack channel before proceeding with this step.",
"ATTENTION_REQUIRED": "Attention required"
"ATTENTION_REQUIRED": "Attention required",
"EXPIRED": "Your Slack integration has expired. To continue receiving messages on Slack, please delete the integration and connect your workspace again."
},
"UPDATE_ERROR": "There was an error updating the integration, please try again",
"UPDATE_SUCCESS": "The channel is connected successfully",
@@ -124,6 +125,18 @@
"CANCEL": "إلغاء"
}
},
"CTA_MODAL": {
"TITLE": "Integrate with OpenAI",
"DESC": "Bring advanced AI features to your dashboard with OpenAI's GPT models. To begin, enter the API key from your OpenAI account.",
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
"BUTTONS": {
"NEED_HELP": "تحتاج مساعدة؟",
"DISMISS": "Dismiss",
"FINISH": "Finish Setup"
},
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",
"SUCCESS_MESSAGE": "OpenAI integration setup successfully"
},
"TITLE": "Improve With AI",
"SUMMARY_TITLE": "Summary with AI",
"REPLY_TITLE": "Reply suggestion with AI",

View File

@@ -39,7 +39,10 @@
"NOTE": "إنشاء توقيع رسالة شخصية يتم إضافتها إلى جميع الرسائل التي ترسلها من المنصة. استخدم محرر المحتوى الغني لإنشاء توقيع شديد التخصيص.",
"BTN_TEXT": "حفظ توقيع الرسالة",
"API_ERROR": "تعذر إرسال الرسالة! حاول مرة أخرى",
"API_SUCCESS": "تم حفظ التوقيع بنجاح"
"API_SUCCESS": "تم حفظ التوقيع بنجاح",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "توقيع الرسالة",

View File

@@ -56,6 +56,14 @@
"EDIT": "%{agentName} updated a macro (#%{id})",
"DELETE": "%{agentName} deleted a macro (#%{id})"
},
"INBOX_MEMBER": {
"ADD": "%{agentName} added %{user} to the inbox(#%{inbox_id})",
"REMOVE": "%{agentName} removed %{user} from the inbox(#%{inbox_id})"
},
"TEAM_MEMBER": {
"ADD": "%{agentName} added %{user} to the team(#%{team_id})",
"REMOVE": "%{agentName} removed %{user} from the team(#%{team_id})"
},
"ACCOUNT": {
"EDIT": "%{agentName} updated the account configuration (#%{id})"
}

View File

@@ -71,7 +71,7 @@
"SUBMIT": "Внасяне",
"CANCEL": "Отмени"
},
"SUCCESS_MESSAGE": "Успешно запазване на контактите",
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "Възникна грешка, моля опитайте отново"
},
"EXPORT_CONTACTS": {

View File

@@ -88,7 +88,8 @@
"UPDATE": "Обновяване",
"BUTTON_TEXT": "Connect channel",
"DESCRIPTION": "Your Slack workspace is now linked with Chatwoot. However, the integration is currently inactive. To activate the integration and connect a channel to Chatwoot, please click the button below.\n\n**Note:** If you are attempting to connect a private channel, add the Chatwoot app to the Slack channel before proceeding with this step.",
"ATTENTION_REQUIRED": "Attention required"
"ATTENTION_REQUIRED": "Attention required",
"EXPIRED": "Your Slack integration has expired. To continue receiving messages on Slack, please delete the integration and connect your workspace again."
},
"UPDATE_ERROR": "There was an error updating the integration, please try again",
"UPDATE_SUCCESS": "The channel is connected successfully",
@@ -124,6 +125,18 @@
"CANCEL": "Отмени"
}
},
"CTA_MODAL": {
"TITLE": "Integrate with OpenAI",
"DESC": "Bring advanced AI features to your dashboard with OpenAI's GPT models. To begin, enter the API key from your OpenAI account.",
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
"BUTTONS": {
"NEED_HELP": "Need help?",
"DISMISS": "Dismiss",
"FINISH": "Finish Setup"
},
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",
"SUCCESS_MESSAGE": "OpenAI integration setup successfully"
},
"TITLE": "Improve With AI",
"SUMMARY_TITLE": "Summary with AI",
"REPLY_TITLE": "Reply suggestion with AI",

View File

@@ -39,7 +39,10 @@
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
"API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",

View File

@@ -56,6 +56,14 @@
"EDIT": "%{agentName} updated a macro (#%{id})",
"DELETE": "%{agentName} deleted a macro (#%{id})"
},
"INBOX_MEMBER": {
"ADD": "%{agentName} added %{user} to the inbox(#%{inbox_id})",
"REMOVE": "%{agentName} removed %{user} from the inbox(#%{inbox_id})"
},
"TEAM_MEMBER": {
"ADD": "%{agentName} added %{user} to the team(#%{team_id})",
"REMOVE": "%{agentName} removed %{user} from the team(#%{team_id})"
},
"ACCOUNT": {
"EDIT": "%{agentName} updated the account configuration (#%{id})"
}

View File

@@ -71,7 +71,7 @@
"SUBMIT": "Importa",
"CANCEL": "Cancel·la"
},
"SUCCESS_MESSAGE": "Contactes desat correctament",
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "S'ha produït un error; tornau-ho a provar"
},
"EXPORT_CONTACTS": {

View File

@@ -88,7 +88,8 @@
"UPDATE": "Actualitza",
"BUTTON_TEXT": "Connect channel",
"DESCRIPTION": "Your Slack workspace is now linked with Chatwoot. However, the integration is currently inactive. To activate the integration and connect a channel to Chatwoot, please click the button below.\n\n**Note:** If you are attempting to connect a private channel, add the Chatwoot app to the Slack channel before proceeding with this step.",
"ATTENTION_REQUIRED": "Attention required"
"ATTENTION_REQUIRED": "Attention required",
"EXPIRED": "Your Slack integration has expired. To continue receiving messages on Slack, please delete the integration and connect your workspace again."
},
"UPDATE_ERROR": "There was an error updating the integration, please try again",
"UPDATE_SUCCESS": "The channel is connected successfully",
@@ -124,6 +125,18 @@
"CANCEL": "Cancel·la"
}
},
"CTA_MODAL": {
"TITLE": "Integrate with OpenAI",
"DESC": "Bring advanced AI features to your dashboard with OpenAI's GPT models. To begin, enter the API key from your OpenAI account.",
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
"BUTTONS": {
"NEED_HELP": "Need help?",
"DISMISS": "Dismiss",
"FINISH": "Finish Setup"
},
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",
"SUCCESS_MESSAGE": "OpenAI integration setup successfully"
},
"TITLE": "Improve With AI",
"SUMMARY_TITLE": "Summary with AI",
"REPLY_TITLE": "Reply suggestion with AI",

View File

@@ -39,7 +39,10 @@
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
"API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",

View File

@@ -56,6 +56,14 @@
"EDIT": "%{agentName} updated a macro (#%{id})",
"DELETE": "%{agentName} deleted a macro (#%{id})"
},
"INBOX_MEMBER": {
"ADD": "%{agentName} added %{user} to the inbox(#%{inbox_id})",
"REMOVE": "%{agentName} removed %{user} from the inbox(#%{inbox_id})"
},
"TEAM_MEMBER": {
"ADD": "%{agentName} added %{user} to the team(#%{team_id})",
"REMOVE": "%{agentName} removed %{user} from the team(#%{team_id})"
},
"ACCOUNT": {
"EDIT": "%{agentName} updated the account configuration (#%{id})"
}

View File

@@ -71,7 +71,7 @@
"SUBMIT": "Import",
"CANCEL": "Zrušit"
},
"SUCCESS_MESSAGE": "Contacts saved successfully",
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "Došlo k chybě, zkuste to prosím znovu"
},
"EXPORT_CONTACTS": {

View File

@@ -88,7 +88,8 @@
"UPDATE": "Aktualizovat",
"BUTTON_TEXT": "Connect channel",
"DESCRIPTION": "Your Slack workspace is now linked with Chatwoot. However, the integration is currently inactive. To activate the integration and connect a channel to Chatwoot, please click the button below.\n\n**Note:** If you are attempting to connect a private channel, add the Chatwoot app to the Slack channel before proceeding with this step.",
"ATTENTION_REQUIRED": "Attention required"
"ATTENTION_REQUIRED": "Attention required",
"EXPIRED": "Your Slack integration has expired. To continue receiving messages on Slack, please delete the integration and connect your workspace again."
},
"UPDATE_ERROR": "There was an error updating the integration, please try again",
"UPDATE_SUCCESS": "The channel is connected successfully",
@@ -124,6 +125,18 @@
"CANCEL": "Zrušit"
}
},
"CTA_MODAL": {
"TITLE": "Integrate with OpenAI",
"DESC": "Bring advanced AI features to your dashboard with OpenAI's GPT models. To begin, enter the API key from your OpenAI account.",
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
"BUTTONS": {
"NEED_HELP": "Need help?",
"DISMISS": "Dismiss",
"FINISH": "Finish Setup"
},
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",
"SUCCESS_MESSAGE": "OpenAI integration setup successfully"
},
"TITLE": "Improve With AI",
"SUMMARY_TITLE": "Summary with AI",
"REPLY_TITLE": "Reply suggestion with AI",

View File

@@ -39,7 +39,10 @@
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
"API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",

View File

@@ -56,6 +56,14 @@
"EDIT": "%{agentName} updated a macro (#%{id})",
"DELETE": "%{agentName} deleted a macro (#%{id})"
},
"INBOX_MEMBER": {
"ADD": "%{agentName} added %{user} to the inbox(#%{inbox_id})",
"REMOVE": "%{agentName} removed %{user} from the inbox(#%{inbox_id})"
},
"TEAM_MEMBER": {
"ADD": "%{agentName} added %{user} to the team(#%{team_id})",
"REMOVE": "%{agentName} removed %{user} from the team(#%{team_id})"
},
"ACCOUNT": {
"EDIT": "%{agentName} updated the account configuration (#%{id})"
}

View File

@@ -71,7 +71,7 @@
"SUBMIT": "Importer",
"CANCEL": "Annuller"
},
"SUCCESS_MESSAGE": "Kontakter gemt",
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "Der opstod en fejl. Prøv venligst igen"
},
"EXPORT_CONTACTS": {

View File

@@ -88,7 +88,8 @@
"UPDATE": "Opdater",
"BUTTON_TEXT": "Connect channel",
"DESCRIPTION": "Your Slack workspace is now linked with Chatwoot. However, the integration is currently inactive. To activate the integration and connect a channel to Chatwoot, please click the button below.\n\n**Note:** If you are attempting to connect a private channel, add the Chatwoot app to the Slack channel before proceeding with this step.",
"ATTENTION_REQUIRED": "Attention required"
"ATTENTION_REQUIRED": "Attention required",
"EXPIRED": "Your Slack integration has expired. To continue receiving messages on Slack, please delete the integration and connect your workspace again."
},
"UPDATE_ERROR": "There was an error updating the integration, please try again",
"UPDATE_SUCCESS": "The channel is connected successfully",
@@ -124,6 +125,18 @@
"CANCEL": "Annuller"
}
},
"CTA_MODAL": {
"TITLE": "Integrate with OpenAI",
"DESC": "Bring advanced AI features to your dashboard with OpenAI's GPT models. To begin, enter the API key from your OpenAI account.",
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
"BUTTONS": {
"NEED_HELP": "Brug for hjælp?",
"DISMISS": "Dismiss",
"FINISH": "Finish Setup"
},
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",
"SUCCESS_MESSAGE": "OpenAI integration setup successfully"
},
"TITLE": "Improve With AI",
"SUMMARY_TITLE": "Summary with AI",
"REPLY_TITLE": "Reply suggestion with AI",

View File

@@ -39,7 +39,10 @@
"NOTE": "Opret en personlig besked signatur, der vil blive føjet til alle de meddelelser, du sender fra din e-mail indbakke. Brug den rige content editor til at oprette en meget personlig signatur.",
"BTN_TEXT": "Gem beskedsignatur",
"API_ERROR": "Kunne ikke gemme signatur! Prøv igen",
"API_SUCCESS": "Signatur gemt"
"API_SUCCESS": "Signatur gemt",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Besked Signatur",

View File

@@ -56,6 +56,14 @@
"EDIT": "%{agentName} updated a macro (#%{id})",
"DELETE": "%{agentName} deleted a macro (#%{id})"
},
"INBOX_MEMBER": {
"ADD": "%{agentName} added %{user} to the inbox(#%{inbox_id})",
"REMOVE": "%{agentName} removed %{user} from the inbox(#%{inbox_id})"
},
"TEAM_MEMBER": {
"ADD": "%{agentName} added %{user} to the team(#%{team_id})",
"REMOVE": "%{agentName} removed %{user} from the team(#%{team_id})"
},
"ACCOUNT": {
"EDIT": "%{agentName} updated the account configuration (#%{id})"
}

View File

@@ -71,7 +71,7 @@
"SUBMIT": "Importieren",
"CANCEL": "Abbrechen"
},
"SUCCESS_MESSAGE": "Kontakte erfolgreich gespeichert",
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "Es ist ein Fehler aufgetreten, bitte versuchen Sie es erneut"
},
"EXPORT_CONTACTS": {

View File

@@ -88,7 +88,8 @@
"UPDATE": "Aktualisieren",
"BUTTON_TEXT": "Connect channel",
"DESCRIPTION": "Your Slack workspace is now linked with Chatwoot. However, the integration is currently inactive. To activate the integration and connect a channel to Chatwoot, please click the button below.\n\n**Note:** If you are attempting to connect a private channel, add the Chatwoot app to the Slack channel before proceeding with this step.",
"ATTENTION_REQUIRED": "Attention required"
"ATTENTION_REQUIRED": "Attention required",
"EXPIRED": "Your Slack integration has expired. To continue receiving messages on Slack, please delete the integration and connect your workspace again."
},
"UPDATE_ERROR": "There was an error updating the integration, please try again",
"UPDATE_SUCCESS": "The channel is connected successfully",
@@ -124,6 +125,18 @@
"CANCEL": "Stornieren"
}
},
"CTA_MODAL": {
"TITLE": "Integrate with OpenAI",
"DESC": "Bring advanced AI features to your dashboard with OpenAI's GPT models. To begin, enter the API key from your OpenAI account.",
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
"BUTTONS": {
"NEED_HELP": "Brauchen Sie Hilfe?",
"DISMISS": "Verwerfen",
"FINISH": "Finish Setup"
},
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",
"SUCCESS_MESSAGE": "OpenAI integration setup successfully"
},
"TITLE": "Mit KI verbessern",
"SUMMARY_TITLE": "Zusammenfassung mit KI",
"REPLY_TITLE": "Vorschlag mit KI beantworten",

View File

@@ -39,7 +39,10 @@
"NOTE": "Erstellen Sie eine persönliche Nachrichtensignatur, die allen Nachrichten hinzugefügt wird, die Sie aus Ihrem E-Mail-Posteingang senden. Verwenden Sie den Rich-Content-Editor, um eine stark personalisierte Signatur zu erstellen.",
"BTN_TEXT": "Nachrichten-Signatur speichern",
"API_ERROR": "Signatur konnte nicht gespeichert werden! Versuch es noch einmal",
"API_SUCCESS": "Signatur erfolgreich gespeichert"
"API_SUCCESS": "Signatur erfolgreich gespeichert",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Bildgröße sollte kleiner als {size}MB sein"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Nachrichten-Signatur",

View File

@@ -56,6 +56,14 @@
"EDIT": "%{agentName} updated a macro (#%{id})",
"DELETE": "%{agentName} deleted a macro (#%{id})"
},
"INBOX_MEMBER": {
"ADD": "%{agentName} added %{user} to the inbox(#%{inbox_id})",
"REMOVE": "%{agentName} removed %{user} from the inbox(#%{inbox_id})"
},
"TEAM_MEMBER": {
"ADD": "%{agentName} added %{user} to the team(#%{team_id})",
"REMOVE": "%{agentName} removed %{user} from the team(#%{team_id})"
},
"ACCOUNT": {
"EDIT": "%{agentName} updated the account configuration (#%{id})"
}

View File

@@ -71,7 +71,7 @@
"SUBMIT": "Εισαγωγή",
"CANCEL": "Άκυρο"
},
"SUCCESS_MESSAGE": "Οι επαφές αποθηκεύτηκαν με επιτυχία",
"SUCCESS_MESSAGE": "Θα ειδοποιηθείτε μέσω email όταν ολοκληρωθεί η εισαγωγή.",
"ERROR_MESSAGE": "Υπήρξε ένα σφάλμα, παρακαλώ προσπαθήστε ξανά"
},
"EXPORT_CONTACTS": {

View File

@@ -88,7 +88,8 @@
"UPDATE": "Ενημέρωση",
"BUTTON_TEXT": "Connect channel",
"DESCRIPTION": "Your Slack workspace is now linked with Chatwoot. However, the integration is currently inactive. To activate the integration and connect a channel to Chatwoot, please click the button below.\n\n**Note:** If you are attempting to connect a private channel, add the Chatwoot app to the Slack channel before proceeding with this step.",
"ATTENTION_REQUIRED": "Attention required"
"ATTENTION_REQUIRED": "Attention required",
"EXPIRED": "Your Slack integration has expired. To continue receiving messages on Slack, please delete the integration and connect your workspace again."
},
"UPDATE_ERROR": "There was an error updating the integration, please try again",
"UPDATE_SUCCESS": "The channel is connected successfully",
@@ -124,6 +125,18 @@
"CANCEL": "Άκυρο"
}
},
"CTA_MODAL": {
"TITLE": "Integrate with OpenAI",
"DESC": "Bring advanced AI features to your dashboard with OpenAI's GPT models. To begin, enter the API key from your OpenAI account.",
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
"BUTTONS": {
"NEED_HELP": "Χρειάζεστε βοήθεια;",
"DISMISS": "Dismiss",
"FINISH": "Finish Setup"
},
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",
"SUCCESS_MESSAGE": "OpenAI integration setup successfully"
},
"TITLE": "Improve With AI",
"SUMMARY_TITLE": "Summary with AI",
"REPLY_TITLE": "Reply suggestion with AI",

View File

@@ -39,7 +39,10 @@
"NOTE": "Δημιουργήστε μια προσωπική υπογραφή, η οποία θα προστεθεί σε όλα τα μηνύματα που στέλνετε από τα εισερχόμενα email σας. Χρησιμοποιήστε τον επεξεργαστή πλούσιου περιεχομένου για να δημιουργήσετε μια εξατομικευμένη υπογραφή.",
"BTN_TEXT": "Αποθήκευση υπογραφής μηνύματος",
"API_ERROR": "Δεν ήταν δυνατή η αποθήκευση της υπογραφής! Δοκιμάστε ξανά",
"API_SUCCESS": "Η υπογραφή αποθηκεύτηκε με επιτυχία"
"API_SUCCESS": "Η υπογραφή αποθηκεύτηκε με επιτυχία",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Υπογραφή μηνύματος",

View File

@@ -71,7 +71,7 @@
"SUBMIT": "Import",
"CANCEL": "Cancel"
},
"SUCCESS_MESSAGE": "Contacts saved successfully",
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"EXPORT_CONTACTS": {

View File

@@ -85,7 +85,8 @@
"UPDATE": "Update",
"BUTTON_TEXT": "Connect channel",
"DESCRIPTION": "Your Slack workspace is now linked with Chatwoot. However, the integration is currently inactive. To activate the integration and connect a channel to Chatwoot, please click the button below.\n\n**Note:** If you are attempting to connect a private channel, add the Chatwoot app to the Slack channel before proceeding with this step.",
"ATTENTION_REQUIRED": "Attention required"
"ATTENTION_REQUIRED": "Attention required",
"EXPIRED": "Your Slack integration has expired. To continue receiving messages on Slack, please delete the integration and connect your workspace again."
},
"UPDATE_ERROR": "There was an error updating the integration, please try again",
"UPDATE_SUCCESS": "The channel is connected successfully",

View File

@@ -39,7 +39,10 @@
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
"API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",

View File

@@ -56,8 +56,16 @@
"EDIT": "%{agentName} actualizó una macro (#%{id})",
"DELETE": "%{agentName} eliminó una macro (#%{id})"
},
"INBOX_MEMBER": {
"ADD": "%{agentName} agregó %{user} a la bandeja de entrada (#%{inbox_id})",
"REMOVE": "%{agentName} eliminó %{user} de la bandeja de entrada(#%{inbox_id})"
},
"TEAM_MEMBER": {
"ADD": "%{agentName} agregó %{user} al equipo (#%{team_id})",
"REMOVE": "%{agentName} eliminó %{user} del equipo (#%{team_id})"
},
"ACCOUNT": {
"EDIT": "%{agentName} updated the account configuration (#%{id})"
"EDIT": "%{agentName} Actualizó la configuración de la cuenta (#%{id})"
}
}
}

View File

@@ -71,7 +71,7 @@
"SUBMIT": "Importar",
"CANCEL": "Cancelar"
},
"SUCCESS_MESSAGE": "Contacto guardado correctamente",
"SUCCESS_MESSAGE": "Se le notificará por correo electrónico cuando se complete la importación.",
"ERROR_MESSAGE": "Hubo un error, por favor inténtelo de nuevo"
},
"EXPORT_CONTACTS": {

View File

@@ -13,8 +13,8 @@
"NO_INBOX_AGENT": "¡Uh Oh! Parece que no eres parte de ninguna bandeja de entrada. Por favor, contacta con tu administrador",
"SEARCH_MESSAGES": "Buscar mensajes en conversaciones",
"EMPTY_STATE": {
"CMD_BAR": "to open command menu",
"KEYBOARD_SHORTCUTS": "to view keyboard shortcuts"
"CMD_BAR": "para abrir el menú de comandos",
"KEYBOARD_SHORTCUTS": "para ver los atajos del teclado"
},
"SEARCH": {
"TITLE": "Buscar mensajes",

View File

@@ -111,8 +111,8 @@
"ADD_LABEL": "Añadir etiqueta a la conversación",
"REMOVE_LABEL": "Eliminar etiqueta de la conversación",
"SETTINGS": "Ajustes",
"AI_ASSIST": "AI Assist",
"APPEARANCE": "Appearance"
"AI_ASSIST": "Asistencia AI",
"APPEARANCE": "Apariencia"
},
"COMMANDS": {
"GO_TO_CONVERSATION_DASHBOARD": "Ir al panel de conversaciones",
@@ -134,7 +134,7 @@
"GO_TO_NOTIFICATIONS": "Ir a Notificaciones",
"ADD_LABELS_TO_CONVERSATION": "Añadir etiqueta a la conversación",
"ASSIGN_AN_AGENT": "Asignar un agente",
"AI_ASSIST": "AI Assist",
"AI_ASSIST": "Asistencia AI",
"ASSIGN_PRIORITY": "Asignar prioridad",
"ASSIGN_A_TEAM": "Asignar un equipo",
"MUTE_CONVERSATION": "Silenciar conversación",
@@ -150,9 +150,9 @@
"UNTIL_NEXT_MONTH": "Hasta el mes próximo",
"AN_HOUR_FROM_NOW": "Hasta una hora a partir de ahora",
"CUSTOM": "Personalizar...",
"CHANGE_APPEARANCE": "Change Appearance",
"LIGHT_MODE": "Light",
"DARK_MODE": "Dark",
"CHANGE_APPEARANCE": "Cambiar apariencia",
"LIGHT_MODE": "Claro",
"DARK_MODE": "Oscuro",
"SYSTEM_MODE": "Sistema"
}
},
@@ -160,7 +160,7 @@
"LOADING_MESSAGE": "Cargando aplicación del tablero..."
},
"COMMON": {
"OR": "Or",
"OR": "O",
"CLICK_HERE": "haz clic aquí"
}
}

View File

@@ -112,14 +112,14 @@
"ERROR": "Este campo es obligatorio"
},
"API_KEY": {
"USE_API_KEY": "Use API Key Authentication",
"USE_API_KEY": "Usar autenticación de Clave API",
"LABEL": "API Key SID",
"PLACEHOLDER": "Please enter your API Key SID",
"PLACEHOLDER": "Por favor ingrese su API Key SID",
"ERROR": "Este campo es obligatorio"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Please enter your API Key Secret",
"LABEL": "Clave API secreta",
"PLACEHOLDER": "Por favor ingrese su Clave API secreta",
"ERROR": "Este campo es obligatorio"
},
"MESSAGING_SERVICE_SID": {
@@ -403,21 +403,21 @@
"DISABLED": "Deshabilitado"
},
"SENDER_NAME_SECTION": {
"TITLE": "Sender name",
"SUB_TEXT": "Select the name shown to the your customer when they receive emails from your agents.",
"FOR_EG": "For eg:",
"TITLE": "Nombre del remitente",
"SUB_TEXT": "Seleccione el nombre que se muestra al cliente cuando reciba correos electrónicos de sus agentes.",
"FOR_EG": "Por ejemplo:",
"FRIENDLY": {
"TITLE": "Amigable",
"FROM": "De",
"SUBTITLE": "Add the name of the agent who sent the reply in the sender name to make it friendly."
"SUBTITLE": "Añada el nombre del agente que envió la respuesta en el nombre del remitente para que sea amistoso."
},
"PROFESSIONAL": {
"TITLE": "Profesional",
"SUBTITLE": "Use only the configured business name as the sender name in the email header."
"SUBTITLE": "Utilice sólo el nombre del negocio configurado como nombre del remitente en el encabezado del correo electrónico."
},
"BUSINESS_NAME": {
"BUTTON_TEXT": "+ Configure your business name",
"PLACEHOLDER": "Enter your business name",
"BUTTON_TEXT": "+ Configura el nombre de tu negocio",
"PLACEHOLDER": "Introduce el nombre de tu negocio",
"SAVE_BUTTON_TEXT": "Guardar"
}
},
@@ -484,9 +484,9 @@
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Activar o desactivar la caja de recolección de correo electrónico",
"AUTO_ASSIGNMENT": "Activar asignación automática",
"ENABLE_CSAT": "Habilitar Encuesta de Satisfacción",
"SENDER_NAME_SECTION": "Enable Agent Name in Email",
"SENDER_NAME_SECTION": "Habilitar nombre del agente en el correo electrónico",
"ENABLE_CSAT_SUB_TEXT": "Habilitar/deshabilitar encuesta CSAT(satisfacción del cliente) después de resolver una conversación",
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
"SENDER_NAME_SECTION_TEXT": "Habilitar/Deshabilitar mostrando el nombre del agente en el correo electrónico, si está deshabilitado, mostrará el nombre del negocio",
"ENABLE_CONTINUITY_VIA_EMAIL": "Habilitar continuidad de conversación por correo electrónico",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Las conversaciones continuarán por correo electrónico si la dirección de correo electrónico de contacto está disponible.",
"LOCK_TO_SINGLE_CONVERSATION": "Bloquear a una sola conversación",

View File

@@ -75,24 +75,25 @@
"SLACK": {
"DELETE": "Eliminar",
"DELETE_CONFIRMATION": {
"TITLE": "Delete the integration",
"MESSAGE": "Are you sure you want to delete the integration? Doing so will result in the loss of access to conversations on your Slack workspace."
"TITLE": "Eliminar la integración",
"MESSAGE": "¿Está seguro que desea eliminar la integración? Si lo hace, perderá el acceso a las conversaciones en su espacio de trabajo Slack."
},
"HELP_TEXT": {
"TITLE": "Utilizando integración de Slack",
"TITLE": "¿Cómo utilizar la Integración Slack?",
"BODY": "<br/><p>Chatwoot ahora sincronizará todas las conversaciones entrantes en el canal <b><i>de conversaciones del cliente</i></b> dentro de tu lugar de trabajo slack.</p><p>Respondiendo a un tema de conversación en <b><i>conversaciones de clientes</i></b> canal de slack creará una respuesta al cliente a través de chatwoot.</p><p>Inicie las respuestas con <b><i>nota:</i></b> para crear notas privadas en lugar de respuestas.</p><p>Si el respondente de slack tiene un perfil de agente en el chatwoot bajo el mismo correo electrónico, las respuestas se asociarán en consecuencia.</p><p>Cuando el replicador no tiene un perfil de agente asociado, las respuestas se harán con el perfil del bot.</p>",
"SELECTED": "selected"
"SELECTED": "seleccionado"
},
"SELECT_CHANNEL": {
"OPTION_LABEL": "Select a channel",
"OPTION_LABEL": "Seleccione un canal",
"UPDATE": "Actualizar",
"BUTTON_TEXT": "Connect channel",
"DESCRIPTION": "Your Slack workspace is now linked with Chatwoot. However, the integration is currently inactive. To activate the integration and connect a channel to Chatwoot, please click the button below.\n\n**Note:** If you are attempting to connect a private channel, add the Chatwoot app to the Slack channel before proceeding with this step.",
"ATTENTION_REQUIRED": "Attention required"
"BUTTON_TEXT": "Conectar canal",
"DESCRIPTION": "Su espacio de trabajo de Slack ahora esta enlazado con Chatwoot. Sin embarbo, la integración esta actualmente inactiva. Para activar la integración y conectar un canal a Chatwoot, por favor haga clic en el siguiente boton.\n\n**Nota.** Si esta intentando conectar un canal privado, agregue la aplicación Chatwoot al canal de Slack antes de proceder con este paso.",
"ATTENTION_REQUIRED": "Atención requerida",
"EXPIRED": "Su integración de Slack ha caducado. Para continuar recibiendo mensajes en Slack, por favor elimine la integración y vuelva a conectar su área de trabajo."
},
"UPDATE_ERROR": "There was an error updating the integration, please try again",
"UPDATE_SUCCESS": "The channel is connected successfully",
"FAILED_TO_FETCH_CHANNELS": "There was an error fetching the channels from Slack, please try again"
"UPDATE_ERROR": "Se presento un error actualizando la integración, por favor inténtelo nuevamente",
"UPDATE_SUCCESS": "El canal está conectado correctamente",
"FAILED_TO_FETCH_CHANNELS": "Hubo un error obteniendo los canales de Slack, por favor inténtalo de nuevo"
},
"DYTE": {
"CLICK_HERE_TO_JOIN": "Haga clic aquí para unirse",
@@ -102,28 +103,40 @@
"CREATE_ERROR": "Hubo un error al crear un enlace de reunión, por favor inténtelo de nuevo"
},
"OPEN_AI": {
"AI_ASSIST": "AI Assist",
"WITH_AI": " %{option} with AI ",
"AI_ASSIST": "Asistencia AI",
"WITH_AI": " %{option} con IA ",
"OPTIONS": {
"REPLY_SUGGESTION": "Reply Suggestion",
"SUMMARIZE": "Summarize",
"REPHRASE": "Improve Writing",
"FIX_SPELLING_GRAMMAR": "Fix Spelling and Grammar",
"SHORTEN": "Shorten",
"EXPAND": "Expand",
"MAKE_FRIENDLY": "Change message tone to friendly",
"MAKE_FORMAL": "Use formal tone",
"SIMPLIFY": "Simplify"
"REPLY_SUGGESTION": "Responder sugerencia",
"SUMMARIZE": "Resumir",
"REPHRASE": "Mejorar escritura",
"FIX_SPELLING_GRAMMAR": "Corregir ortografía y gramática",
"SHORTEN": "Acortar",
"EXPAND": "Expandir",
"MAKE_FRIENDLY": "Cambiar tono de mensaje a amigable",
"MAKE_FORMAL": "Usar tono formal",
"SIMPLIFY": "Simplificar"
},
"ASSISTANCE_MODAL": {
"DRAFT_TITLE": "Draft content",
"GENERATED_TITLE": "Generated content",
"AI_WRITING": "AI is writing",
"DRAFT_TITLE": "Contenido de borrador",
"GENERATED_TITLE": "Contenido generado",
"AI_WRITING": "AI está escribiendo",
"BUTTONS": {
"APPLY": "Use this suggestion",
"APPLY": "Usar esta sugerencia",
"CANCEL": "Cancelar"
}
},
"CTA_MODAL": {
"TITLE": "Integrar con OpenAI",
"DESC": "Trae las características avanzadas de IA a tu panel de control con los modelos GPT de OpenAI. Para empezar, introduce la clave API desde tu cuenta OpenAI.",
"KEY_PLACEHOLDER": "Introduzca su clave API OpenAI",
"BUTTONS": {
"NEED_HELP": "¿Necesitas ayuda?",
"DISMISS": "Descartar",
"FINISH": "Finalizar configuración"
},
"DISMISS_MESSAGE": "Puedes configurar la integración OpenAI más tarde cuando quieras.",
"SUCCESS_MESSAGE": "Configuración de integración OpenAI exitosa"
},
"TITLE": "Mejorar con IA",
"SUMMARY_TITLE": "Resumen con IA",
"REPLY_TITLE": "Responder sugerencia con IA",
@@ -140,7 +153,7 @@
"GENERATING": "Generando...",
"CANCEL": "Cancelar"
},
"GENERATE_ERROR": "Hubo un error al procesar el contenido, por favor inténtelo de nuevo"
"GENERATE_ERROR": "Hubo un error al procesar el contenido, por favor verifique su clave API OpenAI e inténtelo de nuevo"
},
"DELETE": {
"BUTTON_TEXT": "Eliminar",

View File

@@ -40,16 +40,16 @@
},
"SUGGESTIONS": {
"TOOLTIP": {
"SINGLE_SUGGESTION": "Add label to conversation",
"MULTIPLE_SUGGESTION": "Select this label",
"DESELECT": "Deselect label",
"DISMISS": "Dismiss suggestion"
"SINGLE_SUGGESTION": "Añadir etiqueta a la conversación",
"MULTIPLE_SUGGESTION": "Seleccionar esta etiqueta",
"DESELECT": "Deseleccionar etiqueta",
"DISMISS": "Descartar sugerencia"
},
"POWERED_BY": "Chatwoot AI",
"DISMISS": "Descartar",
"ADD_SELECTED_LABELS": "Add selected labels",
"ADD_SELECTED_LABEL": "Add selected label",
"ADD_ALL_LABELS": "Add all labels"
"ADD_SELECTED_LABELS": "Asignar etiquetas seleccionadas",
"ADD_SELECTED_LABEL": "Añadir etiqueta seleccionada",
"ADD_ALL_LABELS": "Añadir todas las etiquetas"
},
"ADD": {
"TITLE": "Añadir etiqueta",

View File

@@ -12,11 +12,11 @@
"DESC": "( Total )"
},
"INCOMING_MESSAGES": {
"NAME": "Mensajes entrantes",
"NAME": "Mensajes recibidos",
"DESC": "( Total )"
},
"OUTGOING_MESSAGES": {
"NAME": "Mensajes salientes",
"NAME": "Mensajes enviados",
"DESC": "( Total )"
},
"FIRST_RESPONSE_TIME": {
@@ -36,8 +36,8 @@
"DESC": "( Total )"
},
"REPLY_TIME": {
"NAME": "Customer waiting time",
"TOOLTIP_TEXT": "Waiting time is %{metricValue} (based on %{conversationCount} conversations)"
"NAME": "Tiempo de espera del cliente",
"TOOLTIP_TEXT": "El tiempo de espera es %{metricValue} (basado en %{conversationCount} conversaciones)"
}
},
"DATE_RANGE_OPTIONS": {
@@ -117,10 +117,6 @@
}
],
"GROUP_BY_YEAR_OPTIONS": [
{
"id": 1,
"groupBy": "Día"
},
{
"id": 2,
"groupBy": "Semana"
@@ -128,6 +124,10 @@
{
"id": 3,
"groupBy": "Mes"
},
{
"id": 4,
"groupBy": "Mes"
}
],
"BUSINESS_HOURS": "Horarios"

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