Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b759496f2 | ||
|
|
568aae875b | ||
|
|
2324a344dc | ||
|
|
7c7d67fd06 | ||
|
|
b058d84034 | ||
|
|
0e122188e9 | ||
|
|
f8f0caf443 | ||
|
|
6aeda0ddf6 | ||
|
|
fed32d5964 | ||
|
|
735bc73c96 | ||
|
|
5e79dd699e | ||
|
|
05dd31389e | ||
|
|
f7bbd40816 | ||
|
|
7820739f3a | ||
|
|
224556fd1b | ||
|
|
51eb626b88 | ||
|
|
b0aa844a32 | ||
|
|
c8e551820b | ||
|
|
035d2858f5 | ||
|
|
16b8693e1b | ||
|
|
279dd1876c | ||
|
|
2266eb493b | ||
|
|
0920a01e66 | ||
|
|
06467057be | ||
|
|
8faa5a74b1 | ||
|
|
a651949c33 | ||
|
|
2ada713f29 | ||
|
|
751c28d94d | ||
|
|
4959a1ff1e | ||
|
|
661608c0b1 | ||
|
|
c5fb8d73cc | ||
|
|
9a89e1f522 | ||
|
|
667cd1ba1f | ||
|
|
98fa5f60e9 | ||
|
|
ecdeb891ff | ||
|
|
7a8829e79e | ||
|
|
2182165201 | ||
|
|
ce34f93917 | ||
|
|
475db85318 | ||
|
|
f12118a3c0 | ||
|
|
ca66218cb9 | ||
|
|
6a9c44476e | ||
|
|
6928f14a76 | ||
|
|
d2625d8544 | ||
|
|
7d42edd17f | ||
|
|
437dd9d38c | ||
|
|
50720f2395 | ||
|
|
1de90ec136 | ||
|
|
3a4738729d | ||
|
|
787fcc4a95 | ||
|
|
6cbddbdb67 | ||
|
|
5e82f24be5 | ||
|
|
bf397750f3 | ||
|
|
f253ea0abc | ||
|
|
3aea425abc | ||
|
|
eee307b36b |
@@ -84,6 +84,7 @@ gem 'barnes'
|
||||
gem 'devise', '>= 4.9.4'
|
||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||
gem 'devise_token_auth', '>= 1.2.3'
|
||||
gem 'rails-i18n', '~> 7.0'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
|
||||
@@ -727,6 +727,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.1)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
railties (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
@@ -1125,6 +1128,7 @@ DEPENDENCIES
|
||||
rack-mini-profiler (>= 3.2.0)
|
||||
rack-timeout
|
||||
rails (~> 7.1)
|
||||
rails-i18n (~> 7.0)
|
||||
redis
|
||||
redis-namespace
|
||||
responders (>= 3.1.1)
|
||||
|
||||
@@ -44,7 +44,11 @@ class AccountBuilder
|
||||
end
|
||||
|
||||
def create_account
|
||||
@account = Account.create!(name: account_name, locale: I18n.locale)
|
||||
@account = Account.create!(
|
||||
name: account_name,
|
||||
locale: I18n.locale,
|
||||
custom_attributes: { 'onboarding_step' => 'account_details' }
|
||||
)
|
||||
Current.account = @account
|
||||
end
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ class AgentBuilder
|
||||
user = User.from_email(email)
|
||||
return user if user
|
||||
|
||||
@name = email.split('@').first if @name.blank?
|
||||
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
|
||||
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
|
||||
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
|
||||
end
|
||||
|
||||
# Checks if the user needs confirmation.
|
||||
|
||||
@@ -41,7 +41,7 @@ class Email::BaseBuilder
|
||||
end
|
||||
|
||||
def business_name
|
||||
inbox.business_name || inbox.sanitized_name
|
||||
inbox.sanitized_business_name
|
||||
end
|
||||
|
||||
def account_support_email
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class V2::ReportBuilder
|
||||
include DateRangeHelper
|
||||
include ReportHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
@@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group('assignee_id').count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.map do |account_user|
|
||||
build_agent_stats(account_user)
|
||||
|
||||
@@ -9,37 +9,13 @@ class V2::Reports::BaseSummaryBuilder
|
||||
private
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
results = data_source.summary
|
||||
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
# Override this method
|
||||
@conversations_count = results.transform_values { |data| data[:conversations_count] }
|
||||
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
|
||||
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
|
||||
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
|
||||
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
@@ -50,7 +26,26 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
def data_source
|
||||
@data_source ||= Reports::DataSource.for(
|
||||
account: account,
|
||||
metric: nil,
|
||||
dimension_type: summary_dimension_type,
|
||||
dimension_id: nil,
|
||||
scope: nil,
|
||||
range: range,
|
||||
group_by: 'day',
|
||||
timezone_offset: params[:timezone_offset],
|
||||
business_hours: params[:business_hours]
|
||||
)
|
||||
end
|
||||
|
||||
def summary_dimension_type
|
||||
{
|
||||
'account_id' => 'account',
|
||||
'user_id' => 'agent',
|
||||
'inbox_id' => 'inbox',
|
||||
'conversations.team_id' => 'team'
|
||||
}.fetch(group_by_key.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder
|
||||
|
||||
private
|
||||
|
||||
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
|
||||
COUNT_METRICS = %w[
|
||||
conversations_count
|
||||
incoming_messages_count
|
||||
outgoing_messages_count
|
||||
resolutions_count
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].freeze
|
||||
|
||||
def builder_class(metric)
|
||||
case metric
|
||||
when *AVG_METRICS
|
||||
V2::Reports::Timeseries::AverageReportBuilder
|
||||
when *COUNT_METRICS
|
||||
V2::Reports::Timeseries::CountReportBuilder
|
||||
end
|
||||
return unless Reports::ReportMetricRegistry.supported?(metric)
|
||||
|
||||
V2::Reports::Timeseries::ReportBuilder
|
||||
end
|
||||
|
||||
def log_invalid_metric
|
||||
|
||||
@@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(group_by_key).count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.inboxes.map do |inbox|
|
||||
build_inbox_stats(inbox)
|
||||
@@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
def group_by_key
|
||||
:inbox_id
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(:team_id).count
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.map do |team|
|
||||
build_team_stats(team)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_average_time = reporting_events.average(average_value_key)
|
||||
grouped_event_count = reporting_events.count
|
||||
grouped_average_time.each_with_object([]) do |element, arr|
|
||||
event_date, average_time = element
|
||||
arr << {
|
||||
value: average_time,
|
||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
||||
count: grouped_event_count[event_date]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.average(average_value_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
metric_to_event_name = {
|
||||
avg_first_response_time: :first_response,
|
||||
avg_resolution_time: :conversation_resolved,
|
||||
reply_time: :reply_time
|
||||
}
|
||||
metric_to_event_name[params[:metric].to_sym]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -1,12 +1,13 @@
|
||||
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
include TimezoneHelper
|
||||
include DateRangeHelper
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
def scope
|
||||
case params[:type].to_sym
|
||||
case dimension_type.to_sym
|
||||
when :account
|
||||
account
|
||||
when :inbox
|
||||
@@ -20,6 +21,20 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def data_source
|
||||
@data_source ||= Reports::DataSource.for(
|
||||
account: account,
|
||||
metric: params[:metric],
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: params[:id],
|
||||
scope: scope,
|
||||
range: range,
|
||||
group_by: group_by,
|
||||
timezone_offset: params[:timezone_offset],
|
||||
business_hours: params[:business_hours]
|
||||
)
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find(params[:id])
|
||||
end
|
||||
@@ -43,4 +58,10 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dimension_type
|
||||
(params[:type].presence || 'account').to_s
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_count.each_with_object([]) do |element, arr|
|
||||
event_date, event_count = element
|
||||
|
||||
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
|
||||
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
|
||||
# because it converts the date to 12:00 AM server timezone.
|
||||
# The desired output should be 12:00 AM in the specified timezone.
|
||||
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metric
|
||||
@metric ||= params[:metric]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
send("scope_for_#{metric}")
|
||||
end
|
||||
|
||||
def scope_for_conversations_count
|
||||
scope.conversations.where(account_id: account.id, created_at: range)
|
||||
end
|
||||
|
||||
def scope_for_incoming_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_outgoing_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_bot_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_handoffs_count
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_bot_handoff,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
|
||||
# It converts timestamps to the target timezone before grouping, which means
|
||||
# the same event can fall into different day buckets depending on timezone
|
||||
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
).count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
data_source.timeseries
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
data_source.aggregate
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :set_articles, only: [:update_status, :delete_articles]
|
||||
|
||||
def translate
|
||||
head :not_implemented
|
||||
end
|
||||
|
||||
def update_status
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@articles.find_each { |article| article.update!(status: params[:status]) }
|
||||
end
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def delete_articles
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
|
||||
@articles.destroy_all
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Article, :create?)
|
||||
end
|
||||
|
||||
def set_articles
|
||||
@articles = @portal.articles.where(id: params[:ids])
|
||||
end
|
||||
end
|
||||
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
|
||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
sort_on :phone_number, type: :string
|
||||
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
||||
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
||||
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
|
||||
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
|
||||
|
||||
|
||||
@@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def process_attached_logo
|
||||
blob_id = params[:blob_id]
|
||||
blob = ActiveStorage::Blob.find_signed(blob_id)
|
||||
@portal.logo.attach(blob)
|
||||
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
|
||||
@portal.logo.attach(blob) if blob
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.settings.merge!(settings_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||
@account.save!
|
||||
end
|
||||
@@ -71,9 +72,10 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
private
|
||||
|
||||
def enqueue_branding_enrichment
|
||||
return if account_params[:email].blank?
|
||||
email = account_params[:email].presence || @user&.email
|
||||
return if email.blank?
|
||||
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, email)
|
||||
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
|
||||
rescue StandardError => e
|
||||
# Enrichment is optional — never let queue/Redis failures abort signup
|
||||
@@ -109,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone)
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
|
||||
end
|
||||
|
||||
def settings_params
|
||||
|
||||
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
before_action :check_mfa_feature_available
|
||||
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
|
||||
before_action :check_mfa_disabled, only: [:create, :verify]
|
||||
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
||||
before_action :validate_password, only: [:destroy]
|
||||
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
def validate_otp
|
||||
authenticated = Mfa::AuthenticationService.new(
|
||||
user: current_user,
|
||||
otp_code: mfa_params[:otp_code]
|
||||
otp_code: mfa_params[:otp_code],
|
||||
backup_code: mfa_params[:backup_code]
|
||||
).authenticate
|
||||
|
||||
return if authenticated
|
||||
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
end
|
||||
|
||||
def mfa_params
|
||||
params.permit(:otp_code, :password)
|
||||
params.permit(:otp_code, :backup_code, :password)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
|
||||
end
|
||||
|
||||
def team
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder)
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
|
||||
end
|
||||
|
||||
def inbox
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
|
||||
end
|
||||
|
||||
def label
|
||||
@@ -38,8 +38,9 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
}
|
||||
end
|
||||
|
||||
def render_report_with(builder_class)
|
||||
builder = builder_class.new(account: Current.account, params: @builder_params)
|
||||
def render_report_with(builder_class, type: nil)
|
||||
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
|
||||
builder = builder_class.new(account: Current.account, params: builder_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
before_action :validate_platform_app_permissible, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
|
||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController
|
||||
def show
|
||||
@query = params[:user_query].to_s.strip
|
||||
@user = resolve_user(@query)
|
||||
@subscriptions = @user ? @user.notification_subscriptions.order(:id) : []
|
||||
@results = []
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.find_by(id: params[:user_id])
|
||||
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil?
|
||||
|
||||
ids = parsed_subscription_ids
|
||||
if ids.empty?
|
||||
return redirect_to super_admin_push_diagnostics_path(user_query: @user.id),
|
||||
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test')
|
||||
end
|
||||
|
||||
run_test_and_render(ids)
|
||||
end
|
||||
|
||||
def destroy_subscriptions
|
||||
user = User.find_by(id: params[:user_id])
|
||||
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil?
|
||||
|
||||
ids = parsed_subscription_ids
|
||||
if ids.empty?
|
||||
return redirect_to super_admin_push_diagnostics_path(user_query: user.id),
|
||||
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete')
|
||||
end
|
||||
|
||||
deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size
|
||||
log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}")
|
||||
redirect_to super_admin_push_diagnostics_path(user_query: user.id),
|
||||
notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run_test_and_render(ids)
|
||||
@query = @user.id.to_s
|
||||
@subscriptions = @user.notification_subscriptions.order(:id)
|
||||
@results = Notification::PushTestService.new(
|
||||
user: @user, subscription_ids: ids,
|
||||
title: params[:push_title], body: params[:push_body]
|
||||
).perform
|
||||
|
||||
log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}")
|
||||
render :show
|
||||
end
|
||||
|
||||
def log_super_admin_action(message)
|
||||
Rails.logger.info(
|
||||
"[SuperAdmin] push diagnostics #{message} " \
|
||||
"(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})"
|
||||
)
|
||||
end
|
||||
|
||||
def resolve_user(query)
|
||||
return if query.blank?
|
||||
|
||||
query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query)
|
||||
end
|
||||
|
||||
def parsed_subscription_ids
|
||||
Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i)
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,7 @@ module EmailHelper
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s
|
||||
end
|
||||
|
||||
# Raise a standard error if any email address is invalid
|
||||
|
||||
@@ -59,7 +59,6 @@ export default {
|
||||
isRTL: 'accounts/isRTL',
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
}),
|
||||
hideOnOnboardingView() {
|
||||
return !isOnOnboardingView(this.$route);
|
||||
@@ -107,8 +106,9 @@ export default {
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
accountId: this.currentAccountId,
|
||||
});
|
||||
const account = this.getAccount(this.currentAccountId);
|
||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||
this.getAccount(this.currentAccountId);
|
||||
account;
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(this.uiSettings?.locale || locale);
|
||||
@@ -131,7 +131,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
v-if="!authUIFlags.isFetching"
|
||||
id="app"
|
||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
|
||||
@@ -72,6 +72,27 @@ class ArticlesAPI extends PortalsAPI {
|
||||
category_slug: categorySlug,
|
||||
});
|
||||
}
|
||||
|
||||
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
|
||||
return axios.post(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
|
||||
{ ids: articleIds, locale, category_id: categoryId, force }
|
||||
);
|
||||
}
|
||||
|
||||
bulkUpdateStatus({ portalSlug, articleIds, status }) {
|
||||
return axios.patch(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
|
||||
{ ids: articleIds, status }
|
||||
);
|
||||
}
|
||||
|
||||
bulkDelete({ portalSlug, articleIds }) {
|
||||
return axios.delete(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
|
||||
{ data: { ids: articleIds } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ArticlesAPI();
|
||||
|
||||
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
||||
}
|
||||
|
||||
disable(password, otpCode) {
|
||||
disable(password, { otpCode, backupCode } = {}) {
|
||||
return axios.delete(this.url, {
|
||||
data: { password, otp_code: otpCode },
|
||||
data: { password, otp_code: otpCode, backup_code: backupCode },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -107,11 +107,10 @@ const closeMobileSidebar = () => {
|
||||
size="sm"
|
||||
/>
|
||||
<ComposeConversation :contact-id="contactId">
|
||||
<template #trigger="{ toggle }">
|
||||
<template #trigger>
|
||||
<Button
|
||||
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
|
||||
size="sm"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
|
||||
@@ -114,8 +114,8 @@ const emit = defineEmits([
|
||||
</div>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
<template #trigger>
|
||||
<Button :label="buttonLabel" size="sm" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
@@ -34,9 +34,7 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contact: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectConversation']);
|
||||
|
||||
const hovered = ref(false);
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
|
||||
const onThumbnailLeave = () => {
|
||||
hovered.value = false;
|
||||
};
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
emit('selectConversation', value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center flex-shrink-0"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="contact.name"
|
||||
:src="contact.thumbnail"
|
||||
:size="24"
|
||||
:status="contact.availability_status"
|
||||
hide-offline-status
|
||||
>
|
||||
<template v-if="enableSelection" #overlay>
|
||||
<div
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
</template>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import UnreadBadge from './UnreadBadge.vue';
|
||||
|
||||
defineProps({
|
||||
lastMessage: { type: Object, default: null },
|
||||
voiceCallStatus: { type: String, default: '' },
|
||||
voiceCallDirection: { type: String, default: '' },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
showExpandedPreview: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_auto] gap-1.5"
|
||||
:class="showExpandedPreview ? 'items-end' : 'items-center'"
|
||||
>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallStatus"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallStatus"
|
||||
:direction="voiceCallDirection"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessage"
|
||||
key="message-preview"
|
||||
:message="lastMessage"
|
||||
:multi-line="showExpandedPreview"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="size-3.5" />
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
|
||||
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
|
||||
</div>
|
||||
</template>
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, useSlots, watch, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
disableToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const attrs = useAttrs();
|
||||
const slots = useSlots();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
return accountLabels.value.filter(({ title }) =>
|
||||
props.labels.includes(title)
|
||||
);
|
||||
});
|
||||
|
||||
const showAllLabels = ref(false);
|
||||
const showExpandLabelButton = ref(false);
|
||||
const labelPosition = ref(-1);
|
||||
const labelContainer = ref(null);
|
||||
|
||||
// Show if there are labels OR if before slot exists
|
||||
const showSection = computed(
|
||||
() => activeLabels.value.length > 0 || !!slots.before
|
||||
);
|
||||
|
||||
const computeVisibleLabelPosition = () => {
|
||||
const container = labelContainer.value;
|
||||
if (!container || activeLabels.value.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = container.querySelectorAll('[data-label]');
|
||||
if (labels.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if all labels are visible
|
||||
if (showAllLabels.value) return;
|
||||
|
||||
const beforeSlot = container.querySelector('[data-before-slot]');
|
||||
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
|
||||
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
|
||||
|
||||
let totalWidth = 0;
|
||||
const labelsArray = Array.from(labels);
|
||||
|
||||
// Find last visible label index using some() - stops early on overflow
|
||||
const overflowIndex = labelsArray.findIndex(label => {
|
||||
totalWidth += label.offsetWidth + 6;
|
||||
return totalWidth > availableWidth;
|
||||
});
|
||||
|
||||
const visibleIndex =
|
||||
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
|
||||
|
||||
labelPosition.value = visibleIndex;
|
||||
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
|
||||
};
|
||||
|
||||
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
|
||||
|
||||
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
|
||||
|
||||
const hiddenLabelsCount = computed(() => {
|
||||
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
|
||||
return activeLabels.value.length - labelPosition.value - 1;
|
||||
});
|
||||
|
||||
// Check if all labels are hidden (none visible)
|
||||
const allLabelsHidden = computed(() => {
|
||||
return labelPosition.value === -1 && activeLabels.value.length > 0;
|
||||
});
|
||||
|
||||
// Label text for button when disableToggle is true and all labels are hidden
|
||||
const labelsCountText = computed(() => {
|
||||
if (props.disableToggle && allLabelsHidden.value) {
|
||||
return t('CONVERSATION.CARD.LABELS_COUNT', {
|
||||
count: activeLabels.value.length,
|
||||
});
|
||||
}
|
||||
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
|
||||
return hiddenLabelsCount.value;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const hiddenLabelsTooltip = computed(() => {
|
||||
if (!props.disableToggle) return '';
|
||||
// When all labels are hidden, show all label titles
|
||||
if (allLabelsHidden.value) {
|
||||
return activeLabels.value.map(label => label.title).join(', ');
|
||||
}
|
||||
if (!showExpandLabelButton.value) return '';
|
||||
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
|
||||
return hiddenLabels.map(label => label.title).join(', ');
|
||||
});
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (props.disableToggle && hiddenLabelsTooltip.value) {
|
||||
return hiddenLabelsTooltip.value;
|
||||
}
|
||||
return showAllLabels.value
|
||||
? t('CONVERSATION.CARD.HIDE_LABELS')
|
||||
: t('CONVERSATION.CARD.SHOW_LABELS');
|
||||
});
|
||||
|
||||
const onShowLabels = e => {
|
||||
e.stopPropagation();
|
||||
if (props.disableToggle) return;
|
||||
showAllLabels.value = !showAllLabels.value;
|
||||
nextTick(() => computeVisibleLabelPosition());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showSection"
|
||||
ref="labelContainer"
|
||||
v-bind="attrs"
|
||||
v-resize="throttledCalculate"
|
||||
data-labels-container
|
||||
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
|
||||
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
|
||||
>
|
||||
<slot name="before" />
|
||||
|
||||
<Label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label ? label.id : index"
|
||||
data-label
|
||||
:label="label"
|
||||
compact
|
||||
:class="{
|
||||
'invisible absolute': !showAllLabels && index > labelPosition,
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:label="labelsCountText"
|
||||
xs
|
||||
slate
|
||||
:no-animation="disableToggle"
|
||||
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
|
||||
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
|
||||
:class="{ 'cursor-default': disableToggle }"
|
||||
@click="onShowLabels"
|
||||
/>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
|
||||
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
|
||||
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
|
||||
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.status && icons[props.status]) {
|
||||
return icons[props.status];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-status-empty' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: status,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import CardAvatar from './CardAvatar.vue';
|
||||
import CardContent from './CardContent.vue';
|
||||
import CardLabels from './CardLabelsV5.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
import CardStatusIcon from './CardStatusIcon.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
]);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
if (value) {
|
||||
emit('selectConversation', value);
|
||||
} else {
|
||||
emit('deSelectConversation', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="conversation relative cursor-pointer group grid gap-4 items-center px-3 h-12 border-b border-n-slate-3 hover:border-n-surface-1 hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 dark:bg-n-slate-3 !border-n-surface-1': selected,
|
||||
'hover:bg-n-alpha-1': !isActiveChat && !selected,
|
||||
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
|
||||
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<!-- LEFT SECTION -->
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardPriorityIcon :priority="chat.priority" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
content: assignee.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-woot-empty-assignee"
|
||||
class="size-4 text-n-slate-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardStatusIcon :status="chat.status" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isInboxView && showInboxName"
|
||||
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: chat.id,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0 flex-shrink-0"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-hash"
|
||||
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ chat.id }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CardAvatar
|
||||
:contact="currentContact"
|
||||
:selected="false"
|
||||
:enable-selection="false"
|
||||
:hide-thumbnail="false"
|
||||
/>
|
||||
|
||||
<h4
|
||||
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
:show-expanded-preview="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SECTION -->
|
||||
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
|
||||
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||
<CardLabels
|
||||
:labels="chat.labels"
|
||||
disable-toggle
|
||||
class="my-0 [&>div]:justify-end justify-end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
|
||||
<SLACardLabel ref="slaCardLabel" :chat="chat" />
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 w-[4.375rem] text-end">
|
||||
<TimeAgo
|
||||
:conversation-id="chat.id"
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
class="font-440 !text-xs text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showMessageType: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultEmptyMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
multiLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const attachmentIcons = {
|
||||
image: 'i-lucide-image',
|
||||
audio: 'i-lucide-headphones',
|
||||
video: 'i-lucide-video',
|
||||
file: 'i-lucide-file',
|
||||
location: 'i-lucide-map-pin',
|
||||
fallback: 'i-lucide-link-2',
|
||||
};
|
||||
|
||||
const messageByAgent = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||
});
|
||||
|
||||
const isMessageAnActivity = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.ACTIVITY;
|
||||
});
|
||||
|
||||
const isMessagePrivate = computed(() => {
|
||||
const { private: isPrivate } = props.message;
|
||||
return isPrivate;
|
||||
});
|
||||
|
||||
const parsedLastMessage = computed(() => {
|
||||
const { content_attributes: contentAttributes } = props.message;
|
||||
const { email: { subject } = {} } = contentAttributes || {};
|
||||
return getPlainText(subject || props.message.content);
|
||||
});
|
||||
|
||||
const lastMessageFileType = computed(() => {
|
||||
const [{ file_type: fileType } = {}] = props.message.attachments;
|
||||
return fileType;
|
||||
});
|
||||
|
||||
const attachmentIcon = computed(() => {
|
||||
return attachmentIcons[lastMessageFileType.value];
|
||||
});
|
||||
|
||||
const attachmentMessageContent = computed(() => {
|
||||
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
|
||||
});
|
||||
|
||||
const isMessageSticker = computed(() => {
|
||||
return props.message && props.message.content_type === 'sticker';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-0 text-sm"
|
||||
:class="
|
||||
multiLine
|
||||
? 'flex items-start gap-1'
|
||||
: 'grid grid-cols-[auto_1fr] items-center gap-1'
|
||||
"
|
||||
>
|
||||
<template v-if="showMessageType && !multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="min-w-0 text-body-main"
|
||||
:class="multiLine ? 'line-clamp-2' : 'truncate'"
|
||||
>
|
||||
<!-- Case for previous and conversation conversation card -->
|
||||
<template v-if="showMessageType && multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="message.content && isMessageSticker"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
|
||||
>
|
||||
<Icon icon="i-lucide-image" class="size-3.5" />
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
</span>
|
||||
|
||||
<template v-else-if="message.content">
|
||||
{{ parsedLastMessage }}
|
||||
</template>
|
||||
|
||||
<span
|
||||
v-else-if="message.attachments"
|
||||
class="inline-block align-middle truncate"
|
||||
>
|
||||
<Icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
:icon="attachmentIcon"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<span class="inline-block align-middle">
|
||||
{{ $t(attachmentMessageContent) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
count: { type: Number, required: true },
|
||||
alignBottom: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const displayCount = computed(() =>
|
||||
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="count > 0"
|
||||
class="bg-n-teal-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
|
||||
:class="{
|
||||
'mb-0.5': alignBottom,
|
||||
}"
|
||||
>
|
||||
{{ displayCount }}
|
||||
</span>
|
||||
<span v-else />
|
||||
</template>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, default: '' },
|
||||
direction: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const LABEL_KEYS = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||
};
|
||||
|
||||
const isOutbound = computed(
|
||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
||||
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const statusColor = computed(
|
||||
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
|
||||
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
|
||||
<span class="truncate text-body-main" :class="statusColor">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
|
||||
defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
|
||||
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
|
||||
<span class="truncate text-body-main text-n-slate-11">
|
||||
{{ inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: props.chat,
|
||||
});
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.chat, updateSlaStatus);
|
||||
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasSlaThreshold"
|
||||
v-bind="$attrs"
|
||||
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||
>
|
||||
<Label
|
||||
:label="slaStatus.threshold"
|
||||
:color="isSlaMissed ? 'ruby' : 'amber'"
|
||||
compact
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
|
||||
</template>
|
||||
</Label>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -9,11 +9,15 @@ import {
|
||||
ARTICLE_STATUSES,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -44,14 +48,46 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSelectionControl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['openArticle', 'articleAction']);
|
||||
const emit = defineEmits([
|
||||
'openArticle',
|
||||
'articleAction',
|
||||
'toggleSelect',
|
||||
'hover',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const isTranslationAvailable = computed(
|
||||
() =>
|
||||
isEnterprise &&
|
||||
isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_TASKS
|
||||
)
|
||||
);
|
||||
|
||||
const articleMenuItems = computed(() => {
|
||||
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
|
||||
(acc, [key, item]) => {
|
||||
@@ -64,7 +100,9 @@ const articleMenuItems = computed(() => {
|
||||
const statusItems = (
|
||||
ARTICLE_MENU_OPTIONS[props.status] ||
|
||||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
|
||||
).map(key => commonItems[key]);
|
||||
)
|
||||
.filter(key => key !== 'translate' || isTranslationAvailable.value)
|
||||
.map(key => commonItems[key]);
|
||||
|
||||
return [...statusItems, commonItems.delete];
|
||||
});
|
||||
@@ -123,14 +161,27 @@ const handleClick = id => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<CardLayout
|
||||
:selectable="selectable"
|
||||
class="relative"
|
||||
@mouseenter="emit('hover', true)"
|
||||
@mouseleave="emit('hover', false)"
|
||||
>
|
||||
<div
|
||||
v-show="showSelectionControl"
|
||||
class="absolute top-7 ltr:left-3 rtl:right-3"
|
||||
>
|
||||
<Checkbox :model-value="isSelected" @change="emit('toggleSelect', id)" />
|
||||
</div>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
|
||||
+2
-2
@@ -121,7 +121,7 @@ const handleCreateArticle = event => {
|
||||
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
|
||||
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
||||
placeholder="Title"
|
||||
autofocus
|
||||
:autofocus="isNewArticle"
|
||||
@blur="handleCreateArticle"
|
||||
/>
|
||||
<ArticleEditorControls
|
||||
@@ -138,7 +138,7 @@ const handleCreateArticle = event => {
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
|
||||
"
|
||||
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
||||
:autofocus="false"
|
||||
:autofocus="!isNewArticle"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
|
||||
+31
-2
@@ -20,8 +20,14 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedArticleIds: {
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
|
||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
const router = useRouter();
|
||||
@@ -30,12 +36,26 @@ const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const localArticles = ref(props.articles);
|
||||
const hoveredArticleId = ref(null);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
// Enable dragging only for category articles and when there's more than one article
|
||||
return props.isCategoryArticles && localArticles.value?.length > 1;
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
localArticles.value?.length > 1 &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
});
|
||||
|
||||
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
|
||||
|
||||
const shouldShowSelectionControl = id => {
|
||||
return hoveredArticleId.value === id || hasBulkSelection.value;
|
||||
};
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredArticleId.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const getCategoryById = useMapGetter('categories/categoryById');
|
||||
|
||||
const openArticle = id => {
|
||||
@@ -152,6 +172,10 @@ const handleArticleAction = async (action, { status, id }) => {
|
||||
};
|
||||
|
||||
const updateArticle = ({ action, value, id }) => {
|
||||
if (action === 'translate') {
|
||||
emit('translateArticle', id);
|
||||
return;
|
||||
}
|
||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||
handleArticleAction(action, { status, id });
|
||||
};
|
||||
@@ -187,9 +211,14 @@ watch(
|
||||
:category="getCategory(element.category.id)"
|
||||
:views="element.views || 0"
|
||||
:updated-at="element.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(element.id)"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(element.id)"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, element.id)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
+231
-7
@@ -1,9 +1,13 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||
@@ -11,6 +15,10 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
|
||||
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
@@ -39,7 +47,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal']);
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -47,6 +55,42 @@ const { t } = useI18n();
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isFetching = useMapGetter('articles/isFetching');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const isTranslationAvailable = computed(
|
||||
() =>
|
||||
isEnterprise &&
|
||||
isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_TASKS
|
||||
)
|
||||
);
|
||||
|
||||
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
|
||||
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
if (!visibleArticleIds.value.length) return '';
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
|
||||
count: visibleArticleIds.value.length,
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
|
||||
count: selectedArticleIds.value.size,
|
||||
})
|
||||
);
|
||||
|
||||
const bulkTranslateDialogRef = ref(null);
|
||||
|
||||
const hasNoArticles = computed(
|
||||
() => !isFetching.value && !props.articles.length
|
||||
@@ -128,6 +172,80 @@ const navigateToNewArticlePage = () => {
|
||||
params: { categorySlug, locale },
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSelect = articleId => {
|
||||
const newSet = new Set(selectedArticleIds.value);
|
||||
if (newSet.has(articleId)) {
|
||||
newSet.delete(articleId);
|
||||
} else {
|
||||
newSet.add(articleId);
|
||||
}
|
||||
selectedArticleIds.value = newSet;
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedArticleIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleTranslateArticle = articleId => {
|
||||
selectedArticleIds.value = new Set([articleId]);
|
||||
bulkTranslateDialogRef.value?.dialogRef?.open();
|
||||
};
|
||||
|
||||
const openTranslateDialog = () => {
|
||||
bulkTranslateDialogRef.value?.dialogRef?.open();
|
||||
};
|
||||
|
||||
const onBulkActionSuccess = message => {
|
||||
useAlert(message);
|
||||
clearSelection();
|
||||
emit('refreshArticles');
|
||||
};
|
||||
|
||||
const bulkUpdateStatus = async status => {
|
||||
try {
|
||||
await articlesAPI.bulkUpdateStatus({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
status,
|
||||
});
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
deleteConfirmDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const bulkDelete = async () => {
|
||||
try {
|
||||
await articlesAPI.bulkDelete({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
});
|
||||
deleteConfirmDialogRef.value?.close();
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
deleteConfirmDialogRef.value?.close();
|
||||
useAlert(
|
||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear selection when articles change (page change, filter change)
|
||||
watch(
|
||||
() => props.articles,
|
||||
() => clearSelection()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,11 +284,91 @@ const navigateToNewArticlePage = () => {
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<ArticleList
|
||||
v-else-if="!hasNoArticles"
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
/>
|
||||
<template v-else-if="!hasNoArticles">
|
||||
<div
|
||||
v-if="selectedArticleIds.size > 0"
|
||||
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
|
||||
>
|
||||
<BulkSelectBar
|
||||
v-model="selectedArticleIds"
|
||||
:all-items="allItems"
|
||||
:select-all-label="selectAllLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
:label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
|
||||
"
|
||||
class="!px-1.5"
|
||||
@click="clearSelection"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-check"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('published')"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-pencil-line"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('draft')"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-archive-restore"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('archived')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isTranslationAvailable"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-languages"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="openTranslateDialog"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
ruby
|
||||
icon="i-lucide-trash"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
|
||||
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||
@click="confirmBulkDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</div>
|
||||
<ArticleList
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@toggle-select="handleToggleSelect"
|
||||
/>
|
||||
</template>
|
||||
<ArticleEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
@@ -183,5 +381,31 @@ const navigateToNewArticlePage = () => {
|
||||
@click="navigateToNewArticlePage"
|
||||
/>
|
||||
</template>
|
||||
<BulkTranslateDialog
|
||||
ref="bulkTranslateDialogRef"
|
||||
:selected-article-ids="[...selectedArticleIds]"
|
||||
:allowed-locales="allowedLocales"
|
||||
@translate-started="clearSelection"
|
||||
/>
|
||||
<Dialog
|
||||
ref="deleteConfirmDialogRef"
|
||||
type="alert"
|
||||
:title="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
|
||||
selectedArticleIds.size
|
||||
)
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
|
||||
selectedArticleIds.size
|
||||
)
|
||||
"
|
||||
:confirm-button-label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
|
||||
"
|
||||
@confirm="bulkDelete"
|
||||
/>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedArticleIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateStarted']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isSubmitting = ref(false);
|
||||
const selectedLocale = ref('');
|
||||
const selectedCategoryId = ref('');
|
||||
const targetCategories = ref([]);
|
||||
const isFetchingCategories = ref(false);
|
||||
const duplicateArticles = ref([]);
|
||||
|
||||
const currentLocale = computed(() => route.params.locale);
|
||||
|
||||
const localeOptions = computed(() => {
|
||||
return props.allowedLocales
|
||||
.filter(locale => locale.code !== currentLocale.value)
|
||||
.map(locale => ({
|
||||
value: locale.code,
|
||||
label: `${locale.name} (${locale.code})`,
|
||||
}));
|
||||
});
|
||||
|
||||
const categoryOptions = computed(() => {
|
||||
return targetCategories.value.map(category => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
}));
|
||||
});
|
||||
|
||||
const articleCount = computed(() => props.selectedArticleIds.length);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
|
||||
);
|
||||
|
||||
const description = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
|
||||
);
|
||||
|
||||
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
if (hasDuplicates.value) {
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
|
||||
}
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
|
||||
});
|
||||
|
||||
const isConfirmDisabled = computed(() => {
|
||||
return !selectedLocale.value || isSubmitting.value;
|
||||
});
|
||||
|
||||
const articleEditUrl = articleId => {
|
||||
const { portalSlug, categorySlug, tab } = route.params;
|
||||
const resolved = router.resolve({
|
||||
name: 'portals_articles_edit',
|
||||
params: {
|
||||
portalSlug,
|
||||
locale: selectedLocale.value,
|
||||
categorySlug,
|
||||
tab,
|
||||
articleSlug: articleId,
|
||||
},
|
||||
});
|
||||
return resolved.href;
|
||||
};
|
||||
|
||||
const fetchCategoriesForLocale = async locale => {
|
||||
if (!locale) {
|
||||
targetCategories.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isFetchingCategories.value = true;
|
||||
try {
|
||||
const { data } = await categoriesAPI.get({
|
||||
portalSlug: route.params.portalSlug,
|
||||
locale,
|
||||
});
|
||||
targetCategories.value = data.payload;
|
||||
} catch {
|
||||
targetCategories.value = [];
|
||||
} finally {
|
||||
isFetchingCategories.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(selectedLocale, newLocale => {
|
||||
selectedCategoryId.value = '';
|
||||
duplicateArticles.value = [];
|
||||
fetchCategoriesForLocale(newLocale);
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
selectedLocale.value = '';
|
||||
selectedCategoryId.value = '';
|
||||
targetCategories.value = [];
|
||||
duplicateArticles.value = [];
|
||||
};
|
||||
|
||||
const submitTranslation = async (force = false) => {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await store.dispatch('articles/bulkTranslate', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: props.selectedArticleIds,
|
||||
locale: selectedLocale.value,
|
||||
categoryId: selectedCategoryId.value,
|
||||
force,
|
||||
});
|
||||
|
||||
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
|
||||
resetForm();
|
||||
dialogRef.value?.close();
|
||||
emit('translateStarted');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 409) {
|
||||
duplicateArticles.value = error.response.data.duplicate_articles;
|
||||
return;
|
||||
}
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
|
||||
);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
if (isConfirmDisabled.value) return;
|
||||
submitTranslation(hasDuplicates.value);
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="dialogTitle"
|
||||
:description="description"
|
||||
:confirm-button-label="confirmLabel"
|
||||
:disable-confirm-button="isConfirmDisabled"
|
||||
:is-loading="isSubmitting"
|
||||
@close="resetForm"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="selectedLocale"
|
||||
:options="localeOptions"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
|
||||
<span class="text-n-slate-10 font-normal">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
|
||||
</span>
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="selectedCategoryId"
|
||||
:options="categoryOptions"
|
||||
:disabled="!selectedLocale || isFetchingCategories"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasDuplicates"
|
||||
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<p class="text-sm text-n-amber-12 m-0">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
|
||||
duplicateArticles.length
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<a
|
||||
v-for="article in duplicateArticles"
|
||||
:key="article.id"
|
||||
:href="articleEditUrl(article.id)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
|
||||
>
|
||||
{{ article.title }}
|
||||
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-xs text-n-amber-11 m-0">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -2,13 +2,10 @@
|
||||
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
@@ -18,22 +15,18 @@ import {
|
||||
processContactableInboxes,
|
||||
mergeInboxDetails,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
alignPosition: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
},
|
||||
contactId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
isModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
align: {
|
||||
type: String,
|
||||
default: 'end',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,23 +35,16 @@ const emit = defineEmits(['close']);
|
||||
const searchContacts = createContactSearcher();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||
|
||||
const isSmallScreen = computed(
|
||||
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
||||
);
|
||||
|
||||
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
|
||||
|
||||
const popoverRef = ref(null);
|
||||
const contacts = ref([]);
|
||||
const selectedContact = ref(null);
|
||||
const targetInbox = ref(null);
|
||||
const isCreatingContact = ref(false);
|
||||
const isFetchingInboxes = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const showComposeNewConversation = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
message: '',
|
||||
@@ -95,14 +81,6 @@ const directUploadsEnabled = computed(
|
||||
|
||||
const activeContact = computed(() => contactById.value(props.contactId));
|
||||
|
||||
const composePopoverClass = computed(() => {
|
||||
if (viewInModal.value) return '';
|
||||
|
||||
return props.alignPosition === 'right'
|
||||
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
||||
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
|
||||
});
|
||||
|
||||
const onContactSearch = debounce(
|
||||
async query => {
|
||||
isSearching.value = true;
|
||||
@@ -172,7 +150,7 @@ const clearSelectedContact = () => {
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
showComposeNewConversation.value = false;
|
||||
popoverRef.value?.hide();
|
||||
if (!props.contactId) {
|
||||
// If contactId is passed as prop
|
||||
// Then don't allow to remove the selected contact
|
||||
@@ -180,7 +158,6 @@ const closeCompose = () => {
|
||||
}
|
||||
targetInbox.value = null;
|
||||
resetContacts();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
@@ -213,8 +190,15 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
showComposeNewConversation.value = !showComposeNewConversation.value;
|
||||
const onPopoverShow = () => {
|
||||
// Flag to prevent triggering drag n drop,
|
||||
// When compose modal is active
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
};
|
||||
|
||||
const onPopoverHide = () => {
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -242,64 +226,23 @@ watch(
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (!showComposeNewConversation.value) return;
|
||||
|
||||
showComposeNewConversation.value = false;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onModalBackdropClick = () => {
|
||||
if (!viewInModal.value) return;
|
||||
handleClickOutside();
|
||||
};
|
||||
|
||||
onMounted(() => resetContacts());
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
if (showComposeNewConversation.value) {
|
||||
showComposeNewConversation.value = false;
|
||||
emit('close');
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
handleClickOutside,
|
||||
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
||||
]"
|
||||
class="relative"
|
||||
:class="{
|
||||
'z-50': showComposeNewConversation && !viewInModal,
|
||||
}"
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
:align="align"
|
||||
:show-content-border="false"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
<slot
|
||||
name="trigger"
|
||||
:is-open="showComposeNewConversation"
|
||||
:toggle="toggle"
|
||||
/>
|
||||
<div
|
||||
v-if="showComposeNewConversation"
|
||||
:class="{
|
||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
||||
viewInModal,
|
||||
}"
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<template #default="{ isOpen }">
|
||||
<slot name="trigger" :is-open="isOpen" />
|
||||
</template>
|
||||
<template #content>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
@@ -321,6 +264,6 @@ useKeyboardEvents(keyboardEvents);
|
||||
@create-conversation="createConversation"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
+5
-8
@@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
@@ -20,6 +18,7 @@ const props = defineProps({
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
channelType: { type: String, default: '' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
@@ -29,6 +28,7 @@ const props = defineProps({
|
||||
isDropdownActive: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
inboxId: { type: Number, default: null },
|
||||
voiceEnabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -81,11 +81,9 @@ const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||
|
||||
const shouldShowSignatureButton = computed(() => {
|
||||
return (
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
|
||||
);
|
||||
});
|
||||
|
||||
@@ -110,7 +108,7 @@ watch(
|
||||
() => props.hasSelectedInbox,
|
||||
newValue => {
|
||||
nextTick(() => {
|
||||
if (newValue && !isVoiceInbox.value) setSignature();
|
||||
if (newValue && !props.voiceEnabled) setSignature();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -198,7 +196,6 @@ useEventListener(document, 'paste', onPaste);
|
||||
<WhatsAppOptions
|
||||
v-if="isWhatsappInbox"
|
||||
:inbox-id="inboxId"
|
||||
:message-templates="messageTemplates"
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<ContentTemplateSelector
|
||||
@@ -220,7 +217,7 @@ useEventListener(document, 'paste', onPaste);
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
|
||||
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
|
||||
:on-click="onClickInsertEmoji"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+5
-2
@@ -2,7 +2,7 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
@@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
@@ -361,7 +363,7 @@ useKeyboardEvents({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
class="w-full md:w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
|
||||
<ContactSelector
|
||||
@@ -442,6 +444,7 @@ useKeyboardEvents({
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
:channel-type="inboxChannelType"
|
||||
:voice-enabled="voiceCallEnabled"
|
||||
:is-loading="isCreating"
|
||||
:disable-send-button="isCreating"
|
||||
:has-selected-inbox="!!targetInbox"
|
||||
|
||||
+1
-3
@@ -24,9 +24,7 @@ const handleBack = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
|
||||
<div class="w-full">
|
||||
<ContentTemplateParser
|
||||
:template="template"
|
||||
|
||||
+65
-55
@@ -6,6 +6,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import ContentTemplateForm from './ContentTemplateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -22,7 +23,6 @@ const inbox = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const contentTemplates = computed(() => {
|
||||
const inboxData = inbox.value(props.inboxId);
|
||||
@@ -39,29 +39,36 @@ const filteredTemplates = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
const handlePopoverShow = () => {
|
||||
searchQuery.value = '';
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
|
||||
const handlePopoverHide = () => {
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = template => {
|
||||
const handleSendMessage = (template, hide) => {
|
||||
emit('sendMessage', template);
|
||||
selectedTemplate.value = null;
|
||||
hide();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Popover
|
||||
align="start"
|
||||
disable-mobile-view
|
||||
@show="handlePopoverShow"
|
||||
@hide="handlePopoverHide"
|
||||
>
|
||||
<Button
|
||||
icon="i-ph-whatsapp-logo"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
|
||||
@@ -69,56 +76,59 @@ const handleSendMessage = template => {
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
v-if="!selectedTemplate"
|
||||
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-else
|
||||
:template="selectedTemplate"
|
||||
@send-message="payload => handleSendMessage(payload, hide)"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
+60
-51
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import WhatsappTemplate from './WhatsappTemplate.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -24,8 +25,6 @@ const getFilteredWhatsAppTemplates = useMapGetter(
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const whatsAppTemplateMessages = computed(() => {
|
||||
return getFilteredWhatsAppTemplates.value(props.inboxId);
|
||||
});
|
||||
@@ -40,29 +39,36 @@ const getTemplateBody = template => {
|
||||
return template.components.find(component => component.type === 'BODY').text;
|
||||
};
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
const handlePopoverShow = () => {
|
||||
searchQuery.value = '';
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
|
||||
const handlePopoverHide = () => {
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = template => {
|
||||
const handleSendMessage = (template, hide) => {
|
||||
emit('sendMessage', template);
|
||||
selectedTemplate.value = null;
|
||||
hide();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Popover
|
||||
align="start"
|
||||
disable-mobile-view
|
||||
@show="handlePopoverShow"
|
||||
@hide="handlePopoverHide"
|
||||
>
|
||||
<Button
|
||||
icon="i-ri-whatsapp-line"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
|
||||
@@ -70,50 +76,53 @@ const handleSendMessage = template => {
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
v-if="!selectedTemplate"
|
||||
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ getTemplateBody(template) }}
|
||||
</p>
|
||||
<div class="relative w-full">
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ getTemplateBody(template) }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE')
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<WhatsappTemplate
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
<WhatsappTemplate
|
||||
v-else
|
||||
:template="selectedTemplate"
|
||||
@send-message="payload => handleSendMessage(payload, hide)"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
+1
-3
@@ -24,9 +24,7 @@ const handleBack = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
|
||||
<div class="w-full">
|
||||
<WhatsAppTemplateParser
|
||||
:template="template"
|
||||
|
||||
@@ -135,6 +135,16 @@ export function useContactFilterContext() {
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME,
|
||||
value: CONTACT_ATTRIBUTES.COMPANY_NAME,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
value: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const CONTACT_ATTRIBUTES = {
|
||||
IDENTIFIER: 'identifier',
|
||||
COUNTRY_CODE: 'country_code',
|
||||
CITY: 'city',
|
||||
COMPANY_NAME: 'company_name',
|
||||
CREATED_AT: 'created_at',
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
REFERER: 'referer',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
|
||||
export function useChannelIcon(inbox) {
|
||||
const channelTypeIconMap = {
|
||||
@@ -14,7 +15,6 @@ export function useChannelIcon(inbox) {
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
'Channel::Voice': 'i-woot-voice',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
@@ -38,6 +38,11 @@ export function useChannelIcon(inbox) {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
|
||||
if (isVoiceCallEnabled(inboxDetails)) {
|
||||
icon = 'i-woot-voice';
|
||||
}
|
||||
|
||||
return icon ?? 'i-ri-global-fill';
|
||||
});
|
||||
|
||||
|
||||
@@ -19,8 +19,11 @@ describe('useChannelIcon', () => {
|
||||
expect(icon).toBe('i-woot-whatsapp');
|
||||
});
|
||||
|
||||
it('returns correct icon for Voice channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Voice' };
|
||||
it('returns correct icon for voice-enabled Twilio channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-voice');
|
||||
});
|
||||
|
||||
@@ -30,6 +30,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
focusOnMount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -82,6 +86,7 @@ onMounted(() => {
|
||||
|
||||
defineExpose({
|
||||
focus: () => inlineInputRef.value?.focus(),
|
||||
blur: () => inlineInputRef.value?.blur(),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -106,6 +111,7 @@ defineExpose({
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:class="customInputClass"
|
||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
@input="handleInput"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
align: {
|
||||
type: String,
|
||||
default: 'end',
|
||||
validator: v => ['start', 'end'].includes(v),
|
||||
},
|
||||
disableMobileView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showContentBorder: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['show', 'hide']);
|
||||
|
||||
const isActive = ref(false);
|
||||
const triggerRef = ref(null);
|
||||
const popoverRef = ref(null);
|
||||
const mobileContentRef = ref(null);
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
const belowMd = breakpoints.smaller('md');
|
||||
const isMobile = computed(() => !props.disableMobileView && belowMd.value);
|
||||
const showPopover = computed(() => isActive.value && !isMobile.value);
|
||||
|
||||
const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
triggerRef,
|
||||
popoverRef,
|
||||
showPopover,
|
||||
{ align: props.align }
|
||||
);
|
||||
|
||||
const show = async () => {
|
||||
isActive.value = true;
|
||||
if (!isMobile.value) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
}
|
||||
emit('show');
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
if (!isActive.value) return;
|
||||
isActive.value = false;
|
||||
emit('hide');
|
||||
};
|
||||
|
||||
const toggle = async () => {
|
||||
if (isActive.value) hide();
|
||||
else await show();
|
||||
};
|
||||
|
||||
// Recalculate position when switching from mobile to desktop while open
|
||||
watch(isMobile, async mobile => {
|
||||
if (!isActive.value || mobile) return;
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
});
|
||||
|
||||
const handleClickOutside = event => {
|
||||
if (triggerRef.value?.contains(event.target)) return;
|
||||
hide();
|
||||
};
|
||||
|
||||
// Selectors for teleported elements that should not trigger close
|
||||
const clickOutsideIgnore = [
|
||||
'dialog.ProseMirror-prompt-backdrop',
|
||||
'[data-popover-content]',
|
||||
];
|
||||
|
||||
useKeyboardEvents({
|
||||
Escape: {
|
||||
action: () => isActive.value && hide(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineExpose({ show, hide, toggle });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="triggerRef" class="inline-flex" @click="toggle">
|
||||
<slot :is-open="isActive" />
|
||||
</span>
|
||||
|
||||
<TeleportWithDirection to="body">
|
||||
<!-- Mobile: centered modal with backdrop -->
|
||||
<div
|
||||
v-if="isActive && isMobile"
|
||||
data-popover-backdrop
|
||||
class="fixed inset-0 z-[9999] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center bg-n-alpha-black1"
|
||||
>
|
||||
<div
|
||||
ref="mobileContentRef"
|
||||
v-on-click-outside="[
|
||||
handleClickOutside,
|
||||
{ ignore: clickOutsideIgnore },
|
||||
]"
|
||||
data-popover-content
|
||||
class="relative flex flex-col w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
|
||||
>
|
||||
<slot name="content" :hide="hide" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: fixed popover -->
|
||||
<div
|
||||
v-else-if="showPopover"
|
||||
ref="popoverRef"
|
||||
v-on-click-outside="[handleClickOutside, { ignore: clickOutsideIgnore }]"
|
||||
data-popover-content
|
||||
:class="fixedPosition.class"
|
||||
:style="fixedPosition.style"
|
||||
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
|
||||
:class="{ 'border border-n-strong': showContentBorder }"
|
||||
>
|
||||
<slot name="content" :hide="hide" />
|
||||
</div>
|
||||
</div>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
@@ -10,8 +10,6 @@ import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
@@ -184,15 +182,6 @@ const closeMobileSidebar = () => {
|
||||
emit('closeMobileSidebar');
|
||||
};
|
||||
|
||||
const onComposeOpen = toggleFn => {
|
||||
toggleFn();
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
};
|
||||
|
||||
const onComposeClose = () => {
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
};
|
||||
|
||||
const newReportRoutes = () => [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
@@ -734,7 +723,13 @@ const menuItems = computed(() => {
|
||||
<aside
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#mobile-sidebar-launcher'] },
|
||||
{
|
||||
ignore: [
|
||||
'#mobile-sidebar-launcher',
|
||||
'[data-popover-content]',
|
||||
'[data-popover-backdrop]',
|
||||
],
|
||||
},
|
||||
]"
|
||||
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
||||
:class="[
|
||||
@@ -802,8 +797,8 @@ const menuItems = computed(() => {
|
||||
>
|
||||
<span class="i-lucide-search size-4 text-n-slate-11" />
|
||||
</RouterLink>
|
||||
<ComposeConversation align-position="right" @close="onComposeClose">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<ComposeConversation align="start">
|
||||
<template #trigger="{ isOpen }">
|
||||
<Button
|
||||
icon="i-lucide-pen-line"
|
||||
color="slate"
|
||||
@@ -815,7 +810,6 @@ const menuItems = computed(() => {
|
||||
: '!h-7 !outline-n-weak !text-n-slate-11',
|
||||
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
|
||||
]"
|
||||
@click="onComposeOpen(toggle)"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<script setup>
|
||||
// [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
|
||||
// composables and components, useVirtualChatList, useChatlistFilters
|
||||
import {
|
||||
ref,
|
||||
unref,
|
||||
provide,
|
||||
computed,
|
||||
watch,
|
||||
onMounted,
|
||||
defineEmits,
|
||||
} from 'vue';
|
||||
import { ref, unref, provide, computed, watch, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
@@ -17,23 +7,19 @@ import {
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import ConversationList from './ConversationList.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||
import SaveCustomView from 'next/filter/SaveCustomView.vue';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
||||
import { useFilter } from 'shared/composables/useFilter';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
@@ -85,10 +71,6 @@ const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
@@ -100,7 +82,6 @@ const chatsOnView = ref([]);
|
||||
const foldersQuery = ref({});
|
||||
const showAddFoldersModal = ref(false);
|
||||
const showDeleteFoldersModal = ref(false);
|
||||
const isContextMenuOpen = ref(false);
|
||||
const appliedFilter = ref([]);
|
||||
const advancedFilterTypes = ref(
|
||||
advancedFilterOptions.map(filter => ({
|
||||
@@ -130,7 +111,6 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const getTeamFn = useMapGetter('teams/getTeam');
|
||||
const getConversationById = useMapGetter('getConversationById');
|
||||
|
||||
useChatListKeyboardEvents(conversationListRef);
|
||||
const {
|
||||
selectedConversations,
|
||||
selectedInboxes,
|
||||
@@ -360,7 +340,7 @@ const conversationList = computed(() => {
|
||||
});
|
||||
|
||||
const showEndOfListMessage = computed(() => {
|
||||
return (
|
||||
return !!(
|
||||
conversationList.value.length &&
|
||||
hasCurrentPageEndReached.value &&
|
||||
!chatListLoading.value
|
||||
@@ -605,14 +585,6 @@ function loadMoreConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
||||
// If the list doesn’t fill the viewport, loading can stall.
|
||||
// IntersectionObserver triggers as soon as the sentinel is visible.
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
function updateAssigneeTab(selectedTab) {
|
||||
if (activeAssigneeTab.value !== selectedTab) {
|
||||
resetBulkActions();
|
||||
@@ -806,10 +778,6 @@ function allSelectedConversationsStatus(status) {
|
||||
});
|
||||
}
|
||||
|
||||
function onContextMenuToggle(state) {
|
||||
isContextMenuOpen.value = state;
|
||||
}
|
||||
|
||||
function toggleSelectAll(check) {
|
||||
selectAllConversations(check, conversationList);
|
||||
}
|
||||
@@ -857,7 +825,6 @@ provide('assignTeam', onAssignTeam);
|
||||
provide('assignLabels', onAssignLabels);
|
||||
provide('removeLabels', onRemoveLabels);
|
||||
provide('updateConversationStatus', handleResolveConversation);
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
provide('markAsUnread', markAsUnread);
|
||||
provide('markAsRead', markAsRead);
|
||||
provide('assignPriority', assignPriority);
|
||||
@@ -970,43 +937,18 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
@assign-labels="onAssignLabels"
|
||||
@assign-team="onAssignTeamsForBulk"
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item, index }"
|
||||
:data="conversationList"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:data-index="index"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
<ConversationList
|
||||
:conversation-list="conversationList"
|
||||
:is-loading="chatListLoading"
|
||||
:show-end-of-list-message="showEndOfListMessage"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@load-more="loadMoreConversations"
|
||||
/>
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
type="alert"
|
||||
|
||||
@@ -1,74 +1,244 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch, inject } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
||||
export default {
|
||||
components: {
|
||||
ConversationCard,
|
||||
},
|
||||
inject: [
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabels',
|
||||
'removeLabels',
|
||||
'updateConversationStatus',
|
||||
'toggleContextMenu',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'isConversationSelected',
|
||||
'deleteConversation',
|
||||
],
|
||||
props: {
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
teamId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
foldersId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
import ConversationCardExpanded from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import ConversationContextMenu from './widgets/conversation/contextMenu/Index.vue';
|
||||
|
||||
const props = defineProps({
|
||||
source: { type: Object, required: true },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
conversationType: { type: String, default: '' },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showExpanded: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const selectConversation = inject('selectConversation');
|
||||
const deSelectConversation = inject('deSelectConversation');
|
||||
const assignAgent = inject('assignAgent');
|
||||
const assignTeam = inject('assignTeam');
|
||||
const assignLabels = inject('assignLabels');
|
||||
const removeLabels = inject('removeLabels');
|
||||
const updateConversationStatus = inject('updateConversationStatus');
|
||||
const toggleContextMenu = inject('toggleContextMenu');
|
||||
const markAsUnread = inject('markAsUnread');
|
||||
const markAsRead = inject('markAsRead');
|
||||
const assignPriority = inject('assignPriority');
|
||||
const isConversationSelected = inject('isConversationSelected');
|
||||
const deleteConversation = inject('deleteConversation');
|
||||
|
||||
// --- Context menu state (shared by both layouts) ---
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
// Reset context menu state when the row is recycled to a different conversation.
|
||||
watch(
|
||||
() => props.source.id,
|
||||
() => {
|
||||
if (showContextMenu.value) {
|
||||
toggleContextMenu(false);
|
||||
}
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value = { x: null, y: null };
|
||||
}
|
||||
);
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const chatMetadata = computed(() => props.source.meta || {});
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
|
||||
const currentContact = computed(() =>
|
||||
senderId.value ? store.getters['contacts/getContact'](senderId.value) : {}
|
||||
);
|
||||
|
||||
const isActiveChat = computed(() => currentChat.value.id === props.source.id);
|
||||
|
||||
const inbox = computed(() => {
|
||||
const inboxId = props.source.inbox_id;
|
||||
return inboxId ? store.getters['inboxes/getInbox'](inboxId) : {};
|
||||
});
|
||||
|
||||
const showInboxName = computed(
|
||||
() => !activeInbox.value && inboxesList.value.length > 1
|
||||
);
|
||||
const isInboxView = computed(() => !!activeInbox.value);
|
||||
const showAssigneeForExpandedCard = computed(
|
||||
() => props.showExpanded || props.showAssignee
|
||||
);
|
||||
|
||||
const conversationPath = computed(() =>
|
||||
frontendURL(
|
||||
conversationUrl({
|
||||
accountId: accountId.value,
|
||||
activeInbox: activeInbox.value,
|
||||
id: props.source.id,
|
||||
label: props.label,
|
||||
teamId: props.teamId,
|
||||
conversationType: props.conversationType,
|
||||
foldersId: props.foldersId,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = conversationPath.value;
|
||||
if (!path) return;
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveChat.value) return;
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const onExpandedSelect = checked => {
|
||||
if (checked) {
|
||||
selectConversation(props.source.id, inbox.value.id);
|
||||
} else {
|
||||
deSelectConversation(props.source.id, inbox.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = e => {
|
||||
e.preventDefault();
|
||||
toggleContextMenu(true);
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
toggleContextMenu(false);
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
};
|
||||
|
||||
const onUpdateConversation = (status, snoozedUntil) => {
|
||||
closeContextMenu();
|
||||
updateConversationStatus(props.source.id, status, snoozedUntil);
|
||||
};
|
||||
|
||||
const onAssignAgent = agent => {
|
||||
assignAgent(agent, [props.source.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignLabel = label => {
|
||||
assignLabels([label.title], [props.source.id]);
|
||||
};
|
||||
|
||||
const onRemoveLabel = label => {
|
||||
removeLabels([label.title], [props.source.id]);
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
assignTeam(team, props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onMarkAsUnread = () => {
|
||||
markAsUnread(props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onMarkAsRead = () => {
|
||||
markAsRead(props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignPriority = priority => {
|
||||
assignPriority(priority, props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onDeleteConversation = () => {
|
||||
deleteConversation(props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConversationCard
|
||||
:active-label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
<!-- Expanded layout: wide screen + expanded setting -->
|
||||
<ConversationCardExpanded
|
||||
v-if="showExpanded"
|
||||
:chat="source"
|
||||
:conversation-type="conversationType"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="isConversationSelected(source.id)"
|
||||
:is-active-chat="isActiveChat"
|
||||
:show-assignee="showAssigneeForExpandedCard"
|
||||
:show-inbox-name="showInboxName"
|
||||
:is-inbox-view="isInboxView"
|
||||
@select-conversation="onExpandedSelect"
|
||||
@de-select-conversation="onExpandedSelect"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
/>
|
||||
|
||||
<!-- Default (condensed) layout -->
|
||||
<ConversationCard
|
||||
v-else
|
||||
:chat="source"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="isConversationSelected(source.id)"
|
||||
:is-active-chat="isActiveChat"
|
||||
:show-assignee="showAssignee"
|
||||
enable-context-menu
|
||||
:show-inbox-name="showInboxName"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
@assign-agent="assignAgent"
|
||||
@assign-team="assignTeam"
|
||||
@assign-label="assignLabels"
|
||||
@remove-label="removeLabels"
|
||||
@update-conversation-status="updateConversationStatus"
|
||||
@context-menu-toggle="toggleContextMenu"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
/>
|
||||
|
||||
<!-- Shared context menu for both layouts -->
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="source.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="source.priority"
|
||||
:chat-id="source.id"
|
||||
:has-unread-messages="source.unread_count > 0"
|
||||
:conversation-labels="source.labels"
|
||||
:conversation-url="conversationPath"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@remove-label="onRemoveLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="onMarkAsUnread"
|
||||
@mark-as-read="onMarkAsRead"
|
||||
@assign-priority="onAssignPriority"
|
||||
@delete-conversation="onDeleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { ref, computed, provide } from 'vue';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import { useBreakpoints } from '@vueuse/core';
|
||||
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
conversationList: { type: Array, default: () => [] },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
showEndOfListMessage: { type: Boolean, default: false },
|
||||
label: { type: String, default: '' },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
conversationType: { type: String, default: '' },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
isOnExpandedLayout: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['loadMore']);
|
||||
|
||||
const conversationListRef = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
const isContextMenuOpen = ref(false);
|
||||
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const breakpoints = useBreakpoints({
|
||||
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
|
||||
});
|
||||
const isLgScreen = breakpoints.greaterOrEqual('lg');
|
||||
const showExpandedCards = computed(
|
||||
() => props.isOnExpandedLayout && isLgScreen.value
|
||||
);
|
||||
|
||||
useChatListKeyboardEvents(conversationListRef);
|
||||
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
const onContextMenuToggle = state => {
|
||||
isContextMenuOpen.value = state;
|
||||
};
|
||||
|
||||
const loadMoreConversations = () => {
|
||||
emit('loadMore');
|
||||
};
|
||||
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
|
||||
defineExpose({ conversationListRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item }"
|
||||
:data="conversationList"
|
||||
class="[&>div:has(+_div_.active)>*]:!border-n-surface-1 [&>div:has(+_div_.selected)>*]:!border-n-surface-1"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssignee"
|
||||
:show-expanded="showExpandedCards"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<div v-if="isLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p v-else-if="showEndOfListMessage" class="p-4 text-center text-n-slate-11">
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
|
||||
import {
|
||||
appendSignature,
|
||||
collapseSelection,
|
||||
findNodeToInsertImage,
|
||||
getContentNode,
|
||||
insertAtCursor,
|
||||
@@ -66,6 +67,7 @@ import {
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
hasPressedCommandAndEnter,
|
||||
isEscape,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
@@ -515,7 +517,9 @@ function setMenubarPosition({ selection } = {}) {
|
||||
|
||||
function checkSelection(editorState) {
|
||||
showSelectionMenu.value = false;
|
||||
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
||||
const { selection } = editorState;
|
||||
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
||||
const hasSelection = !selection.empty && !selection.node;
|
||||
if (hasSelection === isTextSelected.value) return;
|
||||
|
||||
isTextSelected.value = hasSelection;
|
||||
@@ -711,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (isEscape(event)) {
|
||||
collapseSelection(editorView);
|
||||
return true;
|
||||
}
|
||||
if (isEnterToSendEnabled()) {
|
||||
handleLineBreakWhenEnterToSendEnabled(event);
|
||||
}
|
||||
if (isCmdPlusEnterToSendEnabled()) {
|
||||
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createEditorView() {
|
||||
@@ -744,6 +753,9 @@ function createEditorView() {
|
||||
blur: () => {
|
||||
if (props.disabled) return;
|
||||
typingIndicator.stop();
|
||||
// PM keeps its selection on blur — clear the menu flags manually.
|
||||
isTextSelected.value = false;
|
||||
editorRoot.value?.classList.remove('has-selection');
|
||||
emit('blur');
|
||||
},
|
||||
paste: (view, event) => {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
import { collapseSelection } from 'dashboard/helper/editorHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
@@ -362,19 +364,33 @@ export default {
|
||||
onKeyup() {
|
||||
this.$emit('keyup');
|
||||
},
|
||||
onKeydown() {
|
||||
onKeydown(view, event) {
|
||||
this.$emit('keydown');
|
||||
if (isEscape(event)) {
|
||||
if (this.showSlashMenu) {
|
||||
this.showSlashMenu = false;
|
||||
this.slashSearchTerm = '';
|
||||
this.slashMenuPosition = null;
|
||||
return true;
|
||||
}
|
||||
collapseSelection(editorView);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onBlur() {
|
||||
// ProseMirror keeps its selection on blur — clear the menu flag manually.
|
||||
this.isTextSelected = false;
|
||||
this.$refs.editor?.classList.remove('has-selection');
|
||||
this.$emit('blur');
|
||||
},
|
||||
onFocus() {
|
||||
this.$emit('focus');
|
||||
},
|
||||
checkSelection(editorState) {
|
||||
const { from, to } = editorState.selection;
|
||||
// Check if there's a selection (from and to are different)
|
||||
const hasSelection = from !== to;
|
||||
const { selection } = editorState;
|
||||
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
||||
const hasSelection = !selection.empty && !selection.node;
|
||||
// If the selection state is the same as the previous state, do nothing
|
||||
if (hasSelection === this.isTextSelected) return;
|
||||
// Update the selection state
|
||||
|
||||
@@ -1,98 +1,41 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import UnreadBadge from 'dashboard/components-next/Conversation/ConversationCard/UnreadBadge.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
chat: { type: Object, default: () => ({}) },
|
||||
hideInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
conversationType: { type: String, default: '' },
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
enableContextMenu: { type: Boolean, default: false },
|
||||
allowedContextMenuOptions: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'removeLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'updateConversationStatus',
|
||||
'deleteConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const hovered = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
|
||||
// This prevents context menu/hover state from leaking to a different conversation
|
||||
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
|
||||
const resetState = () => {
|
||||
if (showContextMenu.value) {
|
||||
emit('contextMenuToggle', false);
|
||||
}
|
||||
hovered.value = false;
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value = { x: null, y: null };
|
||||
};
|
||||
|
||||
watch(() => props.chat.id, resetState);
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta || {});
|
||||
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
|
||||
const currentContact = computed(() => {
|
||||
return senderId.value
|
||||
? store.getters['contacts/getContact'](senderId.value)
|
||||
: {};
|
||||
});
|
||||
|
||||
const isActiveChat = computed(() => {
|
||||
return currentChat.value.id === props.chat.id;
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
|
||||
const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
@@ -100,24 +43,10 @@ const voiceCallData = computed(() => ({
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
const inbox = computed(() => {
|
||||
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
|
||||
});
|
||||
|
||||
const showInboxName = computed(() => {
|
||||
return (
|
||||
!props.hideInboxName &&
|
||||
isInboxNameVisible.value &&
|
||||
inboxesList.value.length > 1
|
||||
);
|
||||
});
|
||||
|
||||
const showMetaSection = computed(() => {
|
||||
return (
|
||||
showInboxName.value ||
|
||||
(props.showAssignee && assignee.value.name) ||
|
||||
props.showInboxName ||
|
||||
(props.showAssignee && props.assignee.name) ||
|
||||
props.chat.priority
|
||||
);
|
||||
});
|
||||
@@ -136,41 +65,6 @@ const messagePreviewClass = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId: accountId.value,
|
||||
activeInbox: activeInbox.value,
|
||||
id: props.chat.id,
|
||||
label: props.activeLabel,
|
||||
teamId: props.teamId,
|
||||
conversationType: props.conversationType,
|
||||
foldersId: props.foldersId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = conversationPath.value;
|
||||
if (!path) return;
|
||||
|
||||
// Handle Ctrl/Cmd + Click for new tab
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already active
|
||||
if (isActiveChat.value) return;
|
||||
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
@@ -181,83 +75,37 @@ const onThumbnailLeave = () => {
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, inbox.value.id);
|
||||
emit('selectConversation', props.chat.id, props.inbox.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, inbox.value.id);
|
||||
emit('deSelectConversation', props.chat.id, props.inbox.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = e => {
|
||||
if (!props.enableContextMenu) return;
|
||||
e.preventDefault();
|
||||
emit('contextMenuToggle', true);
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => onSelectConversation(value),
|
||||
});
|
||||
|
||||
const closeContextMenu = () => {
|
||||
emit('contextMenuToggle', false);
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
};
|
||||
|
||||
const onUpdateConversation = (status, snoozedUntil) => {
|
||||
closeContextMenu();
|
||||
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
|
||||
};
|
||||
|
||||
const onAssignAgent = agent => {
|
||||
emit('assignAgent', agent, [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignLabel = label => {
|
||||
emit('assignLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onRemoveLabel = label => {
|
||||
emit('removeLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
emit('assignTeam', team, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsUnread = () => {
|
||||
emit('markAsUnread', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsRead = () => {
|
||||
emit('markAsRead', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const assignPriority = priority => {
|
||||
emit('assignPriority', priority, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const deleteConversation = () => {
|
||||
emit('deleteConversation', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
watch(
|
||||
() => props.chat.id,
|
||||
() => {
|
||||
hovered.value = false;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
|
||||
'bg-n-slate-2': selected,
|
||||
'active animate-card-select bg-n-background !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 !border-n-surface-1': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
}"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
@@ -272,7 +120,6 @@ const deleteConversation = () => {
|
||||
:status="currentContact.availability_status"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<template #overlay="{ size }">
|
||||
<label
|
||||
@@ -281,20 +128,12 @@ const deleteConversation = () => {
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
class="!m-0 cursor-pointer"
|
||||
type="checkbox"
|
||||
@change="onSelectConversation($event.target.checked)"
|
||||
/>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div
|
||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
|
||||
>
|
||||
<div class="px-0 py-3 flex-1 min-w-0 border-line">
|
||||
<div
|
||||
v-if="showMetaSection"
|
||||
class="flex items-center min-w-0 gap-1"
|
||||
@@ -369,12 +208,11 @@ const deleteConversation = () => {
|
||||
:conversation-id="chat.id"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||
:class="hasUnread ? 'block' : 'hidden'"
|
||||
>
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
<UnreadBadge
|
||||
v-if="hasUnread"
|
||||
:count="unreadCount"
|
||||
class="ltr:ml-auto rtl:mr-auto mt-1"
|
||||
/>
|
||||
</div>
|
||||
<CardLabels
|
||||
v-if="showLabelsSection"
|
||||
@@ -386,32 +224,5 @@ const deleteConversation = () => {
|
||||
</template>
|
||||
</CardLabels>
|
||||
</div>
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="chat.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="hasUnread"
|
||||
:conversation-labels="chat.labels"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="allowedContextMenuOptions"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@remove-label="onRemoveLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,7 +41,16 @@ const closeContactPanel = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="() => closeContactPanel()"
|
||||
v-on-click-outside="[
|
||||
() => closeContactPanel(),
|
||||
{
|
||||
ignore: [
|
||||
'dialog.ProseMirror-prompt-backdrop',
|
||||
'[data-popover-content]',
|
||||
'[data-popover-backdrop]',
|
||||
],
|
||||
},
|
||||
]"
|
||||
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
|
||||
:class="[
|
||||
{
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { ref } from 'vue';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
|
||||
// Mock @vueuse/core — return reactive refs we can control per test
|
||||
const mockBounding = () => ({
|
||||
top: ref(0),
|
||||
bottom: ref(0),
|
||||
left: ref(0),
|
||||
right: ref(0),
|
||||
width: ref(0),
|
||||
height: ref(0),
|
||||
update: vi.fn(),
|
||||
});
|
||||
|
||||
const triggerBounds = mockBounding();
|
||||
const dropdownBounds = mockBounding();
|
||||
const winWidth = ref(1024);
|
||||
const winHeight = ref(768);
|
||||
|
||||
vi.mock('@vueuse/core', () => {
|
||||
let callCount = 0;
|
||||
return {
|
||||
// First call = trigger, second = dropdown, third = container (if any)
|
||||
useElementBounding: () => {
|
||||
callCount += 1;
|
||||
return callCount % 3 === 1 ? triggerBounds : dropdownBounds;
|
||||
},
|
||||
useWindowSize: () => ({ width: winWidth, height: winHeight }),
|
||||
};
|
||||
});
|
||||
|
||||
const setTrigger = ({ top, bottom, left, right }) => {
|
||||
triggerBounds.top.value = top;
|
||||
triggerBounds.bottom.value = bottom;
|
||||
triggerBounds.left.value = left ?? 100;
|
||||
triggerBounds.right.value = right ?? 200;
|
||||
};
|
||||
|
||||
const setDropdown = ({ width, height }) => {
|
||||
dropdownBounds.width.value = width ?? 200;
|
||||
dropdownBounds.height.value = height;
|
||||
};
|
||||
|
||||
describe('useDropdownPosition', () => {
|
||||
beforeEach(() => {
|
||||
winWidth.value = 1024;
|
||||
winHeight.value = 768;
|
||||
document.body.innerHTML = '<div id="app" dir="ltr"></div>';
|
||||
});
|
||||
|
||||
describe('verticalClass (relative mode)', () => {
|
||||
it('places below when enough space', () => {
|
||||
// Trigger at y=100, dropdown 200px tall
|
||||
// Space below = 768 - 140 = 628 → fits (628 > 216)
|
||||
setTrigger({ top: 100, bottom: 140 });
|
||||
setDropdown({ height: 200 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('top-full mt-2');
|
||||
});
|
||||
|
||||
it('places above when not enough space below but enough above', () => {
|
||||
// Trigger near bottom at y=600
|
||||
// Space below = 128 → doesn't fit. Space above = 600 → fits
|
||||
setTrigger({ top: 600, bottom: 640 });
|
||||
setDropdown({ height: 200 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('bottom-full mb-2');
|
||||
});
|
||||
|
||||
it('picks the side with more space when dropdown fits neither', () => {
|
||||
// Dropdown 500px tall, won't fit above (300) or below (428)
|
||||
// Below has more room → stays below
|
||||
setTrigger({ top: 300, bottom: 340 });
|
||||
setDropdown({ height: 500 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('top-full mt-2');
|
||||
});
|
||||
|
||||
it('picks above when above has more space and neither fits', () => {
|
||||
// Dropdown 600px tall, won't fit above (500) or below (228)
|
||||
// Above has more room → flips above
|
||||
setTrigger({ top: 500, bottom: 540 });
|
||||
setDropdown({ height: 600 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('bottom-full mb-2');
|
||||
});
|
||||
|
||||
it('returns default when disabled', () => {
|
||||
setTrigger({ top: 700, bottom: 740 });
|
||||
setDropdown({ height: 200 });
|
||||
|
||||
const { position } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(false)
|
||||
);
|
||||
expect(position.value.class).toBe('top-full mt-2');
|
||||
expect(position.value.style).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixedPosition', () => {
|
||||
it('places below with correct top and maxHeight', () => {
|
||||
// Trigger at y=140, space below = 628
|
||||
// top = 140 + 8(gap) = 148
|
||||
// maxHeight = 628 - 8(gap) - 16(margin) = 604
|
||||
setTrigger({ top: 100, bottom: 140 });
|
||||
setDropdown({ height: 200, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.top).toBe('148px');
|
||||
expect(fixedPosition.value.style.bottom).toBeUndefined();
|
||||
expect(fixedPosition.value.style.maxHeight).toBe('604px');
|
||||
});
|
||||
|
||||
it('flips above with correct bottom and maxHeight', () => {
|
||||
// Trigger near bottom at y=650, space below = 78 → doesn't fit
|
||||
// Flips above: bottom = 768 - 650 + 8 = 126
|
||||
// maxHeight = 650 - 8 - 16 = 626
|
||||
setTrigger({ top: 650, bottom: 690 });
|
||||
setDropdown({ height: 200, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.bottom).toBe('126px');
|
||||
expect(fixedPosition.value.style.top).toBeUndefined();
|
||||
expect(fixedPosition.value.style.maxHeight).toBe('626px');
|
||||
});
|
||||
|
||||
it('constrains maxHeight to available space on short viewports', () => {
|
||||
// Short viewport (400px), trigger in the middle, dropdown 500px tall
|
||||
// Neither side fits → above (200) > below (160) → places above
|
||||
// maxHeight capped to 200 - 8 - 16 = 176
|
||||
winHeight.value = 400;
|
||||
setTrigger({ top: 200, bottom: 240 });
|
||||
setDropdown({ height: 500, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.bottom).toBeDefined();
|
||||
expect(fixedPosition.value.style.maxHeight).toBe('176px');
|
||||
});
|
||||
|
||||
it('returns defaults when disabled', () => {
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(false)
|
||||
);
|
||||
expect(fixedPosition.value.class).toBe('fixed z-[9999]');
|
||||
expect(fixedPosition.value.style).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('horizontal positioning (fixedPosition)', () => {
|
||||
it('anchors to the right edge by default (align=end, LTR)', () => {
|
||||
// align=end + LTR → anchorLeft=false → uses style.right
|
||||
// right = 1024 - 900 = 124
|
||||
setTrigger({ top: 100, bottom: 140, left: 800, right: 900 });
|
||||
setDropdown({ height: 100, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.right).toBe('124px');
|
||||
});
|
||||
|
||||
it('anchors to the left edge when align=start (LTR)', () => {
|
||||
// align=start + LTR → anchorLeft=true → uses style.left
|
||||
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
|
||||
setDropdown({ height: 100, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true),
|
||||
{ align: 'start' }
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.left).toBe('100px');
|
||||
});
|
||||
|
||||
it('shifts left when dropdown overflows right edge', () => {
|
||||
// Trigger at x=900, dropdown 300px wide → 900+300=1200 > 1024
|
||||
// Falls back to right: 16px (margin)
|
||||
setTrigger({ top: 100, bottom: 140, left: 900, right: 1000 });
|
||||
setDropdown({ height: 100, width: 300 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true),
|
||||
{ align: 'start' }
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.right).toBe('16px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RTL', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app" dir="rtl"></div>';
|
||||
});
|
||||
|
||||
it('flips anchor direction in RTL (align=end anchors left)', () => {
|
||||
// align=end + RTL → anchorLeft=true → uses style.left
|
||||
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
|
||||
setDropdown({ height: 100, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.left).toBe('100px');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,11 @@ const mockStore = createStore({
|
||||
11: { id: 11, channel_type: INBOX_TYPES.API },
|
||||
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
||||
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
||||
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
|
||||
14: {
|
||||
id: 14,
|
||||
channel_type: INBOX_TYPES.TWILIO,
|
||||
voice_enabled: true,
|
||||
},
|
||||
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
|
||||
};
|
||||
return inboxes[id] || null;
|
||||
@@ -211,11 +215,11 @@ describe('useInbox', () => {
|
||||
});
|
||||
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
|
||||
|
||||
// Test Voice
|
||||
// Test Voice (Twilio with voice_enabled)
|
||||
wrapper = mount(createTestComponent(14), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isAVoiceChannel).toBe(true);
|
||||
expect(wrapper.vm.voiceCallEnabled).toBe(true);
|
||||
|
||||
// Test Tiktok
|
||||
wrapper = mount(createTestComponent(15), {
|
||||
@@ -274,7 +278,8 @@ describe('useInbox', () => {
|
||||
'isAnEmailChannel',
|
||||
'isAnInstagramChannel',
|
||||
'isATiktokChannel',
|
||||
'isAVoiceChannel',
|
||||
'voiceCallEnabled',
|
||||
'voiceCallProvider',
|
||||
];
|
||||
|
||||
expectedProperties.forEach(prop => {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { computed, unref, watch } from 'vue';
|
||||
import { useElementBounding, useWindowSize } from '@vueuse/core';
|
||||
|
||||
const FALLBACK_SIZE = 200;
|
||||
const SAFE_MARGIN = 16;
|
||||
const GAP = 8;
|
||||
|
||||
/**
|
||||
* Auto-position a floating element based on available viewport space.
|
||||
*
|
||||
* @param {Ref} triggerRef - Trigger element ref
|
||||
* @param {Ref} dropdownRef - Dropdown/popover element ref
|
||||
* @param {Ref} enabled - Whether to calculate position
|
||||
* @param {Object} options
|
||||
* @param {Ref} [options.container] - Constraining container ref
|
||||
* @param {number} [options.margin=16] - Min distance from viewport/container edges
|
||||
* @param {string} [options.align='end'] - 'start' or 'end' (flips automatically for RTL)
|
||||
*/
|
||||
export function useDropdownPosition(
|
||||
triggerRef,
|
||||
dropdownRef,
|
||||
enabled,
|
||||
{ container = null, margin = SAFE_MARGIN, align = 'end' } = {}
|
||||
) {
|
||||
const trigger = useElementBounding(triggerRef);
|
||||
const dropdown = useElementBounding(dropdownRef);
|
||||
const bounds = useElementBounding(container);
|
||||
const { width: winWidth, height: winHeight } = useWindowSize();
|
||||
|
||||
const isRTL = computed(
|
||||
() => document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl'
|
||||
);
|
||||
|
||||
// Whether to anchor to the left edge of the trigger
|
||||
const anchorLeft = computed(() => (align === 'start') !== isRTL.value);
|
||||
|
||||
const verticalClass = computed(() => {
|
||||
if (!unref(enabled)) return 'top-full mt-2';
|
||||
const dh = dropdown.height.value || FALLBACK_SIZE;
|
||||
const spaceBelow = winHeight.value - trigger.bottom.value;
|
||||
const spaceAbove = trigger.top.value;
|
||||
// Only flip above if it fits there; otherwise stay below (more room or equal)
|
||||
if (spaceBelow >= dh + margin) return 'top-full mt-2';
|
||||
if (spaceAbove >= dh + margin) return 'bottom-full mb-2';
|
||||
return spaceBelow >= spaceAbove ? 'top-full mt-2' : 'bottom-full mb-2';
|
||||
});
|
||||
|
||||
// Relative mode: Tailwind class + style for absolute-in-parent dropdowns
|
||||
const position = computed(() => {
|
||||
if (!unref(enabled)) return { class: 'top-full mt-2', style: {} };
|
||||
|
||||
const dw = dropdown.width.value || FALLBACK_SIZE;
|
||||
const leftBound = container ? bounds.left.value : 0;
|
||||
const rightBound = container ? bounds.right.value : winWidth.value;
|
||||
const style = {};
|
||||
|
||||
if (anchorLeft.value) {
|
||||
const available = rightBound - trigger.left.value;
|
||||
const overflow = dw - available;
|
||||
style.left = overflow > 0 ? `-${overflow}px` : '0px';
|
||||
} else {
|
||||
const available = trigger.right.value - leftBound;
|
||||
const overflow = dw - available;
|
||||
style.right = overflow > 0 ? `-${overflow}px` : '0px';
|
||||
}
|
||||
|
||||
return { class: verticalClass.value, style };
|
||||
});
|
||||
|
||||
// Fixed mode: styles for teleported popovers
|
||||
const fixedPosition = computed(() => {
|
||||
if (!unref(enabled)) return { class: 'fixed z-[9999]', style: {} };
|
||||
|
||||
const dh = dropdown.height.value || FALLBACK_SIZE;
|
||||
const dw = dropdown.width.value || FALLBACK_SIZE;
|
||||
const spaceBelow = winHeight.value - trigger.bottom.value;
|
||||
const style = {};
|
||||
|
||||
// Vertical: prefer below, flip above only if it fits, else pick the larger side
|
||||
const spaceAbove = trigger.top.value;
|
||||
const placeAbove =
|
||||
spaceBelow < dh + margin &&
|
||||
(spaceAbove >= dh + margin || spaceAbove > spaceBelow);
|
||||
|
||||
if (placeAbove) {
|
||||
style.bottom = `${winHeight.value - trigger.top.value + GAP}px`;
|
||||
style.maxHeight = `${spaceAbove - GAP - margin}px`;
|
||||
} else {
|
||||
style.top = `${trigger.bottom.value + GAP}px`;
|
||||
style.maxHeight = `${spaceBelow - GAP - margin}px`;
|
||||
}
|
||||
|
||||
// Horizontal
|
||||
if (anchorLeft.value) {
|
||||
const left = trigger.left.value;
|
||||
if (left + dw > winWidth.value - margin) {
|
||||
style.right = `${margin}px`;
|
||||
} else {
|
||||
style.left = `${Math.max(margin, left)}px`;
|
||||
}
|
||||
} else {
|
||||
const right = winWidth.value - trigger.right.value;
|
||||
if (trigger.right.value - dw < margin) {
|
||||
style.left = `${margin}px`;
|
||||
} else {
|
||||
style.right = `${right}px`;
|
||||
}
|
||||
}
|
||||
|
||||
return { class: 'fixed z-[9999]', style };
|
||||
});
|
||||
|
||||
const updatePosition = () => {
|
||||
trigger.update();
|
||||
dropdown.update();
|
||||
if (container) bounds.update();
|
||||
};
|
||||
|
||||
// Update position when dropdown opens to ensure RTL state is current
|
||||
watch(
|
||||
() => unref(enabled),
|
||||
isEnabled => {
|
||||
if (isEnabled) updatePosition();
|
||||
}
|
||||
);
|
||||
|
||||
return { position, fixedPosition, updatePosition };
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
INBOX_TYPES,
|
||||
isVoiceCallEnabled,
|
||||
getVoiceCallProvider,
|
||||
} from 'dashboard/helper/inbox';
|
||||
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
@@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => {
|
||||
return channelType.value === INBOX_TYPES.TIKTOK;
|
||||
});
|
||||
|
||||
const isAVoiceChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.VOICE;
|
||||
});
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
|
||||
|
||||
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
||||
|
||||
return {
|
||||
inbox,
|
||||
@@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => {
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
isAVoiceChannel,
|
||||
voiceCallEnabled,
|
||||
voiceCallProvider,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -109,11 +109,6 @@ export const FORMATTING = {
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Voice': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::Tiktok': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
|
||||
@@ -45,6 +45,7 @@ export default {
|
||||
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
|
||||
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
|
||||
SMALL_SCREEN_BREAKPOINT: 768,
|
||||
LARGE_SCREEN_BREAKPOINT: 1024,
|
||||
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
|
||||
SNOOZE_OPTIONS: {
|
||||
UNTIL_NEXT_REPLY: 'until_next_reply',
|
||||
|
||||
@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
|
||||
CHATWOOT_V4: 'chatwoot_v4',
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CHANNEL_VOICE: 'channel_voice',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
|
||||
@@ -153,3 +153,8 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
NEXT_CLICKED: 'Year in Review: Next clicked',
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
};
|
||||
}
|
||||
@@ -194,6 +195,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||
};
|
||||
|
||||
onEnrichmentCompleted = () => {
|
||||
this.app.$store.dispatch('accounts/get', { silent: true });
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
messageSchema,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
@@ -273,6 +274,18 @@ export const scrollCursorIntoView = view => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse the current selection to a cursor near its head. Used to override
|
||||
* the default Escape -> selectParentNode behavior which would otherwise keep
|
||||
* the text highlight visible.
|
||||
*
|
||||
* @param {EditorView} view - The ProseMirror EditorView
|
||||
*/
|
||||
export const collapseSelection = view => {
|
||||
const { tr, selection } = view.state;
|
||||
view.dispatch(tr.setSelection(Selection.near(selection.$head)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a transaction that inserts a node into editor at the given position
|
||||
* Has an optional param 'content' to check if the
|
||||
|
||||
@@ -11,9 +11,29 @@ export const INBOX_TYPES = {
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
TIKTOK: 'Channel::Tiktok',
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
export const VOICE_CALL_PROVIDERS = {
|
||||
TWILIO: 'twilio',
|
||||
};
|
||||
|
||||
export const getVoiceCallProvider = inbox => {
|
||||
if (!inbox) return null;
|
||||
|
||||
// Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes.
|
||||
const channelType = inbox.channel_type || inbox.channelType;
|
||||
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
|
||||
|
||||
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
|
||||
return VOICE_CALL_PROVIDERS.TWILIO;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
@@ -30,7 +50,6 @@ const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
|
||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||
@@ -45,7 +64,6 @@ const INBOX_ICON_MAP_LINE = {
|
||||
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
|
||||
[INBOX_TYPES.LINE]: 'i-woot-line',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
||||
[INBOX_TYPES.VOICE]: 'i-woot-voice',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
@@ -58,7 +76,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
|
||||
|
||||
case INBOX_TYPES.TWILIO:
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
case INBOX_TYPES.VOICE:
|
||||
return phoneNumber || '';
|
||||
|
||||
case INBOX_TYPES.EMAIL:
|
||||
@@ -97,9 +114,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
@@ -142,9 +156,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.TIKTOK:
|
||||
return 'brand-tiktok';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
|
||||
action: 'archive',
|
||||
icon: 'i-lucide-archive-restore',
|
||||
},
|
||||
translate: {
|
||||
label:
|
||||
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
|
||||
value: 'translate',
|
||||
action: 'translate',
|
||||
icon: 'i-lucide-languages',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
|
||||
value: 'delete',
|
||||
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
|
||||
};
|
||||
|
||||
export const ARTICLE_MENU_OPTIONS = {
|
||||
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
|
||||
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
|
||||
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
|
||||
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
|
||||
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
|
||||
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
|
||||
};
|
||||
|
||||
export const ARTICLE_TABS = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
stripInlineBase64Images,
|
||||
collapseSelection,
|
||||
} from '../editorHelper';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
@@ -454,6 +455,37 @@ describe('stripInlineBase64Images', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapseSelection', () => {
|
||||
it('collapses a text range to a cursor at its head', () => {
|
||||
const editorView = new EditorView(document.body, {
|
||||
state: createEditorState('Hello world'),
|
||||
});
|
||||
|
||||
// Build a TextSelection via the initial selection's constructor (avoids
|
||||
// importing prosemirror-state, which isn't a direct dep).
|
||||
const { doc, selection } = editorView.state;
|
||||
editorView.dispatch(
|
||||
editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6))
|
||||
);
|
||||
expect(editorView.state.selection.empty).toBe(false);
|
||||
|
||||
collapseSelection(editorView);
|
||||
|
||||
expect(editorView.state.selection.empty).toBe(true);
|
||||
expect(editorView.state.selection.head).toBe(6);
|
||||
});
|
||||
|
||||
it('leaves an already-collapsed selection as a cursor', () => {
|
||||
const editorView = new EditorView(document.body, {
|
||||
state: createEditorState('Hi'),
|
||||
});
|
||||
|
||||
collapseSelection(editorView);
|
||||
|
||||
expect(editorView.state.selection.empty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAtCursor', () => {
|
||||
it('should return undefined if editorView is not provided', () => {
|
||||
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"BROWSER_LANGUAGE": "Browser Language",
|
||||
"MAIL_SUBJECT": "Email Subject",
|
||||
"COUNTRY_NAME": "Country",
|
||||
"COMPANY_NAME": "Company",
|
||||
"REFERER_LINK": "Referrer Link",
|
||||
"ASSIGNEE_NAME": "Assignee",
|
||||
"TEAM_NAME": "Team",
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
|
||||
"SHOW_QUOTED_TEXT": "Show Quoted Text",
|
||||
"MESSAGE_READ": "Read",
|
||||
"SENDING": "Sending"
|
||||
"SENDING": "Sending",
|
||||
"UNREAD_COUNT_OVERFLOW": "9+"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +387,7 @@
|
||||
"IDENTIFIER": "Identifier",
|
||||
"COUNTRY": "Country",
|
||||
"CITY": "City",
|
||||
"COMPANY": "Company",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY": "Last activity",
|
||||
"REFERER_LINK": "Referer link",
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "Show labels",
|
||||
"HIDE_LABELS": "Hide labels"
|
||||
"HIDE_LABELS": "Hide labels",
|
||||
"LABELS_COUNT": "{count} labels"
|
||||
},
|
||||
"VOICE_CALL": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
|
||||
@@ -525,6 +525,7 @@
|
||||
"PUBLISH": "Publish",
|
||||
"DRAFT": "Draft",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "Translate",
|
||||
"DELETE": "Delete"
|
||||
},
|
||||
"STATUS": {
|
||||
@@ -579,6 +580,41 @@
|
||||
"TITLE": "There are no articles in this category",
|
||||
"SUBTITLE": "Articles in this category will appear here"
|
||||
}
|
||||
},
|
||||
"BULK_TRANSLATE": {
|
||||
"TITLE": "Translate article | Translate {count} articles",
|
||||
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
|
||||
"LOCALE_LABEL": "Target language",
|
||||
"LOCALE_PLACEHOLDER": "Select a language",
|
||||
"CATEGORY_LABEL": "Target category",
|
||||
"CATEGORY_PLACEHOLDER": "Select a category",
|
||||
"OPTIONAL": "(optional)",
|
||||
"CONFIRM": "Translate",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"SELECTED_COUNT": "{count} selected",
|
||||
"CLEAR_SELECTION": "Clear selection",
|
||||
"TRANSLATE_BUTTON": "Translate",
|
||||
"CONFIRM_OVERWRITE": "Overwrite and translate",
|
||||
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
|
||||
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
|
||||
"ERROR_MESSAGE": "Failed to start translation. Please try again."
|
||||
}
|
||||
},
|
||||
"BULK_ACTIONS": {
|
||||
"PUBLISH": "Publish",
|
||||
"DRAFT": "Draft",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "Translate",
|
||||
"DELETE": "Delete",
|
||||
"STATUS_SUCCESS": "Articles updated successfully",
|
||||
"STATUS_ERROR": "Failed to update articles",
|
||||
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
|
||||
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
|
||||
"DELETE_CONFIRM": "Delete",
|
||||
"DELETE_SUCCESS": "Articles deleted successfully",
|
||||
"DELETE_ERROR": "Failed to delete articles"
|
||||
}
|
||||
},
|
||||
"CATEGORY_PAGE": {
|
||||
|
||||
@@ -636,7 +636,17 @@
|
||||
"WIDGET_BUILDER": "Widget Builder",
|
||||
"BOT_CONFIGURATION": "Bot Configuration",
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "CSAT"
|
||||
"CSAT": "CSAT",
|
||||
"VOICE": "Voice"
|
||||
},
|
||||
"VOICE_CONFIGURATION": {
|
||||
"ENABLE_VOICE": {
|
||||
"LABEL": "Enable Voice Calling",
|
||||
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
|
||||
},
|
||||
"CREDENTIALS": {
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
"WIDGET_FEATURES": "Widget features",
|
||||
@@ -745,6 +755,7 @@
|
||||
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
|
||||
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
||||
|
||||
@@ -39,6 +39,7 @@ import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
@@ -83,5 +84,6 @@ export default {
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -51,10 +51,14 @@
|
||||
},
|
||||
"DISABLE": {
|
||||
"TITLE": "Disable Two-Factor Authentication",
|
||||
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
|
||||
"DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.",
|
||||
"PASSWORD": "Password",
|
||||
"OTP_CODE": "Verification Code",
|
||||
"OTP_CODE_PLACEHOLDER": "000000",
|
||||
"BACKUP_CODE": "Backup Code",
|
||||
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
|
||||
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
|
||||
"USE_OTP_CODE": "Use a verification code from your authenticator app",
|
||||
"CONFIRM": "Disable 2FA",
|
||||
"CANCEL": "Cancel",
|
||||
"SUCCESS": "Two-factor authentication has been disabled",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"ONBOARDING_NEXT": {
|
||||
"GREETING": "Hello {name}!",
|
||||
"SUBTITLE": "Please review the following details",
|
||||
"YOUR_DETAILS": "Your details",
|
||||
"COMPANY_DETAILS": "Company details",
|
||||
"FIELDS": {
|
||||
"EMAIL": "Email",
|
||||
"YOUR_ROLE": "Your Role",
|
||||
"WEBSITE": "Website",
|
||||
"LANGUAGE": "Language",
|
||||
"TIMEZONE": "Timezone",
|
||||
"COMPANY_SIZE": "Company Size",
|
||||
"INDUSTRY": "Industry",
|
||||
"REFERRAL_SOURCE": "Where did you find us?"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"SELECT_ROLE": "Select your role",
|
||||
"ENTER_WEBSITE": "www.example.com",
|
||||
"SELECT_LANGUAGE": "Select language",
|
||||
"SELECT_TIMEZONE": "Select timezone",
|
||||
"SELECT_COMPANY_SIZE": "Select company size",
|
||||
"SELECT_INDUSTRY": "Select industry",
|
||||
"SELECT_REFERRAL_SOURCE": "Select source"
|
||||
},
|
||||
"EMAIL_VERIFIED": "Email verified",
|
||||
"SETTING_UP": "Setting up your account...",
|
||||
"CONTINUE": "Continue",
|
||||
"SAVING": "Saving...",
|
||||
"VALIDATION_ERROR": "Please fill in all required fields",
|
||||
"SUCCESS": "Details saved successfully",
|
||||
"ERROR": "Could not save details. Please try again."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
import {
|
||||
isAConversationRoute,
|
||||
isAInboxViewRoute,
|
||||
getConversationDashboardRoute,
|
||||
} from 'dashboard/helper/routeHelpers';
|
||||
|
||||
const props = defineProps({
|
||||
contact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'deleted']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const confirmMessage = computed(
|
||||
() => `${t('DELETE_CONTACT.CONFIRM.MESSAGE')} ${props.contact.name}?`
|
||||
);
|
||||
|
||||
const onDelete = async hide => {
|
||||
try {
|
||||
await store.dispatch('contacts/delete', props.contact.id);
|
||||
useAlert(t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
|
||||
hide();
|
||||
emit('deleted');
|
||||
emit('close');
|
||||
|
||||
if (isAConversationRoute(route.name)) {
|
||||
router.push({ name: getConversationDashboardRoute(route.name) });
|
||||
} else if (isAInboxViewRoute(route.name)) {
|
||||
router.push({ name: 'inbox_view' });
|
||||
} else if (route.name !== 'contacts_dashboard') {
|
||||
router.push({ name: 'contacts_dashboard' });
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(error.message || t('DELETE_CONTACT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover @hide="$emit('close')">
|
||||
<slot name="trigger" />
|
||||
<template #content="{ hide }">
|
||||
<div class="w-full md:w-80 p-6 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ $t('DELETE_CONTACT.CONFIRM.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ confirmMessage }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
faded
|
||||
slate
|
||||
sm
|
||||
:label="$t('DELETE_CONTACT.CONFIRM.NO')"
|
||||
@click="hide"
|
||||
/>
|
||||
<Button
|
||||
ruby
|
||||
sm
|
||||
:label="$t('DELETE_CONTACT.CONFIRM.YES')"
|
||||
:is-loading="uiFlags.isDeleting"
|
||||
:disabled="uiFlags.isDeleting"
|
||||
@click="onDelete(hide)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -5,8 +5,8 @@ import { useStore } from 'vuex';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
|
||||
@@ -23,7 +23,6 @@ const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isSearching = ref(false);
|
||||
const searchResults = ref([]);
|
||||
|
||||
@@ -35,21 +34,6 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
|
||||
const onClose = () => {
|
||||
close();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onContactSearch = async query => {
|
||||
isSearching.value = true;
|
||||
searchResults.value = [];
|
||||
@@ -68,7 +52,7 @@ const onContactSearch = async query => {
|
||||
}
|
||||
};
|
||||
|
||||
const onMergeContacts = async parentContactId => {
|
||||
const onMergeContacts = async (parentContactId, hide) => {
|
||||
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
|
||||
try {
|
||||
await store.dispatch('contacts/merge', {
|
||||
@@ -76,7 +60,7 @@ const onMergeContacts = async parentContactId => {
|
||||
parentId: parentContactId,
|
||||
});
|
||||
useAlert(t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
|
||||
close();
|
||||
hide();
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
useAlert(t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
|
||||
@@ -85,24 +69,29 @@ const onMergeContacts = async parentContactId => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
width="2xl"
|
||||
:title="$t('MERGE_CONTACTS.TITLE')"
|
||||
:description="$t('MERGE_CONTACTS.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<MergeContact
|
||||
:key="primaryContact.id"
|
||||
:primary-contact="primaryContact"
|
||||
:is-searching="isSearching"
|
||||
:is-merging="uiFlags.isMerging"
|
||||
:search-results="searchResults"
|
||||
@search="onContactSearch"
|
||||
@cancel="onClose"
|
||||
@submit="onMergeContacts"
|
||||
/>
|
||||
</Dialog>
|
||||
<Popover @hide="$emit('close')">
|
||||
<slot name="trigger" />
|
||||
<template #content="{ hide }">
|
||||
<div class="w-full md:w-96 p-6 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ $t('MERGE_CONTACTS.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ $t('MERGE_CONTACTS.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<MergeContact
|
||||
:key="primaryContact.id"
|
||||
:primary-contact="primaryContact"
|
||||
:is-searching="isSearching"
|
||||
:is-merging="uiFlags.isMerging"
|
||||
:search-results="searchResults"
|
||||
@search="onContactSearch"
|
||||
@cancel="hide"
|
||||
@submit="id => onMergeContacts(id, hide)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
@@ -49,7 +49,6 @@ const navigateTo = computed(() => {
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -53,6 +53,14 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
attribute_type: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'company_name',
|
||||
attributeI18nKey: 'COMPANY',
|
||||
inputType: 'plain_text',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
attributeI18nKey: 'CREATED_AT',
|
||||
@@ -124,6 +132,10 @@ export const filterAttributeGroups = [
|
||||
key: 'city',
|
||||
i18nKey: 'CITY',
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
i18nKey: 'COMPANY',
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
i18nKey: 'CREATED_AT',
|
||||
|
||||
@@ -1,49 +1,131 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
isOnFoldersView,
|
||||
} from 'dashboard/store/modules/conversations/helpers/actionHelpers';
|
||||
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConversationCard,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
contactId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
conversations() {
|
||||
return this.$store.getters['contactConversations/getContactConversation'](
|
||||
this.contactId
|
||||
);
|
||||
},
|
||||
previousConversations() {
|
||||
return this.conversations.filter(
|
||||
conversation => conversation.id !== Number(this.conversationId)
|
||||
);
|
||||
},
|
||||
...mapGetters({
|
||||
uiFlags: 'contactConversations/getUIFlags',
|
||||
}),
|
||||
},
|
||||
watch: {
|
||||
contactId(newContactId, prevContactId) {
|
||||
if (newContactId && newContactId !== prevContactId) {
|
||||
this.$store.dispatch('contactConversations/get', newContactId);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('contactConversations/get', this.contactId);
|
||||
},
|
||||
const props = defineProps({
|
||||
contactId: { type: [String, Number], required: true },
|
||||
conversationId: { type: [String, Number], required: true },
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
||||
|
||||
const contactGetter = useMapGetter('contacts/getContact');
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const showInboxName = computed(
|
||||
() => !activeInbox.value && inboxesList.value.length > 1
|
||||
);
|
||||
|
||||
const contactConversationGetter = useMapGetter(
|
||||
'contactConversations/getContactConversation'
|
||||
);
|
||||
const conversations = computed(() =>
|
||||
contactConversationGetter.value(props.contactId)
|
||||
);
|
||||
|
||||
const previousConversations = computed(() =>
|
||||
conversations.value.filter(c => c.id !== Number(props.conversationId))
|
||||
);
|
||||
|
||||
const activeContextChat = ref(null);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
const buildConversationUrl = conversationId => {
|
||||
const {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||||
name,
|
||||
} = route;
|
||||
|
||||
let conversationType = '';
|
||||
if (isOnMentionsView({ route: { name } })) {
|
||||
conversationType = 'mention';
|
||||
} else if (isOnUnattendedView({ route: { name } })) {
|
||||
conversationType = 'unattended';
|
||||
}
|
||||
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId,
|
||||
activeInbox: inboxId,
|
||||
id: conversationId,
|
||||
label,
|
||||
teamId,
|
||||
foldersId: isOnFoldersView({ route: { name } }) ? route.params.id : 0,
|
||||
conversationType,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
if (!activeContextChat.value) return '';
|
||||
return buildConversationUrl(activeContextChat.value.id);
|
||||
});
|
||||
|
||||
const onCardClick = (conversation, e) => {
|
||||
const path = buildConversationUrl(conversation.id);
|
||||
if (!path) return;
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const openContextMenu = (conversation, e) => {
|
||||
e.preventDefault();
|
||||
activeContextChat.value = conversation;
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
activeContextChat.value = null;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.contactId,
|
||||
(newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
showContextMenu.value = false;
|
||||
activeContextChat.value = null;
|
||||
store.dispatch('contactConversations/get', newId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('contactConversations/get', props.contactId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,18 +135,43 @@ export default {
|
||||
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="contact-conversation--list">
|
||||
<div
|
||||
v-else
|
||||
class="contact-conversation--list [&>.conversation:last-child]:!border-b-0 [&>.conversation:last-child:hover]:!border-b-0 [&>.conversation:last-child]:!rounded-b-lg"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in previousConversations"
|
||||
:key="conversation.id"
|
||||
:chat="conversation"
|
||||
:hide-inbox-name="false"
|
||||
:current-contact="contactGetter(conversation.meta?.sender?.id) || {}"
|
||||
:assignee="conversation.meta?.assignee || {}"
|
||||
:inbox="inboxGetter(conversation.inbox_id) || {}"
|
||||
:is-active-chat="currentChat.id === conversation.id"
|
||||
:show-inbox-name="showInboxName"
|
||||
hide-thumbnail
|
||||
enable-context-menu
|
||||
compact
|
||||
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
|
||||
@click="onCardClick(conversation, $event)"
|
||||
@contextmenu="openContextMenu(conversation, $event)"
|
||||
/>
|
||||
</div>
|
||||
<ContextMenu
|
||||
v-if="showContextMenu && activeContextChat"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="activeContextChat.status"
|
||||
:inbox-id="activeContextChat.inbox_id"
|
||||
:priority="activeContextChat.priority"
|
||||
:chat-id="activeContextChat.id"
|
||||
:has-unread-messages="activeContextChat.unread_count > 0"
|
||||
:conversation-labels="activeContextChat.labels"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="['open-new-tab', 'copy-link']"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center py-5">
|
||||
<Spinner />
|
||||
|
||||
@@ -125,7 +125,7 @@ export default {
|
||||
set(priorityItem) {
|
||||
const conversationId = this.currentChat.id;
|
||||
const oldValue = this.currentChat?.priority;
|
||||
const priority = priorityItem ? priorityItem.id : null;
|
||||
const priority = priorityItem.id;
|
||||
|
||||
this.$store.dispatch('setCurrentChatPriority', {
|
||||
priority,
|
||||
@@ -203,7 +203,9 @@ export default {
|
||||
this.assignedPriority &&
|
||||
this.assignedPriority.id === selectedPriorityItem.id;
|
||||
|
||||
this.assignedPriority = isSamePriority ? null : selectedPriorityItem;
|
||||
this.assignedPriority = isSamePriority
|
||||
? this.priorityOptions[0]
|
||||
: selectedPriorityItem;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,19 +12,12 @@ import Avatar from 'next/avatar/Avatar.vue';
|
||||
import SocialIcons from './SocialIcons.vue';
|
||||
import EditContact from './EditContact.vue';
|
||||
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
|
||||
import ContactDeleteModal from 'dashboard/modules/contact/ContactDeleteModal.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
|
||||
import {
|
||||
isAConversationRoute,
|
||||
isAInboxViewRoute,
|
||||
getConversationDashboardRoute,
|
||||
} from '../../../../helper/routeHelpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
@@ -34,6 +27,7 @@ export default {
|
||||
ComposeConversation,
|
||||
SocialIcons,
|
||||
ContactMergeModal,
|
||||
ContactDeleteModal,
|
||||
VoiceCallButton,
|
||||
InlineInput,
|
||||
},
|
||||
@@ -57,7 +51,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showEditModal: false,
|
||||
showDeleteModal: false,
|
||||
isEditingName: false,
|
||||
editName: '',
|
||||
};
|
||||
@@ -99,10 +92,6 @@ export default {
|
||||
telegram,
|
||||
};
|
||||
},
|
||||
// Delete Modal
|
||||
confirmDeleteMessage() {
|
||||
return ` ${this.contact.name}?`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'contact.id': {
|
||||
@@ -117,28 +106,6 @@ export default {
|
||||
toggleEditModal() {
|
||||
this.showEditModal = !this.showEditModal;
|
||||
},
|
||||
openComposeConversationModal(toggleFn) {
|
||||
toggleFn();
|
||||
// Flag to prevent triggering drag n drop,
|
||||
// When compose modal is active
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
},
|
||||
closeComposeConversationModal() {
|
||||
// Flag to enable drag n drop,
|
||||
// When compose modal is closed
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
},
|
||||
toggleDeleteModal() {
|
||||
this.showDeleteModal = !this.showDeleteModal;
|
||||
},
|
||||
confirmDeletion() {
|
||||
this.deleteContact(this.contact);
|
||||
this.closeDelete();
|
||||
},
|
||||
closeDelete() {
|
||||
this.showDeleteModal = false;
|
||||
this.showEditModal = false;
|
||||
},
|
||||
findCountryFlag(countryCode, cityAndCountry) {
|
||||
try {
|
||||
if (!countryCode) {
|
||||
@@ -151,36 +118,6 @@ export default {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
async deleteContact({ id }) {
|
||||
try {
|
||||
await this.$store.dispatch('contacts/delete', id);
|
||||
this.$emit('panelClose');
|
||||
useAlert(this.$t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
|
||||
|
||||
if (isAConversationRoute(this.$route.name)) {
|
||||
this.$router.push({
|
||||
name: getConversationDashboardRoute(this.$route.name),
|
||||
});
|
||||
} else if (isAInboxViewRoute(this.$route.name)) {
|
||||
this.$router.push({
|
||||
name: 'inbox_view',
|
||||
});
|
||||
} else if (this.$route.name !== 'contacts_dashboard') {
|
||||
this.$router.push({
|
||||
name: 'contacts_dashboard',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message
|
||||
? error.message
|
||||
: this.$t('DELETE_CONTACT.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
openMergeModal() {
|
||||
this.$refs.mergeModal?.open();
|
||||
},
|
||||
startEditingName() {
|
||||
this.editName = this.contact.name || '';
|
||||
this.isEditingName = true;
|
||||
@@ -354,19 +291,14 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center w-full mt-0.5 gap-2">
|
||||
<ComposeConversation
|
||||
:contact-id="String(contact.id)"
|
||||
is-modal
|
||||
@close="closeComposeConversationModal"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<ComposeConversation :contact-id="String(contact.id)">
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="openComposeConversationModal(toggle)"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
@@ -387,45 +319,41 @@ export default {
|
||||
sm
|
||||
@click="toggleEditModal"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
|
||||
icon="i-ph-arrows-merge"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:disabled="uiFlags.isMerging"
|
||||
@click="openMergeModal"
|
||||
/>
|
||||
<NextButton
|
||||
<ContactMergeModal :primary-contact="contact">
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
|
||||
icon="i-ph-arrows-merge"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:disabled="uiFlags.isMerging"
|
||||
/>
|
||||
</template>
|
||||
</ContactMergeModal>
|
||||
<ContactDeleteModal
|
||||
v-if="isAdmin"
|
||||
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
|
||||
icon="i-ph-trash"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
ruby
|
||||
:disabled="uiFlags.isDeleting"
|
||||
@click="toggleDeleteModal"
|
||||
/>
|
||||
:contact="contact"
|
||||
@deleted="$emit('panelClose')"
|
||||
>
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
|
||||
icon="i-ph-trash"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
ruby
|
||||
:disabled="uiFlags.isDeleting"
|
||||
/>
|
||||
</template>
|
||||
</ContactDeleteModal>
|
||||
</div>
|
||||
<EditContact
|
||||
v-if="showEditModal"
|
||||
:show="showEditModal"
|
||||
:contact="contact"
|
||||
@cancel="toggleEditModal"
|
||||
/>
|
||||
<ContactMergeModal ref="mergeModal" :primary-contact="contact" />
|
||||
</div>
|
||||
<woot-delete-modal
|
||||
v-if="showDeleteModal"
|
||||
v-model:show="showDeleteModal"
|
||||
:on-close="closeDelete"
|
||||
:on-confirm="confirmDeletion"
|
||||
:title="$t('DELETE_CONTACT.CONFIRM.TITLE')"
|
||||
:message="$t('DELETE_CONTACT.CONFIRM.MESSAGE')"
|
||||
:message-value="confirmDeleteMessage"
|
||||
:confirm-text="$t('DELETE_CONTACT.CONFIRM.YES')"
|
||||
:reject-text="$t('DELETE_CONTACT.CONFIRM.NO')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,74 +1,70 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import ContactForm from './ContactForm.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContactForm,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['cancel', 'update:show'],
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'contacts/getUIFlags',
|
||||
}),
|
||||
localShow: {
|
||||
get() {
|
||||
return this.show;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:show', value);
|
||||
},
|
||||
},
|
||||
},
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
contact: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
methods: {
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
onSuccess() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
async onSubmit(contactItem) {
|
||||
await this.$store.dispatch('contacts/update', contactItem);
|
||||
await this.$store.dispatch(
|
||||
'contacts/fetchContactableInbox',
|
||||
this.contact.id
|
||||
);
|
||||
},
|
||||
},
|
||||
const emit = defineEmits(['cancel']);
|
||||
|
||||
const store = useStore();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const onCancel = () => emit('cancel');
|
||||
|
||||
const onSubmit = async contactItem => {
|
||||
await store.dispatch('contacts/update', contactItem);
|
||||
await store.dispatch('contacts/fetchContactableInbox', props.contact.id);
|
||||
};
|
||||
|
||||
// Restore Escape-to-close behavior that was provided by woot-modal before
|
||||
// this drawer was reimplemented as a plain fixed panel.
|
||||
useKeyboardEvents({
|
||||
Escape: {
|
||||
action: () => {
|
||||
if (props.show) onCancel();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal
|
||||
v-model:show="localShow"
|
||||
:on-close="onCancel"
|
||||
modal-type="right-aligned"
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full opacity-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-to-class="ltr:translate-x-[30%] rtl:-translate-x-[30%] opacity-0"
|
||||
>
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<woot-modal-header
|
||||
:header-title="`${$t('EDIT_CONTACT.TITLE')} - ${
|
||||
contact.name || contact.email
|
||||
}`"
|
||||
:header-content="$t('EDIT_CONTACT.DESC')"
|
||||
/>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-y-0 ltr:right-0 rtl:left-0 z-50 flex flex-col w-[30rem] max-w-full h-full bg-n-surface-2 ltr:border-l rtl:border-r border-n-weak shadow-lg overflow-auto"
|
||||
>
|
||||
<div class="flex items-center justify-between px-8 pt-8 pb-2">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-n-slate-12 mb-1">
|
||||
{{
|
||||
`${$t('EDIT_CONTACT.TITLE')} - ${contact.name || contact.email}`
|
||||
}}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11 mb-0">
|
||||
{{ $t('EDIT_CONTACT.DESC') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="i-lucide-x" slate ghost sm @click="onCancel" />
|
||||
</div>
|
||||
<ContactForm
|
||||
:contact="contact"
|
||||
:in-progress="uiFlags.isUpdating"
|
||||
:on-submit="onSubmit"
|
||||
@success="onSuccess"
|
||||
@success="onCancel"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { routes as captainRoutes } from './captain/captain.routes';
|
||||
import AppContainer from './Dashboard.vue';
|
||||
import Suspended from './suspended/Index.vue';
|
||||
import NoAccounts from './noAccounts/Index.vue';
|
||||
import OnboardingAccountDetails from './onboarding/Index.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -31,6 +32,14 @@ export default {
|
||||
...campaignsRoutes.routes,
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/onboarding'),
|
||||
name: 'onboarding_account_details',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'custom_role'],
|
||||
},
|
||||
component: OnboardingAccountDetails,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/suspended'),
|
||||
name: 'account_suspended',
|
||||
|
||||
+1
@@ -119,6 +119,7 @@ watch(
|
||||
:is-category-articles="isCategoryArticles"
|
||||
@page-change="onPageChange"
|
||||
@fetch-portal="fetchPortalAndItsCategories"
|
||||
@refresh-articles="fetchArticles"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import OnboardingLayout from './OnboardingLayout.vue';
|
||||
import OnboardingSection from './OnboardingSection.vue';
|
||||
import OnboardingFormRow from './OnboardingFormRow.vue';
|
||||
import OnboardingFormSelect from './OnboardingFormSelect.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import {
|
||||
COMPANY_SIZE_OPTIONS,
|
||||
INDUSTRY_OPTIONS,
|
||||
REFERRAL_SOURCE_OPTIONS,
|
||||
USER_ROLE_OPTIONS,
|
||||
} from './constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId, currentAccount, updateAccount } = useAccount();
|
||||
const { enabledLanguages } = useConfig();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const userRole = ref('');
|
||||
const website = ref('');
|
||||
const locale = ref('');
|
||||
const timezone = ref('');
|
||||
const companySize = ref('');
|
||||
const industry = ref('');
|
||||
const referralSource = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
const isEditingWebsite = ref(false);
|
||||
const websiteInput = ref(null);
|
||||
const showErrorOnFields = ref(false);
|
||||
|
||||
const validationRules = {
|
||||
userRole: {},
|
||||
website: {},
|
||||
locale: {},
|
||||
timezone: {},
|
||||
companySize: {},
|
||||
industry: {},
|
||||
referralSource: {},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, {
|
||||
userRole,
|
||||
website,
|
||||
locale,
|
||||
timezone,
|
||||
companySize,
|
||||
industry,
|
||||
referralSource,
|
||||
});
|
||||
|
||||
const userName = computed(() => currentUser.value?.name || '');
|
||||
const userEmail = computed(() => currentUser.value?.email || '');
|
||||
const accountName = computed(() => currentAccount.value?.name || '');
|
||||
const enrichmentTimedOut = ref(false);
|
||||
const isEnriching = computed(
|
||||
() =>
|
||||
!enrichmentTimedOut.value &&
|
||||
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
||||
);
|
||||
const companyLogo = computed(() => {
|
||||
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
|
||||
if (!logos?.length) return '';
|
||||
const square = logos.find(l => l.resolution?.aspect_ratio === 1);
|
||||
return (square || logos[0])?.url || '';
|
||||
});
|
||||
|
||||
const languageOptions = computed(() => {
|
||||
const langs = [...(enabledLanguages || [])];
|
||||
return langs
|
||||
.sort((a, b) => a.iso_639_1_code.localeCompare(b.iso_639_1_code))
|
||||
.map(l => ({ value: l.iso_639_1_code, label: l.name }));
|
||||
});
|
||||
|
||||
const timezoneOptions = computed(() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone').map(tz => ({
|
||||
value: tz,
|
||||
label: tz.replace(/_/g, ' '),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Best-effort match browser language to enabled Chatwoot locales.
|
||||
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
|
||||
// falls back to account locale or 'en'.
|
||||
const detectBestLocale = () => {
|
||||
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
|
||||
const browserLang = navigator.language?.replace('-', '_');
|
||||
const accountLocale = currentAccount.value?.locale || 'en';
|
||||
if (!browserLang) return accountLocale;
|
||||
|
||||
if (codes.includes(browserLang)) return browserLang;
|
||||
const base = browserLang.split('_')[0];
|
||||
if (codes.includes(base)) return base;
|
||||
|
||||
return accountLocale;
|
||||
};
|
||||
|
||||
// Snapshot of auto-populated values to detect user edits at submit time
|
||||
const initialValues = ref({});
|
||||
|
||||
const snapshotInitialValues = () => {
|
||||
initialValues.value = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
};
|
||||
|
||||
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
||||
// populates untouched fields without clobbering user edits.
|
||||
const populateFormFields = () => {
|
||||
const account = currentAccount.value;
|
||||
const attrs = account?.custom_attributes || {};
|
||||
const brandInfo = attrs.brand_info;
|
||||
|
||||
if (!locale.value) locale.value = detectBestLocale();
|
||||
if (!website.value) {
|
||||
website.value = account?.domain || brandInfo?.domain || '';
|
||||
}
|
||||
if (!timezone.value) {
|
||||
timezone.value =
|
||||
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
}
|
||||
if (!companySize.value) companySize.value = attrs.company_size || '';
|
||||
if (!industry.value) {
|
||||
industry.value =
|
||||
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
|
||||
}
|
||||
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
|
||||
|
||||
snapshotInitialValues();
|
||||
};
|
||||
|
||||
let enrichmentTimer = null;
|
||||
|
||||
const startEnrichmentTimer = () => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
enrichmentTimer = setTimeout(() => {
|
||||
enrichmentTimedOut.value = true;
|
||||
populateFormFields();
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
populateFormFields();
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
|
||||
if (isEnriching.value) startEnrichmentTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
});
|
||||
|
||||
watch(isEnriching, newVal => {
|
||||
if (newVal) {
|
||||
startEnrichmentTimer();
|
||||
} else {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
populateFormFields();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-populate when account data arrives after mount, or when brand_info
|
||||
// appears after enrichment. populateFormFields is idempotent so this is safe.
|
||||
watch(
|
||||
() => currentAccount.value?.custom_attributes,
|
||||
() => populateFormFields()
|
||||
);
|
||||
|
||||
const enableWebsiteEditing = () => {
|
||||
isEditingWebsite.value = true;
|
||||
nextTick(() => websiteInput.value?.focus());
|
||||
};
|
||||
|
||||
const handleWebsiteEnter = () => {
|
||||
websiteInput.value?.blur();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Block submit while enrichment is still running so users can't bypass
|
||||
// the form with empty values — the controller would otherwise clear
|
||||
// onboarding_step and persist incomplete data.
|
||||
if (isEnriching.value) return;
|
||||
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
useAlert(t('ONBOARDING_NEXT.VALIDATION_ERROR'));
|
||||
showErrorOnFields.value = true;
|
||||
setTimeout(() => {
|
||||
showErrorOnFields.value = false;
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await updateAccount({
|
||||
name: accountName.value,
|
||||
locale: locale.value,
|
||||
domain: website.value,
|
||||
industry: industry.value,
|
||||
company_size: companySize.value,
|
||||
timezone: timezone.value,
|
||||
referral_source: referralSource.value,
|
||||
user_role: userRole.value,
|
||||
});
|
||||
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
||||
has_enriched_data: Boolean(
|
||||
currentAccount.value?.custom_attributes?.brand_info
|
||||
),
|
||||
fields_changed: Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key),
|
||||
user_role: userRole.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
referral_source: referralSource.value,
|
||||
});
|
||||
|
||||
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
|
||||
store.commit('RESET_ONBOARDING', accountId.value);
|
||||
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
|
||||
} catch {
|
||||
useAlert(t('ONBOARDING_NEXT.ERROR'));
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<OnboardingLayout
|
||||
:greeting="t('ONBOARDING_NEXT.GREETING', { name: userName })"
|
||||
:subtitle="t('ONBOARDING_NEXT.SUBTITLE')"
|
||||
:continue-label="t('ONBOARDING_NEXT.CONTINUE')"
|
||||
:is-loading="isSubmitting"
|
||||
:disabled="isEnriching"
|
||||
>
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_NEXT.YOUR_DETAILS')"
|
||||
icon="i-lucide-user"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-3 py-3">
|
||||
<Avatar :name="userName" :size="16" rounded-full />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ userName }}
|
||||
</span>
|
||||
</div>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.EMAIL')"
|
||||
icon="i-lucide-mail"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
<span class="text-sm text-n-slate-12">{{ userEmail }}</span>
|
||||
<Icon
|
||||
v-tooltip="t('ONBOARDING_NEXT.EMAIL_VERIFIED')"
|
||||
icon="i-lucide-circle-check"
|
||||
class="size-4 text-n-teal-11 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.YOUR_ROLE')"
|
||||
icon="i-lucide-user"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="userRole"
|
||||
:has-error="showErrorOnFields && v$.userRole.$error"
|
||||
:options="USER_ROLE_OPTIONS"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_ROLE')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
</OnboardingSection>
|
||||
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_NEXT.COMPANY_DETAILS')"
|
||||
icon="i-lucide-briefcase-business"
|
||||
>
|
||||
<div
|
||||
v-if="isEnriching"
|
||||
class="flex items-center justify-center gap-3 py-8"
|
||||
>
|
||||
<Spinner :size="16" class="text-n-blue-10" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('ONBOARDING_NEXT.SETTING_UP') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 px-3 py-3">
|
||||
<img
|
||||
v-if="companyLogo"
|
||||
:src="companyLogo"
|
||||
:alt="accountName"
|
||||
class="size-4 object-contain"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ accountName }}
|
||||
</span>
|
||||
</div>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.WEBSITE')"
|
||||
icon="i-lucide-globe"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<InlineInput
|
||||
ref="websiteInput"
|
||||
v-model="website"
|
||||
:readonly="!isEditingWebsite"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.ENTER_WEBSITE')"
|
||||
:custom-input-class="[
|
||||
'w-auto text-end px-1 py-0.5 -my-0.5 mx-0 placeholder:text-n-slate-9 rounded',
|
||||
{ 'animate-shake': showErrorOnFields && v$.website.$error },
|
||||
]"
|
||||
@enter-press="handleWebsiteEnter"
|
||||
@blur="isEditingWebsite = false"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
icon="i-lucide-pencil"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
@click="enableWebsiteEditing"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.LANGUAGE')"
|
||||
icon="i-lucide-languages"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="locale"
|
||||
:has-error="showErrorOnFields && v$.locale.$error"
|
||||
:options="languageOptions"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.TIMEZONE')"
|
||||
icon="i-lucide-clock"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="timezone"
|
||||
:has-error="showErrorOnFields && v$.timezone.$error"
|
||||
:options="timezoneOptions"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_TIMEZONE')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.INDUSTRY')"
|
||||
icon="i-lucide-factory"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="industry"
|
||||
:has-error="showErrorOnFields && v$.industry.$error"
|
||||
:options="INDUSTRY_OPTIONS"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_INDUSTRY')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.COMPANY_SIZE')"
|
||||
icon="i-lucide-users"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="companySize"
|
||||
:has-error="showErrorOnFields && v$.companySize.$error"
|
||||
:options="COMPANY_SIZE_OPTIONS"
|
||||
:placeholder="
|
||||
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_COMPANY_SIZE')
|
||||
"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.REFERRAL_SOURCE')"
|
||||
icon="i-lucide-megaphone"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="referralSource"
|
||||
:has-error="showErrorOnFields && v$.referralSource.$error"
|
||||
:options="REFERRAL_SOURCE_OPTIONS"
|
||||
:placeholder="
|
||||
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_REFERRAL_SOURCE')
|
||||
"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
</template>
|
||||
</OnboardingSection>
|
||||
</OnboardingLayout>
|
||||
</form>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user