Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d49d499e4 | ||
|
|
29503af824 | ||
|
|
75cc9356a4 | ||
|
|
0e47a14350 | ||
|
|
5c30cc2da3 | ||
|
|
e93af2681e | ||
|
|
cb2b75ca17 | ||
|
|
608592db0c | ||
|
|
ab1f803354 | ||
|
|
b81c722ac8 | ||
|
|
b40ad399f6 | ||
|
|
dcd18072b4 | ||
|
|
bda2d1d9a4 | ||
|
|
83231b9678 |
@@ -1,2 +1,7 @@
|
||||
## All javascript files should be reviewed by pranav before merging
|
||||
*.js @pranavrajs
|
||||
*.vue @pranavrajs
|
||||
|
||||
|
||||
## All enterprise related files should be reviewed by sojan before merging
|
||||
/enterprise/* @sojan-official
|
||||
|
||||
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.2'
|
||||
gem 'omniauth-google-oauth2'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
||||
|
||||
## Gems for reponse bot
|
||||
|
||||
+12
-17
@@ -237,8 +237,9 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.9.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday (2.7.4)
|
||||
faraday-net_http (>= 2.0, < 3.1)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-mashify (0.1.1)
|
||||
@@ -246,8 +247,7 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-net_http (3.0.2)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
@@ -394,8 +394,7 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
jwt (2.7.0)
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
@@ -471,8 +470,6 @@ GEM
|
||||
multipart-post (2.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.9)
|
||||
@@ -518,11 +515,11 @@ GEM
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-google-oauth2 (1.1.2)
|
||||
omniauth-google-oauth2 (1.1.1)
|
||||
jwt (>= 2.0)
|
||||
oauth2 (~> 2.0)
|
||||
oauth2 (~> 2.0.6)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-oauth2 (~> 1.8)
|
||||
omniauth-oauth2 (~> 1.8.0)
|
||||
omniauth-oauth2 (1.8.0)
|
||||
oauth2 (>= 1.4, < 3)
|
||||
omniauth (~> 2.0)
|
||||
@@ -562,7 +559,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.7.3)
|
||||
rack (2.2.9)
|
||||
rack (2.2.8.1)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.4.0)
|
||||
@@ -571,8 +568,7 @@ GEM
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (3.2.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack-protection (3.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rack-proxy (0.7.6)
|
||||
rack
|
||||
@@ -794,12 +790,11 @@ GEM
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (2.4.2)
|
||||
uniform_notifier (1.16.0)
|
||||
uri (0.13.0)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (4.0.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
version_gem (1.1.3)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.1)
|
||||
@@ -912,7 +907,7 @@ DEPENDENCIES
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
omniauth (>= 2.1.2)
|
||||
omniauth-google-oauth2 (>= 1.1.2)
|
||||
omniauth-google-oauth2
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
pg
|
||||
|
||||
@@ -17,9 +17,6 @@ db_migrate:
|
||||
db_seed:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed
|
||||
|
||||
db_reset:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:reset
|
||||
|
||||
db:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare
|
||||
|
||||
@@ -52,4 +49,4 @@ debug_worker:
|
||||
docker:
|
||||
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
||||
|
||||
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
|
||||
.PHONY: setup db_create db_migrate db_seed db console server burn docker run force_run debug debug_worker
|
||||
|
||||
@@ -53,23 +53,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= set_conversation_based_on_inbox_config
|
||||
end
|
||||
|
||||
def set_conversation_based_on_inbox_config
|
||||
if @inbox.lock_to_single_conversation
|
||||
Conversation.where(conversation_params).order(created_at: :desc).first || build_conversation
|
||||
else
|
||||
find_or_build_for_multiple_conversations
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_build_for_multiple_conversations
|
||||
# If lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
|
||||
last_conversation = Conversation.where(conversation_params).where.not(status: :resolved).order(created_at: :desc).first
|
||||
return build_conversation if last_conversation.nil?
|
||||
|
||||
last_conversation
|
||||
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
|
||||
@@ -69,28 +69,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= set_conversation_based_on_inbox_config
|
||||
end
|
||||
|
||||
def instagram_direct_message_conversation
|
||||
Conversation.where(conversation_params)
|
||||
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
|
||||
end
|
||||
|
||||
def set_conversation_based_on_inbox_config
|
||||
if @inbox.lock_to_single_conversation
|
||||
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
|
||||
else
|
||||
find_or_build_for_multiple_conversations
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_build_for_multiple_conversations
|
||||
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
|
||||
|
||||
return build_conversation if last_conversation.nil?
|
||||
|
||||
last_conversation
|
||||
@conversation ||= Conversation.where(conversation_params).find_by(
|
||||
"additional_attributes ->> 'type' = 'instagram_direct_message'"
|
||||
) || build_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
account: Current.account
|
||||
)
|
||||
|
||||
@agent = builder.perform
|
||||
builder.perform
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -2,9 +2,13 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||
before_action :authorize_request
|
||||
|
||||
def create
|
||||
process_create
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
ActiveRecord::Base.transaction do
|
||||
authenticate_twilio
|
||||
build_inbox
|
||||
setup_webhooks if @twilio_channel.sms?
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -13,14 +17,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||
authorize ::Inbox
|
||||
end
|
||||
|
||||
def process_create
|
||||
ActiveRecord::Base.transaction do
|
||||
authenticate_twilio
|
||||
build_inbox
|
||||
setup_webhooks if @twilio_channel.sms?
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate_twilio
|
||||
client = if permitted_params[:api_key_sid].present?
|
||||
Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid])
|
||||
|
||||
@@ -65,10 +65,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
contacts = result[:contacts]
|
||||
@contacts_count = result[:count]
|
||||
@contacts = fetch_contacts(contacts)
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def contactable_inboxes
|
||||
|
||||
@@ -44,10 +44,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
|
||||
@conversations = result[:conversations]
|
||||
@conversations_count = result[:count]
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def mute
|
||||
|
||||
@@ -25,4 +25,3 @@ class ApplicationController < ActionController::Base
|
||||
}
|
||||
end
|
||||
end
|
||||
ApplicationController.include_mod_with('Concerns::ApplicationControllerConcern')
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module DomainHelper
|
||||
def self.chatwoot_domain?(domain = request.host)
|
||||
[URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,6 @@ module SwitchLocale
|
||||
def switch_locale(&)
|
||||
# priority is for locale set in query string (mostly for widget/from js sdk)
|
||||
locale ||= locale_from_params
|
||||
locale ||= locale_from_custom_domain
|
||||
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
|
||||
locale ||= locale_from_env_variable
|
||||
set_locale(locale, &)
|
||||
@@ -17,20 +16,6 @@ module SwitchLocale
|
||||
set_locale(locale, &)
|
||||
end
|
||||
|
||||
# If the request is coming from a custom domain, it should be for a helpcenter portal
|
||||
# We will use the portal locale in such cases
|
||||
def locale_from_custom_domain(&)
|
||||
return if params[:locale]
|
||||
|
||||
domain = request.host
|
||||
return if DomainHelper.chatwoot_domain?(domain)
|
||||
|
||||
@portal = Portal.find_by(custom_domain: domain)
|
||||
return unless @portal
|
||||
|
||||
@portal.default_locale
|
||||
end
|
||||
|
||||
def set_locale(locale, &)
|
||||
# if locale is empty, use default_locale
|
||||
locale ||= I18n.default_locale
|
||||
|
||||
@@ -18,7 +18,6 @@ class DashboardController < ActionController::Base
|
||||
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
|
||||
'INSTALLATION_NAME',
|
||||
'WIDGET_BRAND_URL', 'TERMS_URL',
|
||||
'BRAND_URL', 'BRAND_NAME',
|
||||
'PRIVACY_URL',
|
||||
'DISPLAY_MANIFEST',
|
||||
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
|
||||
|
||||
@@ -4,10 +4,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
wrap_parameters format: []
|
||||
before_action :process_sso_auth_token, only: [:create]
|
||||
|
||||
def new
|
||||
redirect_to login_page_url(error: 'access-denied')
|
||||
end
|
||||
|
||||
def create
|
||||
# Authenticate user via the temporary sso auth token
|
||||
if params[:sso_auth_token].present? && @resource.present?
|
||||
@@ -25,12 +21,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
|
||||
private
|
||||
|
||||
def login_page_url(error: nil)
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
|
||||
"#{frontend_url}/app/login?error=#{error}"
|
||||
end
|
||||
|
||||
def authenticate_resource_with_sso_token
|
||||
@token = @resource.create_token
|
||||
@resource.save!
|
||||
|
||||
@@ -47,7 +47,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
@locale = if article.category.present?
|
||||
article.category.locale
|
||||
else
|
||||
article.portal.default_locale
|
||||
'en'
|
||||
end
|
||||
|
||||
I18n.with_locale(@locale, &)
|
||||
|
||||
@@ -8,7 +8,8 @@ class PublicController < ActionController::Base
|
||||
|
||||
def ensure_custom_domain_request
|
||||
domain = request.host
|
||||
return if DomainHelper.chatwoot_domain?(domain)
|
||||
|
||||
return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
|
||||
|
||||
@portal = ::Portal.find_by(custom_domain: domain)
|
||||
return if @portal.present?
|
||||
|
||||
@@ -163,14 +163,10 @@ class ConversationFinder
|
||||
params[:page] || 1
|
||||
end
|
||||
|
||||
def conversations_base_query
|
||||
@conversations.includes(
|
||||
def conversations
|
||||
@conversations = @conversations.includes(
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
|
||||
)
|
||||
end
|
||||
|
||||
def conversations
|
||||
@conversations = conversations_base_query
|
||||
|
||||
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
|
||||
@conversations = @conversations.send(sort_by, sort_order)
|
||||
@@ -182,4 +178,3 @@ class ConversationFinder
|
||||
end
|
||||
end
|
||||
end
|
||||
ConversationFinder.prepend_mod_with('ConversationFinder')
|
||||
|
||||
@@ -2,11 +2,4 @@ module ApplicationHelper
|
||||
def available_locales_with_name
|
||||
LANGUAGES_CONFIG.map { |_key, val| val.slice(:name, :iso_639_1_code) }
|
||||
end
|
||||
|
||||
def feature_help_urls
|
||||
features = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
|
||||
features.each_with_object({}) do |feature, hash|
|
||||
hash[feature['name']] = feature['help_url'] if feature['help_url']
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
module FilterHelper
|
||||
def build_condition_query(model_filters, query_hash, current_index)
|
||||
current_filter = model_filters[query_hash['attribute_key']]
|
||||
|
||||
# Throw InvalidOperator Error if the attribute is a standard attribute
|
||||
# and the operator is not allowed in the config
|
||||
if current_filter.present? && current_filter['filter_operators'].exclude?(query_hash[:filter_operator])
|
||||
raise CustomExceptions::CustomFilter::InvalidOperator.new(
|
||||
attribute_name: query_hash['attribute_key'],
|
||||
allowed_keys: current_filter['filter_operators']
|
||||
)
|
||||
end
|
||||
|
||||
# Every other filter expects a value to be present
|
||||
if %w[is_present is_not_present].exclude?(query_hash[:filter_operator]) && query_hash['values'].blank?
|
||||
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: query_hash['attribute_key'])
|
||||
end
|
||||
|
||||
condition_query = build_condition_query_string(current_filter, query_hash, current_index)
|
||||
# The query becomes empty only when it doesn't match to any supported
|
||||
# standard attribute or custom attribute defined in the account.
|
||||
if condition_query.empty?
|
||||
raise CustomExceptions::CustomFilter::InvalidAttribute.new(key: query_hash['attribute_key'],
|
||||
allowed_keys: model_filters.keys)
|
||||
end
|
||||
|
||||
condition_query
|
||||
end
|
||||
|
||||
def build_condition_query_string(current_filter, query_hash, current_index)
|
||||
filter_operator_value = filter_operation(query_hash, current_index)
|
||||
|
||||
return handle_nil_filter(query_hash, current_index) if current_filter.nil?
|
||||
|
||||
case current_filter['attribute_type']
|
||||
when 'additional_attributes'
|
||||
handle_additional_attributes(query_hash, filter_operator_value, current_filter['data_type'])
|
||||
else
|
||||
handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_nil_filter(query_hash, current_index)
|
||||
attribute_type = "#{filter_config[:entity].downcase}_attribute"
|
||||
custom_attribute_query(query_hash, attribute_type, current_index)
|
||||
end
|
||||
|
||||
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
|
||||
if data_type == 'text_case_insensitive'
|
||||
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
else
|
||||
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]} "
|
||||
end
|
||||
end
|
||||
|
||||
def handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
|
||||
case current_filter['data_type']
|
||||
when 'date'
|
||||
date_filter(current_filter, query_hash, filter_operator_value)
|
||||
when 'labels'
|
||||
tag_filter_query(query_hash, current_index)
|
||||
when 'text_case_insensitive'
|
||||
text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||
else
|
||||
default_filter(query_hash, filter_operator_value)
|
||||
end
|
||||
end
|
||||
|
||||
def date_filter(current_filter, query_hash, filter_operator_value)
|
||||
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
|
||||
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||
"LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def default_filter(query_hash, filter_operator_value)
|
||||
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
end
|
||||
@@ -1,72 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class SLAReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('applied_slas', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({
|
||||
from,
|
||||
to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
page,
|
||||
} = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
page,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
download({
|
||||
from,
|
||||
to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
} = {}) {
|
||||
return axios.get(`${this.url}/download`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({
|
||||
from,
|
||||
to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
} = {}) {
|
||||
return axios.get(`${this.url}/metrics`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new SLAReportsAPI();
|
||||
@@ -1,98 +0,0 @@
|
||||
import SLAReportsAPI from '../slaReports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#SLAReports API', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(SLAReportsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(SLAReportsAPI.apiVersion).toBe('/api/v1');
|
||||
expect(SLAReportsAPI).toHaveProperty('get');
|
||||
expect(SLAReportsAPI).toHaveProperty('getMetrics');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
get: jest.fn(() => Promise.resolve()),
|
||||
patch: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#get', () => {
|
||||
SLAReportsAPI.get({
|
||||
page: 1,
|
||||
from: 1622485800,
|
||||
to: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/applied_slas', {
|
||||
params: {
|
||||
page: 1,
|
||||
since: 1622485800,
|
||||
until: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('#getMetrics', () => {
|
||||
SLAReportsAPI.getMetrics({
|
||||
from: 1622485800,
|
||||
to: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/applied_slas/metrics',
|
||||
{
|
||||
params: {
|
||||
since: 1622485800,
|
||||
until: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
it('#download', () => {
|
||||
SLAReportsAPI.download({
|
||||
from: 1622485800,
|
||||
to: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/applied_slas/download',
|
||||
{
|
||||
params: {
|
||||
since: 1622485800,
|
||||
until: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,17 @@
|
||||
// scss-lint:disable SpaceAfterPropertyColon
|
||||
@import 'shared/assets/fonts/inter';
|
||||
// Inter,
|
||||
// @import 'shared/assets/fonts/inter';
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family:
|
||||
'PlusJakarta',
|
||||
Inter,
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Tahoma,
|
||||
Arial,
|
||||
sans-serif !important;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@@ -63,12 +63,12 @@ input:focus {
|
||||
}
|
||||
|
||||
// Inputs
|
||||
input[type='text']:not(.reset-base),
|
||||
input[type='number']:not(.reset-base),
|
||||
input[type='password']:not(.reset-base),
|
||||
input[type='date']:not(.reset-base),
|
||||
input[type='email']:not(.reset-base),
|
||||
input[type='url']:not(.reset-base) {
|
||||
input[type='text'],
|
||||
input[type='number'],
|
||||
input[type='password'],
|
||||
input[type='date'],
|
||||
input[type='email'],
|
||||
input[type='url'] {
|
||||
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
|
||||
|
||||
&[disabled] {
|
||||
|
||||
@@ -118,7 +118,7 @@ button {
|
||||
@apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
|
||||
|
||||
&.secondary {
|
||||
@apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
|
||||
@apply text-slate-700 border-slate-200 dark:border-slate-600 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
|
||||
}
|
||||
|
||||
&.success {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-shrink-0 overflow-hidden border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
|
||||
:class="[
|
||||
{ hidden: !showConversationList },
|
||||
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
|
||||
]"
|
||||
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
|
||||
:class="{
|
||||
hide: !showConversationList,
|
||||
'list--full-width': isOnExpandedLayout,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-0"
|
||||
class="flex items-center justify-between py-0 px-4"
|
||||
:class="{
|
||||
'pb-3 border-b border-slate-75 dark:border-slate-700':
|
||||
hasAppliedFiltersOrActiveFolders,
|
||||
@@ -16,7 +16,7 @@
|
||||
>
|
||||
<div class="flex max-w-[85%] justify-center items-center">
|
||||
<h1
|
||||
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
|
||||
class="text-xl break-words overflow-hidden whitespace-nowrap font-medium text-ellipsis text-black-900 dark:text-slate-100 mb-0"
|
||||
:title="pageTitle"
|
||||
>
|
||||
{{ pageTitle }}
|
||||
@@ -107,7 +107,7 @@
|
||||
|
||||
<p
|
||||
v-if="!chatListLoading && !conversationList.length"
|
||||
class="flex items-center justify-center p-4 overflow-auto"
|
||||
class="overflow-auto p-4 flex justify-center items-center"
|
||||
>
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
@@ -127,7 +127,7 @@
|
||||
/>
|
||||
<div
|
||||
ref="conversationList"
|
||||
class="flex-1 conversations-list"
|
||||
class="conversations-list flex-1"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<virtual-list
|
||||
@@ -136,16 +136,16 @@
|
||||
:data-sources="conversationList"
|
||||
:data-component="itemComponent"
|
||||
:extra-props="virtualListExtraProps"
|
||||
class="w-full h-full overflow-auto"
|
||||
class="w-full overflow-auto h-full"
|
||||
footer-tag="div"
|
||||
>
|
||||
<template #footer>
|
||||
<div v-if="chatListLoading" class="text-center">
|
||||
<span class="mt-4 mb-4 spinner" />
|
||||
<span class="spinner mt-4 mb-4" />
|
||||
</div>
|
||||
<p
|
||||
v-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-slate-400 dark:text-slate-300"
|
||||
class="text-center text-slate-400 dark:text-slate-300 p-4"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
@@ -1034,10 +1034,24 @@ export default {
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversations-list-wrap {
|
||||
&.hide {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
&.list--full-width {
|
||||
@apply basis-full;
|
||||
}
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
@apply overflow-hidden hover:overflow-y-auto;
|
||||
}
|
||||
|
||||
.load-more--button {
|
||||
@apply text-center rounded-none;
|
||||
}
|
||||
|
||||
.tab--chat-type {
|
||||
@apply py-0 px-4;
|
||||
|
||||
|
||||
@@ -2,27 +2,23 @@
|
||||
<div class="py-3 px-4">
|
||||
<div class="flex items-center mb-1">
|
||||
<h4 class="text-sm flex items-center m-0 w-full error">
|
||||
<div v-if="isAttributeTypeCheckbox" class="flex items-center">
|
||||
<div v-if="isAttributeTypeCheckbox" class="checkbox-wrap">
|
||||
<input
|
||||
v-model="editedValue"
|
||||
class="!my-0 mr-2 ml-0"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span
|
||||
class="w-full font-medium text-sm mb-0"
|
||||
:class="
|
||||
$v.editedValue.$error
|
||||
? 'text-red-400 dark:text-red-500'
|
||||
: 'text-slate-800 dark:text-slate-100'
|
||||
"
|
||||
class="attribute-name w-full text-slate-800 dark:text-slate-100 font-medium text-sm mb-0"
|
||||
:class="{ error: $v.editedValue.$error }"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<woot-button
|
||||
v-if="showCopyAndDeleteButton"
|
||||
v-if="showActions"
|
||||
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
|
||||
variant="link"
|
||||
size="medium"
|
||||
@@ -35,7 +31,7 @@
|
||||
</h4>
|
||||
</div>
|
||||
<div v-if="notAttributeTypeCheckboxAndList">
|
||||
<div v-if="isEditing" v-on-clickaway="onClickAway">
|
||||
<div v-show="isEditing">
|
||||
<div class="mb-2 w-full flex items-center">
|
||||
<input
|
||||
ref="inputfield"
|
||||
@@ -65,7 +61,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-show="!isEditing"
|
||||
class="flex group"
|
||||
class="value--view"
|
||||
:class="{ 'is-editable': showActions }"
|
||||
>
|
||||
<a
|
||||
@@ -73,35 +69,35 @@
|
||||
:href="hrefURL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
|
||||
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1"
|
||||
>
|
||||
{{ urlValue }}
|
||||
</a>
|
||||
<p
|
||||
v-else
|
||||
class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
|
||||
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1"
|
||||
>
|
||||
{{ displayValue || '---' }}
|
||||
</p>
|
||||
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
|
||||
<woot-button
|
||||
v-if="showCopyAndDeleteButton"
|
||||
v-if="showActions"
|
||||
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
|
||||
variant="link"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
icon="clipboard"
|
||||
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
|
||||
class-names="edit-button"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="showEditButton"
|
||||
v-if="showActions"
|
||||
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
|
||||
variant="link"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
|
||||
class-names="edit-button"
|
||||
@click="onEdit"
|
||||
/>
|
||||
</div>
|
||||
@@ -130,7 +126,6 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { required, url } from 'vuelidate/lib/validators';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
@@ -143,7 +138,7 @@ export default {
|
||||
components: {
|
||||
MultiselectDropdown,
|
||||
},
|
||||
mixins: [customAttributeMixin, clickaway],
|
||||
mixins: [customAttributeMixin],
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
values: { type: Array, default: () => [] },
|
||||
@@ -165,18 +160,11 @@ export default {
|
||||
editedValue: null,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
showCopyAndDeleteButton() {
|
||||
return this.value && this.showActions;
|
||||
},
|
||||
showEditButton() {
|
||||
return !this.value && this.showActions;
|
||||
},
|
||||
displayValue() {
|
||||
if (this.isAttributeTypeDate) {
|
||||
return this.value
|
||||
? new Date(this.value || new Date()).toLocaleDateString()
|
||||
: '';
|
||||
return new Date(this.value || new Date()).toLocaleDateString();
|
||||
}
|
||||
if (this.isAttributeTypeCheckbox) {
|
||||
return this.value === 'false' ? false : this.value;
|
||||
@@ -242,10 +230,6 @@ export default {
|
||||
this.isEditing = false;
|
||||
this.editedValue = this.formattedValue;
|
||||
},
|
||||
contactId() {
|
||||
// Fix to solve validation not resetting when contactId changes in contact page
|
||||
this.$v.$reset();
|
||||
},
|
||||
},
|
||||
|
||||
validations() {
|
||||
@@ -284,10 +268,6 @@ export default {
|
||||
this.$refs.inputfield.focus();
|
||||
}
|
||||
},
|
||||
onClickAway() {
|
||||
this.$v.$reset();
|
||||
this.isEditing = false;
|
||||
},
|
||||
onEdit() {
|
||||
this.isEditing = true;
|
||||
this.$nextTick(() => {
|
||||
@@ -314,7 +294,6 @@ export default {
|
||||
},
|
||||
onDelete() {
|
||||
this.isEditing = false;
|
||||
this.$v.$reset();
|
||||
this.$emit('delete', this.attributeKey);
|
||||
},
|
||||
onCopy() {
|
||||
@@ -325,6 +304,35 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.checkbox-wrap {
|
||||
@apply flex items-center;
|
||||
}
|
||||
.checkbox {
|
||||
@apply my-0 mr-2 ml-0;
|
||||
}
|
||||
.attribute-name {
|
||||
&.error {
|
||||
@apply text-red-400 dark:text-red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.value--view {
|
||||
@apply flex;
|
||||
|
||||
&.is-editable:hover {
|
||||
.value {
|
||||
@apply bg-slate-50 dark:bg-slate-700 mb-0;
|
||||
}
|
||||
.edit-button {
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.selector-wrap {
|
||||
@apply m-0 top-1;
|
||||
|
||||
@@ -7,13 +7,7 @@
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'modal-container rtl:text-right shadow-md max-h-full overflow-auto relative bg-white dark:bg-slate-800 skip-context-menu': true,
|
||||
'rounded-xl w-[37.5rem]': !fullWidth,
|
||||
'items-center rounded-none flex h-full justify-center w-full':
|
||||
fullWidth,
|
||||
[size]: true,
|
||||
}"
|
||||
:class="modalContainerClassName"
|
||||
@mouse.stop
|
||||
@mousedown="event => event.stopPropagation()"
|
||||
>
|
||||
@@ -22,7 +16,7 @@
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
variant="clear"
|
||||
class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
|
||||
class="absolute ltr:right-2 rtl:left-2 top-2 z-10"
|
||||
@click="close"
|
||||
/>
|
||||
<slot />
|
||||
@@ -66,6 +60,15 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
modalContainerClassName() {
|
||||
let className =
|
||||
'modal-container rtl:text-right shadow-md rounded-sm max-h-full overflow-auto relative w-[37.5rem] bg-white dark:bg-slate-800 skip-context-menu';
|
||||
if (this.fullWidth) {
|
||||
return `${className} items-center rounded-none flex h-full justify-center w-full`;
|
||||
}
|
||||
|
||||
return `${className} ${this.size}`;
|
||||
},
|
||||
modalClassName() {
|
||||
const modalClassNameMap = {
|
||||
centered: '',
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-start px-8 pt-8 pb-0">
|
||||
<div class="flex flex-col items-start pt-8 px-8 pb-0">
|
||||
<img v-if="headerImage" :src="headerImage" alt="No image" />
|
||||
<h2
|
||||
ref="modalHeaderTitle"
|
||||
class="text-base font-semibold leading-6 text-slate-800 dark:text-slate-50"
|
||||
class="text-slate-800 text-lg font-semibold dark:text-slate-50"
|
||||
>
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="headerContent"
|
||||
ref="modalHeaderContent"
|
||||
class="w-full mt-2 text-sm leading-5 break-words text-slate-600 dark:text-slate-300"
|
||||
class="w-full break-words text-slate-600 mt-2 text-sm dark:text-slate-300"
|
||||
>
|
||||
{{ headerContent }}
|
||||
<span
|
||||
v-if="headerContentValue"
|
||||
class="text-sm font-semibold text-slate-600 dark:text-slate-300"
|
||||
class="font-semibold text-sm text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{{ headerContentValue }}
|
||||
</span>
|
||||
|
||||
@@ -12,7 +12,6 @@ const reports = accountId => ({
|
||||
'label_reports',
|
||||
'inbox_reports',
|
||||
'team_reports',
|
||||
'sla_reports',
|
||||
],
|
||||
menuItems: [
|
||||
{
|
||||
@@ -72,14 +71,6 @@ const reports = accountId => ({
|
||||
toState: frontendURL(`accounts/${accountId}/reports/teams`),
|
||||
toStateName: 'team_reports',
|
||||
},
|
||||
{
|
||||
icon: 'document-list-clock',
|
||||
label: 'REPORTS_SLA',
|
||||
hasSubMenu: false,
|
||||
featureFlag: FEATURE_FLAGS.SLA,
|
||||
toState: frontendURL(`accounts/${accountId}/reports/sla`),
|
||||
toStateName: 'sla_reports',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex ltr:mr-1 rtl:ml-1 mb-1"
|
||||
class="ltr:mr-1 rtl:ml-1 mb-1"
|
||||
:class="labelClass"
|
||||
:style="labelStyle"
|
||||
:title="description"
|
||||
@@ -111,7 +111,7 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.label {
|
||||
@apply items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-slate-75 dark:border-slate-600 h-6;
|
||||
@apply inline-flex items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-slate-75 dark:border-slate-600 h-6;
|
||||
|
||||
&.small {
|
||||
@apply text-xs py-0.5 px-1 leading-tight h-5;
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col min-w-[15rem] max-h-[21.25rem] w-[23rem] rounded-md border border-solid border-slate-75 dark:border-slate-600"
|
||||
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid border-slate-75 dark:border-slate-600"
|
||||
:class="{
|
||||
'border border-solid border-woot-300 dark:border-woot-400': active,
|
||||
'bg-woot-25 dark:bg-slate-700 border border-solid border-woot-300 dark:border-woot-400':
|
||||
active,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between items-center px-2 w-full h-10 rounded-t-[5px]"
|
||||
class="flex justify-between items-center px-2 w-full h-10 bg-slate-50 dark:bg-slate-900 rounded-t-[5px] border-b border-solid border-slate-50 dark:border-slate-600"
|
||||
:class="{
|
||||
'bg-woot-50 border-b border-solid border-woot-75 dark:border-woot-700':
|
||||
active,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div>
|
||||
<!-- <fluent-icon
|
||||
<div class="items-center flex font-medium p-1 text-sm">{{ heading }}</div>
|
||||
<fluent-icon
|
||||
v-if="active"
|
||||
icon="checkmark-circle"
|
||||
type="solid"
|
||||
size="24"
|
||||
class="text-woot-500 dark:text-woot-500"
|
||||
/> -->
|
||||
<div>
|
||||
<input type="radio" name="hotkey" value="enter" :checked="active" />
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="text-slate-700 dark:text-slate-200 text-xs leading-[1.4] px-3 pb-0 text-start"
|
||||
class="text-slate-700 dark:text-slate-200 text-xs leading-[1.4] px-3 pt-3 pb-0 text-start"
|
||||
>
|
||||
{{ content }}
|
||||
</div>
|
||||
<div v-if="src" class="p-3">
|
||||
<img :src="src" class="object-cover w-full" />
|
||||
<img
|
||||
:src="src"
|
||||
class="border border-solid rounded-md"
|
||||
:class="
|
||||
active
|
||||
? 'border-woot-75 dark:border-woot-700'
|
||||
: 'border-slate-50 dark:border-slate-600'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</div>
|
||||
|
||||
@@ -1,48 +1,171 @@
|
||||
<template>
|
||||
<footer
|
||||
v-if="isFooterVisible"
|
||||
class="h-12 flex items-center justify-between px-6"
|
||||
class="bg-white dark:bg-slate-800 h-12 border-t border-solid border-slate-75 dark:border-slate-700/50 flex items-center justify-between px-6"
|
||||
>
|
||||
<table-footer-results
|
||||
:first-index="firstIndex"
|
||||
:last-index="lastIndex"
|
||||
:total-count="totalCount"
|
||||
/>
|
||||
<table-footer-pagination
|
||||
v-if="totalCount"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
:total-count="totalCount"
|
||||
:page-size="pageSize"
|
||||
@page-change="$emit('page-change', $event)"
|
||||
/>
|
||||
<div class="left-aligned-wrap">
|
||||
<div class="text-xs text-slate-600 dark:text-slate-200">
|
||||
<strong>{{ firstIndex }}</strong>
|
||||
- <strong>{{ lastIndex }}</strong> of
|
||||
<strong>{{ totalCount }}</strong> items
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-aligned-wrap">
|
||||
<div
|
||||
v-if="totalCount"
|
||||
class="primary button-group pagination-button-group"
|
||||
>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
class-names="goto-first"
|
||||
:is-disabled="hasFirstPage"
|
||||
@click="onFirstPage"
|
||||
>
|
||||
<fluent-icon icon="chevron-left" size="18" />
|
||||
<fluent-icon
|
||||
icon="chevron-left"
|
||||
size="18"
|
||||
:class="pageFooterIconClass"
|
||||
/>
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasPrevPage"
|
||||
@click="onPrevPage"
|
||||
>
|
||||
<fluent-icon icon="chevron-left" size="18" />
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
@click.prevent
|
||||
>
|
||||
{{ currentPage }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasNextPage"
|
||||
@click="onNextPage"
|
||||
>
|
||||
<fluent-icon icon="chevron-right" size="18" />
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
class-names="goto-last"
|
||||
:is-disabled="hasLastPage"
|
||||
@click="onLastPage"
|
||||
>
|
||||
<fluent-icon icon="chevron-right" size="18" />
|
||||
<fluent-icon
|
||||
icon="chevron-right"
|
||||
size="18"
|
||||
:class="pageFooterIconClass"
|
||||
/>
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import TableFooterResults from './TableFooterResults.vue';
|
||||
import TableFooterPagination from './TableFooterPagination.vue';
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
<script>
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
mixins: [rtlMixin],
|
||||
props: {
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 25,
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 25,
|
||||
computed: {
|
||||
pageFooterIconClass() {
|
||||
return this.isRTLView ? '-mr-3' : '-ml-3';
|
||||
},
|
||||
isFooterVisible() {
|
||||
return this.totalCount && !(this.firstIndex > this.totalCount);
|
||||
},
|
||||
firstIndex() {
|
||||
return this.pageSize * (this.currentPage - 1) + 1;
|
||||
},
|
||||
lastIndex() {
|
||||
return Math.min(this.totalCount, this.pageSize * this.currentPage);
|
||||
},
|
||||
searchButtonClass() {
|
||||
return this.searchQuery !== '' ? 'show' : '';
|
||||
},
|
||||
hasLastPage() {
|
||||
return !!Math.ceil(this.totalCount / this.pageSize);
|
||||
},
|
||||
hasFirstPage() {
|
||||
return this.currentPage === 1;
|
||||
},
|
||||
hasNextPage() {
|
||||
return this.currentPage === Math.ceil(this.totalCount / this.pageSize);
|
||||
},
|
||||
hasPrevPage() {
|
||||
return this.currentPage === 1;
|
||||
},
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
methods: {
|
||||
onNextPage() {
|
||||
if (this.hasNextPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = this.currentPage + 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onPrevPage() {
|
||||
if (this.hasPrevPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = this.currentPage - 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onFirstPage() {
|
||||
if (this.hasFirstPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onLastPage() {
|
||||
if (this.hasLastPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = Math.ceil(this.totalCount / this.pageSize);
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onPageChange(page) {
|
||||
this.$emit('page-change', page);
|
||||
},
|
||||
},
|
||||
});
|
||||
const totalPages = computed(() => Math.ceil(props.totalCount / props.pageSize));
|
||||
const firstIndex = computed(() => props.pageSize * (props.currentPage - 1) + 1);
|
||||
const lastIndex = computed(() =>
|
||||
Math.min(props.totalCount, props.pageSize * props.currentPage)
|
||||
);
|
||||
const isFooterVisible = computed(
|
||||
() => props.totalCount && !(firstIndex.value > props.totalCount)
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.goto-first,
|
||||
.goto-last {
|
||||
i:last-child {
|
||||
@apply -ml-1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center bg-slate-50 dark:bg-slate-800 h-8 rounded-lg">
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasFirstPage"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-l-lg ltr:rounded-r-none rtl:rounded-r-lg rtl:rounded-l-none"
|
||||
:class="buttonClass(hasFirstPage)"
|
||||
@click="onFirstPage"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevrons-left"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasFirstPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
<div class="bg-slate-75 dark:bg-slate-700/50 w-px rounded-sm h-4" />
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasPrevPage"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
|
||||
:class="buttonClass(hasPrevPage)"
|
||||
@click="onPrevPage"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevron-left-single"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasPrevPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
|
||||
<div
|
||||
class="flex px-3 items-center gap-3 tabular-nums bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
<span class="text-sm text-slate-800 dark:text-slate-75">
|
||||
{{ currentPage }}
|
||||
</span>
|
||||
<span class="text-slate-600 dark:text-slate-500">/</span>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-500">
|
||||
{{ totalPages }}
|
||||
</span>
|
||||
</div>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasNextPage"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
|
||||
:class="buttonClass(hasNextPage)"
|
||||
@click="onNextPage"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevron-right-single"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasNextPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
<div class="bg-slate-75 dark:bg-slate-700/50 w-px rounded-sm h-4" />
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-r-lg ltr:rounded-l-none rtl:rounded-l-lg rtl:rounded-r-none"
|
||||
:class="buttonClass(hasLastPage)"
|
||||
:is-disabled="hasLastPage"
|
||||
@click="onLastPage"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevrons-right"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasLastPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineEmits } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const hasLastPage = computed(
|
||||
() => props.currentPage === props.totalPages || props.totalPages === 1
|
||||
);
|
||||
const hasFirstPage = computed(() => props.currentPage === 1);
|
||||
const hasNextPage = computed(() => props.currentPage === props.totalPages);
|
||||
const hasPrevPage = computed(() => props.currentPage === 1);
|
||||
|
||||
const emit = defineEmits(['page-change']);
|
||||
|
||||
function buttonClass(hasPage) {
|
||||
if (hasPage) {
|
||||
return 'hover:!bg-slate-50 dark:hover:!bg-slate-800';
|
||||
}
|
||||
return 'dark:hover:!bg-slate-700/50';
|
||||
}
|
||||
|
||||
function onPageChange(newPage) {
|
||||
emit('page-change', newPage);
|
||||
}
|
||||
|
||||
const onNextPage = () => {
|
||||
if (!onNextPage.value) {
|
||||
onPageChange(props.currentPage + 1);
|
||||
}
|
||||
};
|
||||
const onPrevPage = () => {
|
||||
if (!hasPrevPage.value) {
|
||||
onPageChange(props.currentPage - 1);
|
||||
}
|
||||
};
|
||||
const onFirstPage = () => {
|
||||
if (!hasFirstPage.value) {
|
||||
onPageChange(1);
|
||||
}
|
||||
};
|
||||
const onLastPage = () => {
|
||||
if (!hasLastPage.value) {
|
||||
onPageChange(props.totalPages);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200 font-medium">
|
||||
{{
|
||||
$t('GENERAL.SHOWING_RESULTS', {
|
||||
firstIndex,
|
||||
lastIndex,
|
||||
totalCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
firstIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
lastIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
span: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const spanClass = computed(() => {
|
||||
if (props.span === 1) return 'col-span-1';
|
||||
if (props.span === 2) return 'col-span-2';
|
||||
if (props.span === 3) return 'col-span-3';
|
||||
if (props.span === 4) return 'col-span-4';
|
||||
if (props.span === 5) return 'col-span-5';
|
||||
if (props.span === 6) return 'col-span-6';
|
||||
if (props.span === 7) return 'col-span-7';
|
||||
if (props.span === 8) return 'col-span-8';
|
||||
if (props.span === 9) return 'col-span-9';
|
||||
if (props.span === 10) return 'col-span-10';
|
||||
|
||||
return 'col-span-1';
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center px-0 py-2 text-xs font-medium text-left uppercase text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
:class="spanClass"
|
||||
>
|
||||
<slot>
|
||||
{{ label }}
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,11 +86,7 @@
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<card-labels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
|
||||
<template v-if="hasSlaPolicyId" #before>
|
||||
<SLA-card-label :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
</card-labels>
|
||||
<card-labels :conversation-id="chat.id" />
|
||||
</div>
|
||||
<woot-context-menu
|
||||
v-if="showContextMenu"
|
||||
@@ -129,7 +125,6 @@ import alertMixin from 'shared/mixins/alertMixin';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -140,7 +135,6 @@ export default {
|
||||
TimeAgo,
|
||||
MessagePreview,
|
||||
PriorityMark,
|
||||
SLACardLabel,
|
||||
},
|
||||
|
||||
mixins: [inboxMixin, timeMixin, conversationMixin, alertMixin],
|
||||
@@ -258,9 +252,6 @@ export default {
|
||||
const stateInbox = this.inbox;
|
||||
return stateInbox.name || '';
|
||||
},
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCardClick(e) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col items-center justify-between px-4 py-2 bg-white border-b dark:bg-slate-900 border-slate-50 dark:border-slate-800/50 md:flex-row"
|
||||
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
|
||||
class="flex-1 w-full min-w-0 flex flex-col items-center justify-center"
|
||||
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
|
||||
>
|
||||
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
|
||||
<div class="flex justify-start items-center min-w-0 w-fit max-w-full">
|
||||
<back-button
|
||||
v-if="showBackButton"
|
||||
:back-url="backButtonUrl"
|
||||
@@ -19,10 +19,10 @@
|
||||
:status="currentContact.availability_status"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
||||
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-fit overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
|
||||
class="flex items-center flex-row gap-1 m-0 p-0 w-fit max-w-full"
|
||||
>
|
||||
<woot-button
|
||||
variant="link"
|
||||
@@ -31,7 +31,7 @@
|
||||
@click.prevent="$emit('contact-panel-toggle')"
|
||||
>
|
||||
<span
|
||||
class="text-base font-medium leading-tight text-slate-900 dark:text-slate-100"
|
||||
class="text-base leading-tight font-medium text-slate-900 dark:text-slate-100"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</span>
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
||||
class="conversation--header--actions items-center flex text-xs gap-2 text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
<inbox-name v-if="hasMultipleInboxes" :inbox="inbox" />
|
||||
<span
|
||||
@@ -67,10 +67,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
|
||||
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0"
|
||||
:class="{ 'justify-end': isContactPanelOpen }"
|
||||
>
|
||||
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
|
||||
<more-actions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +85,6 @@ import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
import Thumbnail from '../Thumbnail.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
@@ -97,7 +95,6 @@ export default {
|
||||
InboxName,
|
||||
MoreActions,
|
||||
Thumbnail,
|
||||
SLACardLabel,
|
||||
},
|
||||
mixins: [inboxMixin, agentMixin, eventListenerMixins],
|
||||
props: {
|
||||
@@ -176,9 +173,6 @@ export default {
|
||||
hasMultipleInboxes() {
|
||||
return this.$store.getters['inboxes/getInboxes'].length > 1;
|
||||
},
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasSlaThreshold"
|
||||
class="relative flex items-center border cursor-pointer min-w-fit border-slate-100 dark:border-slate-700"
|
||||
:class="showExtendedInfo ? 'h-[26px] rounded-lg' : 'rounded h-5'"
|
||||
>
|
||||
<div
|
||||
v-on-clickaway="closeSlaPopover"
|
||||
class="flex items-center w-full truncate"
|
||||
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
|
||||
@mouseover="openSlaPopover()"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
:class="
|
||||
showExtendedInfo &&
|
||||
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-solid border-slate-100 dark:border-slate-700'
|
||||
"
|
||||
>
|
||||
<fluent-icon
|
||||
size="14"
|
||||
:icon="slaStatus.icon"
|
||||
type="outline"
|
||||
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
|
||||
class="flex-shrink-0"
|
||||
:class="slaTextStyles"
|
||||
/>
|
||||
<span
|
||||
v-if="showExtendedInfo"
|
||||
class="text-xs font-medium"
|
||||
:class="slaTextStyles"
|
||||
>
|
||||
{{ slaStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium"
|
||||
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
|
||||
>
|
||||
{{ slaStatus.threshold }}
|
||||
</span>
|
||||
</div>
|
||||
<SLA-popover-card
|
||||
v-if="showSlaPopoverCard"
|
||||
:sla-missed-events="slaEvents"
|
||||
class="right-0 top-7"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { evaluateSLAStatus } from '../helpers/SLAHelper';
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SLAPopoverCard,
|
||||
},
|
||||
mixins: [clickaway],
|
||||
props: {
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showExtendedInfo: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timer: null,
|
||||
showSlaPopover: false,
|
||||
slaStatus: {
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
slaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
appliedSLA() {
|
||||
return this.chat?.applied_sla;
|
||||
},
|
||||
slaEvents() {
|
||||
return this.chat?.sla_events;
|
||||
},
|
||||
hasSlaThreshold() {
|
||||
return this.slaStatus?.threshold;
|
||||
},
|
||||
isSlaMissed() {
|
||||
return this.slaStatus?.isSlaMissed;
|
||||
},
|
||||
slaTextStyles() {
|
||||
return this.isSlaMissed
|
||||
? 'text-red-400 dark:text-red-300'
|
||||
: 'text-yellow-600 dark:text-yellow-500';
|
||||
},
|
||||
slaStatusText() {
|
||||
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
|
||||
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
|
||||
|
||||
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
|
||||
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
|
||||
});
|
||||
},
|
||||
showSlaPopoverCard() {
|
||||
return (
|
||||
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
chat() {
|
||||
this.updateSlaStatus();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateSlaStatus();
|
||||
this.createTimer();
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createTimer() {
|
||||
this.timer = setTimeout(() => {
|
||||
this.updateSlaStatus();
|
||||
this.createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
},
|
||||
updateSlaStatus() {
|
||||
this.slaStatus = evaluateSLAStatus(this.appliedSLA, this.chat);
|
||||
},
|
||||
openSlaPopover() {
|
||||
if (!this.showExtendedInfo) return;
|
||||
this.showSlaPopover = true;
|
||||
},
|
||||
closeSlaPopover() {
|
||||
this.showSlaPopover = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup>
|
||||
import { format, fromUnixTime } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const formatDate = timestamp =>
|
||||
format(fromUnixTime(timestamp), 'MMM dd, yyyy, hh:mm a');
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex justify-between w-full">
|
||||
<span
|
||||
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<span
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="text-sm font-normal text-slate-900 dark:text-slate-25 text-right tabular-nums"
|
||||
>
|
||||
{{ formatDate(item.created_at) }}
|
||||
</span>
|
||||
<slot name="showMore" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import SLAEventItem from './SLAEventItem.vue';
|
||||
|
||||
const { SLA_MISS_TYPES } = wootConstants;
|
||||
|
||||
const props = defineProps({
|
||||
slaMissedEvents: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShowAllNrts = ref(false);
|
||||
|
||||
const frtMisses = computed(() =>
|
||||
props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.FRT
|
||||
)
|
||||
);
|
||||
const nrtMisses = computed(() => {
|
||||
const missedEvents = props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.NRT
|
||||
);
|
||||
return shouldShowAllNrts.value ? missedEvents : missedEvents.slice(0, 6);
|
||||
});
|
||||
const rtMisses = computed(() =>
|
||||
props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.RT
|
||||
)
|
||||
);
|
||||
|
||||
const shouldShowMoreNRTButton = computed(() => nrtMisses.value.length > 6);
|
||||
const toggleShowAllNRT = () => {
|
||||
shouldShowAllNrts.value = !shouldShowAllNrts.value;
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<span class="text-sm font-medium text-slate-900 dark:text-slate-25">
|
||||
{{ $t('SLA.EVENTS.TITLE') }}
|
||||
</span>
|
||||
<SLA-event-item
|
||||
v-if="frtMisses.length"
|
||||
:label="$t('SLA.EVENTS.FRT')"
|
||||
:items="frtMisses"
|
||||
/>
|
||||
<SLA-event-item
|
||||
v-if="nrtMisses.length"
|
||||
:label="$t('SLA.EVENTS.NRT')"
|
||||
:items="nrtMisses"
|
||||
>
|
||||
<template #showMore>
|
||||
<div
|
||||
v-if="shouldShowMoreNRTButton"
|
||||
class="flex flex-col items-end w-full"
|
||||
>
|
||||
<woot-button
|
||||
size="small"
|
||||
:icon="!shouldShowAllNrts ? 'plus-sign' : ''"
|
||||
variant="link"
|
||||
color-scheme="secondary"
|
||||
class="hover:!no-underline !gap-1 hover:!bg-transparent dark:hover:!bg-transparent"
|
||||
@click="toggleShowAllNRT"
|
||||
>
|
||||
{{
|
||||
shouldShowAllNrts
|
||||
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
|
||||
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
|
||||
}}
|
||||
</woot-button>
|
||||
</div>
|
||||
</template>
|
||||
</SLA-event-item>
|
||||
<SLA-event-item
|
||||
v-if="rtMisses.length"
|
||||
:label="$t('SLA.EVENTS.RT')"
|
||||
:items="rtMisses"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+15
-25
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="activeLabels.length || $slots.before"
|
||||
v-show="activeLabels.length"
|
||||
ref="labelContainer"
|
||||
v-resize="computeVisibleLabelPosition"
|
||||
class="label-container mt-0.5 mx-2 mb-0"
|
||||
>
|
||||
<div
|
||||
class="flex items-end flex-shrink min-w-0 gap-y-1"
|
||||
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
|
||||
class="labels-wrap flex items-end min-w-0 flex-shrink gap-y-1 flex-wrap"
|
||||
:class="{ expand: showAllLabels }"
|
||||
>
|
||||
<slot name="before" />
|
||||
<woot-label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label.id"
|
||||
@@ -27,7 +26,7 @@
|
||||
? $t('CONVERSATION.CARD.HIDE_LABELS')
|
||||
: $t('CONVERSATION.CARD.SHOW_LABELS')
|
||||
"
|
||||
class="sticky right-0 flex-shrink-0 mr-6 show-more--button rtl:rotate-180"
|
||||
class="show-more--button sticky flex-shrink-0 right-0 mr-6 rtl:rotate-180"
|
||||
color-scheme="secondary"
|
||||
variant="hollow"
|
||||
:icon="showAllLabels ? 'chevron-left' : 'chevron-right'"
|
||||
@@ -46,11 +45,6 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
conversationLabels: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -65,34 +59,26 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// the problem here is that there is a certain amount of delay between the conversation
|
||||
// card being mounted and the resize event eventually being triggered
|
||||
// This means we need to run the function immediately after the component is mounted
|
||||
// Happens especially when used in a virtual list.
|
||||
// We can make the first trigger, a standard part of the directive, in case
|
||||
// we face this issue again
|
||||
this.computeVisibleLabelPosition();
|
||||
},
|
||||
methods: {
|
||||
onShowLabels(e) {
|
||||
e.stopPropagation();
|
||||
this.showAllLabels = !this.showAllLabels;
|
||||
this.$nextTick(() => this.computeVisibleLabelPosition());
|
||||
},
|
||||
computeVisibleLabelPosition() {
|
||||
const beforeSlot = this.$slots.before ? 100 : 0;
|
||||
const labelContainer = this.$refs.labelContainer;
|
||||
if (!labelContainer) return;
|
||||
|
||||
const labels = Array.from(labelContainer.querySelectorAll('.label'));
|
||||
const labels = this.$refs.labelContainer.querySelectorAll('.label');
|
||||
let labelOffset = 0;
|
||||
this.showExpandLabelButton = false;
|
||||
labels.forEach((label, index) => {
|
||||
|
||||
Array.from(labels).forEach((label, index) => {
|
||||
labelOffset += label.offsetWidth + 8;
|
||||
if (labelOffset < labelContainer.clientWidth - 16 - beforeSlot) {
|
||||
|
||||
if (labelOffset < labelContainer.clientWidth - 16) {
|
||||
this.labelPosition = index;
|
||||
} else {
|
||||
this.showExpandLabelButton = labels.length > 1;
|
||||
this.showExpandLabelButton = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -109,6 +95,10 @@ export default {
|
||||
}
|
||||
|
||||
.labels-wrap {
|
||||
&.expand {
|
||||
@apply h-auto overflow-visible flex-row flex-wrap;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
@apply border border-solid border-slate-100 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
const calculateThreshold = (timeOffset, threshold) => {
|
||||
// Calculate the time left for the SLA to breach or the time since the SLA has missed
|
||||
if (threshold === null) return null;
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return timeOffset + threshold - currentTime;
|
||||
};
|
||||
|
||||
const findMostUrgentSLAStatus = SLAStatuses => {
|
||||
// Sort the SLAs based on the threshold and return the most urgent SLA
|
||||
SLAStatuses.sort(
|
||||
(sla1, sla2) => Math.abs(sla1.threshold) - Math.abs(sla2.threshold)
|
||||
);
|
||||
return SLAStatuses[0];
|
||||
};
|
||||
|
||||
const formatSLATime = seconds => {
|
||||
const units = {
|
||||
y: 31536000, // 60 * 60 * 24 * 365
|
||||
mo: 2592000, // 60 * 60 * 24 * 30
|
||||
d: 86400, // 60 * 60 * 24
|
||||
h: 3600, // 60 * 60
|
||||
m: 60,
|
||||
};
|
||||
|
||||
if (seconds < 60) {
|
||||
return '1m';
|
||||
}
|
||||
|
||||
// we will only show two parts, two max granularity's, h-m, y-d, d-h, m, but no seconds
|
||||
const parts = [];
|
||||
|
||||
Object.keys(units).forEach(unit => {
|
||||
const value = Math.floor(seconds / units[unit]);
|
||||
if (seconds < 60 && parts.length > 0) return;
|
||||
if (parts.length === 2) return;
|
||||
if (value > 0) {
|
||||
parts.push(value + unit);
|
||||
seconds -= value * units[unit];
|
||||
}
|
||||
});
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const createSLAObject = (
|
||||
type,
|
||||
{
|
||||
sla_first_response_time_threshold: frtThreshold,
|
||||
sla_next_response_time_threshold: nrtThreshold,
|
||||
sla_resolution_time_threshold: rtThreshold,
|
||||
created_at: createdAt,
|
||||
} = {},
|
||||
{
|
||||
first_reply_created_at: firstReplyCreatedAt,
|
||||
waiting_since: waitingSince,
|
||||
status,
|
||||
} = {}
|
||||
) => {
|
||||
// Mapping of breach types to their logic
|
||||
const SLATypes = {
|
||||
FRT: {
|
||||
threshold: calculateThreshold(createdAt, frtThreshold),
|
||||
// Check FRT only if threshold is not null and first reply hasn't been made
|
||||
condition:
|
||||
frtThreshold !== null &&
|
||||
(!firstReplyCreatedAt || firstReplyCreatedAt === 0),
|
||||
},
|
||||
NRT: {
|
||||
threshold: calculateThreshold(waitingSince, nrtThreshold),
|
||||
// Check NRT only if threshold is not null, first reply has been made and we are waiting since
|
||||
condition:
|
||||
nrtThreshold !== null && !!firstReplyCreatedAt && !!waitingSince,
|
||||
},
|
||||
RT: {
|
||||
threshold: calculateThreshold(createdAt, rtThreshold),
|
||||
// Check RT only if the conversation is open and threshold is not null
|
||||
condition: status === 'open' && rtThreshold !== null,
|
||||
},
|
||||
};
|
||||
|
||||
const SLAStatus = SLATypes[type];
|
||||
return SLAStatus ? { ...SLAStatus, type } : null;
|
||||
};
|
||||
|
||||
const evaluateSLAConditions = (appliedSla, chat) => {
|
||||
// Filter out the SLA based on conditions and update the object with the breach status(icon, isSlaMissed)
|
||||
const SLATypes = ['FRT', 'NRT', 'RT'];
|
||||
return SLATypes.map(type => createSLAObject(type, appliedSla, chat))
|
||||
.filter(SLAStatus => SLAStatus && SLAStatus.condition)
|
||||
.map(SLAStatus => ({
|
||||
...SLAStatus,
|
||||
icon: SLAStatus.threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: SLAStatus.threshold <= 0,
|
||||
}));
|
||||
};
|
||||
|
||||
export const evaluateSLAStatus = (appliedSla, chat) => {
|
||||
if (!appliedSla || !chat)
|
||||
return { type: '', threshold: '', icon: '', isSlaMissed: false };
|
||||
|
||||
// Filter out the SLA and create the object for each breach
|
||||
const SLAStatuses = evaluateSLAConditions(appliedSla, chat);
|
||||
|
||||
// Return the most urgent SLA which is latest to breach or has missed
|
||||
const mostUrgent = findMostUrgentSLAStatus(SLAStatuses);
|
||||
return mostUrgent
|
||||
? {
|
||||
type: mostUrgent.type,
|
||||
threshold: formatSLATime(
|
||||
mostUrgent.threshold <= 0
|
||||
? -mostUrgent.threshold
|
||||
: mostUrgent.threshold
|
||||
),
|
||||
icon: mostUrgent.icon,
|
||||
isSlaMissed: mostUrgent.isSlaMissed,
|
||||
}
|
||||
: { type: '', threshold: '', icon: '', isSlaMissed: false };
|
||||
};
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
import { evaluateSLAStatus } from '../SLAHelper';
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => new Date('2024-01-01T00:00:00Z').getTime());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('SLAHelper', () => {
|
||||
describe('evaluateSLAStatus', () => {
|
||||
it('returns an empty object when sla or chat is not present', () => {
|
||||
expect(evaluateSLAStatus(null, null)).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when FRT SLA is missed
|
||||
it('correctly identifies a missed FRT SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704066540,
|
||||
};
|
||||
const chatMissed = {
|
||||
first_reply_created_at: 0,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
|
||||
type: 'FRT',
|
||||
threshold: '1m',
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when FRT SLA is not missed
|
||||
it('correctly identifies an FRT SLA not yet breached', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704066660,
|
||||
};
|
||||
const chatNotMissed = {
|
||||
first_reply_created_at: 0,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
|
||||
type: 'FRT',
|
||||
threshold: '1m',
|
||||
icon: 'alarm',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when NRT SLA is missed
|
||||
it('correctly identifies a missed NRT SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065200,
|
||||
};
|
||||
const chatMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 1704065940,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
|
||||
type: 'NRT',
|
||||
threshold: '1m',
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when NRT SLA is not missed
|
||||
it('correctly identifies an NRT SLA not yet breached', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065200 - 2000,
|
||||
};
|
||||
const chatNotMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 1704066060,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
|
||||
type: 'NRT',
|
||||
threshold: '1m',
|
||||
icon: 'alarm',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when RT SLA is missed
|
||||
it('correctly identifies a missed RT SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065340,
|
||||
};
|
||||
const chatMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
|
||||
type: 'RT',
|
||||
threshold: '1m',
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when RT SLA is not missed
|
||||
it('correctly identifies an RT SLA not yet breached', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065460,
|
||||
};
|
||||
const chatNotMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
|
||||
type: 'RT',
|
||||
threshold: '1m',
|
||||
icon: 'alarm',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,10 +59,5 @@ export default {
|
||||
TYPE: 'type',
|
||||
SORT_ORDER: 'sort_order',
|
||||
},
|
||||
SLA_MISS_TYPES: {
|
||||
FRT: 'frt',
|
||||
NRT: 'nrt',
|
||||
RT: 'rt',
|
||||
},
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
|
||||
@@ -20,14 +20,4 @@ export const FEATURE_FLAGS = {
|
||||
INBOX_VIEW: 'inbox_view',
|
||||
SLA: 'sla',
|
||||
RESPONSE_BOT: 'response_bot',
|
||||
CHANNEL_EMAIL: 'channel_email',
|
||||
CHANNEL_FACEBOOK: 'channel_facebook',
|
||||
CHANNEL_TWITTER: 'channel_twitter',
|
||||
CHANNEL_WEBSITE: 'channel_website',
|
||||
CUSTOM_REPLY_DOMAIN: 'custom_reply_domain',
|
||||
CUSTOM_REPLY_EMAIL: 'custom_reply_email',
|
||||
DISABLE_BRANDING: 'disable_branding',
|
||||
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
|
||||
INBOUND_EMAILS: 'inbound_emails',
|
||||
IP_LOOKUP: 'ip_lookup',
|
||||
};
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
|
||||
const RESIZE_OBSERVER_DEBOUNCE_TIME = 100;
|
||||
|
||||
function createResizeObserver(el, binding) {
|
||||
const { value } = binding;
|
||||
const observer = new ResizeObserver(
|
||||
debounce(entries => {
|
||||
const entry = entries[0];
|
||||
if (entry && value && typeof value === 'function') {
|
||||
value(entry);
|
||||
}
|
||||
}, RESIZE_OBSERVER_DEBOUNCE_TIME)
|
||||
);
|
||||
|
||||
el.cwResizeObserver = observer;
|
||||
observer.observe(el);
|
||||
}
|
||||
|
||||
function destroyResizeObserver(el) {
|
||||
if (el.cwResizeObserver) {
|
||||
el.cwResizeObserver.unobserve(el);
|
||||
el.cwResizeObserver.disconnect();
|
||||
delete el.cwResizeObserver;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
bind(el, binding) {
|
||||
createResizeObserver(el, binding);
|
||||
},
|
||||
update(el, binding) {
|
||||
if (binding.oldValue !== binding.value) {
|
||||
destroyResizeObserver(el);
|
||||
createResizeObserver(el, binding);
|
||||
}
|
||||
},
|
||||
unbind(el) {
|
||||
destroyResizeObserver(el);
|
||||
},
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
const { helpUrls } = window.chatwootConfig;
|
||||
return helpUrls[featureName];
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import resize from '../../directives/resize';
|
||||
|
||||
class ResizeObserverMock {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
observe() {}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
unobserve() {}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
describe('resize directive', () => {
|
||||
let el;
|
||||
let binding;
|
||||
let observer;
|
||||
|
||||
beforeEach(() => {
|
||||
el = document.createElement('div');
|
||||
binding = {
|
||||
value: jest.fn(),
|
||||
};
|
||||
observer = {
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
window.ResizeObserver = ResizeObserverMock;
|
||||
jest.spyOn(window, 'ResizeObserver').mockImplementation(() => observer);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create ResizeObserver on bind', () => {
|
||||
resize.bind(el, binding);
|
||||
|
||||
expect(ResizeObserver).toHaveBeenCalled();
|
||||
expect(observer.observe).toHaveBeenCalledWith(el);
|
||||
});
|
||||
|
||||
it('should call callback on observer callback', () => {
|
||||
el = document.createElement('div');
|
||||
binding = {
|
||||
value: jest.fn(),
|
||||
};
|
||||
|
||||
resize.bind(el, binding);
|
||||
|
||||
const entries = [{ contentRect: { width: 100, height: 100 } }];
|
||||
const callback = binding.value;
|
||||
callback(entries[0]);
|
||||
|
||||
expect(binding.value).toHaveBeenCalledWith(entries[0]);
|
||||
});
|
||||
|
||||
it('should destroy and recreate observer on update', () => {
|
||||
resize.bind(el, binding);
|
||||
|
||||
resize.update(el, { ...binding, oldValue: 'old' });
|
||||
|
||||
expect(observer.unobserve).toHaveBeenCalledWith(el);
|
||||
expect(observer.disconnect).toHaveBeenCalled();
|
||||
expect(ResizeObserver).toHaveBeenCalledTimes(2);
|
||||
expect(observer.observe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should destroy observer on unbind', () => {
|
||||
resize.bind(el, binding);
|
||||
|
||||
resize.unbind(el);
|
||||
|
||||
expect(observer.unobserve).toHaveBeenCalledWith(el);
|
||||
expect(observer.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -296,8 +296,6 @@
|
||||
"BUTTON": "Add custom attribute",
|
||||
"NOT_AVAILABLE": "There are no custom attributes available for this contact.",
|
||||
"COPY_SUCCESSFUL": "Copied to clipboard successfully",
|
||||
"SHOW_MORE": "Show all attributes",
|
||||
"SHOW_LESS": "Show less attributes",
|
||||
"ACTIONS": {
|
||||
"COPY": "Copy attribute",
|
||||
"DELETE": "Delete attribute",
|
||||
|
||||
@@ -44,8 +44,7 @@
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
|
||||
"CREATED_AT": "Created At",
|
||||
"LAST_ACTIVITY": "Last Activity",
|
||||
"REFERER_LINK": "Referrer link",
|
||||
"BLOCKED": "Blocked"
|
||||
"REFERER_LINK": "Referrer link"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard Filters",
|
||||
|
||||
@@ -64,14 +64,7 @@
|
||||
"SNOOZED_UNTIL": "Snoozed until",
|
||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
|
||||
"SLA_STATUS": {
|
||||
"FRT": "FRT {status}",
|
||||
"NRT": "NRT {status}",
|
||||
"RT": "RT {status}",
|
||||
"MISSED": "missed",
|
||||
"DUE": "due"
|
||||
}
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply"
|
||||
},
|
||||
"RESOLVE_DROPDOWN": {
|
||||
"MARK_PENDING": "Mark as pending",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"GENERAL": {
|
||||
"SHOWING_RESULTS": "Showing {firstIndex}-{lastIndex} of {totalCount} items"
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import sla from './sla.json';
|
||||
import inbox from './inbox.json';
|
||||
import general from './general.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
@@ -67,5 +66,4 @@ export default {
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...inbox,
|
||||
...general,
|
||||
};
|
||||
|
||||
@@ -505,34 +505,5 @@
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
},
|
||||
"SLA_REPORTS": {
|
||||
"HEADER": "SLA Reports",
|
||||
"NO_RECORDS": "SLA applied conversations are not available.",
|
||||
"LOADING": "Loading SLA data...",
|
||||
"DOWNLOAD_SLA_REPORTS": "Download SLA reports",
|
||||
"DOWNLOAD_FAILED": "Failed to download SLA Reports",
|
||||
"METRICS": {
|
||||
"HIT_RATE": {
|
||||
"LABEL": "Hit Rate",
|
||||
"TOOLTIP": "Percentage of SLAs created were completed successfully"
|
||||
},
|
||||
"NO_OF_MISSES": {
|
||||
"LABEL": "Number of Misses",
|
||||
"TOOLTIP": "Total SLA misses in a certain period"
|
||||
},
|
||||
"NO_OF_CONVERSATIONS": {
|
||||
"LABEL": "Number of Conversations",
|
||||
"TOOLTIP": "Total number of conversations with SLA"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"HEADER": {
|
||||
"POLICY": "Policy",
|
||||
"CONVERSATION": "Conversation",
|
||||
"AGENT": "Agent"
|
||||
},
|
||||
"VIEW_DETAILS": "View Details"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +83,7 @@
|
||||
"CONVERSATION_CREATION": "Send email notifications when a new conversation is created",
|
||||
"CONVERSATION_MENTION": "Send email notifications when you are mentioned in a conversation",
|
||||
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in an assigned conversation",
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation",
|
||||
"SLA_MISSED_FIRST_RESPONSE": "Send email notifications when a conversation misses first response SLA",
|
||||
"SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA",
|
||||
"SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA"
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation"
|
||||
},
|
||||
"API": {
|
||||
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
|
||||
@@ -101,10 +98,7 @@
|
||||
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in an assigned conversation",
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in a participating conversation",
|
||||
"HAS_ENABLED_PUSH": "You have enabled push for this browser.",
|
||||
"REQUEST_PUSH": "Enable push notifications",
|
||||
"SLA_MISSED_FIRST_RESPONSE": "Send push notifications when a conversation misses first response SLA",
|
||||
"SLA_MISSED_NEXT_RESPONSE": "Send push notifications when a conversation misses next response SLA",
|
||||
"SLA_MISSED_RESOLUTION": "Send push notifications when a conversation misses resolution SLA"
|
||||
"REQUEST_PUSH": "Enable push notifications"
|
||||
},
|
||||
"PROFILE_IMAGE": {
|
||||
"LABEL": "Profile Image"
|
||||
@@ -240,7 +234,6 @@
|
||||
"CAMPAIGNS": "Campaigns",
|
||||
"ONGOING": "Ongoing",
|
||||
"ONE_OFF": "One off",
|
||||
"REPORTS_SLA": "SLA",
|
||||
"REPORTS_BOT": "Bot",
|
||||
"REPORTS_AGENT": "Agents",
|
||||
"REPORTS_LABEL": "Labels",
|
||||
|
||||
@@ -1,31 +1,15 @@
|
||||
{
|
||||
"SLA": {
|
||||
"HEADER": "SLA",
|
||||
"ADD_ACTION": "Add SLA",
|
||||
"ADD_ACTION_LONG": "Create a new SLA Policy",
|
||||
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
|
||||
"LEARN_MORE": "Learn more about SLA",
|
||||
"HEADER_BTN_TXT": "Add SLA",
|
||||
"LOADING": "Fetching SLAs",
|
||||
"SEARCH_404": "There are no items matching this query",
|
||||
"SIDEBAR_TXT": "<p><b>SLA</b> <p>Think of Service Level Agreements (SLAs) like friendly promises between a service provider and a customer.</p> <p> These promises set clear expectations for things like how quickly the team will respond to issues, making sure you always get a reliable and top-notch experience!</p>",
|
||||
"LIST": {
|
||||
"404": "There are no SLAs available in this account.",
|
||||
"EMPTY": {
|
||||
"TITLE_1": "Enterprise P0",
|
||||
"DESC_1": "Issues raised by enterprise customers, that require immediate attention.",
|
||||
"TITLE_2": "Enterprise P1",
|
||||
"DESC_2": "Issues raised by enterprise customers, that needs to be acknowledged quickly."
|
||||
},
|
||||
"BUSINESS_HOURS_ON": "Business hours on",
|
||||
"BUSINESS_HOURS_OFF": "Business hours off",
|
||||
"RESPONSE_TYPES": {
|
||||
"FRT": "First response time threshold",
|
||||
"NRT": "Next response time threshold",
|
||||
"RT": "Resolution time threshold",
|
||||
"SHORT_HAND": {
|
||||
"FRT": "FRT",
|
||||
"NRT": "NRT",
|
||||
"RT": "RT"
|
||||
}
|
||||
}
|
||||
"TITLE": "Manage SLA",
|
||||
"DESC": "SLAs: Friendly promises for great service!",
|
||||
"TABLE_HEADER": ["Name", "Description", "FRT", "NRT", "RT", "Business Hours"]
|
||||
},
|
||||
"FORM": {
|
||||
"NAME": {
|
||||
@@ -65,7 +49,7 @@
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add SLA",
|
||||
"DESC": "Friendly promises for great service!",
|
||||
"DESC": "SLAs: Friendly promises for great service!",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "SLA added successfully",
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
@@ -83,14 +67,6 @@
|
||||
"YES": "Yes, Delete ",
|
||||
"NO": "No, Keep "
|
||||
}
|
||||
},
|
||||
"EVENTS": {
|
||||
"TITLE": "SLA Misses",
|
||||
"FRT": "First response time",
|
||||
"NRT": "Next response time",
|
||||
"RT": "Resolution time",
|
||||
"SHOW_MORE": "{count} more",
|
||||
"HIDE": "Hide {count} rows"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,33 @@ export default {
|
||||
conversationId() {
|
||||
return this.currentChat.id;
|
||||
},
|
||||
|
||||
filteredAttributes() {
|
||||
return Object.keys(this.customAttributes).map(key => {
|
||||
const item = this.attributes.find(
|
||||
attribute => attribute.attribute_key === key
|
||||
);
|
||||
if (item) {
|
||||
return {
|
||||
...item,
|
||||
value: this.customAttributes[key],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
value: this.customAttributes[key],
|
||||
attribute_description: key,
|
||||
attribute_display_name: key,
|
||||
attribute_display_type: this.attributeDisplayType(
|
||||
this.customAttributes[key]
|
||||
),
|
||||
attribute_key: key,
|
||||
attribute_model: this.attributeType,
|
||||
id: Math.random(),
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isAttributeNumber(attributeValue) {
|
||||
|
||||
@@ -4,15 +4,10 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({ accountLabels: 'labels/getLabels' }),
|
||||
savedLabels() {
|
||||
// If conversationLabels is passed as prop, use it
|
||||
if (this.conversationLabels)
|
||||
return this.conversationLabels.split(',').map(item => item.trim());
|
||||
// Otherwise, get labels from store
|
||||
return this.$store.getters['conversationLabels/getConversationLabels'](
|
||||
this.conversationId
|
||||
);
|
||||
},
|
||||
// TODO - Get rid of this from the mixin
|
||||
activeLabels() {
|
||||
return this.accountLabels.filter(({ title }) =>
|
||||
this.savedLabels.includes(title)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export default [
|
||||
{
|
||||
attribute_description: 'Product name',
|
||||
attribute_display_name: 'Product name',
|
||||
attribute_display_type: 'text',
|
||||
attribute_key: 'product_name',
|
||||
attribute_model: 'conversation_attribute',
|
||||
created_at: '2021-09-03T10:45:09.587Z',
|
||||
default_value: null,
|
||||
id: 6,
|
||||
updated_at: '2021-09-22T10:40:42.511Z',
|
||||
},
|
||||
{
|
||||
attribute_description: 'Product identifier',
|
||||
attribute_display_name: 'Product id',
|
||||
attribute_display_type: 'number',
|
||||
attribute_key: 'product_id',
|
||||
attribute_model: 'conversation_attribute',
|
||||
created_at: '2021-09-16T13:06:47.329Z',
|
||||
default_value: null,
|
||||
icon: 'fluent-calculator',
|
||||
id: 10,
|
||||
updated_at: '2021-09-22T10:42:25.873Z',
|
||||
value: 2021,
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,7 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import attributeMixin from '../attributeMixin';
|
||||
import Vuex from 'vuex';
|
||||
import attributeFixtures from './attributeFixtures';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
@@ -40,6 +41,43 @@ describe('attributeMixin', () => {
|
||||
expect(wrapper.vm.conversationId).toEqual(7165);
|
||||
});
|
||||
|
||||
it('returns filtered attributes from conversation custom attributes', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [attributeMixin],
|
||||
computed: {
|
||||
attributes() {
|
||||
return attributeFixtures;
|
||||
},
|
||||
contact() {
|
||||
return {
|
||||
id: 7165,
|
||||
custom_attributes: {
|
||||
product_id: 2021,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.filteredAttributes).toEqual([
|
||||
{
|
||||
attribute_description: 'Product identifier',
|
||||
attribute_display_name: 'Product id',
|
||||
attribute_display_type: 'number',
|
||||
attribute_key: 'product_id',
|
||||
attribute_model: 'conversation_attribute',
|
||||
created_at: '2021-09-16T13:06:47.329Z',
|
||||
default_value: null,
|
||||
icon: 'fluent-calculator',
|
||||
id: 10,
|
||||
updated_at: '2021-09-22T10:42:25.873Z',
|
||||
value: 2021,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('return display type if attribute passed', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
|
||||
@@ -38,10 +38,13 @@
|
||||
:contact-id="contact.id"
|
||||
attribute-type="contact_attribute"
|
||||
attribute-class="conversation--attribute"
|
||||
attribute-from="contact_panel"
|
||||
:custom-attributes="contact.custom_attributes"
|
||||
class="even"
|
||||
/>
|
||||
<custom-attribute-selector
|
||||
attribute-type="contact_attribute"
|
||||
:contact-id="contact.id"
|
||||
/>
|
||||
</accordion-item>
|
||||
</div>
|
||||
<div v-if="element.name === 'contact_labels'">
|
||||
@@ -82,6 +85,7 @@ import ContactConversations from 'dashboard/routes/dashboard/conversation/Contac
|
||||
import ContactInfo from 'dashboard/routes/dashboard/conversation/contact/ContactInfo.vue';
|
||||
import ContactLabel from 'dashboard/routes/dashboard/contacts/components/ContactLabels.vue';
|
||||
import CustomAttributes from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue';
|
||||
import CustomAttributeSelector from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributeSelector.vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
|
||||
@@ -92,6 +96,7 @@ export default {
|
||||
ContactInfo,
|
||||
ContactLabel,
|
||||
CustomAttributes,
|
||||
CustomAttributeSelector,
|
||||
draggable,
|
||||
},
|
||||
mixins: [uiSettingsMixin],
|
||||
|
||||
+1
-1
@@ -243,7 +243,7 @@ export default {
|
||||
attr.attribute_display_type === 'checkbox'
|
||||
);
|
||||
});
|
||||
if (isCustomAttributeCheckbox || type === 'blocked') {
|
||||
if (isCustomAttributeCheckbox) {
|
||||
return [
|
||||
{
|
||||
id: true,
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
@on-sort-change="onSortChange"
|
||||
/>
|
||||
<table-footer
|
||||
class="border-t border-slate-75 dark:border-slate-700/50"
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-count="meta.count"
|
||||
:page-size="15"
|
||||
|
||||
@@ -76,14 +76,6 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'blocked',
|
||||
attributeI18nKey: 'BLOCKED',
|
||||
inputType: 'search_select',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
];
|
||||
|
||||
export const filterAttributeGroups = [
|
||||
@@ -123,10 +115,6 @@ export const filterAttributeGroups = [
|
||||
key: 'last_activity_at',
|
||||
i18nKey: 'LAST_ACTIVITY',
|
||||
},
|
||||
{
|
||||
key: 'blocked',
|
||||
i18nKey: 'BLOCKED',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -87,7 +87,10 @@
|
||||
attribute-type="contact_attribute"
|
||||
attribute-class="conversation--attribute"
|
||||
class="even"
|
||||
attribute-from="conversation_contact_panel"
|
||||
:contact-id="contact.id"
|
||||
/>
|
||||
<custom-attribute-selector
|
||||
attribute-type="contact_attribute"
|
||||
:contact-id="contact.id"
|
||||
/>
|
||||
</accordion-item>
|
||||
@@ -139,6 +142,7 @@ import ConversationParticipant from './ConversationParticipant.vue';
|
||||
import ContactInfo from './contact/ContactInfo.vue';
|
||||
import ConversationInfo from './ConversationInfo.vue';
|
||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import MacrosList from './Macros/List.vue';
|
||||
@@ -150,6 +154,7 @@ export default {
|
||||
ContactInfo,
|
||||
ConversationInfo,
|
||||
CustomAttributes,
|
||||
CustomAttributeSelector,
|
||||
ConversationAction,
|
||||
ConversationParticipant,
|
||||
draggable,
|
||||
|
||||
@@ -1,109 +1,152 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getLanguageName } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
const props = defineProps({
|
||||
conversationAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
contactAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const referer = computed(() => props.conversationAttributes.referer);
|
||||
const initiatedAt = computed(
|
||||
() => props.conversationAttributes.initiated_at?.timestamp
|
||||
);
|
||||
|
||||
const browserInfo = props.conversationAttributes.browser;
|
||||
|
||||
const browserName = computed(() => {
|
||||
if (!browserInfo) return '';
|
||||
const { browser_name: name = '', browser_version: version = '' } =
|
||||
browserInfo;
|
||||
return `${name} ${version}`;
|
||||
});
|
||||
|
||||
const browserLanguage = computed(() =>
|
||||
getLanguageName(props.conversationAttributes.browser_language)
|
||||
);
|
||||
|
||||
const platformName = computed(() => {
|
||||
if (!browserInfo) return '';
|
||||
const { platform_name: name = '', platform_version: version = '' } =
|
||||
browserInfo;
|
||||
return `${name} ${version}`;
|
||||
});
|
||||
|
||||
const createdAtIp = computed(() => props.contactAttributes.created_at_ip);
|
||||
|
||||
const staticElements = computed(() =>
|
||||
[
|
||||
{
|
||||
content: initiatedAt,
|
||||
title: 'CONTACT_PANEL.INITIATED_AT',
|
||||
},
|
||||
{
|
||||
content: browserLanguage,
|
||||
title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
|
||||
},
|
||||
{
|
||||
content: referer,
|
||||
title: 'CONTACT_PANEL.INITIATED_FROM',
|
||||
type: 'link',
|
||||
},
|
||||
{
|
||||
content: browserName,
|
||||
title: 'CONTACT_PANEL.BROWSER',
|
||||
},
|
||||
{
|
||||
content: platformName,
|
||||
title: 'CONTACT_PANEL.OS',
|
||||
},
|
||||
{
|
||||
content: createdAtIp,
|
||||
title: 'CONTACT_PANEL.IP_ADDRESS',
|
||||
},
|
||||
].filter(attribute => !!attribute.content.value)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="conversation--details">
|
||||
<ContactDetailsItem
|
||||
v-for="element in staticElements"
|
||||
:key="element.title"
|
||||
:title="$t(element.title)"
|
||||
:value="element.content.value"
|
||||
<contact-details-item
|
||||
v-if="initiatedAt"
|
||||
:title="$t('CONTACT_PANEL.INITIATED_AT')"
|
||||
:value="initiatedAt.timestamp"
|
||||
class="conversation--attribute"
|
||||
/>
|
||||
<contact-details-item
|
||||
v-if="browserLanguage"
|
||||
:title="$t('CONTACT_PANEL.BROWSER_LANGUAGE')"
|
||||
:value="browserLanguage"
|
||||
class="conversation--attribute"
|
||||
/>
|
||||
<contact-details-item
|
||||
v-if="referer"
|
||||
:title="$t('CONTACT_PANEL.INITIATED_FROM')"
|
||||
:value="referer"
|
||||
class="conversation--attribute"
|
||||
>
|
||||
<a
|
||||
v-if="element.type === 'link'"
|
||||
:href="referer"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
class="text-woot-400 dark:text-woot-600"
|
||||
>
|
||||
<a :href="referer" rel="noopener noreferrer nofollow" target="_blank">
|
||||
{{ referer }}
|
||||
</a>
|
||||
</ContactDetailsItem>
|
||||
<CustomAttributes
|
||||
:class="staticElements.length % 2 === 0 ? 'even' : 'odd'"
|
||||
attribute-class="conversation--attribute"
|
||||
attribute-from="conversation_panel"
|
||||
attribute-type="conversation_attribute"
|
||||
</contact-details-item>
|
||||
<contact-details-item
|
||||
v-if="browserName"
|
||||
:title="$t('CONTACT_PANEL.BROWSER')"
|
||||
:value="browserName"
|
||||
class="conversation--attribute"
|
||||
/>
|
||||
<contact-details-item
|
||||
v-if="platformName"
|
||||
:title="$t('CONTACT_PANEL.OS')"
|
||||
:value="platformName"
|
||||
class="conversation--attribute"
|
||||
/>
|
||||
<contact-details-item
|
||||
v-if="ipAddress"
|
||||
:title="$t('CONTACT_PANEL.IP_ADDRESS')"
|
||||
:value="ipAddress"
|
||||
class="conversation--attribute"
|
||||
/>
|
||||
<custom-attributes
|
||||
attribute-type="conversation_attribute"
|
||||
attribute-class="conversation--attribute"
|
||||
:class="customAttributeRowClass"
|
||||
/>
|
||||
<custom-attribute-selector attribute-type="conversation_attribute" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getLanguageName } from '../../../components/widgets/conversation/advancedFilterItems/languages';
|
||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContactDetailsItem,
|
||||
CustomAttributes,
|
||||
CustomAttributeSelector,
|
||||
},
|
||||
props: {
|
||||
conversationAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
contactAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
STATIC_ATTRIBUTES: [
|
||||
{
|
||||
name: 'initiated_at',
|
||||
label: 'CONTACT_PANEL.INITIATED_AT',
|
||||
},
|
||||
{
|
||||
name: 'referer',
|
||||
label: 'CONTACT_PANEL.BROWSER',
|
||||
},
|
||||
{
|
||||
name: 'browserName',
|
||||
label: 'CONTACT_PANEL.BROWSER',
|
||||
},
|
||||
{
|
||||
name: 'platformName',
|
||||
label: 'CONTACT_PANEL.OS',
|
||||
},
|
||||
{
|
||||
name: 'ipAddress',
|
||||
label: 'CONTACT_PANEL.IP_ADDRESS',
|
||||
},
|
||||
],
|
||||
computed: {
|
||||
referer() {
|
||||
return this.conversationAttributes.referer;
|
||||
},
|
||||
initiatedAt() {
|
||||
return this.conversationAttributes.initiated_at;
|
||||
},
|
||||
browserName() {
|
||||
if (!this.conversationAttributes.browser) {
|
||||
return '';
|
||||
}
|
||||
const {
|
||||
browser_name: browserName = '',
|
||||
browser_version: browserVersion = '',
|
||||
} = this.conversationAttributes.browser;
|
||||
return `${browserName} ${browserVersion}`;
|
||||
},
|
||||
browserLanguage() {
|
||||
return getLanguageName(this.conversationAttributes.browser_language);
|
||||
},
|
||||
platformName() {
|
||||
if (!this.conversationAttributes.browser) {
|
||||
return '';
|
||||
}
|
||||
const { platform_name: platformName, platform_version: platformVersion } =
|
||||
this.conversationAttributes.browser;
|
||||
return `${platformName || ''} ${platformVersion || ''}`;
|
||||
},
|
||||
ipAddress() {
|
||||
const { created_at_ip: createdAtIp } = this.contactAttributes;
|
||||
return createdAtIp;
|
||||
},
|
||||
customAttributeRowClass() {
|
||||
const attributes = [
|
||||
'initiatedAt',
|
||||
'referer',
|
||||
'browserName',
|
||||
'platformName',
|
||||
'ipAddress',
|
||||
];
|
||||
const availableAttributes = attributes.filter(
|
||||
attribute => !!this[attribute]
|
||||
);
|
||||
return availableAttributes.length % 2 === 0 ? 'even' : 'odd';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.conversation--attribute {
|
||||
@apply border-slate-50 dark:border-slate-700/50 border-b border-solid;
|
||||
@apply border-slate-50 dark:border-slate-700 border-b border-solid;
|
||||
|
||||
&:nth-child(2n) {
|
||||
@apply bg-slate-25 dark:bg-slate-800/50;
|
||||
@apply bg-slate-25 dark:bg-slate-800;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -125,9 +125,8 @@
|
||||
>
|
||||
<span
|
||||
class="flex items-center h-10 px-2 text-sm border-solid bg-slate-50 border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md dark:bg-slate-700 text-slate-800 dark:text-slate-100 border-slate-200 dark:border-slate-600"
|
||||
>{{ socialProfile.prefixURL }}</span
|
||||
>
|
||||
{{ socialProfile.prefixURL }}
|
||||
</span>
|
||||
<input
|
||||
v-model="socialProfileUserNames[socialProfile.key]"
|
||||
class="input-group-field ltr:rounded-l-none rtl:rounded-r-none !mb-0"
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full max-h-[12.5rem]">
|
||||
<h4
|
||||
class="text-sm text-slate-800 dark:text-slate-100 mb-1 overflow-hidden whitespace-nowrap text-ellipsis flex-grow"
|
||||
>
|
||||
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.TITLE') }}
|
||||
</h4>
|
||||
<div class="mb-2 flex-shrink-0 flex-grow-0 flex-auto max-h-8">
|
||||
<input
|
||||
ref="searchbar"
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="search-input"
|
||||
autofocus="true"
|
||||
:placeholder="$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-start items-start flex-grow flex-shrink flex-auto overflow-auto h-32"
|
||||
>
|
||||
<div class="w-full h-full">
|
||||
<woot-dropdown-menu>
|
||||
<custom-attribute-drop-down-item
|
||||
v-for="attribute in filteredAttributes"
|
||||
:key="attribute.attribute_display_name"
|
||||
:title="attribute.attribute_display_name"
|
||||
@click="onAddAttribute(attribute)"
|
||||
/>
|
||||
</woot-dropdown-menu>
|
||||
<div
|
||||
v-if="noResult"
|
||||
class="w-full justify-center items-center flex mb-2 h-[70%] text-slate-500 dark:text-slate-300 py-2 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis text-sm"
|
||||
>
|
||||
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.NO_RESULT') }}
|
||||
</div>
|
||||
<woot-button
|
||||
class="float-right"
|
||||
icon="add"
|
||||
size="tiny"
|
||||
@click="addNewAttribute"
|
||||
>
|
||||
{{ $t('CUSTOM_ATTRIBUTES.FORM.ADD.TITLE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CustomAttributeDropDownItem from './CustomAttributeDropDownItem.vue';
|
||||
import attributeMixin from 'dashboard/mixins/attributeMixin';
|
||||
export default {
|
||||
components: {
|
||||
CustomAttributeDropDownItem,
|
||||
},
|
||||
mixins: [attributeMixin],
|
||||
props: {
|
||||
attributeType: {
|
||||
type: String,
|
||||
default: 'conversation_attribute',
|
||||
},
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredAttributes() {
|
||||
return this.attributes
|
||||
.filter(
|
||||
item =>
|
||||
!Object.keys(this.customAttributes).includes(item.attribute_key)
|
||||
)
|
||||
.filter(attribute => {
|
||||
return attribute.attribute_display_name
|
||||
.toLowerCase()
|
||||
.includes(this.search.toLowerCase());
|
||||
});
|
||||
},
|
||||
|
||||
noResult() {
|
||||
return this.filteredAttributes.length === 0;
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.focusInput();
|
||||
},
|
||||
|
||||
methods: {
|
||||
focusInput() {
|
||||
this.$refs.searchbar.focus();
|
||||
},
|
||||
addNewAttribute() {
|
||||
this.$router.push(
|
||||
`/app/accounts/${this.accountId}/settings/custom-attributes/list`
|
||||
);
|
||||
},
|
||||
async onAddAttribute(attribute) {
|
||||
this.$emit('add-attribute', attribute);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-input {
|
||||
@apply m-0 w-full border border-solid border-transparent h-8 text-sm text-slate-700 dark:text-slate-100 rounded-md focus:border-woot-500 bg-slate-50 dark:bg-slate-900;
|
||||
}
|
||||
</style>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<woot-dropdown-item>
|
||||
<woot-button variant="clear" @click="onClick">
|
||||
<span class="label-text" :title="title">{{ title }}</span>
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AttributeDropDownItem',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$emit('click', this.title);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.item-wrap {
|
||||
display: flex;
|
||||
|
||||
::v-deep .button__content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-wrap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
&.active {
|
||||
display: flex;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--w-700);
|
||||
}
|
||||
|
||||
.name-label-wrap {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
.label-color--display {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
.label-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.1;
|
||||
padding-right: var(--space-small);
|
||||
padding-left: var(--space-small);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-color--display {
|
||||
border-radius: var(--border-radius-normal);
|
||||
height: var(--space-slab);
|
||||
margin-right: var(--space-smaller);
|
||||
margin-top: var(--space-micro);
|
||||
min-width: var(--space-slab);
|
||||
width: var(--space-slab);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="custom-attribute--selector">
|
||||
<div
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="label-wrap"
|
||||
@keyup.esc="closeDropdown"
|
||||
>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="link"
|
||||
icon="add"
|
||||
@click="toggleAttributeDropDown"
|
||||
>
|
||||
{{ $t('CUSTOM_ATTRIBUTES.ADD_BUTTON_TEXT') }}
|
||||
</woot-button>
|
||||
|
||||
<div class="dropdown-wrap">
|
||||
<div
|
||||
:class="{ 'dropdown-pane--open': showAttributeDropDown }"
|
||||
class="dropdown-pane"
|
||||
>
|
||||
<custom-attribute-drop-down
|
||||
v-if="showAttributeDropDown"
|
||||
:attribute-type="attributeType"
|
||||
:contact-id="contactId"
|
||||
@add-attribute="addAttribute"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CustomAttributeDropDown from './CustomAttributeDropDown.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import attributeMixin from 'dashboard/mixins/attributeMixin';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CustomAttributeDropDown,
|
||||
},
|
||||
mixins: [clickaway, alertMixin, attributeMixin],
|
||||
props: {
|
||||
attributeType: {
|
||||
type: String,
|
||||
default: 'conversation_attribute',
|
||||
},
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAttributeDropDown: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async addAttribute(attribute) {
|
||||
try {
|
||||
const {
|
||||
attribute_key: attributeKey,
|
||||
attribute_display_type: attributeDisplayType,
|
||||
default_value: attributeDefaultValue,
|
||||
} = attribute;
|
||||
const isCheckbox = attributeDisplayType === 'checkbox';
|
||||
const defaultValue = isCheckbox ? false : attributeDefaultValue || null;
|
||||
if (this.attributeType === 'conversation_attribute') {
|
||||
await this.$store.dispatch('updateCustomAttributes', {
|
||||
conversationId: this.conversationId,
|
||||
customAttributes: {
|
||||
...this.customAttributes,
|
||||
[attributeKey]: defaultValue,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.$store.dispatch('contacts/update', {
|
||||
id: this.contactId,
|
||||
custom_attributes: {
|
||||
...this.customAttributes,
|
||||
[attributeKey]: defaultValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
bus.$emit(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, attributeKey);
|
||||
this.showAlert(this.$t('CUSTOM_ATTRIBUTES.FORM.ADD.SUCCESS'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message ||
|
||||
this.$t('CUSTOM_ATTRIBUTES.FORM.ADD.ERROR');
|
||||
this.showAlert(errorMessage);
|
||||
} finally {
|
||||
this.closeDropdown();
|
||||
}
|
||||
},
|
||||
|
||||
toggleAttributeDropDown() {
|
||||
this.showAttributeDropDown = !this.showAttributeDropDown;
|
||||
},
|
||||
|
||||
closeDropdown() {
|
||||
this.showAttributeDropDown = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-attribute--selector {
|
||||
width: 100%;
|
||||
padding: var(--space-slab) var(--space-normal);
|
||||
|
||||
.label-wrap {
|
||||
line-height: var(--space-medium);
|
||||
position: relative;
|
||||
|
||||
.dropdown-wrap {
|
||||
display: flex;
|
||||
left: -1px;
|
||||
margin-right: var(--space-medium);
|
||||
position: absolute;
|
||||
top: var(--space-medium);
|
||||
width: 100%;
|
||||
|
||||
.dropdown-pane {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--r-500);
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
</style>
|
||||
+7
-80
@@ -1,35 +1,23 @@
|
||||
<template>
|
||||
<div class="custom-attributes--panel">
|
||||
<custom-attribute
|
||||
v-for="attribute in displayedAttributes"
|
||||
v-for="attribute in filteredAttributes"
|
||||
:key="attribute.id"
|
||||
:attribute-key="attribute.attribute_key"
|
||||
:attribute-type="attribute.attribute_display_type"
|
||||
:values="attribute.attribute_values"
|
||||
:label="attribute.attribute_display_name"
|
||||
:icon="attribute.icon"
|
||||
emoji=""
|
||||
:value="attribute.value"
|
||||
:show-actions="true"
|
||||
:attribute-regex="attribute.regex_pattern"
|
||||
:regex-cue="attribute.regex_cue"
|
||||
:class="attributeClass"
|
||||
:contact-id="contactId"
|
||||
@update="onUpdate"
|
||||
@delete="onDelete"
|
||||
@copy="onCopy"
|
||||
/>
|
||||
<!-- Show more and show less buttons show it if the filteredAttributes length is greater than 5 -->
|
||||
<div v-if="filteredAttributes.length > 5" class="flex px-2 py-2">
|
||||
<woot-button
|
||||
size="small"
|
||||
:icon="showAllAttributes ? 'chevron-up' : 'chevron-down'"
|
||||
variant="clear"
|
||||
color-scheme="primary"
|
||||
class="!px-2 hover:!bg-transparent dark:hover:!bg-transparent"
|
||||
@click="onClickToggle"
|
||||
>
|
||||
{{ toggleButtonText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -37,14 +25,13 @@
|
||||
import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import attributeMixin from 'dashboard/mixins/attributeMixin';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CustomAttribute,
|
||||
},
|
||||
mixins: [alertMixin, attributeMixin, uiSettingsMixin],
|
||||
mixins: [alertMixin, attributeMixin],
|
||||
props: {
|
||||
attributeType: {
|
||||
type: String,
|
||||
@@ -55,67 +42,8 @@ export default {
|
||||
default: '',
|
||||
},
|
||||
contactId: { type: Number, default: null },
|
||||
attributeFrom: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAllAttributes: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
toggleButtonText() {
|
||||
return !this.showAllAttributes
|
||||
? this.$t('CUSTOM_ATTRIBUTES.SHOW_MORE')
|
||||
: this.$t('CUSTOM_ATTRIBUTES.SHOW_LESS');
|
||||
},
|
||||
filteredAttributes() {
|
||||
return this.attributes.map(attribute => {
|
||||
// Check if the attribute key exists in customAttributes
|
||||
const hasValue = Object.hasOwnProperty.call(
|
||||
this.customAttributes,
|
||||
attribute.attribute_key
|
||||
);
|
||||
|
||||
const isCheckbox = attribute.attribute_display_type === 'checkbox';
|
||||
const defaultValue = isCheckbox ? false : '';
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
// Set value from customAttributes if it exists, otherwise use default value
|
||||
value: hasValue
|
||||
? this.customAttributes[attribute.attribute_key]
|
||||
: defaultValue,
|
||||
};
|
||||
});
|
||||
},
|
||||
displayedAttributes() {
|
||||
// Show only the first 5 attributes or all depending on showAllAttributes
|
||||
if (this.showAllAttributes || this.filteredAttributes.length <= 5) {
|
||||
return this.filteredAttributes;
|
||||
}
|
||||
return this.filteredAttributes.slice(0, 5);
|
||||
},
|
||||
showMoreUISettingsKey() {
|
||||
return `show_all_attributes_${this.attributeFrom}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeSettings();
|
||||
},
|
||||
methods: {
|
||||
initializeSettings() {
|
||||
this.showAllAttributes =
|
||||
this.uiSettings[this.showMoreUISettingsKey] || false;
|
||||
},
|
||||
onClickToggle() {
|
||||
this.showAllAttributes = !this.showAllAttributes;
|
||||
this.updateUISettings({
|
||||
[this.showMoreUISettingsKey]: this.showAllAttributes,
|
||||
});
|
||||
},
|
||||
async onUpdate(key, value) {
|
||||
const updatedAttributes = { ...this.customAttributes, [key]: value };
|
||||
try {
|
||||
@@ -168,17 +96,16 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.custom-attributes--panel {
|
||||
.conversation--attribute {
|
||||
@apply border-slate-50 dark:border-slate-700/50 border-b border-solid;
|
||||
@apply border-slate-50 dark:border-slate-700 border-b border-solid;
|
||||
}
|
||||
|
||||
&.odd {
|
||||
.conversation--attribute {
|
||||
&:nth-child(2n + 1) {
|
||||
@apply bg-slate-25 dark:bg-slate-800/50;
|
||||
@apply bg-slate-25 dark:bg-slate-800;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,7 +113,7 @@ export default {
|
||||
&.even {
|
||||
.conversation--attribute {
|
||||
&:nth-child(2n) {
|
||||
@apply bg-slate-25 dark:bg-slate-800/50;
|
||||
@apply bg-slate-25 dark:bg-slate-800;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
:current-page="currentPage"
|
||||
:total-count="totalCount"
|
||||
:page-size="pageSize"
|
||||
class="dark:bg-slate-900 bottom-0 border-t border-slate-75 dark:border-slate-700/50"
|
||||
class="dark:bg-slate-900 sticky bottom-0"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
-1
@@ -9,7 +9,6 @@
|
||||
:on-mark-all-done-click="onMarkAllDoneClick"
|
||||
/>
|
||||
<table-footer
|
||||
class="border-t border-slate-75 dark:border-slate-700/50"
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-count="meta.count"
|
||||
:page-size="15"
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col w-full h-full gap-10 font-inter">
|
||||
<slot name="header" />
|
||||
<div>
|
||||
<slot v-if="isLoading" name="loading">
|
||||
<woot-loading-state :message="loadingMessage" />
|
||||
</slot>
|
||||
<slot v-else name="body" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
keepAlive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-full h-full px-5 pt-8 pb-3 m-0 overflow-auto bg-white sm:px-16 sm:pt-16 dark:bg-slate-900"
|
||||
>
|
||||
<div class="flex items-start max-w-[900px] w-full">
|
||||
<keep-alive v-if="keepAlive">
|
||||
<router-view />
|
||||
</keep-alive>
|
||||
<router-view v-else />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,7 +57,7 @@
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-count="meta.totalEntries"
|
||||
:page-size="meta.perPage"
|
||||
class="!bg-slate-25 dark:!bg-slate-900 border-t border-slate-75 dark:border-slate-700/50"
|
||||
class="dark:bg-slate-900"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
linkText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const openInNewTab = url => {
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener noreferrer');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full gap-4">
|
||||
<!-- Header section with icon, title and action button -->
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<!-- Icon and title container -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex items-center w-10 h-10 p-1 rounded-full bg-woot-25/60 dark:bg-woot-900/60"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-full h-full rounded-full bg-woot-75/70 dark:bg-woot-800/40"
|
||||
>
|
||||
<fluent-icon
|
||||
size="14"
|
||||
:icon="iconName"
|
||||
type="outline"
|
||||
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1
|
||||
class="text-2xl font-medium tracking-[-1.5%] text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Slot for additional actions on larger screens -->
|
||||
<div class="hidden gap-2 sm:flex">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Description and optional link -->
|
||||
<div
|
||||
class="flex flex-col gap-2 text-slate-600 dark:text-slate-300 max-w-[721px] w-full"
|
||||
>
|
||||
<p
|
||||
class="mb-0 text-sm font-normal tracking-[0.5%] line-clamp-5 sm:line-clamp-none"
|
||||
>
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</p>
|
||||
<!-- Conditional link -->
|
||||
<a
|
||||
v-if="href && linkText"
|
||||
:href="href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="sm:inline-flex hidden tracking-[-0.6%] gap-1 w-fit items-center text-woot-500 dark:text-woot-500 text-sm font-medium tracking=[-0.6%] hover:underline"
|
||||
>
|
||||
{{ linkText }}
|
||||
<fluent-icon
|
||||
size="16"
|
||||
icon="chevron-right"
|
||||
type="outline"
|
||||
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Mobile view for actions and link -->
|
||||
<div class="flex items-start justify-start w-full gap-3 sm:hidden">
|
||||
<slot name="actions" />
|
||||
<woot-button
|
||||
v-if="href && linkText"
|
||||
color-scheme="secondary"
|
||||
icon="arrow-outwards"
|
||||
class="flex-row-reverse rounded-xl min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
|
||||
@click="openInNewTab(href)"
|
||||
>
|
||||
{{ linkText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 max-w-[900px] w-full"
|
||||
>
|
||||
<!-- left side section -->
|
||||
<slot name="leftSection">
|
||||
<div class="flex flex-col min-w-0 items-start gap-3 max-w-[480px] w-full">
|
||||
<div
|
||||
class="flex items-center justify-between w-full gap-3 sm:justify-normal whitespace-nowrap"
|
||||
>
|
||||
<h3
|
||||
class="justify-between text-sm tracking-[-0.6%] font-medium truncate w-fit sm:justify-normal text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</h3>
|
||||
<slot name="label" />
|
||||
</div>
|
||||
<p
|
||||
class="text-sm text-slate-600 tracking-[0.5%] dark:text-slate-300 max-w-[400px] w-full line-clamp-2"
|
||||
>
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- right side section -->
|
||||
<slot name="rightSection" />
|
||||
|
||||
<!-- actions section -->
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="absolute flex-col items-center hidden gap-1 border-none ltr:-right-3 rtl:-left-3 top-3 group-hover:flex"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -589,9 +589,7 @@ export default {
|
||||
return this.inbox.name;
|
||||
},
|
||||
canLocktoSingleConversation() {
|
||||
return (
|
||||
this.isASmsInbox || this.isAWhatsAppChannel || this.isAFacebookInbox
|
||||
);
|
||||
return this.isASmsInbox || this.isAWhatsAppChannel;
|
||||
},
|
||||
inboxNameLabel() {
|
||||
if (this.isAWebWidgetInbox) {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div class="avatar-container" :style="style" aria-hidden="true">
|
||||
<slot>{{ userInitial }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Avatar',
|
||||
props: {
|
||||
username: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 40,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
return {
|
||||
fontSize: `${Math.floor(this.size / 2.5)}px`,
|
||||
};
|
||||
},
|
||||
userInitial() {
|
||||
const parts = this.username.split(/[ -]/);
|
||||
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
|
||||
|
||||
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
|
||||
initials = initials.replace(/[a-z]+/g, '');
|
||||
}
|
||||
initials = initials.substring(0, 2).toUpperCase();
|
||||
|
||||
return initials;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@tailwind components;
|
||||
@layer components {
|
||||
.avatar-color {
|
||||
background-image: linear-gradient(to top, #c2e1ff 0%, #d6ebff 100%);
|
||||
}
|
||||
|
||||
.dark-avatar-color {
|
||||
background-image: linear-gradient(to top, #135899 0%, #135899 100%);
|
||||
}
|
||||
}
|
||||
.avatar-container {
|
||||
@apply flex leading-[100%] font-medium items-center justify-center text-center cursor-default avatar-color dark:dark-avatar-color text-woot-600 dark:text-woot-200;
|
||||
}
|
||||
</style>
|
||||
@@ -1,410 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center w-full overflow-y-auto">
|
||||
<div
|
||||
class="flex flex-col h-full p-5 pt-16 mx-auto my-0 bg-white dark:bg-slate-900 font-inter"
|
||||
>
|
||||
<div class="flex flex-col gap-8 sm:max-w-3xl">
|
||||
<h2 class="text-2xl font-medium text-slate-900 dark:text-white">
|
||||
Profile Settings
|
||||
</h2>
|
||||
<user-profile-picture username="John Doe" size="72px" />
|
||||
|
||||
<personal-wrapper
|
||||
header="Personal message signature"
|
||||
description="Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes."
|
||||
:show-action-button="true"
|
||||
button-text="Save message signature"
|
||||
>
|
||||
<template #settingsItem>
|
||||
<message-signature />
|
||||
</template>
|
||||
</personal-wrapper>
|
||||
<personal-wrapper
|
||||
header="Hot key to send messages"
|
||||
description="You can select a hotkey (either Enter or Cmd/Ctrl+Enter) based on your
|
||||
preference of writing."
|
||||
:show-action-button="false"
|
||||
>
|
||||
<template #settingsItem>
|
||||
<div class="flex flex-row justify-between gap-4">
|
||||
<button
|
||||
v-for="keyOption in keyOptions"
|
||||
:key="keyOption.key"
|
||||
class="p-0 cursor-pointer"
|
||||
>
|
||||
<preview-card
|
||||
:heading="keyOption.heading"
|
||||
:content="keyOption.content"
|
||||
:src="keyOption.src"
|
||||
:active="keyOption.key === 'enter'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</personal-wrapper>
|
||||
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<h4 class="text-lg font-medium text-slate-900 dark:text-slate-25">
|
||||
Change password
|
||||
</h4>
|
||||
<div class="flex flex-row items-center justify-between gap-4">
|
||||
<div class="flex-grow h-0.5 bg-slate-50 dark:bg-slate-700" />
|
||||
</div>
|
||||
<form-input
|
||||
name="current_password"
|
||||
class="flex-1"
|
||||
:class="{ error: false }"
|
||||
label="Current password"
|
||||
placeholder="Please enter your current password"
|
||||
:has-error="false"
|
||||
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
|
||||
/>
|
||||
<form-input
|
||||
name="new_password"
|
||||
class="flex-1"
|
||||
:class="{ error: false }"
|
||||
label="New password"
|
||||
placeholder="Please enter your new password"
|
||||
:has-error="false"
|
||||
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
|
||||
/>
|
||||
|
||||
<form-input
|
||||
name="confirm_password"
|
||||
class="flex-1"
|
||||
:class="{ error: false }"
|
||||
label="Confirm password"
|
||||
placeholder="Please confirm your new password"
|
||||
:has-error="false"
|
||||
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
|
||||
/>
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
class="rounded-xl w-fit"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
Update password
|
||||
</woot-button>
|
||||
</div>
|
||||
|
||||
<personal-wrapper
|
||||
header="Audio notifications"
|
||||
description="Enable audio notifications in dashboard for new messages and conversations."
|
||||
:show-action-button="false"
|
||||
>
|
||||
<template #settingsItem>
|
||||
<form-select
|
||||
name="timezone"
|
||||
spacing="compact"
|
||||
label="Alert tone"
|
||||
placeholder="Ding"
|
||||
class="max-w-xl"
|
||||
>
|
||||
<option
|
||||
v-for="file in audioFiles"
|
||||
:key="file.label"
|
||||
:value="file.value"
|
||||
>
|
||||
{{ file.label }}
|
||||
</option>
|
||||
</form-select>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="flex justify-between pb-1 text-sm font-medium leading-6 text-slate-900 dark:text-white"
|
||||
>
|
||||
Alert events for conversations
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-row justify-between h-10 max-w-xl p-2 border border-solid rounded-md border-slate-75 dark:border-slate-600"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row items-center justify-center gap-1 border-r border-slate-75 grow"
|
||||
>
|
||||
<input type="radio" name="hotkey" value="enter" checked />
|
||||
<label>None</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row items-center justify-center gap-1 border-r border-slate-75 grow"
|
||||
>
|
||||
<input type="radio" name="hotkey" value="enter" />
|
||||
<label>Assigned</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row items-center justify-center gap-1 grow"
|
||||
>
|
||||
<input type="radio" name="hotkey" value="enter" />
|
||||
<label>All</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="flex justify-between pb-1 text-sm font-medium leading-6 text-slate-900 dark:text-white"
|
||||
>
|
||||
Alert conditions
|
||||
</label>
|
||||
<div class="flex flex-row items-center justify-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="send"
|
||||
:checked="true"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
<label>
|
||||
Send audio alerts only if the browser window is not active
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="send"
|
||||
:checked="false"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
<label>
|
||||
Send alerts every 30s until all the assigned conversations are
|
||||
read
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</personal-wrapper>
|
||||
|
||||
<personal-wrapper
|
||||
header="Notification preferences"
|
||||
:show-action-button="false"
|
||||
>
|
||||
<template #settingsItem>
|
||||
<div>
|
||||
<!-- Draw a line -->
|
||||
<div class="flex flex-row items-center justify-between gap-4">
|
||||
<div class="flex-grow h-0.5 bg-slate-50 dark:bg-slate-700" />
|
||||
</div>
|
||||
<div
|
||||
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
|
||||
>
|
||||
<table-header-cell :span="6" label="Notification type" />
|
||||
<table-header-cell :span="3" label="Email" />
|
||||
<table-header-cell :span="3" label="Push notification" />
|
||||
</div>
|
||||
<div
|
||||
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
>
|
||||
<span class="text-slate-700 dark:text-slate-200">
|
||||
A new conversation is created
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="email"
|
||||
:checked="true"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="email"
|
||||
:checked="false"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
>
|
||||
<span class="text-slate-700 dark:text-slate-200">
|
||||
Conversation is assigned to you
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="email"
|
||||
:checked="true"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="email"
|
||||
:checked="false"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
>
|
||||
<span class="text-slate-700 dark:text-slate-200">
|
||||
Conversation is assigned to you
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="email"
|
||||
:checked="true"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="email"
|
||||
:checked="false"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
>
|
||||
<span class="text-slate-700 dark:text-slate-200">
|
||||
You are mentioned in a conversation
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="email"
|
||||
:checked="true"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="email"
|
||||
:checked="false"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
>
|
||||
<span class="text-slate-700 dark:text-slate-200">
|
||||
New message is created in an assigned conversation
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="email"
|
||||
:checked="true"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="email"
|
||||
:checked="false"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</personal-wrapper>
|
||||
<personal-wrapper
|
||||
header="Access Token"
|
||||
description="This token can be used if you are building an API based integration."
|
||||
:show-action-button="false"
|
||||
>
|
||||
<template #settingsItem>
|
||||
<div class="flex flex-row justify-between gap-4">
|
||||
<form-input
|
||||
name="access_token"
|
||||
class="flex-1"
|
||||
type="password"
|
||||
value="1234567890"
|
||||
:class="{ error: false }"
|
||||
placeholder="Please enter your new password"
|
||||
:has-error="false"
|
||||
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
|
||||
/>
|
||||
<woot-button
|
||||
icon="copy"
|
||||
variant="hollow"
|
||||
color-scheme="secondary"
|
||||
class="rounded-xl w-fit"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
Copy
|
||||
</woot-button>
|
||||
</div>
|
||||
</template>
|
||||
</personal-wrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import TableHeaderCell from 'dashboard/components/widgets/TableHeaderCell.vue';
|
||||
import MessageSignature from '../profile/MessageSignature.vue';
|
||||
import UserProfilePicture from './UserProfilePicture.vue';
|
||||
import PersonalWrapper from './PersonalWrapper.vue';
|
||||
import PreviewCard from 'dashboard/components/ui/PreviewCard.vue';
|
||||
import FormInput from 'v3/components/Form/Input.vue';
|
||||
import FormSelect from 'v3/components/Form/Select.vue';
|
||||
const keyOptions = [
|
||||
{
|
||||
key: 'enter',
|
||||
src: '/assets/images/dashboard/profile/hot-key-enter.svg',
|
||||
heading: 'Enter (↵)',
|
||||
content:
|
||||
'Send messages by pressing Enter key instead of clicking send button.',
|
||||
},
|
||||
{
|
||||
key: 'cmd_enter',
|
||||
src: '/assets/images/dashboard/profile/hot-key-ctrl-enter.svg',
|
||||
heading: 'CMD / Ctrl + Enter (⌘ + ↵)',
|
||||
content:
|
||||
'Send messages by pressing Enter key instead of clicking send button.',
|
||||
},
|
||||
];
|
||||
const audioFiles = [
|
||||
{ label: 'Ding', value: 'ding' },
|
||||
{ label: 'Dong', value: 'dong' },
|
||||
];
|
||||
</script>
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full gap-6">
|
||||
<div
|
||||
class="flex flex-col w-full gap-4 divide-y divide-slate-200 dark:divide-slate-700"
|
||||
>
|
||||
<h4
|
||||
v-if="header"
|
||||
class="text-lg font-medium text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
{{ header }}
|
||||
</h4>
|
||||
<p
|
||||
v-if="description"
|
||||
class="pt-4 mb-0 text-sm font-normal text-slate-600 dark:text-slate-400"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.settingsItem" class="flex flex-col w-full gap-6">
|
||||
<slot name="settingsItem" />
|
||||
<woot-button
|
||||
v-if="showActionButton"
|
||||
color-scheme="primary"
|
||||
class="rounded-xl w-fit"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showActionButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: 'Save',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,218 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative rounded-xl"
|
||||
:class="thumbnailBoxClass"
|
||||
:style="{ height: size, width: size }"
|
||||
:title="title"
|
||||
>
|
||||
<img
|
||||
class="rounded-xl"
|
||||
src="https://picsum.photos/200"
|
||||
draggable="false"
|
||||
:class="thumbnailClass"
|
||||
@load="onImgLoad"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<div
|
||||
class="absolute select-none w-6 h-6 z-10 p-1 -top-2 -right-2 border border-white dark:border-white rounded-full flex items-center justify-center bg-slate-200 dark:bg-slate-700"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="dismiss"
|
||||
size="16"
|
||||
class="text-slate-700 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:style="{ height: size, width: size }"
|
||||
class="absolute top-0 left-0 rounded-xl select-none flex items-center justify-center bg-modal-backdrop-dark dark:bg-modal-backdrop-dark"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="avatar-upload"
|
||||
size="32"
|
||||
class="text-white dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
/**
|
||||
* Thumbnail Component
|
||||
* Src - source for round image
|
||||
* Size - Size of the thumbnail
|
||||
* Badge - Chat source indication { fb / telegram }
|
||||
* Username - Username for avatar
|
||||
*/
|
||||
|
||||
import Avatar from './Avatar.vue';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Avatar,
|
||||
},
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '40px',
|
||||
},
|
||||
badge: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasBorder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shouldShowStatusAlways: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'circle',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageLoaded: false,
|
||||
imgError: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
userNameWithoutEmoji() {
|
||||
return removeEmoji(this.username);
|
||||
},
|
||||
showStatusIndicator() {
|
||||
if (this.shouldShowStatusAlways) return true;
|
||||
return this.status === 'online' || this.status === 'busy';
|
||||
},
|
||||
avatarSize() {
|
||||
return Number(this.size.replace(/\D+/g, ''));
|
||||
},
|
||||
badgeSrc() {
|
||||
return {
|
||||
instagram_direct_message: 'instagram-dm',
|
||||
facebook: 'messenger',
|
||||
'twitter-tweet': 'twitter-tweet',
|
||||
'twitter-dm': 'twitter-dm',
|
||||
whatsapp: 'whatsapp',
|
||||
sms: 'sms',
|
||||
'Channel::Line': 'line',
|
||||
'Channel::Telegram': 'telegram',
|
||||
'Channel::WebWidget': '',
|
||||
}[this.badge];
|
||||
},
|
||||
badgeStyle() {
|
||||
const size = Math.floor(this.avatarSize / 3);
|
||||
const badgeSize = `${size + 2}px`;
|
||||
const borderRadius = `${size / 2}px`;
|
||||
return { width: badgeSize, height: badgeSize, borderRadius };
|
||||
},
|
||||
statusStyle() {
|
||||
const statusSize = `${this.avatarSize / 4}px`;
|
||||
return { width: statusSize, height: statusSize };
|
||||
},
|
||||
thumbnailClass() {
|
||||
const className = this.hasBorder
|
||||
? 'border border-solid border-white dark:border-slate-700/50'
|
||||
: '';
|
||||
const variant =
|
||||
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
|
||||
return `user-thumbnail ${className} ${variant}`;
|
||||
},
|
||||
thumbnailBoxClass() {
|
||||
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
|
||||
return `user-thumbnail-box ${boxClass}`;
|
||||
},
|
||||
shouldShowImage() {
|
||||
if (!this.src) {
|
||||
return false;
|
||||
}
|
||||
if (this.hasImageLoaded) {
|
||||
return !this.imgError;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
src(value, oldValue) {
|
||||
if (value !== oldValue && this.imgError) {
|
||||
this.imgError = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onImgError() {
|
||||
this.imgError = true;
|
||||
},
|
||||
onImgLoad() {
|
||||
this.hasImageLoaded = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-thumbnail-box {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
|
||||
.user-thumbnail {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
object-fit: cover;
|
||||
vertical-align: initial;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
border-radius: var(--border-radius-small);
|
||||
bottom: var(--space-minus-micro);
|
||||
box-shadow: var(--shadow-small);
|
||||
height: var(--space-slab);
|
||||
padding: var(--space-micro);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: var(--space-slab);
|
||||
@apply bg-white dark:bg-slate-900;
|
||||
}
|
||||
|
||||
.user-online-status {
|
||||
border-radius: 50%;
|
||||
bottom: var(--space-micro);
|
||||
|
||||
&:after {
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
|
||||
.user-online-status--online {
|
||||
@apply bg-green-400 dark:bg-green-400;
|
||||
}
|
||||
|
||||
.user-online-status--busy {
|
||||
@apply bg-yellow-500 dark:bg-yellow-500;
|
||||
}
|
||||
|
||||
.user-online-status--offline {
|
||||
@apply bg-slate-500 dark:bg-slate-500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,130 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<span class="font-medium text-slate-900 dark:text-slate-25 text-normal">
|
||||
Profile picture
|
||||
</span>
|
||||
<div
|
||||
class="relative rounded-xl h-[72px] w-[72px]"
|
||||
:title="title"
|
||||
@mouse-over="showUpload"
|
||||
>
|
||||
<img
|
||||
class="rounded-xl h-[72px] w-[72px]"
|
||||
src="https://i.pravatar.cc/300"
|
||||
draggable="false"
|
||||
@load="onImgLoad"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<div
|
||||
class="absolute z-10 flex items-center justify-center w-6 h-6 p-1 border border-white rounded-full select-none -top-2 -right-2 dark:border-white bg-slate-200 dark:bg-slate-700"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="dismiss"
|
||||
size="16"
|
||||
class="text-slate-800 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute h-[72px] w-[72px] top-0 left-0 rounded-xl select-none flex items-center justify-center bg-modal-backdrop-dark dark:bg-modal-backdrop-dark"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="avatar-upload"
|
||||
size="32"
|
||||
class="text-white dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-6">
|
||||
<form-input
|
||||
name="full_name"
|
||||
class="flex-1"
|
||||
:class="{ error: false }"
|
||||
:label="$t('REGISTER.FULL_NAME.LABEL')"
|
||||
placeholder="Enter your name"
|
||||
:has-error="false"
|
||||
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
|
||||
/>
|
||||
<form-input
|
||||
name="display_name"
|
||||
class="flex-1"
|
||||
:class="{ error: false }"
|
||||
label="Display name"
|
||||
placeholder="Enter your display name"
|
||||
:has-error="false"
|
||||
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
|
||||
/>
|
||||
<form-input
|
||||
name="email"
|
||||
class="flex-1"
|
||||
:class="{ error: false }"
|
||||
label="Email"
|
||||
placeholder="Enter your email"
|
||||
:has-error="false"
|
||||
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
|
||||
/>
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
class="rounded-xl w-fit"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
Update profile
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import FormInput from 'v3/components/Form/Input.vue';
|
||||
export default {
|
||||
components: {
|
||||
FormInput,
|
||||
},
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageLoaded: false,
|
||||
imgError: false,
|
||||
showUploadIcon: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
shouldShowImage() {
|
||||
if (!this.src) {
|
||||
return false;
|
||||
}
|
||||
if (this.hasImageLoaded) {
|
||||
return !this.imgError;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
src(value, oldValue) {
|
||||
if (value !== oldValue && this.imgError) {
|
||||
this.imgError = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onImgError() {
|
||||
this.imgError = true;
|
||||
},
|
||||
onImgLoad() {
|
||||
this.hasImageLoaded = true;
|
||||
},
|
||||
showUpload() {
|
||||
this.showUploadIcon = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,20 +0,0 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const Index = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/personal'),
|
||||
name: 'personal_settings',
|
||||
roles: ['administrator', 'agent'],
|
||||
component: Index,
|
||||
props: {
|
||||
headerTitle: 'PROFILE_SETTINGS.TITLE',
|
||||
icon: 'edit',
|
||||
showNewButton: false,
|
||||
showSidemenuIcon: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 359 B |
@@ -1,14 +1,40 @@
|
||||
<template>
|
||||
<woot-message-editor
|
||||
id="message-signature-input"
|
||||
v-model="messageSignature"
|
||||
class="message-editor h-[10rem]"
|
||||
:is-format-mode="true"
|
||||
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
|
||||
:enabled-menu-options="customEditorMenuList"
|
||||
:enable-suggestions="false"
|
||||
:show-image-resize-toolbar="true"
|
||||
/>
|
||||
<div
|
||||
class="profile--settings--row text-black-900 dark:text-slate-300 flex items-center"
|
||||
>
|
||||
<div class="w-1/4 py-4 pr-6 ml-0">
|
||||
<h4 class="text-lg text-black-900 dark:text-slate-200">
|
||||
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE') }}
|
||||
</h4>
|
||||
<p>{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.NOTE') }}</p>
|
||||
</div>
|
||||
<div class="p-4 w-[45%]">
|
||||
<div>
|
||||
<label for="message-signature-input">{{
|
||||
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.LABEL')
|
||||
}}</label>
|
||||
<woot-message-editor
|
||||
id="message-signature-input"
|
||||
v-model="messageSignature"
|
||||
class="message-editor h-[10rem]"
|
||||
:is-format-mode="true"
|
||||
:placeholder="
|
||||
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')
|
||||
"
|
||||
:enabled-menu-options="customEditorMenuList"
|
||||
:enable-suggestions="false"
|
||||
:show-image-resize-toolbar="true"
|
||||
/>
|
||||
</div>
|
||||
<woot-button
|
||||
:is-loading="isUpdating"
|
||||
type="button"
|
||||
@click.prevent="updateSignature"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -73,7 +99,7 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-editor {
|
||||
@apply px-3;
|
||||
@apply px-3 mb-4;
|
||||
|
||||
::v-deep {
|
||||
.ProseMirror-menubar {
|
||||
|
||||
@@ -236,54 +236,6 @@
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedEmailFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="email_sla_missed_first_response"
|
||||
@input="handleEmailInput"
|
||||
/>
|
||||
<label for="sla_missed_first_response">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_FIRST_RESPONSE'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedEmailFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="email_sla_missed_next_response"
|
||||
@input="handleEmailInput"
|
||||
/>
|
||||
<label for="sla_missed_next_response">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_NEXT_RESPONSE'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedEmailFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="email_sla_missed_resolution"
|
||||
@input="handleEmailInput"
|
||||
/>
|
||||
<label for="sla_missed_resolution">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_RESOLUTION'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -400,57 +352,6 @@
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedPushFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="push_sla_missed_first_response"
|
||||
@input="handlePushInput"
|
||||
/>
|
||||
<label for="sla_missed_first_response">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_FIRST_RESPONSE'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedPushFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="push_sla_missed_next_response"
|
||||
@input="handlePushInput"
|
||||
/>
|
||||
<label for="sla_missed_next_response">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_NEXT_RESPONSE'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedPushFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="push_sla_missed_resolution"
|
||||
@input="handlePushInput"
|
||||
/>
|
||||
<label for="sla_missed_resolution">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_RESOLUTION'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -466,7 +367,6 @@ import {
|
||||
requestPushPermissions,
|
||||
verifyServiceWorkerExistence,
|
||||
} from '../../../../helper/pushHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin, configMixin, uiSettingsMixin],
|
||||
@@ -493,18 +393,13 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
emailFlags: 'userNotificationSettings/getSelectedEmailFlags',
|
||||
pushFlags: 'userNotificationSettings/getSelectedPushFlags',
|
||||
uiSettings: 'getUISettings',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
hasPushAPISupport() {
|
||||
return !!('Notification' in window);
|
||||
},
|
||||
isSLAEnabled() {
|
||||
return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SLA);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
emailFlags(value) {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 px-4 pt-4 overflow-auto">
|
||||
<SLAReportFilters @filter-change="onFilterChange" />
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="arrow-download"
|
||||
@click="downloadReports"
|
||||
>
|
||||
{{ $t('SLA_REPORTS.DOWNLOAD_SLA_REPORTS') }}
|
||||
</woot-button>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SLAMetrics
|
||||
:hit-rate="slaMetrics.hitRate"
|
||||
:no-of-breaches="slaMetrics.numberOfSLAMisses"
|
||||
:no-of-conversations="slaMetrics.numberOfConversations"
|
||||
:is-loading="uiFlags.isFetchingMetrics"
|
||||
/>
|
||||
<SLATable
|
||||
:sla-reports="slaReports"
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:current-page="Number(slaMeta.currentPage)"
|
||||
:total-count="Number(slaMeta.count)"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import SLAMetrics from './components/SLA/SLAMetrics.vue';
|
||||
import SLATable from './components/SLA/SLATable.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import SLAReportFilters from './components/SLA/SLAReportFilters.vue';
|
||||
import { generateFileName } from 'dashboard/helper/downloadHelper';
|
||||
export default {
|
||||
name: 'SLAReports',
|
||||
components: {
|
||||
SLAMetrics,
|
||||
SLATable,
|
||||
SLAReportFilters,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
data() {
|
||||
return {
|
||||
pageNumber: 1,
|
||||
from: 0,
|
||||
to: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
slaReports: 'slaReports/getAll',
|
||||
slaMetrics: 'slaReports/getMetrics',
|
||||
slaMeta: 'slaReports/getMeta',
|
||||
uiFlags: 'slaReports/getUIFlags',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.fetchSLAMetrics();
|
||||
this.fetchSLAReports();
|
||||
},
|
||||
methods: {
|
||||
fetchSLAReports({ pageNumber } = {}) {
|
||||
this.$store.dispatch('slaReports/get', {
|
||||
page: pageNumber || this.pageNumber,
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
});
|
||||
},
|
||||
fetchSLAMetrics() {
|
||||
this.$store.dispatch('slaReports/getMetrics', {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
});
|
||||
},
|
||||
onPageChange(pageNumber) {
|
||||
this.fetchSLAReports({ pageNumber });
|
||||
},
|
||||
onFilterChange({ from, to }) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.fetchSLAReports();
|
||||
this.fetchSLAMetrics();
|
||||
},
|
||||
downloadReports() {
|
||||
const type = 'sla';
|
||||
try {
|
||||
this.$store.dispatch('slaReports/download', {
|
||||
fileName: generateFileName({ type, to: this.to }),
|
||||
...this.requestPayload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.showAlert(this.$t('SLA_REPORTS.DOWNLOAD_FAILED'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+1
-3
@@ -1,8 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col md:flex-row justify-between mb-4">
|
||||
<div
|
||||
class="md:grid flex-col gap-3 w-full grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] p-5"
|
||||
>
|
||||
<div class="md:grid flex flex-col filter-container gap-3 w-full">
|
||||
<reports-filters-date-range @on-range-change="onDateRangeChange" />
|
||||
<woot-date-range-picker
|
||||
v-if="isDateRangeSelected"
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex relative items-center p-1.5 w-fit h-8 gap-1.5 bg-white dark:bg-slate-900 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot name="leftIcon" />
|
||||
<span
|
||||
v-if="buttonText"
|
||||
class="text-sm font-medium text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</span>
|
||||
<slot name="rightIcon" />
|
||||
<div v-if="$slots.dropdown" class="absolute right-0 top-10" @click.stop>
|
||||
<slot name="dropdown" />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-white z-10 dark:bg-slate-800 gap-2 px-3 border-b rounded-t-xl border-slate-50 dark:border-slate-700"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<fluent-icon
|
||||
icon="search"
|
||||
size="18"
|
||||
class="text-slate-400 dark:text-slate-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full mb-0 text-sm bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-75 reset-base"
|
||||
:placeholder="inputPlaceholder"
|
||||
:value="inputValue"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="clear"
|
||||
color-scheme="primary"
|
||||
class="!px-1 !py-1.5"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user