Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da0fc87583 | ||
|
|
8a760da6eb | ||
|
|
e1d512cddd | ||
|
|
bdb3bec11a | ||
|
|
b733ddb8ff | ||
|
|
3c5c9574a3 | ||
|
|
de1469d2ca | ||
|
|
49aec5517b | ||
|
|
c6210a4476 | ||
|
|
6d8893c20c | ||
|
|
860c9c966c | ||
|
|
4e90084d9e | ||
|
|
552d598060 | ||
|
|
b2dbdb908a | ||
|
|
03feaad417 | ||
|
|
ff35dcb7ff | ||
|
|
fca9d9009d | ||
|
|
06e15ff081 | ||
|
|
e5c140158e | ||
|
|
274e92e0e4 | ||
|
|
27404b4a27 | ||
|
|
006b529918 | ||
|
|
b1c2db5435 | ||
|
|
92a1fb8ab7 | ||
|
|
e055cead35 | ||
|
|
c041fde3a2 | ||
|
|
c6a38e2fc6 | ||
|
|
2663a8495d | ||
|
|
56d9b232d1 | ||
|
|
15f920c9af | ||
|
|
d191603620 | ||
|
|
20911a01dc | ||
|
|
d0d275d962 | ||
|
|
95d6aecb51 | ||
|
|
a901c87ab4 | ||
|
|
6d26e7930a | ||
|
|
8d5d02ea97 | ||
|
|
ce93ddec78 | ||
|
|
940428c10e | ||
|
|
4d3196b02f | ||
|
|
c6e5657ee5 | ||
|
|
e9a92a6366 | ||
|
|
bec795f764 | ||
|
|
6f6769b66f | ||
|
|
e6dfb91fcc | ||
|
|
d9c07fe2e9 | ||
|
|
f93f2067b6 | ||
|
|
3dfb5061e1 | ||
|
|
72a59e4795 | ||
|
|
cabe9bc733 | ||
|
|
33f7550525 | ||
|
|
9ed49bfc80 | ||
|
|
59d869d1ed | ||
|
|
3680da7e48 | ||
|
|
7fd3338112 | ||
|
|
6ffc1c5f75 | ||
|
|
37470d7e57 | ||
|
|
6d9242cfb7 | ||
|
|
601625aeb3 | ||
|
|
574efb4d9e | ||
|
|
67dc0d7090 | ||
|
|
54662b17f7 | ||
|
|
879606fd19 | ||
|
|
462100cba1 | ||
|
|
461df26d90 | ||
|
|
aab8c848b6 | ||
|
|
29e1eaed19 | ||
|
|
3bbaacac40 | ||
|
|
ea2c08669f | ||
|
|
eeca70e841 | ||
|
|
1db8b7e722 | ||
|
|
dcfb0d9857 | ||
|
|
d6c75586df | ||
|
|
8a3b129292 | ||
|
|
af1dfc21f6 | ||
|
|
d1c482cb64 | ||
|
|
d8656edc61 | ||
|
|
e919a2cef5 | ||
|
|
7718f2a62c | ||
|
|
45f4b423ae | ||
|
|
78a6b2457d | ||
|
|
73ba0b26e5 | ||
|
|
f9385a31fc | ||
|
|
9d808a18df | ||
|
|
ec43975f3f | ||
|
|
64c5aeebee | ||
|
|
13402a3a9a | ||
|
|
ba38d54321 | ||
|
|
cd9192f7d1 | ||
|
|
eaffad12e7 | ||
|
|
18ef019cd4 | ||
|
|
ea910227ac | ||
|
|
94ddd98050 | ||
|
|
0e87519ecd | ||
|
|
de89391031 | ||
|
|
8e42307bdc | ||
|
|
1beaa284c6 | ||
|
|
d028cc1984 | ||
|
|
0d59fb4459 | ||
|
|
7acbe8b3ff | ||
|
|
b791d75b30 | ||
|
|
f1bc3208e9 | ||
|
|
bbd575d8fa | ||
|
|
4bf6a15c06 | ||
|
|
36a05097fa | ||
|
|
8995203473 | ||
|
|
4e33490676 | ||
|
|
0768ce70ab | ||
|
|
b38618b5d3 |
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
gem 'puma'
|
||||
gem 'puma', '~> 7.2', '>= 7.2.1'
|
||||
gem 'vite_rails'
|
||||
# metrics on heroku
|
||||
gem 'barnes'
|
||||
|
||||
+35
-24
@@ -136,6 +136,8 @@ GEM
|
||||
audited (5.4.1)
|
||||
activerecord (>= 5.0, < 7.7)
|
||||
activesupport (>= 5.0, < 7.7)
|
||||
auth-sanitizer (0.2.1)
|
||||
version_gem (~> 1.1, >= 1.1.10)
|
||||
aws-actionmailbox-ses (0.1.0)
|
||||
actionmailbox (>= 7.1.0)
|
||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||
@@ -168,7 +170,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bigdecimal (3.3.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -184,6 +186,7 @@ GEM
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
byebug (11.1.3)
|
||||
cgi (0.5.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
cld3 (3.7.0)
|
||||
@@ -312,7 +315,7 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.2)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
@@ -435,7 +438,8 @@ GEM
|
||||
hana (1.3.7)
|
||||
hash_diff (1.1.1)
|
||||
hashdiff (1.1.0)
|
||||
hashie (5.0.0)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
html2text (0.4.0)
|
||||
nokogiri (>= 1.0, < 2.0)
|
||||
http (5.1.1)
|
||||
@@ -470,7 +474,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.5)
|
||||
json (2.19.8)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -568,7 +572,7 @@ GEM
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.8.0)
|
||||
multi_xml (0.9.1)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
@@ -578,7 +582,7 @@ GEM
|
||||
uri (>= 0.11.1)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.24)
|
||||
net-imap (0.6.4.1)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -593,7 +597,7 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
@@ -603,19 +607,26 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oauth-tty (1.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
oauth2 (2.0.9)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
jwt (>= 1.0, < 3.0)
|
||||
oauth (1.1.6)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
base64 (~> 0.1)
|
||||
cgi
|
||||
oauth-tty (~> 1.0, >= 1.0.8)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oauth-tty (1.0.8)
|
||||
auth-sanitizer (~> 0.1, >= 0.1.3)
|
||||
cgi
|
||||
version_gem (~> 1.1, >= 1.1.9)
|
||||
oauth2 (2.0.22)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
faraday (>= 0.17.3, < 4.0)
|
||||
jwt (>= 1.0, < 4.0)
|
||||
logger (~> 1.2)
|
||||
multi_xml (~> 0.5)
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -682,7 +693,7 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (7.0.5)
|
||||
puma (6.4.3)
|
||||
puma (7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -935,9 +946,9 @@ GEM
|
||||
gli
|
||||
hashie
|
||||
logger
|
||||
snaky_hash (2.0.1)
|
||||
hashie
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
snaky_hash (2.0.5)
|
||||
hashie (>= 0.1.0, < 6)
|
||||
version_gem (>= 1.1.8, < 3)
|
||||
sorbet-runtime (0.5.11934)
|
||||
spring (4.1.1)
|
||||
spring-watcher-listen (2.1.0)
|
||||
@@ -995,7 +1006,7 @@ GEM
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
version_gem (1.1.11)
|
||||
vite_rails (3.10.0)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
@@ -1132,7 +1143,7 @@ DEPENDENCIES
|
||||
pgvector
|
||||
procore-sift
|
||||
pry-rails
|
||||
puma
|
||||
puma (~> 7.2, >= 7.2.1)
|
||||
pundit
|
||||
rack-attack (>= 6.7.0)
|
||||
rack-cors (= 2.0.0)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.14.1
|
||||
4.14.2
|
||||
|
||||
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
|
||||
|
||||
def create_contact
|
||||
account.contacts.create!(
|
||||
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
|
||||
name: contact_name,
|
||||
phone_number: contact_attributes[:phone_number],
|
||||
email: contact_attributes[:email],
|
||||
identifier: contact_attributes[:identifier],
|
||||
@@ -59,6 +59,11 @@ class ContactInboxWithContactBuilder
|
||||
)
|
||||
end
|
||||
|
||||
def contact_name
|
||||
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
|
||||
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
|
||||
end
|
||||
|
||||
def find_contact
|
||||
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
||||
contact ||= find_contact_by_email(contact_attributes[:email])
|
||||
|
||||
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: scope,
|
||||
state: state,
|
||||
prompt: 'consent'
|
||||
state: state
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -16,6 +16,10 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
|
||||
render 'api/v1/accounts/update', format: :json
|
||||
end
|
||||
|
||||
def help_center_generation
|
||||
render json: help_center_generation_status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finalizing_account_details?
|
||||
@@ -33,4 +37,15 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
end
|
||||
|
||||
def help_center_generation_status
|
||||
{
|
||||
generation_id: nil,
|
||||
state: nil,
|
||||
articles_count: 0,
|
||||
categories_count: 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
|
||||
|
||||
@@ -80,10 +80,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
|
||||
)
|
||||
end
|
||||
|
||||
def locale_translation_keys
|
||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||
end
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(:inbox_id)
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
class Api::V1::Profile::SessionsController < Api::BaseController
|
||||
before_action :set_session, only: [:destroy]
|
||||
|
||||
def index
|
||||
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
|
||||
@current_client_id = request.headers['client']
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @session.current?(request.headers['client'])
|
||||
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
revoke_token!(@session.client_id)
|
||||
@session.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_session
|
||||
@session = current_user.user_sessions.find(params[:id])
|
||||
end
|
||||
|
||||
def revoke_token!(client_id)
|
||||
tokens = current_user.tokens
|
||||
tokens.delete(client_id)
|
||||
current_user.update!(tokens: tokens)
|
||||
end
|
||||
|
||||
def active_token_client_ids
|
||||
now = Time.current.to_i
|
||||
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
|
||||
include RequestExceptionHandler
|
||||
include Pundit::Authorization
|
||||
include SwitchLocale
|
||||
include TrackSessionActivity
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
'api/v1/accounts/conversations/assignments' => ['create'],
|
||||
'api/v1/accounts/conversations/labels' => %w[index create]
|
||||
}.freeze
|
||||
|
||||
def ensure_access_token
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
module TrackSessionActivity
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :update_session_activity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_session_activity
|
||||
return unless current_user
|
||||
return if request.headers['client'].blank?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: current_user,
|
||||
request: request,
|
||||
client_id: request.headers['client']
|
||||
).update_activity!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session activity update failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -24,6 +24,7 @@ class DashboardController < ActionController::Base
|
||||
DISABLE_USER_PROFILE_UPDATE
|
||||
DEPLOYMENT_ENV
|
||||
INSTALLATION_PRICING_PLAN
|
||||
MULTIPLE_CURRENCY_SUPPORTED
|
||||
].freeze
|
||||
|
||||
before_action :set_application_pack
|
||||
|
||||
@@ -20,6 +20,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
track_user_session
|
||||
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
|
||||
end
|
||||
|
||||
@@ -114,6 +115,19 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
def render_mfa_error(message_key, status = :bad_request)
|
||||
render json: { error: I18n.t(message_key) }, status: status
|
||||
end
|
||||
|
||||
def track_user_session
|
||||
client_id = @token&.try(:client) || response.headers['client']
|
||||
return unless client_id.present? && @resource.present?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: @resource,
|
||||
request: request,
|
||||
client_id: client_id
|
||||
).create_or_update!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session tracking failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|
||||
|
||||
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
|
||||
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
|
||||
end
|
||||
|
||||
def sitemap
|
||||
|
||||
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize)
|
||||
end
|
||||
|
||||
def last_name
|
||||
|
||||
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize)
|
||||
end
|
||||
|
||||
def last_name
|
||||
|
||||
@@ -40,7 +40,7 @@ class ConversationFinder
|
||||
def perform
|
||||
set_up
|
||||
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
mine_count, unassigned_count, all_count = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
filter_by_assignee_type
|
||||
@@ -184,6 +184,17 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def set_count_for_all_conversations
|
||||
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
|
||||
|
||||
counts = @conversations.unscope(:order).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
|
||||
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
|
||||
Arel.sql('COUNT(*)')
|
||||
)
|
||||
counts || [0, 0, 0]
|
||||
end
|
||||
|
||||
def legacy_count_for_all_conversations
|
||||
[
|
||||
@conversations.assigned_to(current_user).count,
|
||||
@conversations.unassigned.count,
|
||||
|
||||
@@ -22,4 +22,16 @@ module BillingHelper
|
||||
def agents(account)
|
||||
account.users.count
|
||||
end
|
||||
|
||||
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
|
||||
def subscription_period_end(subscription)
|
||||
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
|
||||
end
|
||||
|
||||
def subscription_ends_on(subscription)
|
||||
period_end = subscription_period_end(subscription)
|
||||
return if period_end.blank?
|
||||
|
||||
Time.zone.at(period_end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,6 +34,12 @@ disable_branding:
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-sailbot-fill'
|
||||
enterprise: true
|
||||
voice_calls:
|
||||
name: 'Voice Calls'
|
||||
description: 'Enable voice calling capabilities for your agents and customers.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-voice-line'
|
||||
enterprise: true
|
||||
|
||||
# ------- Product Features ------- #
|
||||
help_center:
|
||||
|
||||
@@ -106,4 +106,10 @@ export default {
|
||||
const urlData = endPoints('resetAccessToken');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
getSessions() {
|
||||
return axios.get('/api/v1/profile/sessions');
|
||||
},
|
||||
revokeSession(id) {
|
||||
return axios.delete(`/api/v1/profile/sessions/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget {
|
||||
return !!this.activeConnection;
|
||||
}
|
||||
|
||||
setMuted(shouldMute) {
|
||||
if (!this.activeConnection) return false;
|
||||
this.activeConnection.mute(shouldMute);
|
||||
return shouldMute;
|
||||
}
|
||||
|
||||
endClientCall() {
|
||||
if (this.activeConnection) {
|
||||
this.activeConnection.disconnect();
|
||||
|
||||
@@ -27,6 +27,15 @@ class EnterpriseAccountAPI extends ApiClient {
|
||||
createTopupCheckout(credits) {
|
||||
return axios.post(`${this.url}topup_checkout`, { credits });
|
||||
}
|
||||
|
||||
// Topup packages for the account's billing currency.
|
||||
getTopupOptions() {
|
||||
return axios.get(`${this.url}topup_options`);
|
||||
}
|
||||
|
||||
switchCurrency(currency) {
|
||||
return axios.post(`${this.url}switch_currency`, { currency });
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
|
||||
@@ -60,6 +60,12 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
disableWhatsappCalling(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
|
||||
}
|
||||
|
||||
setInboundCalls(inboxId, enabled) {
|
||||
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
|
||||
inbound_calls_enabled: enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -9,6 +9,10 @@ class OnboardingAPI extends ApiClient {
|
||||
update(data) {
|
||||
return axios.patch(this.url, data);
|
||||
}
|
||||
|
||||
getHelpCenterGeneration() {
|
||||
return axios.get(`${this.url}/help_center_generation`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OnboardingAPI();
|
||||
|
||||
@@ -46,9 +46,8 @@ const formattedLastActivityAt = computed(() => {
|
||||
:src="avatarSource"
|
||||
class="shrink-0"
|
||||
:name="name"
|
||||
:size="48"
|
||||
:size="42"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
||||
|
||||
-1
@@ -141,7 +141,6 @@ const handleUpdateCompany = async () => {
|
||||
:src="avatarSource"
|
||||
:size="72"
|
||||
:allow-upload="!isAvatarBusy"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
|
||||
@@ -124,10 +124,9 @@ const handleAvatarHover = isHovered => {
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:size="42"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<template v-if="selectable" #overlay="{ size }">
|
||||
<label
|
||||
|
||||
@@ -53,6 +53,9 @@ const localeMenuLabels = computed(() => ({
|
||||
'publish-locale': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
||||
),
|
||||
'customize-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
@@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => {
|
||||
<DropdownMenu
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="localeMenuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const activeLocale = ref('');
|
||||
const name = ref('');
|
||||
const pageTitle = ref('');
|
||||
const headerText = ref('');
|
||||
|
||||
const localeTranslations = computed(
|
||||
() => props.portal?.config?.locale_translations || {}
|
||||
);
|
||||
|
||||
const openForLocale = localeCode => {
|
||||
const existing = localeTranslations.value[localeCode] || {};
|
||||
activeLocale.value = localeCode;
|
||||
name.value = existing.name || '';
|
||||
pageTitle.value = existing.page_title || '';
|
||||
headerText.value = existing.header_text || '';
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
const translations = { ...localeTranslations.value };
|
||||
const fields = {};
|
||||
if (name.value.trim()) fields.name = name.value.trim();
|
||||
if (pageTitle.value.trim()) fields.page_title = pageTitle.value.trim();
|
||||
if (headerText.value.trim()) fields.header_text = headerText.value.trim();
|
||||
|
||||
if (Object.keys(fields).length) {
|
||||
translations[activeLocale.value] = fields;
|
||||
} else {
|
||||
delete translations[activeLocale.value];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: { locale_translations: translations },
|
||||
});
|
||||
dialogRef.value?.close();
|
||||
useAlert(t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ openForLocale });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.DESCRIPTION')"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.NAME.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="name" :placeholder="portal.name" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.PAGE_TITLE.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="pageTitle" :placeholder="portal.page_title" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.HEADER_TEXT.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="headerText" :placeholder="portal.header_text" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -23,6 +25,8 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contentDialogRef = ref(null);
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
};
|
||||
@@ -148,6 +152,8 @@ const handleAction = ({ action }, localeCode) => {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'customize-content') {
|
||||
contentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -167,5 +173,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
+6
-1
@@ -182,7 +182,10 @@ const MIN_SEARCH_LENGTH = 2;
|
||||
export const createContactSearcher = () => {
|
||||
let controller = null;
|
||||
|
||||
return async (query, { skipMinLength = false } = {}) => {
|
||||
return async (
|
||||
query,
|
||||
{ skipMinLength = false, reachableOnly = true } = {}
|
||||
) => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
|
||||
controller?.abort();
|
||||
@@ -199,6 +202,8 @@ export const createContactSearcher = () => {
|
||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
||||
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
if (!reachableOnly) return camelCasedPayload || [];
|
||||
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
|
||||
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
|
||||
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
|
||||
"
|
||||
icon="i-ph-phone-x-bold"
|
||||
icon="i-ph-phone-bold"
|
||||
ruby
|
||||
class="!rounded-full rotate-[134deg]"
|
||||
class="!rounded-full rotate-[135deg]"
|
||||
@click="isOngoing ? $emit('end') : $emit('reject')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
@@ -11,7 +12,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit
|
||||
import CallCard from 'dashboard/components-next/call/CallCard.vue';
|
||||
import countriesList from 'shared/constants/countries.js';
|
||||
|
||||
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
|
||||
const RINGTONE_URL = '/audio/dashboard/ringtone.mp3';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -29,8 +30,8 @@ const {
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
|
||||
// don't expose a mic track on the browser side.
|
||||
// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses
|
||||
// the Voice SDK connection's native mute. Both surface the same button.
|
||||
const isMuted = ref(false);
|
||||
const isWhatsappActive = computed(
|
||||
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
@@ -63,7 +64,11 @@ const stackedCardState = call =>
|
||||
|
||||
const toggleMute = () => {
|
||||
isMuted.value = !isMuted.value;
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
if (isWhatsappActive.value) {
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
} else {
|
||||
TwilioVoiceClient.setMuted(isMuted.value);
|
||||
}
|
||||
};
|
||||
|
||||
watch(hasActiveCall, active => {
|
||||
@@ -256,7 +261,7 @@ onBeforeUnmount(stopRingtone);
|
||||
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
|
||||
:duration="hasActiveCall ? formattedCallDuration : ''"
|
||||
:is-muted="isMuted"
|
||||
:show-mute="hasActiveCall && isWhatsappActive"
|
||||
:show-mute="hasActiveCall"
|
||||
@accept="handleJoinCall(primaryIncomingCall)"
|
||||
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
|
||||
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, h, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import FilterSelect from './inputs/FilterSelect.vue';
|
||||
@@ -109,6 +110,34 @@ const inputFieldType = computed(() => {
|
||||
return 'text';
|
||||
});
|
||||
|
||||
const asyncOptions = ref([]);
|
||||
const isSearching = ref(false);
|
||||
const lastSearchQuery = ref('');
|
||||
|
||||
const performAsyncSearch = async query => {
|
||||
let results;
|
||||
try {
|
||||
results = await currentFilter.value.searchOptions(query);
|
||||
} catch {
|
||||
results = [];
|
||||
}
|
||||
// skip stale responses — a newer search in this row owns the UI
|
||||
if (query !== lastSearchQuery.value) return;
|
||||
// null means another row's search aborted ours, reset instead of staying stuck on the searching state
|
||||
if (results !== null) asyncOptions.value = results;
|
||||
isSearching.value = false;
|
||||
};
|
||||
|
||||
const debouncedAsyncSearch = debounce(performAsyncSearch, 300);
|
||||
|
||||
const onAsyncSearch = query => {
|
||||
const hasQuery = !!query.trim();
|
||||
lastSearchQuery.value = query;
|
||||
if (!hasQuery) asyncOptions.value = [];
|
||||
isSearching.value = hasQuery;
|
||||
debouncedAsyncSearch(query);
|
||||
};
|
||||
|
||||
const resetModelOnAttributeKeyChange = newAttributeKey => {
|
||||
/**
|
||||
* Resets the filter values and operator when the attribute key changes. This ensures that
|
||||
@@ -121,11 +150,16 @@ const resetModelOnAttributeKeyChange = newAttributeKey => {
|
||||
const newInputType = getInputType(newOperator, filter);
|
||||
if (newInputType === 'multiSelect') {
|
||||
values.value = [];
|
||||
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
|
||||
} else if (
|
||||
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
|
||||
newInputType
|
||||
)
|
||||
) {
|
||||
values.value = {};
|
||||
} else {
|
||||
values.value = '';
|
||||
}
|
||||
asyncOptions.value = [];
|
||||
filterOperator.value = newOperator.value;
|
||||
};
|
||||
|
||||
@@ -185,6 +219,16 @@ defineExpose({ validate, resetValidation });
|
||||
:options="currentFilter.options"
|
||||
dropdown-max-height="max-h-64"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'asyncSearchSelect'"
|
||||
v-model="values"
|
||||
async-search
|
||||
:options="asyncOptions"
|
||||
:is-searching="isSearching"
|
||||
:search-placeholder="currentFilter.searchPlaceholder"
|
||||
dropdown-max-height="max-h-64"
|
||||
@search="onAsyncSearch"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'booleanSelect'"
|
||||
v-model="values"
|
||||
|
||||
@@ -7,6 +7,7 @@ export const CONVERSATION_ATTRIBUTES = {
|
||||
ASSIGNEE_ID: 'assignee_id',
|
||||
INBOX_ID: 'inbox_id',
|
||||
TEAM_ID: 'team_id',
|
||||
CONTACT_ID: 'contact_id',
|
||||
DISPLAY_ID: 'display_id',
|
||||
CAMPAIGN_ID: 'campaign_id',
|
||||
LABELS: 'labels',
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { ref } from 'vue';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConversationFilterContext } from '../provider';
|
||||
import {
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
getCustomAttributeInputType,
|
||||
buildAttributesFilterTypes,
|
||||
replaceUnderscoreWithSpace,
|
||||
} from './filterHelper';
|
||||
|
||||
vi.mock('dashboard/api/contacts', () => ({
|
||||
default: {
|
||||
search: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store.js', () => ({
|
||||
useMapGetter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/icon/provider', () => ({
|
||||
useChannelIcon: () => ref('i-test-channel'),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params = {}) => {
|
||||
if (key === 'FILTER.CONTACT_FALLBACK') return `Contact #${params.id}`;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('filterHelper', () => {
|
||||
describe('getCustomAttributeInputType', () => {
|
||||
it('returns date for date type', () => {
|
||||
@@ -135,3 +163,64 @@ describe('filterHelper', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const storeValues = {
|
||||
'attributes/getConversationAttributes': ref([]),
|
||||
'labels/getLabels': ref([]),
|
||||
'agents/getAgents': ref([]),
|
||||
'inboxes/getInboxes': ref([]),
|
||||
'teams/getTeams': ref([]),
|
||||
'campaigns/getAllCampaigns': ref([]),
|
||||
};
|
||||
|
||||
describe('useConversationFilterContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useMapGetter.mockImplementation(key => storeValues[key] || ref([]));
|
||||
});
|
||||
|
||||
it('exposes contact as an async searchable conversation filter', () => {
|
||||
const { filterTypes } = useConversationFilterContext();
|
||||
const contactFilter = filterTypes.value.find(
|
||||
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
|
||||
);
|
||||
|
||||
expect(contactFilter).toMatchObject({
|
||||
attributeKey: 'contact_id',
|
||||
label: 'FILTER.ATTRIBUTES.CONTACT',
|
||||
inputType: 'asyncSearchSelect',
|
||||
dataType: 'number',
|
||||
attributeModel: 'standard',
|
||||
});
|
||||
expect(
|
||||
contactFilter.filterOperators.map(operator => operator.value)
|
||||
).toEqual(['equal_to', 'not_equal_to']);
|
||||
});
|
||||
|
||||
it('uses the existing contact search API for contact filter options', async () => {
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: {
|
||||
payload: [
|
||||
{ id: 1, name: 'Jane Doe' },
|
||||
{ id: 2, email: 'alex@example.com' },
|
||||
{ id: 3 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { filterTypes } = useConversationFilterContext();
|
||||
const contactFilter = filterTypes.value.find(
|
||||
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
|
||||
);
|
||||
const options = await contactFilter.searchOptions('jane');
|
||||
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('jane', 1, 'name', '', {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(options).toEqual([
|
||||
{ id: 1, name: 'Jane Doe' },
|
||||
{ id: 2, name: 'alex@example.com' },
|
||||
{ id: 3, name: 'Contact #3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
const {
|
||||
options,
|
||||
asyncSearch,
|
||||
isSearching,
|
||||
disableSearch,
|
||||
disableDeselect,
|
||||
placeholderIcon,
|
||||
@@ -23,6 +25,14 @@ const {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
asyncSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -53,6 +63,12 @@ const {
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search']);
|
||||
|
||||
// the input is re-inserted on every dropdown open (v-if),
|
||||
// where the native autofocus attribute is ignored so focus it via a directive instead
|
||||
const vFocus = { mounted: el => el.focus() };
|
||||
|
||||
const { t } = useI18n();
|
||||
const selected = defineModel({
|
||||
type: Object,
|
||||
@@ -60,7 +76,9 @@ const selected = defineModel({
|
||||
});
|
||||
|
||||
const searchTerm = ref('');
|
||||
|
||||
const searchResults = computed(() => {
|
||||
if (asyncSearch) return options;
|
||||
if (!options) return [];
|
||||
return picoSearch(options, searchTerm.value, ['name']);
|
||||
});
|
||||
@@ -77,7 +95,11 @@ const selectedItem = computed(() => {
|
||||
if (!optionToSearch) return null;
|
||||
// extract the selected item from the options array
|
||||
// this ensures that options like icon is also included
|
||||
return options.find(option => option.id === optionToSearch.id);
|
||||
return (
|
||||
options.find(option => option.id === optionToSearch.id) ||
|
||||
// async options may not include the selected option, fall back to it
|
||||
(asyncSearch && optionToSearch.id !== undefined ? optionToSearch : null)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleSelected = option => {
|
||||
@@ -131,13 +153,19 @@ const toggleSelected = option => {
|
||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
autofocus
|
||||
v-focus
|
||||
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
@input="emit('search', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection :height="dropdownMaxHeight">
|
||||
<template v-if="searchResults.length">
|
||||
<template v-if="isSearching">
|
||||
<DropdownItem disabled>
|
||||
{{ t('DROPDOWN_MENU.SEARCHING') }}
|
||||
</DropdownItem>
|
||||
</template>
|
||||
<template v-else-if="searchResults.length">
|
||||
<DropdownItem
|
||||
v-for="option in searchResults"
|
||||
:key="option.id"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useOperators } from './operators';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useChannelIcon } from 'next/icon/provider';
|
||||
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import {
|
||||
buildAttributesFilterTypes,
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
@@ -30,7 +31,7 @@ import languages from 'dashboard/components/widgets/conversation/advancedFilterI
|
||||
* @property {string} value - This is a proxy for the attribute key used in FilterSelect
|
||||
* @property {string} attributeName - The attribute name used to display on the UI
|
||||
* @property {string} label - This is a proxy for the attribute name used in FilterSelect
|
||||
* @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
|
||||
* @property {'multiSelect'|'searchSelect'|'asyncSearchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
|
||||
* @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
|
||||
* @property {'text'|'number'} dataType
|
||||
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
|
||||
@@ -68,6 +69,30 @@ export function useConversationFilterContext() {
|
||||
getOperatorTypes,
|
||||
} = useOperators();
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
|
||||
const contactOptionName = contact =>
|
||||
contact.name ||
|
||||
contact.email ||
|
||||
contact.phoneNumber ||
|
||||
contact.identifier ||
|
||||
t('FILTER.CONTACT_FALLBACK', { id: contact.id });
|
||||
|
||||
const searchContactOptions = async query => {
|
||||
const contacts = await searchContacts(query, {
|
||||
skipMinLength: true,
|
||||
reachableOnly: false,
|
||||
});
|
||||
|
||||
// null means the request was aborted (a newer search is in-flight)
|
||||
if (contacts === null) return null;
|
||||
|
||||
return contacts.map(contact => ({
|
||||
id: contact.id,
|
||||
name: contactOptionName(contact),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
@@ -158,6 +183,18 @@ export function useConversationFilterContext() {
|
||||
filterOperators: presenceOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.CONTACT_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.CONTACT_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.CONTACT'),
|
||||
label: t('FILTER.ATTRIBUTES.CONTACT'),
|
||||
inputType: 'asyncSearchSelect',
|
||||
searchOptions: searchContactOptions,
|
||||
searchPlaceholder: t('FILTER.CONTACT_SEARCH_PLACEHOLDER'),
|
||||
dataType: 'number',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
|
||||
@@ -175,6 +175,9 @@ useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const allUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getAllUnreadCount'
|
||||
);
|
||||
const getInboxUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getInboxUnreadCount'
|
||||
);
|
||||
@@ -297,18 +300,22 @@ const menuItems = computed(() => {
|
||||
{
|
||||
name: 'All',
|
||||
label: t('SIDEBAR.ALL_CONVERSATIONS'),
|
||||
icon: 'i-lucide-inbox',
|
||||
badgeCount: allUnreadCount.value,
|
||||
activeOn: ['inbox_conversation'],
|
||||
to: accountScopedRoute('home'),
|
||||
},
|
||||
{
|
||||
name: 'Mentions',
|
||||
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
|
||||
icon: 'i-lucide-at-sign',
|
||||
activeOn: ['conversation_through_mentions'],
|
||||
to: accountScopedRoute('conversation_mentions'),
|
||||
},
|
||||
{
|
||||
name: 'Participating',
|
||||
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
|
||||
icon: 'i-lucide-user-round-check',
|
||||
activeOn: ['conversation_through_participating'],
|
||||
to: accountScopedRoute('conversation_participating'),
|
||||
},
|
||||
@@ -316,6 +323,7 @@ const menuItems = computed(() => {
|
||||
name: 'Unattended',
|
||||
activeOn: ['conversation_through_unattended'],
|
||||
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
|
||||
icon: 'i-lucide-clock-alert',
|
||||
to: accountScopedRoute('conversation_unattended'),
|
||||
},
|
||||
{
|
||||
@@ -323,6 +331,8 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
|
||||
icon: 'i-lucide-folder',
|
||||
activeOn: ['conversations_through_folders'],
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: conversationCustomViews.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
@@ -334,6 +344,8 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: ['conversations_through_team'],
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedTeams.value.map(team => ({
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
@@ -346,6 +358,8 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CHANNELS'),
|
||||
icon: 'i-lucide-mailbox',
|
||||
activeOn: ['conversation_through_inbox'],
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
@@ -366,6 +380,8 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tag',
|
||||
activeOn: ['conversations_through_label'],
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedLabels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
@@ -477,6 +493,8 @@ const menuItems = computed(() => {
|
||||
name: 'Segments',
|
||||
icon: 'i-lucide-group',
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: contactCustomViews.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
@@ -495,6 +513,8 @@ const menuItems = computed(() => {
|
||||
name: 'Tagged With',
|
||||
icon: 'i-lucide-tag',
|
||||
label: t('SIDEBAR.TAGGED_WITH'),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: labels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
|
||||
@@ -99,20 +99,39 @@ const handleWindowBlur = () => {
|
||||
closeActivePopover();
|
||||
};
|
||||
|
||||
const accessibleItems = computed(() => {
|
||||
const hasAccessibleSubChildren = child => {
|
||||
return child.children?.some(
|
||||
subChild => subChild.to && isAllowed(subChild.to)
|
||||
);
|
||||
};
|
||||
|
||||
const visibleChildren = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
|
||||
return props.children.filter(child => {
|
||||
// If a item has no link, it means it's just a subgroup header
|
||||
// So we don't need to check for permissions here, because there's nothing to
|
||||
// access here anyway
|
||||
if (child.children) return hasAccessibleSubChildren(child);
|
||||
|
||||
return child.to && isAllowed(child.to);
|
||||
});
|
||||
});
|
||||
|
||||
const hasAccessibleChildren = computed(() => {
|
||||
return accessibleItems.value.length > 0;
|
||||
const accessibleItems = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
|
||||
return visibleChildren.value
|
||||
.flatMap(child => child.children || child)
|
||||
.filter(child => child.to && isAllowed(child.to));
|
||||
});
|
||||
|
||||
const hasAccessibleChildren = computed(() => {
|
||||
return visibleChildren.value.length > 0;
|
||||
});
|
||||
|
||||
const isLastVisibleChild = child => {
|
||||
const lastChild = visibleChildren.value[visibleChildren.value.length - 1];
|
||||
return lastChild === child;
|
||||
};
|
||||
|
||||
const isActive = computed(() => {
|
||||
if (props.to) {
|
||||
if (route.path === resolvePath(props.to)) return true;
|
||||
@@ -274,14 +293,18 @@ watch(
|
||||
<ul
|
||||
v-if="hasChildren"
|
||||
v-show="isExpanded || hasActiveChild"
|
||||
class="grid m-0 list-none sidebar-group-children min-w-0"
|
||||
class="grid m-0 list-none min-w-0"
|
||||
>
|
||||
<template v-for="child in children" :key="child.name">
|
||||
<template v-for="child in visibleChildren" :key="child.name">
|
||||
<SidebarSubGroup
|
||||
v-if="child.children"
|
||||
:name="`${name}:${child.name}`"
|
||||
:label="child.label"
|
||||
:icon="child.icon"
|
||||
:children="child.children"
|
||||
:collapsible="child.collapsible"
|
||||
:show-tree-line="child.showTreeLine"
|
||||
:end-tree-line="child.showTreeLine && isLastVisibleChild(child)"
|
||||
:is-expanded="isExpanded"
|
||||
:active-child="activeChild"
|
||||
/>
|
||||
@@ -299,59 +322,3 @@ watch(
|
||||
</template>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.sidebar-group-children .child-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0.125rem;
|
||||
/* 0.5px */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-group-children .child-item:first-child::before {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
/* This selects the last child in a group */
|
||||
/* https://codepen.io/scmmishra/pen/yLmKNLW */
|
||||
.sidebar-group-children > .child-item:last-child::before,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::before {
|
||||
height: 20%;
|
||||
}
|
||||
|
||||
.sidebar-group-children > .child-item:last-child::after,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
bottom: calc(50% - 2px);
|
||||
border-bottom-width: 0.125rem;
|
||||
border-left-width: 0.125rem;
|
||||
border-right-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 0 4px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#app[dir='rtl'] .sidebar-group-children > .child-item:last-child::after,
|
||||
#app[dir='rtl']
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
right: 0;
|
||||
border-bottom-width: 0.125rem;
|
||||
border-right-width: 0.125rem;
|
||||
border-left-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 4px 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,8 @@ const props = defineProps({
|
||||
active: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
badgeCount: { type: [Number, String], default: 0 },
|
||||
hideTreeLine: { type: Boolean, default: false },
|
||||
thinTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
@@ -19,21 +21,30 @@ const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
const shouldRenderComponent = computed(() => {
|
||||
return typeof props.component === 'function' || isVNode(props.component);
|
||||
});
|
||||
|
||||
// Tree-line connector per leaf: vertical line (::before) + rounded elbow on the
|
||||
// last child (::after). Logical props (start / border-s / rounded-es)
|
||||
const TREE_CONNECTOR =
|
||||
"child-item before:content-[''] before:absolute before:start-0 before:w-0.5 before:h-full before:bg-n-slate-4 first:before:rounded-t last:before:h-1/5 last:after:content-[''] last:after:absolute last:after:start-0 last:after:bottom-[calc(50%_-_2px)] last:after:h-3 last:after:w-2.5 last:after:border-b-2 last:after:border-s-2 last:after:rounded-es last:after:border-n-slate-4";
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Policy
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
|
||||
class="py-0.5 ps-2 ms-3 relative text-n-slate-11 min-w-0"
|
||||
:class="{
|
||||
[TREE_CONNECTOR]: !hideTreeLine,
|
||||
'before:!w-px last:after:!border-b last:after:!border-s':
|
||||
!hideTreeLine && thinTreeLine,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
:to="to"
|
||||
:title="label"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg ltr:hover:bg-gradient-to-r rtl:hover:bg-gradient-to-l from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2 active': active,
|
||||
}"
|
||||
|
||||
@@ -2,24 +2,69 @@
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
type: [Object, String],
|
||||
default: '',
|
||||
},
|
||||
showTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
endTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const TREE_VERTICAL_LINE =
|
||||
"before:content-[''] before:absolute before:-top-1 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
|
||||
const TREE_ELBOW =
|
||||
"after:content-[''] after:absolute after:w-2.5 after:h-3 after:bottom-1/2 after:start-[-0.5rem] after:border-b-2 after:border-s-2 after:rounded-es after:border-n-slate-4";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none pointer-events-none"
|
||||
<component
|
||||
:is="collapsible ? 'button' : 'div'"
|
||||
:type="collapsible ? 'button' : undefined"
|
||||
:aria-expanded="collapsible ? isExpanded : undefined"
|
||||
:title="label"
|
||||
class="relative justify-between flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none min-w-0"
|
||||
:class="[
|
||||
showTreeLine && TREE_VERTICAL_LINE,
|
||||
showTreeLine &&
|
||||
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
|
||||
{
|
||||
'w-full': !collapsible,
|
||||
'pointer-events-none': !collapsible,
|
||||
'ms-5 cursor-pointer hover:bg-n-alpha-2': collapsible,
|
||||
},
|
||||
]"
|
||||
@click.stop="emit('toggle')"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 inline-flex gap-2 items-center">
|
||||
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow truncate text-start">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="collapsible"
|
||||
class="size-3 flex-shrink-0 text-n-slate-10 me-0.5"
|
||||
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -145,7 +145,6 @@ const allowedMenuItems = computed(() => {
|
||||
:src="currentUser.avatar_url"
|
||||
:status="currentUserAvailability"
|
||||
class="flex-shrink-0"
|
||||
rounded-full
|
||||
/>
|
||||
<div v-if="!isCollapsed" class="min-w-0">
|
||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
|
||||
|
||||
@@ -7,15 +11,42 @@ import { useSidebarContext } from './provider';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
label: { type: String, required: true },
|
||||
icon: { type: [Object, String], required: true },
|
||||
children: { type: Array, default: undefined },
|
||||
activeChild: { type: Object, default: undefined },
|
||||
collapsible: { type: Boolean, default: false },
|
||||
showTreeLine: { type: Boolean, default: false },
|
||||
endTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { isAllowed } = useSidebarContext();
|
||||
const scrollableContainer = ref(null);
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const minimizedSectionsKey = LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS;
|
||||
|
||||
const getMinimizedSections = () => {
|
||||
const minimizedSections = LocalStorage.get(minimizedSectionsKey);
|
||||
return minimizedSections &&
|
||||
typeof minimizedSections === 'object' &&
|
||||
!Array.isArray(minimizedSections)
|
||||
? minimizedSections
|
||||
: {};
|
||||
};
|
||||
|
||||
const minimizedSections = ref(getMinimizedSections());
|
||||
const storageKey = computed(() =>
|
||||
accountId.value ? `${accountId.value}:${props.name}` : props.name
|
||||
);
|
||||
const isSubGroupExpanded = computed(
|
||||
() => !props.collapsible || !minimizedSections.value[storageKey.value]
|
||||
);
|
||||
const hasActiveChild = computed(() =>
|
||||
props.children.some(child => child.name === props.activeChild?.name)
|
||||
);
|
||||
|
||||
const accessibleItems = computed(() =>
|
||||
props.children.filter(child => {
|
||||
@@ -28,84 +59,117 @@ const hasAccessibleItems = computed(() => {
|
||||
});
|
||||
|
||||
const isScrollable = computed(() => {
|
||||
return accessibleItems.value.length > 7;
|
||||
return (
|
||||
props.isExpanded &&
|
||||
isSubGroupExpanded.value &&
|
||||
accessibleItems.value.length > 7
|
||||
);
|
||||
});
|
||||
|
||||
const scrollEnd = ref(false);
|
||||
|
||||
const CHILDREN_TRUNK =
|
||||
"before:content-[''] before:absolute before:top-0 before:bottom-0 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
|
||||
|
||||
const hideLeafTreeLine = computed(
|
||||
() => props.showTreeLine && !props.isExpanded
|
||||
);
|
||||
|
||||
const toggleSubGroup = () => {
|
||||
if (!props.collapsible) return;
|
||||
|
||||
if (isSubGroupExpanded.value) {
|
||||
LocalStorage.updateJsonStore(minimizedSectionsKey, storageKey.value, true);
|
||||
} else {
|
||||
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
|
||||
}
|
||||
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
};
|
||||
|
||||
const expandSubGroupOnActiveChild = () => {
|
||||
if (!props.collapsible || !hasActiveChild.value || isSubGroupExpanded.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
};
|
||||
|
||||
const shouldShowItem = child => {
|
||||
return (
|
||||
isSubGroupExpanded.value &&
|
||||
(props.isExpanded || props.activeChild?.name === child.name)
|
||||
);
|
||||
};
|
||||
|
||||
// set scrollEnd to true when the scroll reaches the end
|
||||
useEventListener(scrollableContainer, 'scroll', () => {
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollableContainer.value;
|
||||
scrollEnd.value = scrollHeight - scrollTop === clientHeight;
|
||||
});
|
||||
|
||||
useEventListener(window, 'storage', event => {
|
||||
if (event.key === minimizedSectionsKey) {
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
}
|
||||
});
|
||||
|
||||
watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroupSeparator
|
||||
v-if="hasAccessibleItems"
|
||||
v-show="isExpanded"
|
||||
:label
|
||||
:icon
|
||||
class="my-1"
|
||||
/>
|
||||
<ul
|
||||
v-if="children.length"
|
||||
class="m-0 list-none reset-base relative group min-w-0"
|
||||
>
|
||||
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
|
||||
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
<li class="relative flex flex-col list-none min-w-0">
|
||||
<template v-if="hasAccessibleItems">
|
||||
<SidebarGroupSeparator
|
||||
v-show="isExpanded"
|
||||
:label
|
||||
:icon
|
||||
:collapsible
|
||||
:is-expanded="isSubGroupExpanded"
|
||||
:show-tree-line="showTreeLine"
|
||||
:end-tree-line="endTreeLine"
|
||||
class="my-1"
|
||||
@toggle="toggleSubGroup"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="24"
|
||||
viewBox="0 0 16 24"
|
||||
fill="none"
|
||||
class="text-n-slate-9 opacity-50 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<ul
|
||||
v-if="children.length"
|
||||
class="m-0 list-none reset-base relative group min-w-0"
|
||||
:class="[
|
||||
{ 'ms-5': collapsible },
|
||||
showTreeLine && !endTreeLine && CHILDREN_TRUNK,
|
||||
]"
|
||||
>
|
||||
<path
|
||||
d="M4 4L8 8L12 4"
|
||||
stroke="currentColor"
|
||||
opacity="0.5"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 10L8 14L12 10"
|
||||
stroke="currentColor"
|
||||
opacity="0.75"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 16L8 20L12 16"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</ul>
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'max-h-60 overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="shouldShowItem(child)"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
:hide-tree-line="hideLeafTreeLine"
|
||||
thin-tree-line
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-chevrons-down"
|
||||
class="w-4 h-6 text-n-slate-9 opacity-50 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
import SidebarSubGroup from '../SidebarSubGroup.vue';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { provideSidebarContext } from '../provider';
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: () => ref(1),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/usePolicy', () => ({
|
||||
usePolicy: () => ({
|
||||
shouldShow: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
resolve: () => ({ path: '/' }),
|
||||
getRoutes: () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const children = [
|
||||
{
|
||||
name: 'Sales-1',
|
||||
label: 'Sales',
|
||||
to: { name: 'team_conversations' },
|
||||
},
|
||||
];
|
||||
|
||||
const mountSubGroup = props => {
|
||||
return mount(
|
||||
{
|
||||
components: { SidebarSubGroup },
|
||||
setup() {
|
||||
provideSidebarContext({});
|
||||
},
|
||||
template: '<SidebarSubGroup v-bind="$attrs" />',
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
name: 'Conversation:Teams',
|
||||
label: 'Teams',
|
||||
icon: 'i-lucide-users',
|
||||
children,
|
||||
isExpanded: true,
|
||||
collapsible: true,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
SidebarGroupLeaf: {
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
hideTreeLine: { type: Boolean, default: false },
|
||||
thinTreeLine: { type: Boolean, default: false },
|
||||
},
|
||||
template:
|
||||
'<li class="sidebar-leaf" :data-hide-tree-line="String(hideTreeLine)" :data-thin-tree-line="String(thinTreeLine)">{{ label }}</li>',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
describe('SidebarSubGroup', () => {
|
||||
let localStorageStore;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageStore = {};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: key => localStorageStore[key] || null,
|
||||
setItem: (key, value) => {
|
||||
localStorageStore[key] = String(value);
|
||||
},
|
||||
removeItem: key => {
|
||||
delete localStorageStore[key];
|
||||
},
|
||||
clear: () => {
|
||||
localStorageStore = {};
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('keeps collapsible sections expanded by default', () => {
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the tree line on the separator, positioned relative to it', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
const button = wrapper.find('button');
|
||||
|
||||
expect(button.classes()).toContain('relative');
|
||||
expect(button.classes()).toContain('before:bg-n-slate-4');
|
||||
expect(button.classes()).toContain('before:-bottom-1');
|
||||
});
|
||||
|
||||
it('renders the end curve on the last separator', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true, endTreeLine: true });
|
||||
const button = wrapper.find('button');
|
||||
|
||||
expect(button.classes()).toContain('before:h-3');
|
||||
expect(button.classes()).toContain('after:border-b-2');
|
||||
});
|
||||
|
||||
it('draws nested item tree lines via the leaf connectors', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
|
||||
expect(
|
||||
wrapper.find('.sidebar-leaf').attributes('data-hide-tree-line')
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('marks nested item connectors as thin', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
|
||||
expect(
|
||||
wrapper.find('.sidebar-leaf').attributes('data-thin-tree-line')
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('minimizes the section and stores it by account and section name', async () => {
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
await wrapper.find('button').trigger('click');
|
||||
|
||||
const storedSections = JSON.parse(
|
||||
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
|
||||
);
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
|
||||
expect(storedSections).toEqual({ '1:Conversation:Teams': true });
|
||||
});
|
||||
|
||||
it('uses the stored minimized state when mounted again', () => {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
|
||||
JSON.stringify({ '1:Conversation:Teams': true })
|
||||
);
|
||||
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('expands a stored minimized section when one of its children is active', () => {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
|
||||
JSON.stringify({ '1:Conversation:Teams': true })
|
||||
);
|
||||
|
||||
const wrapper = mountSubGroup({ activeChild: children[0] });
|
||||
|
||||
const storedSections = JSON.parse(
|
||||
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
|
||||
);
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
|
||||
expect(storedSections).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,9 @@ const activeFolder = computed(() => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const getContact = useMapGetter('contacts/getContact');
|
||||
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
|
||||
|
||||
const activeFolderName = computed(() => {
|
||||
return activeFolder.value?.name;
|
||||
});
|
||||
@@ -456,6 +459,7 @@ function setParamsForEditFolderModal() {
|
||||
inboxes: inboxesList.value,
|
||||
labels: labels.value,
|
||||
campaigns: campaigns.value,
|
||||
contacts: [getContact.value(folderContactId.value)],
|
||||
languages: languages,
|
||||
countries: countries,
|
||||
priority: [
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
CONVERSATION_EVENTS,
|
||||
CAPTAIN_EVENTS,
|
||||
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||
|
||||
import {
|
||||
messageSchema,
|
||||
@@ -43,6 +42,7 @@ import {
|
||||
MessageMarkdownSerializer,
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
insertAtCursor,
|
||||
removeSignature as removeSignatureHelper,
|
||||
scrollCursorIntoView,
|
||||
setURLWithQueryAndSize,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
@@ -72,6 +71,7 @@ import {
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -93,7 +93,6 @@ const props = defineProps({
|
||||
channelType: { type: String, default: '' },
|
||||
conversationId: { type: Number, default: null },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
@@ -119,6 +118,14 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
|
||||
const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature';
|
||||
const INLINE_IMAGE_PASTE_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(props.channelType, props.medium)
|
||||
@@ -192,12 +199,8 @@ const cannedSearchTerm = ref('');
|
||||
const variableSearchTerm = ref('');
|
||||
const emojiSearchTerm = ref('');
|
||||
const range = ref(null);
|
||||
const isImageNodeSelected = ref(false);
|
||||
const toolbarPosition = ref({ top: 0, left: 0 });
|
||||
const selectedImageNode = ref(null);
|
||||
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
const showSelectionMenu = ref(false);
|
||||
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
|
||||
|
||||
// element ref
|
||||
const editorRoot = useTemplateRef('editorRoot');
|
||||
@@ -475,16 +478,6 @@ function removeSignature() {
|
||||
reloadState(content);
|
||||
}
|
||||
|
||||
function setToolbarPosition() {
|
||||
const editorRect = editorRoot.value.getBoundingClientRect();
|
||||
const rect = selectedImageNode.value.getBoundingClientRect();
|
||||
|
||||
toolbarPosition.value = {
|
||||
top: `${rect.top - editorRect.top - 30}px`,
|
||||
left: `${rect.left - editorRect.left - 4}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function setMenubarPosition({ selection } = {}) {
|
||||
const wrapper = editorRoot.value;
|
||||
if (!selection || !wrapper) return;
|
||||
@@ -520,30 +513,6 @@ function checkSelection(editorState) {
|
||||
if (hasSelection) setMenubarPosition(editorState);
|
||||
}
|
||||
|
||||
function setURLWithQueryAndImageSize(size) {
|
||||
if (!props.showImageResizeToolbar) {
|
||||
return;
|
||||
}
|
||||
setURLWithQueryAndSize(selectedImageNode.value, size, editorView);
|
||||
isImageNodeSelected.value = false;
|
||||
}
|
||||
|
||||
function isEditorMouseFocusedOnAnImage() {
|
||||
if (!props.showImageResizeToolbar) {
|
||||
return;
|
||||
}
|
||||
selectedImageNode.value = document.querySelector(
|
||||
'img.ProseMirror-selectednode'
|
||||
);
|
||||
if (selectedImageNode.value) {
|
||||
isImageNodeSelected.value = !!selectedImageNode.value;
|
||||
// Get the position of the selected node
|
||||
setToolbarPosition();
|
||||
} else {
|
||||
isImageNodeSelected.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function emitOnChange() {
|
||||
emit('input', contentFromEditor());
|
||||
emit('update:modelValue', contentFromEditor());
|
||||
@@ -563,21 +532,6 @@ function toggleSignatureInEditor(signatureEnabled) {
|
||||
emitOnChange();
|
||||
}
|
||||
|
||||
function updateImgToolbarOnDelete() {
|
||||
// check if the selected node is present or not on keyup
|
||||
// this is needed because the user can select an image and then delete it
|
||||
// in that case, the selected node will be null and we need to hide the toolbar
|
||||
// otherwise, the toolbar will be visible even when the image is deleted and cause some errors
|
||||
if (selectedImageNode.value) {
|
||||
const hasImgSelectedNode = document.querySelector(
|
||||
'img.ProseMirror-selectednode'
|
||||
);
|
||||
if (!hasImgSelectedNode) {
|
||||
isImageNodeSelected.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isEnterToSendEnabled() {
|
||||
return isEditorHotKeyEnabled('enter');
|
||||
}
|
||||
@@ -586,17 +540,6 @@ function isCmdPlusEnterToSendEnabled() {
|
||||
return isEditorHotKeyEnabled('cmd_enter');
|
||||
}
|
||||
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyP': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
});
|
||||
|
||||
function onImageInsertInEditor(fileUrl) {
|
||||
const { tr } = editorView.state;
|
||||
|
||||
@@ -617,7 +560,11 @@ async function uploadImageToStorage(file) {
|
||||
onImageInsertInEditor(fileUrl);
|
||||
}
|
||||
useAlert(
|
||||
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS')
|
||||
props.channelType === MESSAGE_SIGNATURE_FORMATTING
|
||||
? t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
|
||||
)
|
||||
: t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
@@ -626,8 +573,8 @@ async function uploadImageToStorage(file) {
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange() {
|
||||
const file = imageUpload.value.files[0];
|
||||
function uploadImageIfWithinSizeLimit(file) {
|
||||
if (!file) return;
|
||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
uploadImageToStorage(file);
|
||||
} else {
|
||||
@@ -640,10 +587,61 @@ function onFileChange() {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
imageUpload.value = '';
|
||||
}
|
||||
|
||||
function onFileChange() {
|
||||
const input = imageUpload.value;
|
||||
uploadImageIfWithinSizeLimit(input.files[0]);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
const allowsInlineImagePaste = computed(
|
||||
() =>
|
||||
!props.isPrivate &&
|
||||
(props.channelType === INBOX_TYPES.EMAIL ||
|
||||
props.channelType === INBOX_TYPES.WEB)
|
||||
);
|
||||
|
||||
// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This
|
||||
// gesture's native paste event carries no image, so clipboard.read() is the
|
||||
// only way to get the bytes. No preventDefault: text still pastes natively.
|
||||
async function pasteInlineImageFromClipboard() {
|
||||
if (!editorView?.hasFocus()) return;
|
||||
if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return;
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
const imageItem = items.find(item =>
|
||||
item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type))
|
||||
);
|
||||
if (!imageItem) return;
|
||||
const imageType = imageItem.types.find(type =>
|
||||
INLINE_IMAGE_PASTE_TYPES.includes(type)
|
||||
);
|
||||
const blob = await imageItem.getType(imageType);
|
||||
uploadImageIfWithinSizeLimit(
|
||||
new File([blob], 'pasted-image', { type: imageType })
|
||||
);
|
||||
} catch (error) {
|
||||
// clipboard-read denied/unfocused (NotAllowedError): image can't be read.
|
||||
// Text paste is unaffected — ProseMirror handles it from the native event.
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyP': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'$mod+Shift+KeyV': {
|
||||
action: pasteInlineImageFromClipboard,
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
function handleLineBreakWhenEnterToSendEnabled(event) {
|
||||
if (
|
||||
hasPressedEnterAndNotCmdOrShift(event) &&
|
||||
@@ -736,6 +734,9 @@ function createEditorView() {
|
||||
editorView = new EditorView(editor.value, {
|
||||
state: state,
|
||||
editable: () => !props.disabled,
|
||||
nodeViews: {
|
||||
image: imageResizeView,
|
||||
},
|
||||
dispatchTransaction: tx => {
|
||||
state = state.apply(tx);
|
||||
editorView.updateState(state);
|
||||
@@ -748,12 +749,10 @@ function createEditorView() {
|
||||
keyup: () => {
|
||||
if (!props.disabled) {
|
||||
typingIndicator.start();
|
||||
updateImgToolbarOnDelete();
|
||||
}
|
||||
},
|
||||
keydown: (view, event) => !props.disabled && onKeydown(event),
|
||||
focus: () => !props.disabled && emit('focus'),
|
||||
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
|
||||
blur: () => {
|
||||
if (props.disabled) return;
|
||||
typingIndicator.stop();
|
||||
@@ -918,23 +917,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div ref="editor" />
|
||||
<div
|
||||
v-show="isImageNodeSelected && showImageResizeToolbar"
|
||||
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
|
||||
:style="{
|
||||
top: toolbarPosition.top,
|
||||
left: toolbarPosition.left,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-for="size in sizes"
|
||||
:key="size.name"
|
||||
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
|
||||
@click="setURLWithQueryAndImageSize(size)"
|
||||
>
|
||||
{{ size.name }}
|
||||
</button>
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -997,10 +979,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
}
|
||||
|
||||
ol li {
|
||||
@apply list-item list-decimal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,6 @@ const copyConversationId = async () => {
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
|
||||
|
||||
@@ -375,10 +375,10 @@ export default {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
if (this.isATelegramChannel) {
|
||||
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
|
||||
return AUDIO_FORMATS.MP3;
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
|
||||
@@ -46,6 +46,14 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'contact_id',
|
||||
attributeI18nKey: 'CONTACT',
|
||||
inputType: 'search_select',
|
||||
dataType: 'number',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'display_id',
|
||||
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
|
||||
@@ -133,6 +141,10 @@ export const filterAttributeGroups = [
|
||||
key: 'team_id',
|
||||
i18nKey: 'TEAM_NAME',
|
||||
},
|
||||
{
|
||||
key: 'contact_id',
|
||||
i18nKey: 'CONTACT',
|
||||
},
|
||||
{
|
||||
key: 'display_id',
|
||||
i18nKey: 'CONVERSATION_IDENTIFIER',
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useFacebookPageConnect } from '../useFacebookPageConnect';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import ChannelApi from 'dashboard/api/channels';
|
||||
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({ useMapGetter: vi.fn() }));
|
||||
vi.mock('dashboard/api/channels', () => ({
|
||||
default: { fetchFacebookPages: vi.fn() },
|
||||
}));
|
||||
vi.mock(
|
||||
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
|
||||
() => ({ setupFacebookSdk: vi.fn() })
|
||||
);
|
||||
|
||||
const flushPromises = () =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const ACCOUNT_ID = 42;
|
||||
const PAGES = [
|
||||
{ id: 'p1', name: 'Page One', access_token: 'page-token-1' },
|
||||
{ id: 'p2', name: 'Page Two', access_token: 'page-token-2', exists: true },
|
||||
];
|
||||
|
||||
const pagesResponse = {
|
||||
data: { data: { page_details: PAGES, user_access_token: 'long-token' } },
|
||||
};
|
||||
|
||||
// FB.login invokes its callback with the given response.
|
||||
const stubLogin = response => {
|
||||
window.FB = { login: vi.fn(callback => callback(response)) };
|
||||
};
|
||||
|
||||
const connected = {
|
||||
status: 'connected',
|
||||
authResponse: { accessToken: 'user-token' },
|
||||
};
|
||||
|
||||
describe('useFacebookPageConnect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.chatwootConfig = { fbAppId: 'fb-app', fbApiVersion: 'v22.0' };
|
||||
useMapGetter.mockReturnValue({ value: ACCOUNT_ID });
|
||||
setupFacebookSdk.mockResolvedValue();
|
||||
ChannelApi.fetchFacebookPages.mockResolvedValue(pagesResponse);
|
||||
stubLogin(connected);
|
||||
});
|
||||
|
||||
it('resolves the user token and pages on a connected login', async () => {
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).resolves.toEqual({
|
||||
userAccessToken: 'long-token',
|
||||
pages: PAGES,
|
||||
});
|
||||
expect(setupFacebookSdk).toHaveBeenCalledWith('fb-app', 'v22.0');
|
||||
expect(ChannelApi.fetchFacebookPages).toHaveBeenCalledWith(
|
||||
'user-token',
|
||||
ACCOUNT_ID
|
||||
);
|
||||
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,pages_show_list,pages_read_engagement',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves null when the user is not authorized', async () => {
|
||||
stubLogin({ status: 'not_authorized' });
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).resolves.toBeNull();
|
||||
expect(ChannelApi.fetchFacebookPages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves null on an unknown login status', async () => {
|
||||
stubLogin({ status: 'unknown' });
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects when fetching pages fails', async () => {
|
||||
ChannelApi.fetchFacebookPages.mockRejectedValue(new Error('fetch failed'));
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).rejects.toThrow('fetch failed');
|
||||
});
|
||||
|
||||
it('rejects when the SDK fails to load', async () => {
|
||||
const error = new Error('script load failed');
|
||||
error.name = 'ScriptLoaderError';
|
||||
setupFacebookSdk.mockRejectedValue(error);
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).rejects.toThrow('script load failed');
|
||||
});
|
||||
|
||||
it('ignores a second call while a run is in flight', async () => {
|
||||
const pending = createDeferred();
|
||||
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
|
||||
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
const first = loginAndFetchPages();
|
||||
const second = loginAndFetchPages();
|
||||
|
||||
await expect(second).resolves.toBeNull();
|
||||
|
||||
pending.resolve(pagesResponse);
|
||||
await first;
|
||||
expect(window.FB.login).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles isAuthenticating across a run', async () => {
|
||||
const pending = createDeferred();
|
||||
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
|
||||
|
||||
const { isAuthenticating, loginAndFetchPages } = useFacebookPageConnect();
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
|
||||
const result = loginAndFetchPages();
|
||||
await flushPromises();
|
||||
expect(isAuthenticating.value).toBe(true);
|
||||
|
||||
pending.resolve(pagesResponse);
|
||||
await result;
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
});
|
||||
|
||||
it('preloads the SDK once and reuses it for login', async () => {
|
||||
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
preloadSdk();
|
||||
preloadSdk();
|
||||
await loginAndFetchPages();
|
||||
|
||||
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useWhatsappEmbeddedSignup } from '../useWhatsappEmbeddedSignup';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
vi.mock(
|
||||
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
|
||||
() => ({
|
||||
setupFacebookSdk: vi.fn(),
|
||||
initWhatsAppEmbeddedSignup: vi.fn(),
|
||||
createMessageHandler: vi.fn(),
|
||||
isValidBusinessData: vi.fn(data =>
|
||||
Boolean(data && data.business_id && data.waba_id)
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
const flushPromises = () =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const VALID_BUSINESS = {
|
||||
business_id: 'biz-1',
|
||||
waba_id: 'waba-1',
|
||||
phone_number_id: 'phone-1',
|
||||
};
|
||||
|
||||
describe('useWhatsappEmbeddedSignup', () => {
|
||||
// The mocked createMessageHandler captures the callback the composable
|
||||
// registers, so tests can simulate Meta's WA_EMBEDDED_SIGNUP postMessages
|
||||
// directly without the window-event + origin plumbing (that is covered by
|
||||
// the utils' own tests).
|
||||
let signupCallback;
|
||||
let registeredListener;
|
||||
|
||||
const emit = data => signupCallback(data);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
window.chatwootConfig = {
|
||||
whatsappAppId: 'app-id',
|
||||
whatsappConfigurationId: 'config-id',
|
||||
whatsappApiVersion: 'v22.0',
|
||||
};
|
||||
|
||||
setupFacebookSdk.mockResolvedValue();
|
||||
createMessageHandler.mockImplementation(callback => {
|
||||
signupCallback = callback;
|
||||
registeredListener = () => {};
|
||||
return registeredListener;
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves credentials when the auth code arrives before the business data', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
await flushPromises(); // SDK setup + FB.login resolve the code first
|
||||
emit({ event: 'FINISH', data: VALID_BUSINESS });
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
code: 'auth-code',
|
||||
business_id: 'biz-1',
|
||||
waba_id: 'waba-1',
|
||||
phone_number_id: 'phone-1',
|
||||
});
|
||||
expect(setupFacebookSdk).toHaveBeenCalledWith('app-id', 'v22.0');
|
||||
expect(initWhatsAppEmbeddedSignup).toHaveBeenCalledWith('config-id');
|
||||
});
|
||||
|
||||
it('resolves credentials when the business data arrives before the auth code', async () => {
|
||||
const code = createDeferred();
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(code.promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
// Business data lands first, while FB.login is still pending.
|
||||
emit({
|
||||
event: 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING',
|
||||
data: VALID_BUSINESS,
|
||||
});
|
||||
code.resolve('late-code');
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
code: 'late-code',
|
||||
business_id: 'biz-1',
|
||||
waba_id: 'waba-1',
|
||||
phone_number_id: 'phone-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults phone_number_id to an empty string when absent', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
await flushPromises();
|
||||
emit({
|
||||
event: 'FINISH',
|
||||
data: { business_id: 'biz-1', waba_id: 'waba-1' },
|
||||
});
|
||||
|
||||
await expect(result).resolves.toMatchObject({ phone_number_id: '' });
|
||||
});
|
||||
|
||||
it('resolves null when FB.login is cancelled', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('Login cancelled'));
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
|
||||
await expect(runEmbeddedSignup()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('resolves null on a CANCEL event', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
emit({ event: 'CANCEL' });
|
||||
|
||||
await expect(result).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects with the Meta error message on an error event', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
emit({ event: 'error', error_message: 'WABA not eligible' });
|
||||
|
||||
await expect(result).rejects.toThrow('WABA not eligible');
|
||||
});
|
||||
|
||||
it('rejects when the business data is invalid', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
emit({ event: 'FINISH', data: { business_id: 'biz-1' } }); // no waba_id
|
||||
|
||||
await expect(result).rejects.toThrow('Invalid business data');
|
||||
});
|
||||
|
||||
it('rejects when the SDK or login fails for a non-cancel reason', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('popup blocked'));
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
|
||||
await expect(runEmbeddedSignup()).rejects.toThrow('popup blocked');
|
||||
});
|
||||
|
||||
it('ignores a second call while a run is in flight', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const first = runEmbeddedSignup();
|
||||
const second = runEmbeddedSignup();
|
||||
|
||||
await expect(second).resolves.toBeNull();
|
||||
|
||||
// Let the first run finish so it doesn't leak into the next test.
|
||||
await flushPromises();
|
||||
emit({ event: 'FINISH', data: VALID_BUSINESS });
|
||||
await first;
|
||||
|
||||
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles isAuthenticating and removes the listener once settled', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
|
||||
const result = runEmbeddedSignup();
|
||||
expect(isAuthenticating.value).toBe(true);
|
||||
|
||||
await flushPromises();
|
||||
emit({ event: 'FINISH', data: VALID_BUSINESS });
|
||||
await result;
|
||||
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
expect(removeSpy).toHaveBeenCalledWith('message', registeredListener);
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import ChannelApi from 'dashboard/api/channels';
|
||||
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
|
||||
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
|
||||
// FB.login for page scopes, and fetch the user's pages. The caller owns the
|
||||
// page-picker UI and the channel creation, because choosing a page is an
|
||||
// interactive step (a user can manage several pages).
|
||||
//
|
||||
// Split into preloadSdk() + loginAndFetchPages() for popup safety: FB.login
|
||||
// opens a popup and needs the click's transient activation. Preloading the SDK
|
||||
// when the picker opens means the click-time `await` resolves within that
|
||||
// activation window; a cold load resolves on the script's `load` task seconds
|
||||
// later, after activation has expired, and the popup gets blocked.
|
||||
export function useFacebookPageConnect() {
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
let sdkSetupPromise = null;
|
||||
|
||||
// Idempotent — call this when the picker UI opens. A failed load clears the
|
||||
// cache so a later attempt can retry instead of being stuck on a rejection.
|
||||
const preloadSdk = () => {
|
||||
if (!sdkSetupPromise) {
|
||||
sdkSetupPromise = setupFacebookSdk(
|
||||
window.chatwootConfig?.fbAppId,
|
||||
window.chatwootConfig?.fbApiVersion
|
||||
).catch(error => {
|
||||
sdkSetupPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return sdkSetupPromise;
|
||||
};
|
||||
|
||||
// FB.login never rejects; resolve the user access token on success and null
|
||||
// for any other status (closed popup, not_authorized, unknown).
|
||||
const login = () =>
|
||||
new Promise(resolve => {
|
||||
window.FB.login(
|
||||
response => {
|
||||
resolve(
|
||||
response.status === 'connected'
|
||||
? response.authResponse?.accessToken || null
|
||||
: null
|
||||
);
|
||||
},
|
||||
{ scope: buildFacebookLoginScopes() }
|
||||
);
|
||||
});
|
||||
|
||||
// Resolves { userAccessToken, pages } on success, null when the user cancels,
|
||||
// and rejects on SDK-load or page-fetch failure (the caller maps it to UI).
|
||||
const loginAndFetchPages = async () => {
|
||||
if (isAuthenticating.value) return null;
|
||||
isAuthenticating.value = true;
|
||||
try {
|
||||
await preloadSdk();
|
||||
const token = await login();
|
||||
if (!token) return null;
|
||||
|
||||
const response = await ChannelApi.fetchFacebookPages(
|
||||
token,
|
||||
accountId.value
|
||||
);
|
||||
const { page_details: pages, user_access_token: userAccessToken } =
|
||||
response.data.data;
|
||||
return { userAccessToken, pages };
|
||||
} finally {
|
||||
isAuthenticating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { isAuthenticating, preloadSdk, loginAndFetchPages };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
isValidBusinessData,
|
||||
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
// Drives Meta's WhatsApp embedded-signup popup (Facebook JS SDK). FB.login()
|
||||
// resolves an auth `code` while the WABA identifiers (waba_id, phone_number_id)
|
||||
// arrive separately over a postMessage event — order isn't guaranteed, so we
|
||||
// hold both and resolve once both are present.
|
||||
//
|
||||
// `runEmbeddedSignup` returns the signup credentials; the caller exchanges them
|
||||
// for an inbox via `inboxes/createWhatsAppEmbeddedSignup` and owns its own UX
|
||||
// (alerts, navigation, etc). Resolves `null` when the user cancels the popup;
|
||||
// rejects on SDK load or signup errors. The window listener is scoped to a
|
||||
// single run, so this is safe to call from anywhere without lifecycle wiring.
|
||||
export function useWhatsappEmbeddedSignup() {
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
const runEmbeddedSignup = () => {
|
||||
if (isAuthenticating.value) return Promise.resolve(null);
|
||||
isAuthenticating.value = true;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let authCode = null;
|
||||
let businessData = null;
|
||||
let settled = false;
|
||||
let messageHandler;
|
||||
|
||||
const settle = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.removeEventListener('message', messageHandler);
|
||||
isAuthenticating.value = false;
|
||||
fn(value);
|
||||
};
|
||||
|
||||
// Both the auth code and the business data arrive asynchronously and in
|
||||
// no fixed order; only resolve once we're holding both.
|
||||
const resolveIfReady = () => {
|
||||
if (!authCode || !businessData) return;
|
||||
settle(resolve, {
|
||||
code: authCode,
|
||||
business_id: businessData.business_id,
|
||||
waba_id: businessData.waba_id,
|
||||
phone_number_id: businessData.phone_number_id || '',
|
||||
});
|
||||
};
|
||||
|
||||
messageHandler = createMessageHandler(data => {
|
||||
if (
|
||||
data.event === 'FINISH' ||
|
||||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
|
||||
) {
|
||||
if (!isValidBusinessData(data.data)) {
|
||||
settle(reject, new Error('Invalid business data'));
|
||||
return;
|
||||
}
|
||||
businessData = data.data;
|
||||
resolveIfReady();
|
||||
} else if (data.event === 'CANCEL') {
|
||||
settle(resolve, null);
|
||||
} else if (data.event === 'error') {
|
||||
settle(reject, new Error(data.error_message || 'Signup error'));
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await setupFacebookSdk(
|
||||
window.chatwootConfig?.whatsappAppId,
|
||||
window.chatwootConfig?.whatsappApiVersion
|
||||
);
|
||||
authCode = await initWhatsAppEmbeddedSignup(
|
||||
window.chatwootConfig?.whatsappConfigurationId
|
||||
);
|
||||
resolveIfReady();
|
||||
} catch (error) {
|
||||
// FB.login() rejects with 'Login cancelled' when the user dismisses
|
||||
// the popup — treat it as a cancel rather than an error.
|
||||
if (error.message === 'Login cancelled') {
|
||||
settle(resolve, null);
|
||||
} else {
|
||||
settle(reject, error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
};
|
||||
|
||||
return { isAuthenticating, runEmbeddedSignup };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Single source of truth for billing currencies on the frontend.
|
||||
|
||||
export const DEFAULT_BILLING_CURRENCY = 'usd';
|
||||
|
||||
// Order here drives the order of the currency toggle in the UI.
|
||||
export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl'];
|
||||
|
||||
export const BILLING_CURRENCY_CONFIG = {
|
||||
usd: {
|
||||
code: 'usd',
|
||||
intlLocale: 'en-US',
|
||||
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD',
|
||||
},
|
||||
brl: {
|
||||
code: 'brl',
|
||||
intlLocale: 'pt-BR',
|
||||
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL',
|
||||
},
|
||||
};
|
||||
|
||||
export const getCurrencyConfig = code =>
|
||||
BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] ||
|
||||
BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY];
|
||||
|
||||
export const formatCurrencyAmount = (amount, code, options = {}) => {
|
||||
const { intlLocale, code: currencyCode } = getCurrencyConfig(code);
|
||||
return new Intl.NumberFormat(intlLocale, {
|
||||
style: 'currency',
|
||||
currency: currencyCode.toUpperCase(),
|
||||
...options,
|
||||
}).format(amount);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ export const FORMATTING = {
|
||||
'link',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'imageUpload',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
@@ -30,6 +31,7 @@ export const FORMATTING = {
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'imageUpload',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
@@ -263,23 +265,3 @@ export const MARKDOWN_PATTERNS = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Editor image resize options for Message Editor
|
||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||
{
|
||||
name: 'Small',
|
||||
height: '24px',
|
||||
},
|
||||
{
|
||||
name: 'Medium',
|
||||
height: '48px',
|
||||
},
|
||||
{
|
||||
name: 'Large',
|
||||
height: '72px',
|
||||
},
|
||||
{
|
||||
name: 'Original Size',
|
||||
height: 'auto',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -7,4 +7,5 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||
MESSAGE_REPLY_TO: 'messageReplyTo',
|
||||
RECENT_SEARCHES: 'recentSearches',
|
||||
SIDEBAR_MINIMIZED_SECTIONS: 'sidebarMinimizedSections',
|
||||
};
|
||||
|
||||
@@ -154,6 +154,10 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const SESSION_EVENTS = Object.freeze({
|
||||
REVOKED_FROM_PROFILE: 'Revoked an active session',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
|
||||
@@ -37,6 +37,13 @@ export const getValuesName = (values, list, idKey, nameKey) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getValuesForContact = (values, contacts) => ({
|
||||
id: values[0],
|
||||
name:
|
||||
contacts?.find(contact => contact.id === values[0])?.name ||
|
||||
`Contact #${values[0]}`,
|
||||
});
|
||||
|
||||
export const getValuesForStatus = values => {
|
||||
return values.map(value => ({ id: value, name: value }));
|
||||
};
|
||||
@@ -84,6 +91,7 @@ export const getValuesForFilter = (filter, params) => {
|
||||
campaigns,
|
||||
labels,
|
||||
priority,
|
||||
contacts,
|
||||
} = params;
|
||||
switch (attribute_key) {
|
||||
case 'status':
|
||||
@@ -94,6 +102,8 @@ export const getValuesForFilter = (filter, params) => {
|
||||
return getValuesName(values, inboxes, 'id', 'name');
|
||||
case 'team_id':
|
||||
return getValuesName(values, teams, 'id', 'name');
|
||||
case 'contact_id':
|
||||
return getValuesForContact(values, contacts);
|
||||
case 'campaign_id':
|
||||
return getValuesName(values, campaigns, 'id', 'title');
|
||||
case 'labels':
|
||||
|
||||
@@ -379,31 +379,6 @@ export const findNodeToInsertImage = (editorState, fileUrl) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Set URL with query and size.
|
||||
*
|
||||
* @param {Object} selectedImageNode - The current selected node.
|
||||
* @param {Object} size - The size to set.
|
||||
* @param {Object} editorView - The editor view.
|
||||
*/
|
||||
export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
||||
if (selectedImageNode) {
|
||||
// Create and apply the transaction
|
||||
const tr = editorView.state.tr.setNodeMarkup(
|
||||
editorView.state.selection.from,
|
||||
null,
|
||||
{
|
||||
src: selectedImageNode.src,
|
||||
height: size.height,
|
||||
}
|
||||
);
|
||||
|
||||
if (tr.docChanged) {
|
||||
editorView.dispatch(tr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips unsupported markdown formatting from content based on the editor schema.
|
||||
* This ensures canned responses with rich formatting can be inserted into channels
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const FACEBOOK_PAGE_SCOPES = [
|
||||
'pages_manage_metadata',
|
||||
'business_management',
|
||||
'pages_messaging',
|
||||
'pages_show_list',
|
||||
'pages_read_engagement',
|
||||
];
|
||||
|
||||
export const INSTAGRAM_SCOPES = [
|
||||
'instagram_basic',
|
||||
'instagram_manage_messages',
|
||||
];
|
||||
|
||||
export const buildFacebookLoginScopes = ({
|
||||
includeInstagramScopes = false,
|
||||
} = {}) => {
|
||||
const scopes = [...FACEBOOK_PAGE_SCOPES];
|
||||
if (includeInstagramScopes) {
|
||||
scopes.push(...INSTAGRAM_SCOPES);
|
||||
}
|
||||
return scopes.join(',');
|
||||
};
|
||||
@@ -159,6 +159,13 @@ export const LOCALE_MENU_ITEMS = {
|
||||
value: 'publish',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
customizeContent: {
|
||||
label:
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT',
|
||||
action: 'customize-content',
|
||||
value: 'customize-content',
|
||||
icon: 'i-lucide-pencil',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
@@ -172,20 +179,28 @@ const disableLocaleMenuItems = menuItems =>
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
return [
|
||||
...disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
]),
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
|
||||
];
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.publishLocale,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -273,6 +273,41 @@ describe('customViewsHelper', () => {
|
||||
};
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
|
||||
});
|
||||
|
||||
it('returns contact name for contact filters when contact is available', () => {
|
||||
const filter = {
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [123],
|
||||
};
|
||||
const params = {
|
||||
contacts: [{ id: 123, name: 'John Doe' }],
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: [],
|
||||
};
|
||||
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
|
||||
id: 123,
|
||||
name: 'John Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns fallback contact display value when contact is not available', () => {
|
||||
const filter = {
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [123],
|
||||
};
|
||||
const params = {
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: [],
|
||||
};
|
||||
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
|
||||
id: 123,
|
||||
name: 'Contact #123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#generateCustomAttributesInputType', () => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
insertAtCursor,
|
||||
removeSignature,
|
||||
replaceSignature,
|
||||
setURLWithQueryAndSize,
|
||||
stripInlineBase64Images,
|
||||
stripUnsupportedFormatting,
|
||||
stripUnsupportedMarkdown,
|
||||
@@ -653,71 +652,6 @@ describe('findNodeToInsertImage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setURLWithQueryAndSize', () => {
|
||||
let selectedNode;
|
||||
let editorView;
|
||||
|
||||
beforeEach(() => {
|
||||
selectedNode = {
|
||||
setAttribute: vi.fn(),
|
||||
};
|
||||
|
||||
const tr = {
|
||||
setNodeMarkup: vi.fn().mockReturnValue({
|
||||
docChanged: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const state = {
|
||||
selection: { from: 0 },
|
||||
tr,
|
||||
};
|
||||
|
||||
editorView = {
|
||||
state,
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('updates the URL with the given size and updates the editor view', () => {
|
||||
const size = { height: '20px' };
|
||||
|
||||
setURLWithQueryAndSize(selectedNode, size, editorView);
|
||||
|
||||
// Check if the editor view is updated
|
||||
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the URL with the given size and updates the editor view with original size', () => {
|
||||
const size = { height: 'auto' };
|
||||
|
||||
setURLWithQueryAndSize(selectedNode, size, editorView);
|
||||
|
||||
// Check if the editor view is updated
|
||||
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not update the editor view if the document has not changed', () => {
|
||||
editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({
|
||||
docChanged: false,
|
||||
});
|
||||
|
||||
const size = { height: '20px' };
|
||||
|
||||
setURLWithQueryAndSize(selectedNode, size, editorView);
|
||||
|
||||
// Check if the editor view dispatch was not called
|
||||
expect(editorView.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not perform any operations if selectedNode is not provided', () => {
|
||||
setURLWithQueryAndSize(null, { height: '20px' }, editorView);
|
||||
|
||||
// Ensure the dispatch method wasn't called
|
||||
expect(editorView.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentNode', () => {
|
||||
let mockEditorView;
|
||||
|
||||
|
||||
@@ -64,4 +64,25 @@ describe('#filterQueryGenerator', () => {
|
||||
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes a selected contact object to contact id', () => {
|
||||
const result = filterQueryGenerator([
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: { id: 123, name: 'Jane Doe' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [123],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,37 +74,40 @@ describe('PortalHelper', () => {
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('returns disabled actions for the default locale', () => {
|
||||
it('disables other actions but keeps customize enabled for the default locale', () => {
|
||||
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
|
||||
const customize = items.find(item => item.action === 'customize-content');
|
||||
|
||||
expect(customize).toBeTruthy();
|
||||
expect(customize.disabled).toBeFalsy();
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'change-default', disabled: true }),
|
||||
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
|
||||
expect.objectContaining({ action: 'delete', disabled: true }),
|
||||
])
|
||||
);
|
||||
items
|
||||
.filter(item => item.action !== 'customize-content')
|
||||
.every(item => item.disabled)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns publish and delete actions for draft locales', () => {
|
||||
it('returns publish, customize, and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'delete']);
|
||||
).toEqual(['publish-locale', 'customize-content', 'delete']);
|
||||
});
|
||||
|
||||
it('returns default, draft, and delete actions for live locales', () => {
|
||||
it('returns default, draft, customize, and delete actions for live locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||
).toEqual([
|
||||
'change-default',
|
||||
'move-to-draft',
|
||||
'customize-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"OR": "OR"
|
||||
},
|
||||
"INPUT_PLACEHOLDER": "Enter value",
|
||||
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||
"CONTACT_FALLBACK": "Contact #{id}",
|
||||
"OPERATOR_LABELS": {
|
||||
"equal_to": "Equal to",
|
||||
"not_equal_to": "Not equal to",
|
||||
@@ -49,6 +51,7 @@
|
||||
"ASSIGNEE_NAME": "Assignee name",
|
||||
"INBOX_NAME": "Inbox name",
|
||||
"TEAM_NAME": "Team name",
|
||||
"CONTACT": "Contact",
|
||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||
"CAMPAIGN_NAME": "Campaign name",
|
||||
"LABELS": "Labels",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"COPILOT_THINKING": "Copilot is thinking",
|
||||
|
||||
@@ -700,9 +700,27 @@
|
||||
"MAKE_DEFAULT": "Make default",
|
||||
"MOVE_TO_DRAFT": "Move to draft",
|
||||
"PUBLISH_LOCALE": "Publish locale",
|
||||
"CUSTOMIZE_CONTENT": "Localize content",
|
||||
"DELETE": "Delete"
|
||||
}
|
||||
},
|
||||
"CONTENT_DIALOG": {
|
||||
"TITLE": "Localize content",
|
||||
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||
"NAME": {
|
||||
"LABEL": "Name"
|
||||
},
|
||||
"PAGE_TITLE": {
|
||||
"LABEL": "Page title"
|
||||
},
|
||||
"HEADER_TEXT": {
|
||||
"LABEL": "Header text"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||
}
|
||||
},
|
||||
"ADD_LOCALE_DIALOG": {
|
||||
"TITLE": "Add a new locale",
|
||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||
|
||||
@@ -653,6 +653,10 @@
|
||||
},
|
||||
"CREDENTIALS": {
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
},
|
||||
"INBOUND": {
|
||||
"LABEL": "Allow incoming calls",
|
||||
"DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING": {
|
||||
|
||||
@@ -86,6 +86,17 @@
|
||||
"NOTE": "Manage additional security features for your account.",
|
||||
"MFA_BUTTON": "Manage Two-Factor Authentication"
|
||||
},
|
||||
"SESSIONS_SECTION": {
|
||||
"TITLE": "Active Sessions",
|
||||
"NOTE": "These are the devices currently logged in to your account.",
|
||||
"CURRENT": "Current session",
|
||||
"REVOKE": "Revoke",
|
||||
"REVOKE_SUCCESS": "Session revoked successfully",
|
||||
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
|
||||
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
|
||||
"LAST_ACTIVE": "Last active",
|
||||
"UNKNOWN_DEVICE": "Unknown device"
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"NOTE": "This token can be used if you are building an API based integration",
|
||||
@@ -439,7 +450,14 @@
|
||||
"TITLE": "Current Plan",
|
||||
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
|
||||
"SEAT_COUNT": "Number of seats",
|
||||
"RENEWS_ON": "Renews on"
|
||||
"RENEWS_ON": "Renews on",
|
||||
"CURRENCY": "Currency"
|
||||
},
|
||||
"CURRENCY": {
|
||||
"OPTIONS": {
|
||||
"USD": "US Dollar (USD)",
|
||||
"BRL": "Brazilian Real (BRL)"
|
||||
}
|
||||
},
|
||||
"VIEW_PRICING": "View Pricing",
|
||||
"MANAGE_SUBSCRIPTION": {
|
||||
@@ -447,6 +465,23 @@
|
||||
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
|
||||
"BUTTON_TXT": "Go to the billing portal"
|
||||
},
|
||||
"CURRENCY": {
|
||||
"TITLE": "Billing currency",
|
||||
"DESCRIPTION": "Choose the currency used for your subscription and credit purchases.",
|
||||
"OPTIONS": {
|
||||
"USD": "US Dollar (USD)",
|
||||
"BRL": "Brazilian Real (BRL)"
|
||||
},
|
||||
"SUCCESS": "Billing currency updated successfully.",
|
||||
"ERROR": "Failed to update billing currency. Please try again.",
|
||||
"CONFIRM": {
|
||||
"TITLE": "Switch billing currency?",
|
||||
"DESCRIPTION": "You are about to switch your billing currency to {currency}.",
|
||||
"WARNING": "Your current subscription will be cancelled and a new one will be started in the selected currency. Pricing and any remaining credits may differ. This cannot be undone automatically.",
|
||||
"CONFIRM_BUTTON": "Switch currency",
|
||||
"CANCEL_BUTTON": "Cancel"
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
"TITLE": "Captain",
|
||||
"DESCRIPTION": "Manage usage and credits for Captain AI.",
|
||||
@@ -475,6 +510,7 @@
|
||||
"PURCHASE": "Purchase Credits",
|
||||
"LOADING": "Loading options...",
|
||||
"FETCH_ERROR": "Failed to load credit options. Please try again.",
|
||||
"RETRY": "Retry",
|
||||
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
|
||||
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
|
||||
"CONFIRM": {
|
||||
|
||||
@@ -188,7 +188,6 @@ export default {
|
||||
:status="contact.availability_status"
|
||||
:size="48"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -94,8 +94,18 @@ export default {
|
||||
return this.getAccount(this.accountId) || {};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'currentAccount.id'(id) {
|
||||
if (id) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeAccount();
|
||||
// Account already in the store (navigated in): seed immediately.
|
||||
if (this.currentAccount.id) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initializeAccount() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import SectionLayout from './SectionLayout.vue';
|
||||
const { t } = useI18n();
|
||||
const { currentAccount } = useAccount();
|
||||
|
||||
const getAccountId = computed(() => currentAccount.value.id.toString());
|
||||
const getAccountId = computed(() => currentAccount.value?.id?.toString());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { format } from 'date-fns';
|
||||
import sessionStorage from 'shared/helpers/sessionStorage';
|
||||
import {
|
||||
SUPPORTED_BILLING_CURRENCIES,
|
||||
DEFAULT_BILLING_CURRENCY,
|
||||
getCurrencyConfig,
|
||||
} from 'dashboard/constants/billing';
|
||||
|
||||
import BillingMeter from './components/BillingMeter.vue';
|
||||
import BillingCard from './components/BillingCard.vue';
|
||||
import BillingHeader from './components/BillingHeader.vue';
|
||||
import DetailItem from './components/DetailItem.vue';
|
||||
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
|
||||
import SwitchCurrencyDialog from './components/SwitchCurrencyDialog.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { currentAccount, isOnChatwootCloud } = useAccount();
|
||||
const {
|
||||
captainEnabled,
|
||||
@@ -28,6 +38,7 @@ const {
|
||||
} = useCaptain();
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const store = useStore();
|
||||
|
||||
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
@@ -61,6 +72,13 @@ const subscribedQuantity = computed(() => {
|
||||
return customAttributes.value.subscribed_quantity;
|
||||
});
|
||||
|
||||
const billingCurrency = computed(() => {
|
||||
if (!customAttributes.value.billing_currency) return '';
|
||||
return t(
|
||||
getCurrencyConfig(customAttributes.value.billing_currency).i18nLabelKey
|
||||
);
|
||||
});
|
||||
|
||||
const subscriptionRenewsOn = computed(() => {
|
||||
if (!customAttributes.value.subscription_ends_on) return '';
|
||||
const endDate = new Date(customAttributes.value.subscription_ends_on);
|
||||
@@ -118,6 +136,55 @@ const handleBillingPageLogic = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isSwitchingCurrency = computed(() => uiFlags.value.isSwitchingCurrency);
|
||||
|
||||
// Currency switching is shown only when multi-currency billing is enabled for the installation.
|
||||
const showCurrencyToggle = computed(
|
||||
() => globalConfig.value.multipleCurrencySupported
|
||||
);
|
||||
|
||||
const currentBillingCurrency = computed(() =>
|
||||
(
|
||||
customAttributes.value.billing_currency || DEFAULT_BILLING_CURRENCY
|
||||
).toLowerCase()
|
||||
);
|
||||
|
||||
const currencyTabs = computed(() =>
|
||||
SUPPORTED_BILLING_CURRENCIES.map(code => ({
|
||||
label: t(getCurrencyConfig(code).i18nLabelKey),
|
||||
value: code,
|
||||
}))
|
||||
);
|
||||
|
||||
const activeCurrencyIndex = computed(() => {
|
||||
const index = SUPPORTED_BILLING_CURRENCIES.indexOf(
|
||||
currentBillingCurrency.value
|
||||
);
|
||||
return index === -1 ? 0 : index;
|
||||
});
|
||||
|
||||
const pendingCurrency = ref(null);
|
||||
const switchCurrencyDialogRef = ref(null);
|
||||
|
||||
const onSelectCurrency = tab => {
|
||||
if (!tab?.value || tab.value === currentBillingCurrency.value) return;
|
||||
if (isSwitchingCurrency.value) return;
|
||||
pendingCurrency.value = tab.value;
|
||||
switchCurrencyDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const onConfirmSwitchCurrency = async () => {
|
||||
try {
|
||||
await store.dispatch('accounts/switchBillingCurrency', {
|
||||
currency: pendingCurrency.value,
|
||||
});
|
||||
switchCurrencyDialogRef.value?.close();
|
||||
useAlert(t('BILLING_SETTINGS.CURRENCY.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(error.message || t('BILLING_SETTINGS.CURRENCY.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const onClickBillingPortal = () => {
|
||||
store.dispatch('accounts/checkout');
|
||||
};
|
||||
@@ -188,8 +255,30 @@ onMounted(handleBillingPageLogic);
|
||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
|
||||
:value="subscriptionRenewsOn"
|
||||
/>
|
||||
<DetailItem
|
||||
v-if="billingCurrency"
|
||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.CURRENCY')"
|
||||
:value="billingCurrency"
|
||||
/>
|
||||
</div>
|
||||
</BillingCard>
|
||||
<BillingCard
|
||||
v-if="showCurrencyToggle"
|
||||
:title="$t('BILLING_SETTINGS.CURRENCY.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CURRENCY.DESCRIPTION')"
|
||||
>
|
||||
<template #action>
|
||||
<div
|
||||
:class="{ 'pointer-events-none opacity-60': isSwitchingCurrency }"
|
||||
>
|
||||
<TabBar
|
||||
:tabs="currencyTabs"
|
||||
:initial-active-tab="activeCurrencyIndex"
|
||||
@tab-changed="onSelectCurrency"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BillingCard>
|
||||
<BillingCard
|
||||
v-if="captainEnabled"
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
|
||||
@@ -263,6 +352,12 @@ onMounted(handleBillingPageLogic);
|
||||
ref="purchaseCreditsModalRef"
|
||||
@success="handleTopupSuccess"
|
||||
/>
|
||||
<SwitchCurrencyDialog
|
||||
ref="switchCurrencyDialogRef"
|
||||
:target-currency="pendingCurrency"
|
||||
:is-loading="isSwitchingCurrency"
|
||||
@confirm="onConfirmSwitchCurrency"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+3
-5
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { formatCurrencyAmount } from 'dashboard/constants/billing';
|
||||
|
||||
defineProps({
|
||||
credits: {
|
||||
type: Number,
|
||||
@@ -33,11 +35,7 @@ const formatCredits = credits => {
|
||||
};
|
||||
|
||||
const formatAmount = (amount, currency) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
return formatCurrencyAmount(amount, currency, { minimumFractionDigits: 0 });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
+91
-43
@@ -4,20 +4,18 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CreditPackageCard from './CreditPackageCard.vue';
|
||||
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
|
||||
import {
|
||||
formatCurrencyAmount,
|
||||
DEFAULT_BILLING_CURRENCY,
|
||||
} from 'dashboard/constants/billing';
|
||||
|
||||
const emit = defineEmits(['close', 'success']);
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const TOPUP_OPTIONS = [
|
||||
{ credits: 1000, amount: 20.0, currency: 'usd' },
|
||||
{ credits: 2500, amount: 50.0, currency: 'usd' },
|
||||
{ credits: 6000, amount: 100.0, currency: 'usd' },
|
||||
{ credits: 12000, amount: 200.0, currency: 'usd' },
|
||||
];
|
||||
|
||||
const POPULAR_CREDITS_AMOUNT = 6000;
|
||||
const STEP_SELECT = 'select';
|
||||
const STEP_CONFIRM = 'confirm';
|
||||
@@ -27,16 +25,20 @@ const selectedCredits = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const currentStep = ref(STEP_SELECT);
|
||||
|
||||
// Topup packages come from the backend for the account's billing currency.
|
||||
const topupOptions = ref([]);
|
||||
const optionsCurrency = ref(DEFAULT_BILLING_CURRENCY);
|
||||
const isFetchingOptions = ref(false);
|
||||
const fetchError = ref(false);
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
|
||||
return topupOptions.value.find(o => o.credits === selectedCredits.value);
|
||||
});
|
||||
|
||||
const formattedAmount = computed(() => {
|
||||
if (!selectedOption.value) return '';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: selectedOption.value.currency.toUpperCase(),
|
||||
}).format(selectedOption.value.amount);
|
||||
const { amount, currency } = selectedOption.value;
|
||||
return formatCurrencyAmount(amount, currency || optionsCurrency.value);
|
||||
});
|
||||
|
||||
const formattedCredits = computed(() => {
|
||||
@@ -64,24 +66,44 @@ const handlePackageSelect = credits => {
|
||||
selectedCredits.value = credits;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
const popularOption = TOPUP_OPTIONS.find(
|
||||
const selectDefaultOption = () => {
|
||||
const popularOption = topupOptions.value.find(
|
||||
o => o.credits === POPULAR_CREDITS_AMOUNT
|
||||
);
|
||||
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
|
||||
selectedCredits.value =
|
||||
popularOption?.credits || topupOptions.value[0]?.credits || null;
|
||||
};
|
||||
|
||||
const fetchOptions = async () => {
|
||||
isFetchingOptions.value = true;
|
||||
fetchError.value = false;
|
||||
try {
|
||||
const { data } = await EnterpriseAccountAPI.getTopupOptions();
|
||||
topupOptions.value = data.options ?? [];
|
||||
optionsCurrency.value = (
|
||||
data.currency || DEFAULT_BILLING_CURRENCY
|
||||
).toLowerCase();
|
||||
selectDefaultOption();
|
||||
} catch {
|
||||
fetchError.value = true;
|
||||
topupOptions.value = [];
|
||||
} finally {
|
||||
isFetchingOptions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
currentStep.value = STEP_SELECT;
|
||||
isLoading.value = false;
|
||||
selectedCredits.value = null;
|
||||
dialogRef.value?.open();
|
||||
fetchOptions();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const goToConfirmStep = () => {
|
||||
if (!selectedOption.value) return;
|
||||
currentStep.value = STEP_CONFIRM;
|
||||
@@ -127,32 +149,58 @@ defineExpose({ open, close });
|
||||
:width="dialogWidth"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Step 1: Select Credits Package -->
|
||||
<template v-if="currentStep === 'select'">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<CreditPackageCard
|
||||
v-for="option in TOPUP_OPTIONS"
|
||||
:key="option.credits"
|
||||
name="credit-package"
|
||||
:credits="option.credits"
|
||||
:amount="option.amount"
|
||||
:currency="option.currency"
|
||||
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
|
||||
:is-selected="selectedCredits === option.credits"
|
||||
@select="handlePackageSelect(option.credits)"
|
||||
<template v-if="currentStep === STEP_SELECT">
|
||||
<div
|
||||
v-if="isFetchingOptions"
|
||||
class="flex items-center justify-center gap-2 py-10"
|
||||
>
|
||||
<Spinner />
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
$t('BILLING_SETTINGS.TOPUP.LOADING')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="fetchError"
|
||||
class="flex flex-col items-center justify-center gap-3 py-10"
|
||||
>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.FETCH_ERROR') }}
|
||||
</p>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.RETRY')"
|
||||
@click="fetchOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
<span class="font-semibold text-n-slate-12">{{
|
||||
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
|
||||
}}</span>
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<CreditPackageCard
|
||||
v-for="option in topupOptions"
|
||||
:key="option.credits"
|
||||
name="credit-package"
|
||||
:credits="option.credits"
|
||||
:amount="option.amount"
|
||||
:currency="option.currency"
|
||||
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
|
||||
:is-selected="selectedCredits === option.credits"
|
||||
@select="handlePackageSelect(option.credits)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
<span class="font-semibold text-n-slate-12">{{
|
||||
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
|
||||
}}</span>
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Confirm Purchase -->
|
||||
@@ -178,7 +226,7 @@ defineExpose({ open, close });
|
||||
<template #footer>
|
||||
<!-- Step 1 Footer -->
|
||||
<div
|
||||
v-if="currentStep === 'select'"
|
||||
v-if="currentStep === STEP_SELECT"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
@@ -192,7 +240,7 @@ defineExpose({ open, close });
|
||||
color="blue"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
||||
class="w-full"
|
||||
:disabled="!selectedCredits"
|
||||
:disabled="!selectedCredits || isFetchingOptions || fetchError"
|
||||
@click="goToConfirmStep"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import { getCurrencyConfig } from 'dashboard/constants/billing';
|
||||
|
||||
const props = defineProps({
|
||||
targetCurrency: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const currencyLabel = computed(() =>
|
||||
t(getCurrencyConfig(props.targetCurrency).i18nLabelKey)
|
||||
);
|
||||
|
||||
const open = () => dialogRef.value?.open();
|
||||
const close = () => dialogRef.value?.close();
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="$t('BILLING_SETTINGS.CURRENCY.CONFIRM.TITLE')"
|
||||
:description="
|
||||
$t('BILLING_SETTINGS.CURRENCY.CONFIRM.DESCRIPTION', {
|
||||
currency: currencyLabel,
|
||||
})
|
||||
"
|
||||
:confirm-button-label="
|
||||
$t('BILLING_SETTINGS.CURRENCY.CONFIRM.CONFIRM_BUTTON')
|
||||
"
|
||||
:cancel-button-label="$t('BILLING_SETTINGS.CURRENCY.CONFIRM.CANCEL_BUTTON')"
|
||||
:is-loading="isLoading"
|
||||
@confirm="emit('confirm')"
|
||||
>
|
||||
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
|
||||
<p class="text-sm text-n-amber-11">
|
||||
{{ $t('BILLING_SETTINGS.CURRENCY.CONFIRM.WARNING') }}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,20 +1,16 @@
|
||||
<script>
|
||||
/* eslint-env browser */
|
||||
/* global FB */
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
|
||||
import ChannelApi from '../../../../../api/channels';
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import router from '../../../../index';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
|
||||
export default {
|
||||
@@ -25,11 +21,12 @@ export default {
|
||||
ComboBox,
|
||||
},
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
|
||||
return {
|
||||
accountId,
|
||||
replaceInstallationName,
|
||||
preloadSdk,
|
||||
loginAndFetchPages,
|
||||
v$: useVuelidate(),
|
||||
};
|
||||
},
|
||||
@@ -37,9 +34,7 @@ export default {
|
||||
return {
|
||||
isCreating: false,
|
||||
hasError: false,
|
||||
omniauth_token: '',
|
||||
user_access_token: '',
|
||||
channel: 'facebook',
|
||||
selectedPage: { name: null, id: null },
|
||||
pageName: '',
|
||||
pageList: [],
|
||||
@@ -78,24 +73,29 @@ export default {
|
||||
},
|
||||
|
||||
mounted() {
|
||||
window.fbAsyncInit = this.runFBInit;
|
||||
// Warm the SDK so the login click opens its popup within the gesture's
|
||||
// activation window (see useFacebookPageConnect).
|
||||
this.preloadSdk();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async startLogin() {
|
||||
this.hasLoginStarted = true;
|
||||
try {
|
||||
// this will load the SDK in a promise, and resolve it when the sdk is loaded
|
||||
// in case the SDK is already present, it will resolve immediately
|
||||
await this.loadFBsdk();
|
||||
this.runFBInit(); // run init anyway, `tryFBlogin` won't wait for `fbAsyncInit` otherwise.
|
||||
this.tryFBlogin(); // make an attempt to login
|
||||
const result = await this.loginAndFetchPages();
|
||||
if (!result) {
|
||||
// Cancelled popup / not authorized — surface a generic auth error.
|
||||
this.hasError = true;
|
||||
this.errorStateMessage = this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH');
|
||||
this.errorStateDescription = '';
|
||||
return;
|
||||
}
|
||||
this.pageList = result.pages;
|
||||
this.user_access_token = result.userAccessToken;
|
||||
} catch (error) {
|
||||
if (error.name === 'ScriptLoaderError') {
|
||||
// if the error was related to script loading, we show a toast
|
||||
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
|
||||
} else {
|
||||
// if the error was anything else, we capture it and show a toast
|
||||
Sentry.captureException(error);
|
||||
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
|
||||
}
|
||||
@@ -114,81 +114,6 @@ export default {
|
||||
this.v$.selectedPage.$touch();
|
||||
},
|
||||
|
||||
initChannelAuth(channel) {
|
||||
if (channel === 'facebook') {
|
||||
this.loadFBsdk();
|
||||
}
|
||||
},
|
||||
|
||||
runFBInit() {
|
||||
FB.init({
|
||||
appId: window.chatwootConfig.fbAppId,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig.fbApiVersion,
|
||||
status: true,
|
||||
});
|
||||
window.fbSDKLoaded = true;
|
||||
FB.AppEvents.logPageView();
|
||||
},
|
||||
|
||||
async loadFBsdk() {
|
||||
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
|
||||
id: 'facebook-jssdk',
|
||||
});
|
||||
},
|
||||
|
||||
tryFBlogin() {
|
||||
FB.login(
|
||||
response => {
|
||||
this.hasError = false;
|
||||
if (response.status === 'connected') {
|
||||
this.fetchPages(response.authResponse.accessToken);
|
||||
} else if (response.status === 'not_authorized') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('FACEBOOK AUTH ERROR', response);
|
||||
this.hasError = true;
|
||||
// The person is logged into Facebook, but not your app.
|
||||
this.errorStateMessage = this.$t(
|
||||
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED'
|
||||
);
|
||||
this.errorStateDescription = this.$t(
|
||||
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED_HELP'
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('FACEBOOK AUTH ERROR', response);
|
||||
this.hasError = true;
|
||||
// The person is not logged into Facebook, so we're not sure if
|
||||
// they are logged into this app or not.
|
||||
this.errorStateMessage = this.$t(
|
||||
'INBOX_MGMT.DETAILS.ERROR_FB_AUTH'
|
||||
);
|
||||
this.errorStateDescription = '';
|
||||
}
|
||||
},
|
||||
{
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async fetchPages(_token) {
|
||||
try {
|
||||
const response = await ChannelApi.fetchFacebookPages(
|
||||
_token,
|
||||
this.accountId
|
||||
);
|
||||
const {
|
||||
data: { data },
|
||||
} = response;
|
||||
this.pageList = data.page_details;
|
||||
this.user_access_token = data.user_access_token;
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
|
||||
channelParams() {
|
||||
return {
|
||||
user_access_token: this.user_access_token,
|
||||
|
||||
+25
-158
@@ -1,21 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import globalConstants from 'dashboard/constants/globals.js';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
isValidBusinessData,
|
||||
} from './whatsapp/utils';
|
||||
|
||||
const props = defineProps({
|
||||
enableCallingOnComplete: {
|
||||
@@ -27,15 +22,10 @@ const props = defineProps({
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
|
||||
// State
|
||||
const fbSdkLoaded = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const processingMessage = ref('');
|
||||
const authCodeReceived = ref(false);
|
||||
const authCode = ref(null);
|
||||
const businessData = ref(null);
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
const benefits = computed(() => [
|
||||
{
|
||||
@@ -54,25 +44,6 @@ const benefits = computed(() => [
|
||||
|
||||
const showLoader = computed(() => isAuthenticating.value || isProcessing.value);
|
||||
|
||||
// Error handling
|
||||
const handleSignupError = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
data.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
const handleSignupCancellation = () => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
};
|
||||
|
||||
const enableCallingForInbox = async inboxId => {
|
||||
try {
|
||||
await InboxesAPI.enableWhatsappCalling(inboxId);
|
||||
@@ -86,14 +57,12 @@ const enableCallingForInbox = async inboxId => {
|
||||
const handleSignupSuccess = async inboxData => {
|
||||
if (inboxData && inboxData.id) {
|
||||
if (props.enableCallingOnComplete) {
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING'
|
||||
);
|
||||
await enableCallingForInbox(inboxData.id);
|
||||
}
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
@@ -104,7 +73,6 @@ const handleSignupSuccess = async inboxData => {
|
||||
});
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
|
||||
router.replace({
|
||||
name: 'settings_inbox_list',
|
||||
@@ -112,12 +80,21 @@ const handleSignupSuccess = async inboxData => {
|
||||
}
|
||||
};
|
||||
|
||||
// Signup flow
|
||||
const completeSignupFlow = async businessDataParam => {
|
||||
if (!authCodeReceived.value || !authCode.value) {
|
||||
handleSignupError({
|
||||
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
|
||||
});
|
||||
const launchEmbeddedSignup = async () => {
|
||||
let credentials;
|
||||
try {
|
||||
credentials = await runEmbeddedSignup();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolves null when the user dismisses the Meta popup.
|
||||
if (!credentials) {
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -125,130 +102,20 @@ const completeSignupFlow = async businessDataParam => {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
|
||||
);
|
||||
|
||||
try {
|
||||
const params = {
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam?.phone_number_id || '',
|
||||
};
|
||||
|
||||
const responseData = await store.dispatch(
|
||||
const inboxData = await store.dispatch(
|
||||
'inboxes/createWhatsAppEmbeddedSignup',
|
||||
params
|
||||
credentials
|
||||
);
|
||||
|
||||
authCode.value = null;
|
||||
handleSignupSuccess(responseData);
|
||||
await handleSignupSuccess(inboxData);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
isProcessing.value = false;
|
||||
useAlert(
|
||||
parseAPIErrorResponse(error) ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
handleSignupError({ error: errorMessage });
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
if (
|
||||
data.event === 'FINISH' ||
|
||||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
|
||||
) {
|
||||
const businessDataLocal = data.data;
|
||||
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
businessData.value = businessDataLocal;
|
||||
if (authCodeReceived.value && authCode.value) {
|
||||
await completeSignupFlow(businessDataLocal);
|
||||
} else {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleSignupError({
|
||||
error: t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
handleSignupCancellation();
|
||||
} else if (data.event === 'error') {
|
||||
handleSignupError({
|
||||
error:
|
||||
data.error_message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
|
||||
error_id: data.error_id,
|
||||
session_id: data.session_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupMessage = createMessageHandler(handleEmbeddedSignupData);
|
||||
|
||||
const launchEmbeddedSignup = async () => {
|
||||
try {
|
||||
isAuthenticating.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
await setupFacebookSdk(
|
||||
window.chatwootConfig?.whatsappAppId,
|
||||
window.chatwootConfig?.whatsappApiVersion
|
||||
);
|
||||
fbSdkLoaded.value = true;
|
||||
|
||||
const code = await initWhatsAppEmbeddedSignup(
|
||||
window.chatwootConfig?.whatsappConfigurationId
|
||||
);
|
||||
|
||||
authCode.value = code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
if (businessData.value) {
|
||||
completeSignupFlow(businessData.value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message === 'Login cancelled') {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
} else {
|
||||
handleSignupError({
|
||||
error:
|
||||
error.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const setupMessageListener = () => {
|
||||
window.addEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const cleanupMessageListener = () => {
|
||||
window.removeEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMessageListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import InboxReconnectionRequired from '../components/InboxReconnectionRequired.v
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
|
||||
export default {
|
||||
@@ -20,6 +21,11 @@ export default {
|
||||
inboxId() {
|
||||
return this.inbox.id;
|
||||
},
|
||||
facebookLoginScopes() {
|
||||
return buildFacebookLoginScopes({
|
||||
includeInstagramScopes: !!this.inbox.instagram_id,
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.fbAsyncInit = this.runFBInit;
|
||||
@@ -77,8 +83,7 @@ export default {
|
||||
}
|
||||
},
|
||||
{
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
|
||||
scope: this.facebookLoginScopes,
|
||||
auth_type: 'reauthorize',
|
||||
}
|
||||
);
|
||||
|
||||
+42
@@ -1,9 +1,11 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import NextInput from 'dashboard/components-next/input/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -11,6 +13,7 @@ export default {
|
||||
SettingsToggleSection,
|
||||
NextInput,
|
||||
NextButton,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
@@ -21,9 +24,11 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
voiceEnabled: this.inbox.voice_enabled || false,
|
||||
inboundCallsEnabled: this.inbox.inbound_calls_enabled !== false,
|
||||
apiKeySid: this.inbox.api_key_sid || '',
|
||||
apiKeySecret: '',
|
||||
isUpdating: false,
|
||||
isTogglingInbound: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -62,8 +67,27 @@ export default {
|
||||
'inbox.api_key_sid'(val) {
|
||||
this.apiKeySid = val || '';
|
||||
},
|
||||
'inbox.inbound_calls_enabled'(val) {
|
||||
this.inboundCallsEnabled = val !== false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async handleInboundToggle(newValue) {
|
||||
if (this.isTogglingInbound) return;
|
||||
const previousValue = this.inboundCallsEnabled;
|
||||
this.inboundCallsEnabled = newValue;
|
||||
this.isTogglingInbound = true;
|
||||
try {
|
||||
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
|
||||
await this.$store.dispatch('inboxes/get', this.inbox.id);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (_) {
|
||||
this.inboundCallsEnabled = previousValue;
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isTogglingInbound = false;
|
||||
}
|
||||
},
|
||||
async updateVoiceSettings() {
|
||||
this.isUpdating = true;
|
||||
try {
|
||||
@@ -123,6 +147,24 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="inbox.voice_enabled"
|
||||
class="relative"
|
||||
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
|
||||
>
|
||||
<SettingsToggleSection
|
||||
:model-value="inboundCallsEnabled"
|
||||
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
|
||||
:description="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')"
|
||||
:hide-toggle="isTogglingInbound"
|
||||
@update:model-value="handleInboundToggle"
|
||||
>
|
||||
<template v-if="isTogglingInbound" #hiddenToggle>
|
||||
<Spinner class="size-4 text-n-slate-11" />
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
</div>
|
||||
|
||||
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
|
||||
+41
@@ -24,10 +24,13 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
|
||||
inboundCallsEnabled:
|
||||
this.inbox.provider_config?.inbound_calls_enabled !== false,
|
||||
permissionRequestBody:
|
||||
this.inbox.provider_config?.call_permission_request_body || '',
|
||||
isUpdating: false,
|
||||
isTogglingCalling: false,
|
||||
isTogglingInbound: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -44,8 +47,27 @@ export default {
|
||||
'inbox.provider_config.call_permission_request_body'(val) {
|
||||
this.permissionRequestBody = val || '';
|
||||
},
|
||||
'inbox.provider_config.inbound_calls_enabled'(val) {
|
||||
this.inboundCallsEnabled = val !== false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async handleInboundToggle(newValue) {
|
||||
if (this.isTogglingInbound) return;
|
||||
const previousValue = this.inboundCallsEnabled;
|
||||
this.inboundCallsEnabled = newValue;
|
||||
this.isTogglingInbound = true;
|
||||
try {
|
||||
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
|
||||
await this.$store.dispatch('inboxes/get', this.inbox.id);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (_) {
|
||||
this.inboundCallsEnabled = previousValue;
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isTogglingInbound = false;
|
||||
}
|
||||
},
|
||||
async handleCallingToggle(newValue) {
|
||||
if (this.isTogglingCalling) return;
|
||||
const previousValue = this.callingEnabled;
|
||||
@@ -117,6 +139,25 @@ export default {
|
||||
</div>
|
||||
|
||||
<template v-if="callingEnabled">
|
||||
<div
|
||||
class="relative"
|
||||
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
|
||||
>
|
||||
<SettingsToggleSection
|
||||
:model-value="inboundCallsEnabled"
|
||||
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')
|
||||
"
|
||||
:hide-toggle="isTogglingInbound"
|
||||
@update:model-value="handleInboundToggle"
|
||||
>
|
||||
<template v-if="isTogglingInbound" #hiddenToggle>
|
||||
<Spinner class="size-4 text-n-slate-11" />
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
</div>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="phoneNumber"
|
||||
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import authAPI from 'dashboard/api/auth';
|
||||
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
|
||||
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessions = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const relativeTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return formatDistanceToNow(parseISO(dateStr), { addSuffix: true });
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const deviceIcon = session => {
|
||||
const name = (session.device_name || '').toLowerCase();
|
||||
if (
|
||||
name.includes('iphone') ||
|
||||
name.includes('android') ||
|
||||
name.includes('mobile')
|
||||
) {
|
||||
return 'i-lucide-smartphone';
|
||||
}
|
||||
if (name.includes('ipad') || name.includes('tablet')) {
|
||||
return 'i-lucide-tablet';
|
||||
}
|
||||
return 'i-lucide-monitor';
|
||||
};
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) {
|
||||
parts.push(
|
||||
session.browser_version
|
||||
? `${session.browser_name} ${session.browser_version}`
|
||||
: session.browser_name
|
||||
);
|
||||
}
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return (
|
||||
parts.join(' on ') ||
|
||||
t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.UNKNOWN_DEVICE')
|
||||
);
|
||||
};
|
||||
|
||||
const locationLabel = session => {
|
||||
const parts = [];
|
||||
if (session.city) parts.push(session.city);
|
||||
if (session.country) parts.push(session.country);
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await authAPI.getSessions();
|
||||
sessions.value = data;
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.FETCH_ERROR'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeSession = async session => {
|
||||
try {
|
||||
await authAPI.revokeSession(session.id);
|
||||
sessions.value = sessions.value.filter(s => s.id !== session.id);
|
||||
AnalyticsHelper.track(SESSION_EVENTS.REVOKED_FROM_PROFILE);
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchSessions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon
|
||||
:icon="deviceIcon(session)"
|
||||
class="size-5 mt-0.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.current"
|
||||
class="rounded-full bg-n-teal-3 px-2 py-0.5 text-caption text-n-teal-11"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="locationLabel(session)"
|
||||
class="text-body-b3 text-n-slate-11"
|
||||
>
|
||||
{{ locationLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.last_activity_at"
|
||||
class="text-body-b3 text-n-slate-10"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
|
||||
{{ relativeTime(session.last_activity_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!session.current"
|
||||
type="button"
|
||||
faded
|
||||
xs
|
||||
:label="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE')"
|
||||
color="ruby"
|
||||
@click="revokeSession(session)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,6 +20,7 @@ import SectionLayout from '../account/components/SectionLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AccessToken from './AccessToken.vue';
|
||||
import MfaSettingsCard from './MfaSettingsCard.vue';
|
||||
import ActiveSessions from './ActiveSessions.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ export default {
|
||||
AudioNotifications,
|
||||
AccessToken,
|
||||
MfaSettingsCard,
|
||||
ActiveSessions,
|
||||
BaseSettingsHeader,
|
||||
},
|
||||
setup() {
|
||||
@@ -307,6 +309,13 @@ export default {
|
||||
>
|
||||
<MfaSettingsCard />
|
||||
</SectionLayout>
|
||||
<SectionLayout
|
||||
with-border
|
||||
:title="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.TITLE')"
|
||||
:description="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.NOTE')"
|
||||
>
|
||||
<ActiveSessions />
|
||||
</SectionLayout>
|
||||
<Policy :permissions="audioNotificationPermissions">
|
||||
<SectionLayout
|
||||
with-border
|
||||
|
||||
@@ -48,7 +48,6 @@ const updateSignature = () => {
|
||||
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
|
||||
channel-type="Context::MessageSignature"
|
||||
:enable-suggestions="false"
|
||||
show-image-resize-toolbar
|
||||
/>
|
||||
<div>
|
||||
<NextButton
|
||||
|
||||
@@ -20,6 +20,7 @@ const state = {
|
||||
isUpdating: false,
|
||||
isCheckoutInProcess: false,
|
||||
isFetchingLimits: false,
|
||||
isSwitchingCurrency: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -152,6 +153,19 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
switchBillingCurrency: async ({ commit, dispatch }, { currency }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSwitchingCurrency: true });
|
||||
try {
|
||||
await EnterpriseAccountAPI.switchCurrency(currency);
|
||||
// Refresh the account so billing_currency and subscription details reflect the switch.
|
||||
await dispatch('get', { silent: true });
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSwitchingCurrency: false });
|
||||
}
|
||||
},
|
||||
|
||||
limits: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
|
||||
try {
|
||||
|
||||
@@ -25,7 +25,9 @@ export const createConversationPayload = ({ params, contactId, files }) => {
|
||||
payload.append('inbox_id', inboxId);
|
||||
payload.append('contact_id', contactId);
|
||||
payload.append('source_id', sourceId);
|
||||
payload.append('additional_attributes[mail_subject]', mailSubject);
|
||||
if (mailSubject) {
|
||||
payload.append('additional_attributes[mail_subject]', mailSubject);
|
||||
}
|
||||
payload.append('assignee_id', assigneeId);
|
||||
|
||||
return payload;
|
||||
|
||||
@@ -2,15 +2,21 @@ import ConversationAPI from '../../api/conversations';
|
||||
import types from '../mutation-types';
|
||||
|
||||
export const state = {
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
};
|
||||
|
||||
const normalizeCount = count => {
|
||||
const parsedCount = Number(count);
|
||||
return Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0;
|
||||
};
|
||||
|
||||
const normalizeCounts = counts => {
|
||||
return Object.entries(counts || {}).reduce((result, [id, count]) => {
|
||||
const parsedCount = Number(count);
|
||||
if (Number.isFinite(parsedCount) && parsedCount > 0) {
|
||||
const parsedCount = normalizeCount(count);
|
||||
if (parsedCount > 0) {
|
||||
result[String(id)] = parsedCount;
|
||||
}
|
||||
|
||||
@@ -19,6 +25,9 @@ const normalizeCounts = counts => {
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getAllUnreadCount($state) {
|
||||
return $state.allCount;
|
||||
},
|
||||
getInboxUnreadCount: $state => inboxId => {
|
||||
return $state.inboxes[String(inboxId)] || 0;
|
||||
},
|
||||
@@ -55,6 +64,7 @@ export const actions = {
|
||||
|
||||
export const mutations = {
|
||||
[types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) {
|
||||
$state.allCount = normalizeCount(payload.all_count);
|
||||
$state.inboxes = normalizeCounts(payload.inboxes);
|
||||
$state.labels = normalizeCounts(payload.labels);
|
||||
$state.teams = normalizeCounts(payload.teams);
|
||||
|
||||
@@ -73,6 +73,12 @@ const getValueFromConversation = (conversation, attributeKey) => {
|
||||
return conversation.display_id || conversation.id;
|
||||
case 'assignee_id':
|
||||
return conversation.meta?.assignee?.id;
|
||||
case 'contact_id':
|
||||
return (
|
||||
conversation.meta?.sender?.id ||
|
||||
conversation.contact?.id ||
|
||||
conversation.contact_id
|
||||
);
|
||||
case 'inbox_id':
|
||||
return conversation.inbox_id;
|
||||
case 'team_id':
|
||||
|
||||
+26
@@ -244,6 +244,32 @@ describe('filterHelpers', () => {
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match conversation with equal_to operator for contact_id', () => {
|
||||
const conversation = { meta: { sender: { id: 42 } } };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: { id: 42, name: 'Jane Doe' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match conversation with saved contact_id filter values', () => {
|
||||
const conversation = { meta: { sender: { id: 42 } } };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [42],
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
// Standard attribute tests - priority
|
||||
it('should match conversation with equal_to operator for priority', () => {
|
||||
const conversation = { priority: 'urgent' };
|
||||
|
||||
@@ -15,6 +15,12 @@ const FILTER_KEYS = {
|
||||
[VIEW_TYPES.CONTACT]: VIEW_TYPES.CONTACT,
|
||||
};
|
||||
|
||||
// a folder's contact_id filter stores only the id, extract it so the
|
||||
// contact can be fetched and its name shown in the edit folder modal
|
||||
const getFolderContactId = folder =>
|
||||
folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id')
|
||||
?.values?.[0];
|
||||
|
||||
export const state = {
|
||||
[VIEW_TYPES.CONVERSATION]: {
|
||||
records: [],
|
||||
@@ -47,6 +53,9 @@ export const getters = {
|
||||
getActiveConversationFolder(_state) {
|
||||
return _state.activeConversationFolder;
|
||||
},
|
||||
getActiveFolderContactId(_state) {
|
||||
return getFolderContactId(_state.activeConversationFolder);
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -104,8 +113,11 @@ export const actions = {
|
||||
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false });
|
||||
}
|
||||
},
|
||||
setActiveConversationFolder({ commit }, data) {
|
||||
setActiveConversationFolder({ commit, dispatch }, data) {
|
||||
commit(types.SET_ACTIVE_CONVERSATION_FOLDER, data);
|
||||
// prefetch the contact of a contact filter so the UI can show its name
|
||||
const contactId = getFolderContactId(data);
|
||||
if (contactId) dispatch('contacts/show', { id: contactId }, { root: true });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -282,6 +282,24 @@ describe('createConversationPayload', () => {
|
||||
expect(payload.get('assignee_id')).toBe(options.params.assigneeId);
|
||||
expect(payload.getAll('message[attachments][]')).toEqual([]);
|
||||
});
|
||||
|
||||
it('omits mail_subject when mailSubject is undefined', () => {
|
||||
const options = {
|
||||
params: {
|
||||
inboxId: '1',
|
||||
message: {
|
||||
content: 'Test message content',
|
||||
},
|
||||
sourceId: '12',
|
||||
assigneeId: '123',
|
||||
},
|
||||
contactId: '23',
|
||||
};
|
||||
|
||||
const payload = createConversationPayload(options);
|
||||
|
||||
expect(payload.has('additional_attributes[mail_subject]')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWhatsAppConversationPayload', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('commits unread counts when API is successful', async () => {
|
||||
const payload = {
|
||||
all_count: 2,
|
||||
inboxes: { 1: '2' },
|
||||
labels: { 3: 4 },
|
||||
teams: { 5: 6 },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getters } from '../../conversationUnreadCounts';
|
||||
describe('#getters', () => {
|
||||
it('returns inbox unread count by id', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: { 1: 2 },
|
||||
labels: {},
|
||||
teams: {},
|
||||
@@ -15,6 +16,7 @@ describe('#getters', () => {
|
||||
|
||||
it('returns label unread count by id', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: { 3: 4 },
|
||||
teams: {},
|
||||
@@ -27,6 +29,7 @@ describe('#getters', () => {
|
||||
|
||||
it('returns team unread count by id', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: { 5: 6 },
|
||||
@@ -37,8 +40,20 @@ describe('#getters', () => {
|
||||
expect(getters.getTeamUnreadCount(state)(6)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns all unread count', () => {
|
||||
const state = {
|
||||
allCount: 7,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
};
|
||||
|
||||
expect(getters.getAllUnreadCount(state)).toBe(7);
|
||||
});
|
||||
|
||||
it('returns unread count maps', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 3: 4 },
|
||||
teams: { 5: 6 },
|
||||
|
||||
+15
-1
@@ -4,9 +4,10 @@ import { mutations } from '../../conversationUnreadCounts';
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
|
||||
it('normalizes unread count payload', () => {
|
||||
const state = { inboxes: {}, labels: {}, teams: {} };
|
||||
const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
|
||||
|
||||
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
|
||||
all_count: '3',
|
||||
inboxes: {
|
||||
1: '2',
|
||||
2: 0,
|
||||
@@ -23,6 +24,7 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
allCount: 3,
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 4: 5 },
|
||||
teams: { 6: 7 },
|
||||
@@ -31,6 +33,7 @@ describe('#mutations', () => {
|
||||
|
||||
it('clears counts when payload is empty', () => {
|
||||
const state = {
|
||||
allCount: 2,
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 4: 5 },
|
||||
teams: { 6: 7 },
|
||||
@@ -39,10 +42,21 @@ describe('#mutations', () => {
|
||||
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
|
||||
|
||||
expect(state).toEqual({
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes invalid aggregate counts to zero', () => {
|
||||
const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} };
|
||||
|
||||
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
|
||||
all_count: 'invalid',
|
||||
});
|
||||
|
||||
expect(state.allCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../customViews';
|
||||
import * as types from '../../../mutation-types';
|
||||
import { customViewList, updateCustomViewList } from './fixtures';
|
||||
import { actions } from '../../customViews';
|
||||
import {
|
||||
contactFilterView,
|
||||
customViewList,
|
||||
updateCustomViewList,
|
||||
} from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
@@ -106,5 +110,27 @@ describe('#actions', () => {
|
||||
[types.default.SET_ACTIVE_CONVERSATION_FOLDER, customViewList[0]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefetches the contact of a contact filter', async () => {
|
||||
const dispatch = vi.fn();
|
||||
await actions.setActiveConversationFolder(
|
||||
{ commit, dispatch },
|
||||
contactFilterView
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
'contacts/show',
|
||||
{ id: 42 },
|
||||
{ root: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not prefetch without a contact filter', async () => {
|
||||
const dispatch = vi.fn();
|
||||
await actions.setActiveConversationFolder(
|
||||
{ commit, dispatch },
|
||||
customViewList[0]
|
||||
);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,21 @@ export const contactViewList = [
|
||||
},
|
||||
];
|
||||
|
||||
export const contactFilterView = {
|
||||
name: 'Contact view',
|
||||
filter_type: 0,
|
||||
query: {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [42],
|
||||
query_operator: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const customViewList = [
|
||||
{
|
||||
name: 'Custom view',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getters } from '../../customViews';
|
||||
import { contactViewList, customViewList } from './fixtures';
|
||||
import { contactFilterView, contactViewList, customViewList } from './fixtures';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('getCustomViewsByFilterType', () => {
|
||||
@@ -43,4 +43,20 @@ describe('#getters', () => {
|
||||
customViewList[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('getActiveFolderContactId', () => {
|
||||
expect(
|
||||
getters.getActiveFolderContactId({
|
||||
activeConversationFolder: contactFilterView,
|
||||
})
|
||||
).toEqual(42);
|
||||
});
|
||||
|
||||
it('getActiveFolderContactId returns undefined without a contact filter', () => {
|
||||
expect(
|
||||
getters.getActiveFolderContactId({
|
||||
activeConversationFolder: customViewList[0],
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import mila from 'markdown-it-link-attributes';
|
||||
import mentionPlugin from './markdownIt/link';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
const setImageHeight = inlineToken => {
|
||||
const setImageSizing = inlineToken => {
|
||||
const imgSrc = inlineToken.attrGet('src');
|
||||
if (!imgSrc) return;
|
||||
const url = new URL(imgSrc);
|
||||
const width = url.searchParams.get('cw_image_width');
|
||||
if (width) {
|
||||
inlineToken.attrSet(
|
||||
'style',
|
||||
`width: ${width}; max-width: 100%; height: auto;`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const height = url.searchParams.get('cw_image_height');
|
||||
if (!height) return;
|
||||
inlineToken.attrSet('style', `height: ${height};`);
|
||||
if (height) inlineToken.attrSet('style', `height: ${height};`);
|
||||
};
|
||||
|
||||
const processInlineToken = blockToken => {
|
||||
blockToken.children.forEach(inlineToken => {
|
||||
if (inlineToken.type === 'image') {
|
||||
setImageHeight(inlineToken);
|
||||
setImageSizing(inlineToken);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const imgResizeManager = md => {
|
||||
// Custom rule for image resize in markdown
|
||||
// If the image url has a query param cw_image_height, then add a style attribute to the image
|
||||
md.core.ruler.after('inline', 'add-image-height', state => {
|
||||
// If the image URL carries a cw_image_width or cw_image_height query param,
|
||||
// add an inline style attribute so the rendered <img> respects the agent's
|
||||
// resize choice. Width takes precedence (HC drag-resize); height is kept for
|
||||
// legacy messages and the message-signature use case.
|
||||
md.core.ruler.after('inline', 'add-image-sizing', state => {
|
||||
state.tokens.forEach(blockToken => {
|
||||
if (blockToken.type === 'inline') {
|
||||
processInlineToken(blockToken);
|
||||
@@ -54,6 +63,13 @@ const createMarkdownInstance = (linkify = true) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Help center article tables persist column widths as an internal
|
||||
// `<!--cw-colwidths:...-->` comment before the table. It exists only for the
|
||||
// editor's markdown round-trip and must never surface as text — markdown-it runs
|
||||
// with `html: false`, which would otherwise escape it into a visible comment in
|
||||
// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
|
||||
const COLWIDTHS_MARKER_REGEX = /<!--cw-colwidths:[\d,]+-->\r?\n?/g;
|
||||
|
||||
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
|
||||
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
|
||||
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
|
||||
@@ -66,7 +82,7 @@ class MessageFormatter {
|
||||
isAPrivateNote = false,
|
||||
linkify = true
|
||||
) {
|
||||
this.message = message || '';
|
||||
this.message = (message || '').replace(COLWIDTHS_MARKER_REGEX, '');
|
||||
this.isAPrivateNote = isAPrivateNote;
|
||||
this.isATweet = isATweet;
|
||||
this.linkify = linkify;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user