Compare commits

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

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