Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90b165b624 | ||
|
|
ca9bedfba1 | ||
|
|
4edf626af2 | ||
|
|
e86222034e | ||
|
|
2b37fef0e0 | ||
|
|
44b32eacec | ||
|
|
5b7430f376 | ||
|
|
5c00a1601a | ||
|
|
a4be38f8f3 | ||
|
|
6fcc888aee | ||
|
|
c27a5916bb | ||
|
|
66609f06fd | ||
|
|
6a262287d2 | ||
|
|
dfd656a7d9 | ||
|
|
b02e732dd1 | ||
|
|
80b132e155 | ||
|
|
32b63ad0c0 | ||
|
|
ac15339456 | ||
|
|
f99187646e | ||
|
|
5ce6e00730 | ||
|
|
f66b551c7d | ||
|
|
c12e1f8348 | ||
|
|
124782938d | ||
|
|
c70a57407d | ||
|
|
fe6368b42e | ||
|
|
352f120c6a | ||
|
|
a585853690 | ||
|
|
64b0ebd8dc | ||
|
|
cf6ee13665 | ||
|
|
41a3ab6dfa | ||
|
|
de137e8297 | ||
|
|
a3d05ef55d | ||
|
|
dda25c0b51 | ||
|
|
1713cabdf2 | ||
|
|
d8a16278b9 | ||
|
|
4816e923b6 | ||
|
|
396631ad7d | ||
|
|
ba0ba46c9c | ||
|
|
ee6382109a | ||
|
|
35bef21f83 | ||
|
|
e5c140158e | ||
|
|
274e92e0e4 | ||
|
|
27404b4a27 | ||
|
|
006b529918 | ||
|
|
b1c2db5435 | ||
|
|
92a1fb8ab7 | ||
|
|
e055cead35 | ||
|
|
c041fde3a2 | ||
|
|
c6a38e2fc6 | ||
|
|
2663a8495d | ||
|
|
d0d275d962 | ||
|
|
95d6aecb51 | ||
|
|
a901c87ab4 | ||
|
|
6d26e7930a | ||
|
|
8d5d02ea97 | ||
|
|
ce93ddec78 | ||
|
|
940428c10e | ||
|
|
4d3196b02f | ||
|
|
c6e5657ee5 | ||
|
|
e9a92a6366 |
+13
-13
@@ -170,7 +170,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.3.1)
|
||||
bigdecimal (4.1.2)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -193,7 +193,7 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.5)
|
||||
concurrent-ruby (1.3.7)
|
||||
connection_pool (2.5.5)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
@@ -274,8 +274,8 @@ GEM
|
||||
dry-logic (~> 1.5)
|
||||
dry-types (~> 1.8)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.8.3)
|
||||
bigdecimal (~> 3.0)
|
||||
dry-types (1.9.1)
|
||||
bigdecimal (>= 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
@@ -304,7 +304,7 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.2)
|
||||
faraday (2.14.3)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -474,7 +474,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.8)
|
||||
json (2.19.9)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -582,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)
|
||||
@@ -598,14 +598,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3)
|
||||
nokogiri (1.19.4)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-darwin)
|
||||
nokogiri (1.19.4-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.6)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
@@ -627,7 +627,7 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oj (3.16.10)
|
||||
oj (3.17.3)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.4)
|
||||
@@ -674,7 +674,7 @@ GEM
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.1)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.14.2
|
||||
4.15.1
|
||||
|
||||
@@ -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,6 +6,11 @@ class Messages::Messenger::MessageBuilder
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
params = attachment_params(attachment)
|
||||
# During Meta's sticker webhook transition, a sticker message carries both an `image`
|
||||
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
|
||||
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
|
||||
return if duplicate_sticker?(attachment, params[:external_url])
|
||||
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
if facebook_reel?(attachment)
|
||||
@@ -13,10 +18,14 @@ class Messages::Messenger::MessageBuilder
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
fetch_attachment_links(attachment_obj)
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def fetch_attachment_links(attachment_obj)
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
@@ -111,13 +120,20 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
# Facebook may send attachment types that don't directly match our file_type enum.
|
||||
# Map known aliases to their canonical enum values.
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
|
||||
|
||||
def normalize_file_type(type)
|
||||
sym = type.to_sym
|
||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||
end
|
||||
|
||||
def duplicate_sticker?(attachment, url)
|
||||
return false unless attachment['type'].to_sym == :sticker
|
||||
return false if url.blank?
|
||||
|
||||
@message.attachments.any? { |existing| existing.external_url == url }
|
||||
end
|
||||
|
||||
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
||||
# direct video URLs. Downloading these yields HTML, not video content.
|
||||
def facebook_reel?(attachment)
|
||||
|
||||
@@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
|
||||
def category_params
|
||||
params.require(:category).permit(
|
||||
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
|
||||
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -214,3 +214,5 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
render json: error, status: error_status
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
|
||||
|
||||
@@ -140,7 +140,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def destroy
|
||||
authorize @conversation, :destroy?
|
||||
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
|
||||
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@assignable_agents = @inbox.assignable_agents
|
||||
end
|
||||
|
||||
def assignable_owners
|
||||
@assignable_agents = @inbox.assignable_agents.select(&:confirmed?)
|
||||
@agent_bots = AgentBot.accessible_to(Current.account)
|
||||
end
|
||||
|
||||
def campaigns
|
||||
@campaigns = @inbox.campaigns
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,6 @@
|
||||
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i
|
||||
|
||||
# Prevent session parameter from being passed
|
||||
# Unpermitted parameter: session
|
||||
wrap_parameters format: []
|
||||
@@ -14,12 +16,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
|
||||
user = find_user_for_authentication
|
||||
return handle_mfa_required(user) if user&.mfa_enabled?
|
||||
return if user && enforce_session_limit_for_password_login(user)
|
||||
|
||||
# Only proceed with standard authentication if no MFA is required
|
||||
super
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
track_user_session unless @impersonation
|
||||
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
|
||||
end
|
||||
|
||||
@@ -53,6 +57,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def handle_sso_authentication
|
||||
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
|
||||
|
||||
authenticate_resource_with_sso_token
|
||||
yield @resource if block_given?
|
||||
render_create_success
|
||||
@@ -65,7 +71,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def authenticate_resource_with_sso_token
|
||||
@token = @resource.create_token
|
||||
# DTA evicts the earliest-expiring token after save when at max_number_of_devices.
|
||||
# The short-lived impersonation token would always be that one, so pre-evict to make room.
|
||||
make_room_for_impersonation_token if @impersonation
|
||||
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil)
|
||||
@resource.save!
|
||||
|
||||
sign_in(:user, @resource, store: false, bypass: false)
|
||||
@@ -73,11 +82,21 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
@resource.invalidate_sso_auth_token(params[:sso_auth_token])
|
||||
end
|
||||
|
||||
def make_room_for_impersonation_token
|
||||
return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices
|
||||
|
||||
oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first
|
||||
@resource.tokens.delete(oldest_client_id) if oldest_client_id
|
||||
end
|
||||
|
||||
def process_sso_auth_token
|
||||
return if params[:email].blank?
|
||||
|
||||
user = User.from_email(params[:email])
|
||||
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
return unless user&.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
|
||||
@resource = user
|
||||
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
|
||||
end
|
||||
|
||||
def handle_mfa_required(user)
|
||||
@@ -103,6 +122,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def sign_in_mfa_user(user)
|
||||
evict_oldest_session(user) if sessions_limit_reached?(user)
|
||||
@resource = user
|
||||
@token = @resource.create_token
|
||||
@resource.save!
|
||||
@@ -114,6 +134,103 @@ 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 sessions_limit_reached?(user)
|
||||
active_token_count(user) >= MAX_SESSIONS
|
||||
end
|
||||
|
||||
def active_token_count(user)
|
||||
now = Time.current.to_i
|
||||
(user.tokens || {}).count { |_, v| v['expiry'].to_i > now }
|
||||
end
|
||||
|
||||
# Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients
|
||||
# auto-evict instead of getting stuck on a UI they can't render.
|
||||
def enforce_session_limit_for_password_login(user)
|
||||
if revoking_sessions?
|
||||
revoke_sessions_for_login(user)
|
||||
return false
|
||||
end
|
||||
|
||||
return false unless sessions_limit_reached?(user)
|
||||
|
||||
# Picker only when every token has a tracked session; partial tracking would
|
||||
# show a misleading count, so fall through to silent eviction instead.
|
||||
if browser_request? && user.user_sessions.count >= user.tokens.size
|
||||
handle_sessions_limit_for_login(user)
|
||||
true
|
||||
else
|
||||
evict_oldest_session(user)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def browser_request?
|
||||
request.user_agent.to_s.include?('Mozilla')
|
||||
end
|
||||
|
||||
def revoking_sessions?
|
||||
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
|
||||
end
|
||||
|
||||
def revoke_sessions_for_login(user)
|
||||
if params[:revoke_all_sessions].present?
|
||||
user.tokens = {}
|
||||
user.save!
|
||||
user.user_sessions.destroy_all
|
||||
elsif params[:revoke_session_id].present?
|
||||
session = user.user_sessions.find_by(id: params[:revoke_session_id])
|
||||
return unless session
|
||||
|
||||
user.tokens.delete(session.client_id)
|
||||
user.save!
|
||||
session.destroy!
|
||||
end
|
||||
end
|
||||
|
||||
def evict_oldest_session(user)
|
||||
# Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted.
|
||||
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
|
||||
|
||||
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
|
||||
return evict_oldest_token(user) unless oldest_session
|
||||
|
||||
user.tokens.delete(oldest_session.client_id)
|
||||
user.save!
|
||||
oldest_session.destroy!
|
||||
end
|
||||
|
||||
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
|
||||
def evict_oldest_token(user)
|
||||
return if user.tokens.blank?
|
||||
|
||||
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
|
||||
return unless oldest_client_id
|
||||
|
||||
user.tokens.delete(oldest_client_id)
|
||||
user.save!
|
||||
end
|
||||
|
||||
PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version
|
||||
ip_address city country last_activity_at created_at].freeze
|
||||
|
||||
def handle_sessions_limit_for_login(user)
|
||||
sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) }
|
||||
render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict
|
||||
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')
|
||||
|
||||
@@ -35,7 +35,17 @@ class Twilio::CallbackController < ApplicationController
|
||||
:ExternalUserId,
|
||||
:ParentExternalUserId,
|
||||
:ProfileUsername,
|
||||
:Username
|
||||
:Username,
|
||||
:ReferralBody,
|
||||
:ReferralHeadline,
|
||||
:ReferralSourceId,
|
||||
:ReferralSourceType,
|
||||
:ReferralSourceUrl,
|
||||
:ReferralMediaId,
|
||||
:ReferralMediaContentType,
|
||||
:ReferralMediaUrl,
|
||||
:ReferralNumMedia,
|
||||
:ReferralCtwaClid
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,6 +12,7 @@ class ConversationFinder
|
||||
'waiting_since_asc' => %w[sort_on_waiting_since asc],
|
||||
'waiting_since_desc' => %w[sort_on_waiting_since desc],
|
||||
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
|
||||
'unread' => %w[sort_on_unread desc],
|
||||
|
||||
# To be removed in v3.5.0
|
||||
'latest' => %w[sort_on_last_activity_at desc],
|
||||
@@ -40,7 +41,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 +185,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,
|
||||
|
||||
@@ -17,15 +17,11 @@ module PortalHelper
|
||||
uri.to_s
|
||||
end
|
||||
|
||||
def generate_portal_bg_color(portal_color, theme)
|
||||
def generate_portal_bg(portal_color, theme)
|
||||
base_color = theme == 'dark' ? 'black' : 'white'
|
||||
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
|
||||
end
|
||||
|
||||
def generate_portal_bg(portal_color, theme)
|
||||
generate_portal_bg_color(portal_color, theme)
|
||||
end
|
||||
|
||||
def generate_gradient_to_bottom(theme)
|
||||
base_color = theme == 'dark' ? '#151718' : 'white'
|
||||
"linear-gradient(to bottom, transparent, #{base_color})"
|
||||
@@ -41,6 +37,10 @@ module PortalHelper
|
||||
language_map[locale] || locale
|
||||
end
|
||||
|
||||
def html_lang_attribute(locale)
|
||||
locale.to_s.tr('_', '-')
|
||||
end
|
||||
|
||||
def theme_query_string(theme)
|
||||
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
|
||||
end
|
||||
@@ -97,6 +97,18 @@ module PortalHelper
|
||||
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
|
||||
end
|
||||
|
||||
# Renders a stored category icon: a bare ri icon name (e.g. `vip-crown-2-fill/line`) saved color, or a plain emoji character.
|
||||
def render_emoji_or_icon(value, color = nil)
|
||||
return '' if value.blank?
|
||||
|
||||
# Emojis are non-ascii; bare icon names match this safe charset.
|
||||
return ERB::Util.html_escape(value) unless value.match?(/\A[a-z][a-z0-9-]*\z/)
|
||||
|
||||
icon_class = value.start_with?('i-') ? value : "i-ri-#{value}"
|
||||
style = "color: #{color};" if color.to_s.match?(/\A#\h{3,8}\z/)
|
||||
tag.span(class: icon_class, style: style, 'aria-hidden': true)
|
||||
end
|
||||
|
||||
def thumbnail_bg_color(username)
|
||||
colors = ['#6D95BA', '#A4C3C3', '#E19191']
|
||||
return colors.sample if username.blank?
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class MessageReports extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/message_reports', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageReports();
|
||||
@@ -40,6 +40,12 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${contactId}/conversations`, { params });
|
||||
}
|
||||
|
||||
getAttachments(contactId, page = 1) {
|
||||
return axios.get(`${this.url}/${contactId}/attachments`, {
|
||||
params: { page },
|
||||
});
|
||||
}
|
||||
|
||||
getContactableInboxes(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class ArticlesAPI extends PortalsAPI {
|
||||
authorId,
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
}) {
|
||||
const url = getArticleSearchURL({
|
||||
pageNumber,
|
||||
@@ -25,6 +26,7 @@ class ArticlesAPI extends PortalsAPI {
|
||||
authorId,
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
host: this.url,
|
||||
});
|
||||
|
||||
|
||||
@@ -62,9 +62,10 @@ class ConversationApi extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
assignAgent({ conversationId, agentId }) {
|
||||
assignAgent({ conversationId, agentId, assigneeType }) {
|
||||
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
||||
assignee_id: agentId,
|
||||
assignee_type: assigneeType,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
return axios.get(`${this.url}/${inboxId}/campaigns`);
|
||||
}
|
||||
|
||||
getAssignableOwners(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/assignable_owners`);
|
||||
}
|
||||
|
||||
deleteInboxAvatar(inboxId) {
|
||||
return axios.delete(`${this.url}/${inboxId}/avatar`);
|
||||
}
|
||||
@@ -60,6 +64,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();
|
||||
|
||||
@@ -90,11 +90,16 @@ describe('#ConversationAPI', () => {
|
||||
});
|
||||
|
||||
it('#assignAgent', () => {
|
||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||
conversationAPI.assignAgent({
|
||||
conversationId: 12,
|
||||
agentId: 34,
|
||||
assigneeType: 'AgentBot',
|
||||
});
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/assignments`,
|
||||
{
|
||||
assignee_id: 34,
|
||||
assignee_type: 'AgentBot',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ describe('#InboxesAPI', () => {
|
||||
expect(inboxesAPI).toHaveProperty('create');
|
||||
expect(inboxesAPI).toHaveProperty('update');
|
||||
expect(inboxesAPI).toHaveProperty('delete');
|
||||
expect(inboxesAPI).toHaveProperty('getAssignableOwners');
|
||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||
@@ -37,6 +38,13 @@ describe('#InboxesAPI', () => {
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns');
|
||||
});
|
||||
|
||||
it('#getAssignableOwners', () => {
|
||||
inboxesAPI.getAssignableOwners(2);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/2/assignable_owners'
|
||||
);
|
||||
});
|
||||
|
||||
it('#deleteInboxAvatar', () => {
|
||||
inboxesAPI.deleteInboxAvatar(2);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
||||
|
||||
@@ -145,6 +145,12 @@
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
|
||||
// Voice call widget - light mode
|
||||
--call-widget: 33, 34, 38, 0.95;
|
||||
--call-widget-border: 255, 255, 255, 0.1;
|
||||
--call-widget-text: 237, 238, 240, 1;
|
||||
--call-widget-sub-text: 173, 177, 184, 1;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -291,6 +297,12 @@
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 255, 255, 255, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
|
||||
// Voice call widget - dark mode
|
||||
--call-widget: 50, 53, 61, 1;
|
||||
--call-widget-border: 255, 255, 255, 0.07;
|
||||
--call-widget-text: 237, 238, 240, 1;
|
||||
--call-widget-sub-text: 173, 177, 184, 1;
|
||||
}
|
||||
}
|
||||
// NEXT COLORS END
|
||||
|
||||
@@ -26,6 +26,13 @@ const resetForm = () => {
|
||||
form.description = '';
|
||||
};
|
||||
|
||||
const open = (company = {}) => {
|
||||
form.name = company.name || '';
|
||||
form.domain = company.domain || '';
|
||||
form.description = company.description || '';
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (isFormInvalid.value) return;
|
||||
|
||||
@@ -45,7 +52,7 @@ const onSuccess = () => {
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef, onSuccess });
|
||||
defineExpose({ dialogRef, onSuccess, open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
// Name of the linked company, so the label shows before the list is loaded.
|
||||
selectedName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isDetailsView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const CREATE_PREFIX = 'create:';
|
||||
|
||||
const options = ref([]);
|
||||
const searchQuery = ref('');
|
||||
const createDialogRef = ref(null);
|
||||
const isCreatingCompany = ref(false);
|
||||
|
||||
const toOption = company => ({ label: company.name, value: company.id });
|
||||
|
||||
const createOption = computed(() => {
|
||||
const name = searchQuery.value.trim();
|
||||
if (!name) return null;
|
||||
|
||||
const exists = options.value.some(
|
||||
option => option.label.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
if (exists) return null;
|
||||
|
||||
return {
|
||||
label: t('COMPANIES.SELECTOR.CREATE_OPTION', { name }),
|
||||
value: `${CREATE_PREFIX}${name}`,
|
||||
};
|
||||
});
|
||||
|
||||
const comboboxOptions = computed(() => {
|
||||
const list = [...options.value];
|
||||
|
||||
// Keep the linked company visible even when it is not in the loaded results.
|
||||
if (
|
||||
props.modelValue &&
|
||||
props.selectedName &&
|
||||
!list.some(option => option.value === Number(props.modelValue))
|
||||
) {
|
||||
list.unshift({
|
||||
label: props.selectedName,
|
||||
value: Number(props.modelValue),
|
||||
});
|
||||
}
|
||||
|
||||
if (createOption.value) list.push(createOption.value);
|
||||
return list;
|
||||
});
|
||||
|
||||
const fetchCompanies = async query => {
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = query
|
||||
? await CompanyAPI.search(query)
|
||||
: await CompanyAPI.get({ page: 1 });
|
||||
options.value = (payload || []).map(toOption);
|
||||
} catch {
|
||||
options.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch lazily, only when the dropdown opens, instead of on mount.
|
||||
const handleOpen = () => {
|
||||
searchQuery.value = '';
|
||||
fetchCompanies('');
|
||||
};
|
||||
|
||||
const handleSearch = useDebounceFn(query => {
|
||||
searchQuery.value = query?.trim() || '';
|
||||
fetchCompanies(searchQuery.value);
|
||||
}, 300);
|
||||
|
||||
// Open the create dialog (prefilled with the typed name) so the user can add
|
||||
// domain/description before saving, instead of creating with just a name.
|
||||
const createCompany = async company => {
|
||||
isCreatingCompany.value = true;
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.create({ company });
|
||||
createDialogRef.value?.onSuccess();
|
||||
emit('select', { id: payload.id, name: payload.name });
|
||||
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
|
||||
} finally {
|
||||
isCreatingCompany.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = value => {
|
||||
if (typeof value === 'string' && value.startsWith(CREATE_PREFIX)) {
|
||||
createDialogRef.value?.open({ name: value.slice(CREATE_PREFIX.length) });
|
||||
// Drop the transient "Add …" option so the button label doesn't stick to
|
||||
// it if the dialog is dismissed without creating.
|
||||
searchQuery.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const id = value ? Number(value) : '';
|
||||
const selected = comboboxOptions.value.find(option => option.value === id);
|
||||
emit('select', { id, name: selected?.label || '' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboBox
|
||||
:model-value="modelValue"
|
||||
:options="comboboxOptions"
|
||||
:display-label="selectedName"
|
||||
:placeholder="t('COMPANIES.SELECTOR.PLACEHOLDER')"
|
||||
:search-placeholder="t('COMPANIES.SEARCH_PLACEHOLDER')"
|
||||
use-api-results
|
||||
class="[&>div>button]:h-8 [&>div>div_ul]:max-h-56"
|
||||
:class="{
|
||||
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
|
||||
!isDetailsView,
|
||||
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
|
||||
}"
|
||||
@open="handleOpen"
|
||||
@search="handleSearch"
|
||||
@update:model-value="handleSelect"
|
||||
/>
|
||||
<CompanyCreateDialog
|
||||
ref="createDialogRef"
|
||||
:is-loading="isCreatingCompany"
|
||||
@create="createCompany"
|
||||
/>
|
||||
</template>
|
||||
@@ -15,6 +15,7 @@ const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
email: { type: String, default: '' },
|
||||
companyId: { type: [Number, String], default: '' },
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
phoneNumber: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
@@ -41,6 +42,7 @@ const getInitialContactData = () => ({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
email: props.email,
|
||||
companyId: props.companyId,
|
||||
phoneNumber: props.phoneNumber,
|
||||
additionalAttributes: props.additionalAttributes,
|
||||
});
|
||||
|
||||
@@ -128,9 +128,14 @@ const closeMobileSidebar = () => {
|
||||
<!-- Desktop sidebar -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar container -->
|
||||
@@ -179,9 +184,14 @@ const closeMobileSidebar = () => {
|
||||
<div
|
||||
v-if="isContactSidebarOpen"
|
||||
id="contact-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
||||
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,11 @@ import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { splitName } from '@chatwoot/utils';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import CompanySelector from 'dashboard/components-next/Companies/CompanySelector.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
|
||||
|
||||
@@ -28,6 +31,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { currentAccount, isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const FORM_CONFIG = {
|
||||
FIRST_NAME: { field: 'firstName' },
|
||||
@@ -54,6 +58,7 @@ const defaultState = {
|
||||
id: 0,
|
||||
name: '',
|
||||
email: '',
|
||||
companyId: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phoneNumber: '',
|
||||
@@ -85,6 +90,23 @@ const validationRules = {
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isFormInvalid = computed(() => v$.value.$invalid);
|
||||
const hasCompaniesFeature = computed(
|
||||
() =>
|
||||
currentAccount.value?.id && isCloudFeatureEnabled(FEATURE_FLAGS.COMPANIES)
|
||||
);
|
||||
const showCompanySelector = computed(
|
||||
() =>
|
||||
hasCompaniesFeature.value &&
|
||||
(Boolean(state.companyId) || !state.additionalAttributes.companyName)
|
||||
);
|
||||
|
||||
const emitContactUpdate = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
const { firstName, lastName, ...stateWithoutNames } = state;
|
||||
emit('update', stateWithoutNames);
|
||||
};
|
||||
|
||||
const prepareStateBasedOnProps = () => {
|
||||
if (props.isNewContact) {
|
||||
@@ -96,6 +118,7 @@ const prepareStateBasedOnProps = () => {
|
||||
name = '',
|
||||
email: emailAddress,
|
||||
phoneNumber,
|
||||
companyId = '',
|
||||
additionalAttributes = {},
|
||||
} = props.contactData || {};
|
||||
const { firstName, lastName } = splitName(name || '');
|
||||
@@ -115,6 +138,7 @@ const prepareStateBasedOnProps = () => {
|
||||
Object.assign(state, {
|
||||
id,
|
||||
name,
|
||||
companyId: companyId || '',
|
||||
firstName,
|
||||
lastName,
|
||||
email: emailAddress,
|
||||
@@ -200,11 +224,7 @@ const getFormBinding = key => {
|
||||
}
|
||||
}
|
||||
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (isFormValid) {
|
||||
const { firstName, lastName, ...stateWithoutNames } = state;
|
||||
emit('update', stateWithoutNames);
|
||||
}
|
||||
await emitContactUpdate();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -221,6 +241,12 @@ const handleCountrySelection = value => {
|
||||
emit('update', state);
|
||||
};
|
||||
|
||||
const handleCompanySelection = async ({ id, name }) => {
|
||||
state.companyId = id || '';
|
||||
state.additionalAttributes.companyName = name || '';
|
||||
await emitContactUpdate();
|
||||
};
|
||||
|
||||
const resetValidation = () => {
|
||||
v$.value.$reset();
|
||||
};
|
||||
@@ -273,6 +299,13 @@ defineExpose({
|
||||
:placeholder="item.placeholder"
|
||||
:show-border="isDetailsView"
|
||||
/>
|
||||
<CompanySelector
|
||||
v-else-if="item.key === 'COMPANY_NAME' && showCompanySelector"
|
||||
:model-value="state.companyId"
|
||||
:selected-name="state.additionalAttributes.companyName"
|
||||
:is-details-view="isDetailsView"
|
||||
@select="handleCompanySelection"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="getFormBinding(item.key).value"
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
|
||||
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6">
|
||||
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
|
||||
<ContactCustomAttributeItem
|
||||
v-for="attribute in usedAttributes"
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ const contactConversations = computed(() =>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="contactConversations.length > 0"
|
||||
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in contactConversations"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import {
|
||||
MEDIA_TYPES,
|
||||
NON_FILE_TYPES,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
|
||||
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const MEDIA_PEEK_LIMIT = 12;
|
||||
const FILES_PEEK_LIMIT = 6;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const attachments = computed(() =>
|
||||
attachmentsByContact.value(route.params.contactId)
|
||||
);
|
||||
const isFetching = computed(() => uiFlags.value.isFetchingAttachments);
|
||||
|
||||
const hasContent = computed(() =>
|
||||
attachments.value.some(
|
||||
a => a.data_url && !NON_FILE_TYPES.includes(a.file_type)
|
||||
)
|
||||
);
|
||||
|
||||
const mediaAttachments = computed(() =>
|
||||
attachments.value
|
||||
.filter(a => MEDIA_TYPES.includes(a.file_type) && a.data_url)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
);
|
||||
|
||||
const showGallery = ref(false);
|
||||
const selectedAttachment = ref(null);
|
||||
|
||||
const onMediaSelect = attachment => {
|
||||
selectedAttachment.value = attachment;
|
||||
showGallery.value = true;
|
||||
};
|
||||
|
||||
const onFileSelect = attachment => {
|
||||
if (attachment.data_url) {
|
||||
window.open(attachment.data_url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const onJumpToMessage = attachment => {
|
||||
if (!attachment.conversation_id || !attachment.message_id) return;
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
conversation_id: attachment.conversation_id,
|
||||
},
|
||||
query: { messageId: attachment.message_id },
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('contacts/fetchAttachments', route.params.contactId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-6">
|
||||
<div v-if="isFetching" class="flex justify-center p-3">
|
||||
<Spinner class="size-5" />
|
||||
</div>
|
||||
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
|
||||
</p>
|
||||
<div v-else class="flex flex-col gap-5">
|
||||
<Media
|
||||
:attachments="attachments"
|
||||
:peek-limit="MEDIA_PEEK_LIMIT"
|
||||
show-jump-to-message
|
||||
@select="onMediaSelect"
|
||||
@jump-to-message="onJumpToMessage"
|
||||
/>
|
||||
<Files
|
||||
:attachments="attachments"
|
||||
:peek-limit="FILES_PEEK_LIMIT"
|
||||
show-jump-to-message
|
||||
@select="onFileSelect"
|
||||
@jump-to-message="onJumpToMessage"
|
||||
/>
|
||||
</div>
|
||||
<GalleryView
|
||||
v-if="showGallery && selectedAttachment"
|
||||
v-model:show="showGallery"
|
||||
:attachment="selectedAttachment"
|
||||
:all-attachments="mediaAttachments"
|
||||
auto-play
|
||||
@close="showGallery = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -103,7 +103,7 @@ const onMergeContacts = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-8 px-6 py-6">
|
||||
<div class="flex flex-col gap-8 px-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="text-base text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
|
||||
|
||||
@@ -55,7 +55,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 py-6">
|
||||
<div class="flex flex-col gap-6">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
|
||||
|
||||
@@ -92,6 +92,7 @@ const handleAvatarHover = (id, isHovered) => {
|
||||
:id="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:company-id="contact.companyId"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -131,7 +132,7 @@ const statusText = computed(() => {
|
||||
|
||||
const categoryName = computed(() => {
|
||||
if (props.category?.slug) {
|
||||
return `${props.category.icon} ${props.category.name}`;
|
||||
return props.category.name;
|
||||
}
|
||||
return t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
|
||||
@@ -209,9 +210,9 @@ const handleClick = id => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 sm:gap-4">
|
||||
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
|
||||
<div class="flex items-center min-w-0 gap-1">
|
||||
<Avatar
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
@@ -222,11 +223,17 @@ const handleClick = id => {
|
||||
{{ authorName || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="block text-sm whitespace-nowrap text-n-slate-11">
|
||||
{{ categoryName }}
|
||||
<span class="flex items-center min-w-0 gap-1 text-sm text-n-slate-11">
|
||||
<EmojiIcon
|
||||
v-if="category?.icon"
|
||||
:value="category.icon"
|
||||
:color="category.icon_color"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate">{{ categoryName }}</span>
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap shrink-0"
|
||||
>
|
||||
<Icon icon="i-lucide-eye" class="size-4" />
|
||||
<span class="text-sm">
|
||||
@@ -238,7 +245,7 @@ const handleClick = id => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1">
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToggle } from '@vueuse/core';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -20,6 +21,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -55,10 +60,6 @@ const categoryMenuItems = [
|
||||
},
|
||||
];
|
||||
|
||||
const categoryTitleWithIcon = computed(() => {
|
||||
return `${props.icon} ${props.title}`;
|
||||
});
|
||||
|
||||
const description = computed(() => {
|
||||
return props.description ? props.description : 'No description added';
|
||||
});
|
||||
@@ -83,10 +84,16 @@ const handleAction = ({ action, value }) => {
|
||||
<div class="flex justify-between w-full gap-2">
|
||||
<div class="flex items-center justify-start w-full min-w-0 gap-2">
|
||||
<span
|
||||
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
|
||||
class="flex items-center gap-1.5 text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
|
||||
@click="handleClick(slug)"
|
||||
>
|
||||
{{ categoryTitleWithIcon }}
|
||||
<EmojiIcon
|
||||
v-if="icon"
|
||||
:value="icon"
|
||||
:color="iconColor"
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate">{{ title }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
|
||||
|
||||
@@ -67,11 +67,11 @@ const togglePortalSwitcher = () => {
|
||||
>
|
||||
<span
|
||||
v-if="activePortalName"
|
||||
class="text-xl font-medium text-n-slate-12"
|
||||
class="min-w-0 text-xl font-medium truncate text-n-slate-12"
|
||||
>
|
||||
{{ activePortalName }}
|
||||
</span>
|
||||
<div v-if="activePortalName" class="relative group">
|
||||
<div v-if="activePortalName" class="relative shrink-0 group">
|
||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
@@ -91,6 +91,9 @@ const togglePortalSwitcher = () => {
|
||||
</OnClickOutside>
|
||||
<CreatePortalDialog ref="createPortalDialogRef" />
|
||||
</div>
|
||||
<div class="flex justify-end min-w-0 grow">
|
||||
<slot name="title-actions" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
|
||||
+16
-10
@@ -8,6 +8,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -107,10 +108,11 @@ const selectedCategory = computed(() => {
|
||||
const categoryList = computed(() => {
|
||||
return (
|
||||
categories.value
|
||||
.map(({ name, id, icon }) => ({
|
||||
.map(({ name, id, icon, icon_color: iconColor }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
emoji: icon,
|
||||
iconColor,
|
||||
isSelected: isNewArticle.value
|
||||
? id === (selectedCategoryId.value || selectedCategory.value?.id)
|
||||
: id === props.article?.category?.id,
|
||||
@@ -202,10 +204,6 @@ onMounted(() => {
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="openCategoryList = false">
|
||||
<Button
|
||||
:label="
|
||||
selectedCategory?.name ||
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
|
||||
"
|
||||
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
@@ -213,12 +211,20 @@ onMounted(() => {
|
||||
@click="openCategoryList = !openCategoryList"
|
||||
>
|
||||
<span
|
||||
v-if="selectedCategory"
|
||||
class="text-sm text-n-slate-12 hover:text-n-slate-11"
|
||||
class="flex items-center gap-1.5 min-w-0 text-sm text-n-slate-12 hover:text-n-slate-11"
|
||||
>
|
||||
{{
|
||||
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
|
||||
}}
|
||||
<EmojiIcon
|
||||
v-if="selectedCategory?.icon"
|
||||
:value="selectedCategory.icon"
|
||||
:color="selectedCategory.icon_color"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate">
|
||||
{{
|
||||
selectedCategory?.name ||
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
|
||||
+21
-13
@@ -13,6 +13,7 @@ import {
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
@@ -63,18 +64,15 @@ const activeTabIndex = computed(() => {
|
||||
return tabs.value.findIndex(tab => tab.value === tabParam);
|
||||
});
|
||||
|
||||
const activeCategoryName = computed(() => {
|
||||
const activeCategory = props.categories.find(
|
||||
category => category.slug === route.params.categorySlug
|
||||
);
|
||||
const activeCategory = computed(() =>
|
||||
props.categories.find(category => category.slug === route.params.categorySlug)
|
||||
);
|
||||
|
||||
if (activeCategory) {
|
||||
const { icon, name } = activeCategory;
|
||||
return `${icon} ${name}`;
|
||||
}
|
||||
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL');
|
||||
});
|
||||
const activeCategoryName = computed(
|
||||
() =>
|
||||
activeCategory.value?.name ||
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL')
|
||||
);
|
||||
|
||||
const activeLocaleName = computed(() => {
|
||||
return props.allowedLocales.find(
|
||||
@@ -94,6 +92,7 @@ const categoryMenuItems = computed(() => {
|
||||
value: category.slug,
|
||||
action: 'filter',
|
||||
emoji: category.icon,
|
||||
iconColor: category.icon_color,
|
||||
}));
|
||||
|
||||
const hasCategorySlug = !!route.params.categorySlug;
|
||||
@@ -167,14 +166,23 @@ const handleTabChange = value => {
|
||||
<div v-if="hasCategoryMenuItems" class="relative group">
|
||||
<OnClickOutside @trigger="isCategoryMenuOpen = false">
|
||||
<Button
|
||||
:label="activeCategoryName"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
|
||||
/>
|
||||
>
|
||||
<span class="flex items-center gap-1.5 min-w-0">
|
||||
<EmojiIcon
|
||||
v-if="activeCategory?.icon"
|
||||
:value="activeCategory.icon"
|
||||
:color="activeCategory.icon_color"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate">{{ activeCategoryName }}</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isCategoryMenuOpen"
|
||||
|
||||
@@ -24,6 +24,10 @@ const props = defineProps({
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
@@ -41,6 +45,7 @@ const hoveredArticleId = ref(null);
|
||||
const dragEnabled = computed(() => {
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
!props.isSearching &&
|
||||
localArticles.value?.length > 1 &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
|
||||
+33
-2
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
@@ -18,6 +19,7 @@ import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
@@ -49,7 +51,12 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
|
||||
const emit = defineEmits([
|
||||
'pageChange',
|
||||
'fetchPortal',
|
||||
'refreshArticles',
|
||||
'search',
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -65,6 +72,9 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
const searchQuery = ref(route.query.search || '');
|
||||
|
||||
const debouncedSearch = debounce(() => emit('search', searchQuery.value), 500);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
@@ -122,6 +132,7 @@ const updateRoute = newParams => {
|
||||
categorySlug: newParams.categorySlug ?? categorySlug,
|
||||
...newParams,
|
||||
},
|
||||
query: route.query,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -145,7 +156,12 @@ const showCategoryHeaderControls = computed(
|
||||
() => props.isCategoryArticles && !isSwitchingPortal.value
|
||||
);
|
||||
|
||||
const isSearching = computed(() => Boolean(searchQuery.value?.trim()));
|
||||
|
||||
const getEmptyStateText = type => {
|
||||
if (isSearching.value) {
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.SEARCH.${type}`);
|
||||
}
|
||||
if (props.isCategoryArticles) {
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
|
||||
}
|
||||
@@ -230,6 +246,7 @@ const categoryMenuItems = computed(() =>
|
||||
value: category.id,
|
||||
action: 'move',
|
||||
emoji: category.icon,
|
||||
iconColor: category.icon_color,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -290,6 +307,19 @@ watch(
|
||||
:show-pagination-footer="shouldShowPaginationFooter"
|
||||
@update:current-page="handlePageChange"
|
||||
>
|
||||
<template #title-actions>
|
||||
<Input
|
||||
v-if="!isSwitchingPortal"
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-full max-w-[16rem] min-w-0"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<div class="flex items-end justify-between">
|
||||
<ArticleHeaderControls
|
||||
@@ -421,6 +451,7 @@ watch(
|
||||
<ArticleList
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:is-searching="isSearching"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@@ -432,7 +463,7 @@ watch(
|
||||
class="pt-14"
|
||||
:title="getEmptyStateTitle"
|
||||
:subtitle="getEmptyStateSubtitle"
|
||||
:show-button="hasNoArticlesInPortal"
|
||||
:show-button="hasNoArticlesInPortal && !isSearching"
|
||||
:button-label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
|
||||
"
|
||||
|
||||
+21
-2
@@ -37,10 +37,21 @@ const { t } = useI18n();
|
||||
|
||||
const editCategoryDialog = ref(null);
|
||||
const selectedCategory = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||
const hasCategories = computed(() => props.categories?.length > 0);
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!query) return props.categories;
|
||||
return props.categories.filter(category =>
|
||||
category.name?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const hasCategories = computed(() => filteredCategories.value?.length > 0);
|
||||
const isSearching = computed(() => searchQuery.value.trim().length > 0);
|
||||
|
||||
const updateRoute = (newParams, routeName) => {
|
||||
const { accountId, portalSlug, locale } = route.params;
|
||||
@@ -115,6 +126,7 @@ const reorderCategories = async reorderedGroup => {
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<CategoryHeaderControls
|
||||
v-model:search-query="searchQuery"
|
||||
:categories="categories"
|
||||
:is-category-articles="false"
|
||||
:allowed-locales="allowedLocales"
|
||||
@@ -130,11 +142,18 @@ const reorderCategories = async reorderedGroup => {
|
||||
</div>
|
||||
<CategoryList
|
||||
v-else-if="hasCategories"
|
||||
:categories="categories"
|
||||
:categories="filteredCategories"
|
||||
:disable-drag="isSearching"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
@reorder="reorderCategories"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else-if="isSearching"
|
||||
class="pt-14"
|
||||
:title="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const handleCategory = async formData => {
|
||||
const { id, name, slug, icon, description, locale } = formData;
|
||||
const categoryData = { name, icon, slug, description };
|
||||
const { id, name, slug, icon, iconColor, description, locale } = formData;
|
||||
const categoryData = { name, icon, icon_color: iconColor, slug, description };
|
||||
|
||||
if (props.mode === 'create') {
|
||||
categoryData.locale = locale;
|
||||
|
||||
+44
-13
@@ -18,6 +18,7 @@ import { convertToCategorySlug } from 'dashboard/helper/commons.js';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -49,8 +50,9 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
const EmojiIconPicker = defineAsyncComponent(
|
||||
() =>
|
||||
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -72,6 +74,7 @@ const state = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
icon: '',
|
||||
iconColor: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
locale: '',
|
||||
@@ -109,8 +112,19 @@ const slugHelpText = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
state.icon = emoji;
|
||||
const onSelectIcon = ({ type, value, color }) => {
|
||||
state.icon = value;
|
||||
state.iconColor = type === 'icon' ? color : '';
|
||||
isEmojiPickerOpen.value = false;
|
||||
};
|
||||
|
||||
const onColorChange = color => {
|
||||
state.iconColor = color;
|
||||
};
|
||||
|
||||
const onRemoveIcon = () => {
|
||||
state.icon = '';
|
||||
state.iconColor = '';
|
||||
isEmojiPickerOpen.value = false;
|
||||
};
|
||||
|
||||
@@ -139,7 +153,14 @@ watch(
|
||||
newCategory => {
|
||||
if (props.mode === 'edit' && newCategory) {
|
||||
const { id, name, icon, slug, description } = newCategory;
|
||||
Object.assign(state, { id, name, icon, slug, description });
|
||||
Object.assign(state, {
|
||||
id,
|
||||
name,
|
||||
icon,
|
||||
iconColor: newCategory.icon_color || '',
|
||||
slug,
|
||||
description,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -197,19 +218,29 @@ defineExpose({ state, isSubmitDisabled });
|
||||
<template #prefix>
|
||||
<OnClickOutside @trigger="isEmojiPickerOpen = false">
|
||||
<Button
|
||||
:label="state.icon"
|
||||
color="slate"
|
||||
size="sm"
|
||||
type="button"
|
||||
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
||||
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
class="!h-[2.38rem] !w-[2.375rem] !p-0 absolute top-[2rem] start-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
||||
/>
|
||||
<EmojiInput
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="state.icon"
|
||||
:value="state.icon"
|
||||
:color="state.iconColor"
|
||||
class="size-5 text-xl !leading-5"
|
||||
/>
|
||||
<span v-else class="i-lucide-smile-plus size-4" />
|
||||
</Button>
|
||||
<EmojiIconPicker
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="left-0 top-16"
|
||||
:value="state.icon"
|
||||
:color="state.iconColor"
|
||||
show-remove-button
|
||||
:on-click="onClickInsertEmoji"
|
||||
@select="onSelectIcon"
|
||||
@color-change="onColorChange"
|
||||
@remove="onRemoveIcon"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</template>
|
||||
|
||||
+35
-17
@@ -6,6 +6,7 @@ import { OnClickOutside } from '@vueuse/components';
|
||||
import { useStoreGetters } from 'dashboard/composables/store.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
|
||||
@@ -27,6 +28,11 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['localeChange', 'newArticle']);
|
||||
|
||||
const searchQuery = defineModel('searchQuery', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const getters = useStoreGetters();
|
||||
@@ -106,6 +112,7 @@ const breadcrumbItems = computed(() => {
|
||||
}
|
||||
),
|
||||
emoji: selectedCategoryEmoji.value,
|
||||
iconColor: selectedCategory.value?.icon_color,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
@@ -161,23 +168,34 @@ const handleBreadcrumbClick = () => {
|
||||
:items="breadcrumbItems"
|
||||
@click="handleBreadcrumbClick"
|
||||
/>
|
||||
<div v-if="!hasSelectedCategory" class="relative">
|
||||
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
||||
/>
|
||||
<CategoryDialog
|
||||
v-if="isCreateCategoryDialogOpen"
|
||||
mode="create"
|
||||
:portal-name="currentPortalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
@close="isCreateCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<div v-if="!hasSelectedCategory" class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-48"
|
||||
/>
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
||||
/>
|
||||
<CategoryDialog
|
||||
v-if="isCreateCategoryDialogOpen"
|
||||
mode="create"
|
||||
:portal-name="currentPortalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
@close="isCreateCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="relative flex items-center gap-2">
|
||||
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
||||
|
||||
+6
-1
@@ -8,6 +8,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
disableDrag: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
@@ -15,7 +19,7 @@ const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
const localCategories = ref(props.categories);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return localCategories.value?.length > 1;
|
||||
return !props.disableDrag && localCategories.value?.length > 1;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
@@ -68,6 +72,7 @@ watch(
|
||||
:id="element.id"
|
||||
:title="element.name"
|
||||
:icon="element.icon"
|
||||
:icon-color="element.icon_color"
|
||||
:description="element.description"
|
||||
:articles-count="element.meta?.articles_count || 0"
|
||||
:slug="element.slug"
|
||||
|
||||
+2
-2
@@ -52,8 +52,8 @@ const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
|
||||
const onUpdateCategory = async () => {
|
||||
if (!categoryFormRef.value) return;
|
||||
const { state } = categoryFormRef.value;
|
||||
const { id, name, slug, icon, description } = state;
|
||||
const categoryData = { name, icon, slug, description };
|
||||
const { id, name, slug, icon, iconColor, description } = state;
|
||||
const categoryData = { name, icon, icon_color: iconColor, slug, description };
|
||||
categoryData.id = id;
|
||||
|
||||
try {
|
||||
|
||||
+42
-7
@@ -2,8 +2,12 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
|
||||
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
|
||||
@@ -19,7 +23,10 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const addLocaleDialogRef = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
|
||||
@@ -28,6 +35,19 @@ const openAddLocaleDialog = () => {
|
||||
};
|
||||
|
||||
const localeCount = computed(() => props.locales?.length);
|
||||
|
||||
const filteredLocales = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!query) return props.locales;
|
||||
return props.locales.filter(
|
||||
locale =>
|
||||
locale.name?.toLowerCase().includes(query) ||
|
||||
locale.code?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const isSearching = computed(() => searchQuery.value.trim().length > 0);
|
||||
const hasResults = computed(() => filteredLocales.value?.length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,12 +59,21 @@ const localeCount = computed(() => props.locales?.length);
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('HELP_CENTER.LOCALES_PAGE.SEARCH_PLACEHOLDER')"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-48"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
@@ -54,7 +83,13 @@ const localeCount = computed(() => props.locales?.length);
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<LocaleList v-else :locales="locales" :portal="portal" />
|
||||
<EmptyStateLayout
|
||||
v-else-if="isSearching && !hasResults"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
|
||||
:show-backdrop="false"
|
||||
/>
|
||||
<LocaleList v-else :locales="filteredLocales" :portal="portal" />
|
||||
</template>
|
||||
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
|
||||
</HelpCenterLayout>
|
||||
|
||||
+6
-4
@@ -53,8 +53,9 @@ const generateUid = () => {
|
||||
const uploadAttachment = ref(null);
|
||||
const isEmojiPickerOpen = ref(false);
|
||||
|
||||
const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
const EmojiIconPicker = defineAsyncComponent(
|
||||
() =>
|
||||
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -215,10 +216,11 @@ useEventListener(document, 'paste', onPaste);
|
||||
class="!w-10"
|
||||
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
||||
/>
|
||||
<EmojiInput
|
||||
<EmojiIconPicker
|
||||
v-if="isEmojiPickerOpen"
|
||||
mode="emoji"
|
||||
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
|
||||
:on-click="onClickInsertEmoji"
|
||||
@select="onClickInsertEmoji($event.value)"
|
||||
/>
|
||||
</div>
|
||||
<FileUpload
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { formatBytes } from 'shared/helpers/FileHelper';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import {
|
||||
MEDIA_TYPES,
|
||||
NON_FILE_TYPES,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
import FileIcon from 'next/icon/FileIcon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachments: { type: Array, default: () => [] },
|
||||
peekLimit: { type: Number, default: 0 },
|
||||
showJumpToMessage: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'jumpToMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fileAttachments = computed(() =>
|
||||
[...props.attachments]
|
||||
.filter(
|
||||
a =>
|
||||
a.data_url &&
|
||||
!MEDIA_TYPES.includes(a.file_type) &&
|
||||
!NON_FILE_TYPES.includes(a.file_type)
|
||||
)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
);
|
||||
|
||||
const showAll = ref(false);
|
||||
const downloadingId = ref(null);
|
||||
|
||||
const isPeekable = computed(() => props.peekLimit > 0);
|
||||
|
||||
const visibleFiles = computed(() => {
|
||||
if (!isPeekable.value || showAll.value) return fileAttachments.value;
|
||||
return fileAttachments.value.slice(0, props.peekLimit);
|
||||
});
|
||||
|
||||
const fileNameFromUrl = url => {
|
||||
if (!url) return '';
|
||||
const name = url.split('/').pop();
|
||||
return name ? decodeURIComponent(name) : '';
|
||||
};
|
||||
|
||||
const displayName = attachment =>
|
||||
fileNameFromUrl(attachment.data_url) ||
|
||||
t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
|
||||
|
||||
const displaySize = attachment => {
|
||||
if (attachment.file_size) return formatBytes(attachment.file_size);
|
||||
if (attachment.extension) return attachment.extension.toUpperCase();
|
||||
return '—';
|
||||
};
|
||||
|
||||
const displayTime = attachment => {
|
||||
if (!attachment.created_at) return '';
|
||||
return shortTimestamp(dynamicTime(attachment.created_at), true);
|
||||
};
|
||||
|
||||
const onActivate = attachment => emit('select', attachment);
|
||||
|
||||
const onDownloadFile = async attachment => {
|
||||
const { id, file_type: type, data_url: url, extension } = attachment;
|
||||
try {
|
||||
downloadingId.value = id;
|
||||
await downloadFile({ url, type, extension });
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
|
||||
} finally {
|
||||
downloadingId.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="fileAttachments.length" class="flex flex-col gap-2.5">
|
||||
<header class="flex items-center justify-between px-0.5">
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
|
||||
<span
|
||||
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
|
||||
>
|
||||
{{ fileAttachments.length }}
|
||||
</span>
|
||||
</h4>
|
||||
<NextButton
|
||||
v-if="isPeekable && fileAttachments.length > peekLimit"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
trailing-icon
|
||||
:icon="showAll ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'"
|
||||
:label="
|
||||
showAll
|
||||
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
|
||||
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
|
||||
"
|
||||
@click="showAll = !showAll"
|
||||
/>
|
||||
</header>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
<li
|
||||
v-for="attachment in visibleFiles"
|
||||
:key="attachment.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex items-center gap-3 px-2 py-2 transition-colors rounded-lg cursor-pointer hover:bg-n-slate-3 group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
|
||||
@click="onActivate(attachment)"
|
||||
@keydown.enter="onActivate(attachment)"
|
||||
@keydown.space.prevent="onActivate(attachment)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-gradient-to-br from-n-slate-3 to-n-slate-4 ring-1 ring-inset ring-n-slate-4/40"
|
||||
>
|
||||
<FileIcon
|
||||
:file-type="attachment.extension?.toLowerCase() || ''"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate text-n-slate-12 mb-1">
|
||||
{{ displayName(attachment) }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ displaySize(attachment) }}
|
||||
<template v-if="displayTime(attachment)">
|
||||
· {{ displayTime(attachment) }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<NextButton
|
||||
v-if="showJumpToMessage && attachment.message_id"
|
||||
v-tooltip.top="{
|
||||
content: t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE'),
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-external-link"
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE')"
|
||||
@click.stop="emit('jumpToMessage', attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-download"
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
:is-loading="downloadingId === attachment.id"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
|
||||
@click.stop="onDownloadFile(attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import {
|
||||
dynamicTime,
|
||||
formatDuration,
|
||||
shortTimestamp,
|
||||
} from 'shared/helpers/timeHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import {
|
||||
ATTACHMENT_TYPES,
|
||||
MEDIA_TYPES,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachments: { type: Array, default: () => [] },
|
||||
peekLimit: { type: Number, default: 0 },
|
||||
showJumpToMessage: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'jumpToMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const mediaAttachments = computed(() =>
|
||||
[...props.attachments]
|
||||
.filter(a => a.data_url && MEDIA_TYPES.includes(a.file_type))
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
);
|
||||
|
||||
const showAll = ref(false);
|
||||
const failedThumbs = ref(new Set());
|
||||
const failedPreviews = ref(new Set());
|
||||
const durations = ref({});
|
||||
const downloadingId = ref(null);
|
||||
|
||||
const isPeekable = computed(() => props.peekLimit > 0);
|
||||
|
||||
const visibleMedia = computed(() => {
|
||||
if (!isPeekable.value || showAll.value) return mediaAttachments.value;
|
||||
return mediaAttachments.value.slice(0, props.peekLimit);
|
||||
});
|
||||
|
||||
const overflow = computed(() => {
|
||||
if (!isPeekable.value) return 0;
|
||||
const total = mediaAttachments.value.length;
|
||||
return total > props.peekLimit ? total - (props.peekLimit - 1) : 0;
|
||||
});
|
||||
|
||||
const fileNameFromUrl = url => {
|
||||
if (!url) return '';
|
||||
const name = url.split('/').pop();
|
||||
return name ? decodeURIComponent(name) : '';
|
||||
};
|
||||
|
||||
const isVideoType = type =>
|
||||
[ATTACHMENT_TYPES.VIDEO, ATTACHMENT_TYPES.IG_REEL].includes(type);
|
||||
const isAudioType = type => type === ATTACHMENT_TYPES.AUDIO;
|
||||
const isPlayableType = type => isVideoType(type) || isAudioType(type);
|
||||
|
||||
const imagePreviewSrc = ({
|
||||
id,
|
||||
file_type: type,
|
||||
thumb_url: thumbUrl,
|
||||
data_url: dataUrl,
|
||||
}) => {
|
||||
const canUseThumb = thumbUrl && !failedThumbs.value.has(id);
|
||||
if (type === ATTACHMENT_TYPES.IMAGE) return canUseThumb ? thumbUrl : dataUrl;
|
||||
if (isVideoType(type)) return canUseThumb ? thumbUrl : null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const onPreviewError = ({
|
||||
id,
|
||||
file_type: type,
|
||||
thumb_url: thumbUrl,
|
||||
data_url: dataUrl,
|
||||
}) => {
|
||||
const canRetryWithFull = thumbUrl && !failedThumbs.value.has(id) && dataUrl;
|
||||
if (
|
||||
canRetryWithFull &&
|
||||
(type === ATTACHMENT_TYPES.IMAGE || isVideoType(type))
|
||||
) {
|
||||
failedThumbs.value.add(id);
|
||||
return;
|
||||
}
|
||||
failedPreviews.value.add(id);
|
||||
};
|
||||
|
||||
const hasPreview = attachment =>
|
||||
!!imagePreviewSrc(attachment) && !failedPreviews.value.has(attachment.id);
|
||||
|
||||
const hasVideoPreview = attachment =>
|
||||
isVideoType(attachment.file_type) &&
|
||||
attachment.data_url &&
|
||||
!failedPreviews.value.has(attachment.id);
|
||||
|
||||
const fallbackIcon = type => {
|
||||
if (isAudioType(type)) return 'i-lucide-music';
|
||||
if (isVideoType(type)) return 'i-lucide-video';
|
||||
return 'i-lucide-image';
|
||||
};
|
||||
|
||||
const onLoadedMetadata = (attachment, event) => {
|
||||
const seconds = event.target?.duration;
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
durations.value[attachment.id] = seconds;
|
||||
}
|
||||
};
|
||||
|
||||
const displayDuration = attachment => {
|
||||
const seconds = durations.value[attachment.id];
|
||||
return seconds ? formatDuration(Math.round(seconds)) : '';
|
||||
};
|
||||
|
||||
const displayTime = attachment => {
|
||||
if (!attachment.created_at) return '';
|
||||
return shortTimestamp(dynamicTime(attachment.created_at), true);
|
||||
};
|
||||
|
||||
const isOverflowTile = index =>
|
||||
isPeekable.value &&
|
||||
!showAll.value &&
|
||||
overflow.value > 0 &&
|
||||
index === props.peekLimit - 1;
|
||||
|
||||
const onTileActivate = (attachment, index) => {
|
||||
if (isOverflowTile(index)) {
|
||||
showAll.value = true;
|
||||
return;
|
||||
}
|
||||
emit('select', attachment);
|
||||
};
|
||||
|
||||
const onDownloadFile = async attachment => {
|
||||
const { id, file_type: type, data_url: url, extension } = attachment;
|
||||
try {
|
||||
downloadingId.value = id;
|
||||
await downloadFile({ url, type, extension });
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
|
||||
} finally {
|
||||
downloadingId.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="mediaAttachments.length" class="flex flex-col gap-2.5">
|
||||
<header class="flex items-center justify-between px-0.5">
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
|
||||
<span
|
||||
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
|
||||
>
|
||||
{{ mediaAttachments.length }}
|
||||
</span>
|
||||
</h4>
|
||||
<NextButton
|
||||
v-if="overflow > 0"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
trailing-icon
|
||||
:icon="showAll ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'"
|
||||
:label="
|
||||
showAll
|
||||
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
|
||||
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
|
||||
"
|
||||
@click="showAll = !showAll"
|
||||
/>
|
||||
</header>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="(attachment, index) in visibleMedia"
|
||||
:key="attachment.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="relative w-full overflow-hidden transition-all duration-200 rounded-lg cursor-pointer aspect-square bg-n-slate-3 shadow-sm hover:shadow-md hover:-translate-y-px group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
|
||||
@click="onTileActivate(attachment, index)"
|
||||
@keydown.enter="onTileActivate(attachment, index)"
|
||||
@keydown.space.prevent="onTileActivate(attachment, index)"
|
||||
>
|
||||
<template v-if="!isOverflowTile(index)">
|
||||
<img
|
||||
v-if="hasPreview(attachment)"
|
||||
:src="imagePreviewSrc(attachment)"
|
||||
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
:alt="fileNameFromUrl(attachment.data_url)"
|
||||
@error="onPreviewError(attachment)"
|
||||
/>
|
||||
<video
|
||||
v-else-if="hasVideoPreview(attachment)"
|
||||
:src="`${attachment.data_url}#t=0.1`"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110 pointer-events-none"
|
||||
@loadedmetadata="onLoadedMetadata(attachment, $event)"
|
||||
@error="onPreviewError(attachment)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full h-full bg-gradient-to-br from-n-slate-3 to-n-slate-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="fallbackIcon(attachment.file_type)"
|
||||
class="size-6 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
v-if="isAudioType(attachment.file_type) && attachment.data_url"
|
||||
:src="attachment.data_url"
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
@loadedmetadata="onLoadedMetadata(attachment, $event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-200 opacity-0 pointer-events-none group-hover:opacity-100 bg-gradient-to-t from-black/40 via-transparent to-transparent"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="hasVideoPreview(attachment)"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-gradient-to-t from-black/30 via-transparent to-transparent"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full size-7 bg-white/95 shadow-md"
|
||||
>
|
||||
<Icon icon="i-lucide-play" class="ms-0.5 size-3.5 text-n-black" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="
|
||||
isPlayableType(attachment.file_type) &&
|
||||
displayDuration(attachment)
|
||||
"
|
||||
class="absolute text-xxs font-medium tabular-nums transition-opacity bottom-1.5 ltr:right-1.5 rtl:left-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-0"
|
||||
>
|
||||
{{ displayDuration(attachment) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="displayTime(attachment)"
|
||||
class="absolute text-xxs font-medium transition-opacity opacity-0 bottom-1.5 ltr:left-1.5 rtl:right-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-100"
|
||||
>
|
||||
{{ displayTime(attachment) }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 bottom-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white disabled:opacity-50"
|
||||
:disabled="downloadingId === attachment.id"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
|
||||
@click.stop="onDownloadFile(attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
<Icon icon="i-lucide-download" class="size-3 text-n-black" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showJumpToMessage && attachment.message_id"
|
||||
v-tooltip.top="{
|
||||
content: t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE'),
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
type="button"
|
||||
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 top-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE')"
|
||||
@click.stop="emit('jumpToMessage', attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
<Icon icon="i-lucide-external-link" class="size-3 text-n-black" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="isOverflowTile(index)"
|
||||
class="absolute inset-0 flex items-center justify-center bg-n-slate-5"
|
||||
>
|
||||
<span class="text-base font-semibold text-n-slate-12">
|
||||
{{
|
||||
t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
|
||||
count: overflow,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
@@ -47,8 +48,17 @@ const onClick = (item, index) => {
|
||||
</button>
|
||||
|
||||
<!-- The last breadcrumb item is plain text -->
|
||||
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
|
||||
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1 text-sm truncate min-w-0"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="item.emoji"
|
||||
:value="item.emoji"
|
||||
:color="item.iconColor"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate text-n-slate-12">{{ item.label }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -75,11 +75,11 @@ const channelIcon = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 pt-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
|
||||
class="flex flex-col gap-1 pt-4 bg-n-call-widget rounded-2xl shadow-xl outline outline-1 outline-n-call-widget-border backdrop-blur-md"
|
||||
:class="call?.conversationId ? 'pb-2' : 'pb-4'"
|
||||
>
|
||||
<!-- Top section: status badge + location/inbox + duration -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-3 pb-3 border-b border-n-call-widget-border">
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<!-- Ongoing: status badge on left -->
|
||||
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
|
||||
@@ -100,10 +100,10 @@ const channelIcon = computed(() => {
|
||||
<Icon
|
||||
v-else-if="!isOngoing"
|
||||
:icon="channelIcon"
|
||||
class="size-3.5 text-n-slate-10 shrink-0"
|
||||
class="size-3.5 text-n-call-widget-sub-text shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
|
||||
class="text-xs font-medium text-n-call-widget-sub-text tracking-tight truncate"
|
||||
>
|
||||
{{ callInfo.location }}
|
||||
</span>
|
||||
@@ -112,7 +112,7 @@ const channelIcon = computed(() => {
|
||||
<!-- Ongoing: duration on right -->
|
||||
<p
|
||||
v-if="isOngoing"
|
||||
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
|
||||
class="font-display text-base font-medium text-n-call-widget-sub-text shrink-0 mb-0 tabular-nums tracking-tight"
|
||||
>
|
||||
{{ duration }}
|
||||
</p>
|
||||
@@ -132,7 +132,7 @@ const channelIcon = computed(() => {
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
class="!rounded-full -my-1"
|
||||
class="!rounded-full -my-1 -me-1 !text-n-call-widget-sub-text"
|
||||
@click="$emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
@@ -149,13 +149,13 @@ const channelIcon = computed(() => {
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
|
||||
class="font-display text-sm font-medium text-n-call-widget-text truncate mb-0.5 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.contactName }}
|
||||
</p>
|
||||
<p
|
||||
v-if="callInfo.phoneNumber"
|
||||
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
|
||||
class="text-sm text-n-call-widget-sub-text truncate mb-0 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.phoneNumber }}
|
||||
</p>
|
||||
@@ -217,23 +217,23 @@ const channelIcon = computed(() => {
|
||||
>
|
||||
<template #icon>
|
||||
<span
|
||||
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
|
||||
class="flex items-center gap-1 text-n-call-widget-sub-text group-hover:text-n-call-widget-text"
|
||||
>
|
||||
<Icon
|
||||
icon="i-ph-chat-circle-text-bold"
|
||||
class="size-3.5 text-n-slate-11 shrink-0"
|
||||
class="size-3.5 text-n-call-widget-sub-text shrink-0"
|
||||
/>
|
||||
<span class="text-sm tracking-tight tabular-nums">
|
||||
#{{ call.conversationId }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-ph-caret-right-bold"
|
||||
class="size-3 text-n-slate-11 shrink-0"
|
||||
class="size-3 text-n-call-widget-sub-text shrink-0"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
|
||||
class="text-sm text-n-call-widget-sub-text tracking-tight group-hover:text-n-call-widget-text"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
|
||||
</span>
|
||||
|
||||
@@ -14,6 +14,9 @@ const props = defineProps({
|
||||
value.every(option => 'value' in option && 'label' in option),
|
||||
},
|
||||
placeholder: { type: String, default: '' },
|
||||
// Fallback label shown when the selected value is not in `options` yet
|
||||
// (e.g. API-backed lists that load lazily on open).
|
||||
displayLabel: { type: String, default: '' },
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
searchPlaceholder: { type: String, default: '' },
|
||||
@@ -23,7 +26,7 @@ const props = defineProps({
|
||||
useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search']);
|
||||
const emit = defineEmits(['update:modelValue', 'search', 'open']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -52,7 +55,7 @@ const selectedLabel = computed(() => {
|
||||
const selected = props.options.find(
|
||||
option => option.value === selectedValue.value
|
||||
);
|
||||
return selected?.label ?? selectPlaceholder.value;
|
||||
return selected?.label ?? (props.displayLabel || selectPlaceholder.value);
|
||||
});
|
||||
|
||||
const selectOption = option => {
|
||||
@@ -72,6 +75,7 @@ const toggleDropdown = () => {
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
search.value = '';
|
||||
emit('open');
|
||||
nextTick(() => dropdownRef.value?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ defineExpose({
|
||||
}"
|
||||
role="option"
|
||||
:aria-selected="isSelected(option)"
|
||||
@click="emit('select', option)"
|
||||
@click.stop="emit('select', option)"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
@@ -162,7 +163,7 @@ onMounted(() => {
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm truncate min-w-0"
|
||||
>
|
||||
{{ section.title }}
|
||||
</p>
|
||||
@@ -207,9 +208,12 @@ onMounted(() => {
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
</slot>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{
|
||||
item.emoji
|
||||
}}</span>
|
||||
<EmojiIcon
|
||||
v-if="item.emoji"
|
||||
:value="item.emoji"
|
||||
:color="item.iconColor"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<slot name="label" :item="item">
|
||||
<span
|
||||
v-if="item.label"
|
||||
@@ -260,7 +264,12 @@ onMounted(() => {
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
</slot>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<EmojiIcon
|
||||
v-if="item.emoji"
|
||||
:value="item.emoji"
|
||||
:color="item.iconColor"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<slot name="label" :item="item">
|
||||
<span
|
||||
v-if="item.label"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ICON_COLORS } from './constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
const selectedColor = defineModel({ type: String, default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-1 px-0.5 mx-2 py-1 mb-px">
|
||||
<button
|
||||
v-for="color in ICON_COLORS"
|
||||
:key="color.value"
|
||||
v-tooltip.top="t(`EMOJI_ICON_PICKER.COLORS.${color.name}`)"
|
||||
type="button"
|
||||
class="flex items-center justify-center flex-shrink-0 transition-all rounded-full size-5 ring-offset-2 ring-offset-n-surface-2 hover:ring-2"
|
||||
:class="{ 'ring-2': selectedColor === color.value }"
|
||||
:style="{ backgroundColor: color.value, '--tw-ring-color': color.value }"
|
||||
@click="selectedColor = color.value"
|
||||
>
|
||||
<span
|
||||
v-if="selectedColor === color.value"
|
||||
class="text-white i-lucide-check size-3"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { isIconValue, iconClassFor } from './constants';
|
||||
|
||||
// Renders a saved picker value: a color-tinted icon or an emoji.
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const isIcon = computed(() => isIconValue(props.value));
|
||||
const iconClass = computed(() => iconClassFor(props.value));
|
||||
const iconStyle = computed(() =>
|
||||
props.color ? { color: props.color } : undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center justify-center leading-none">
|
||||
<Icon
|
||||
v-if="isIcon"
|
||||
:icon="iconClass"
|
||||
class="size-full"
|
||||
:style="iconStyle"
|
||||
/>
|
||||
<span v-else v-dompurify-html="value" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ColorPalette from './ColorPalette.vue';
|
||||
import { CURATED_ICONS } from './icons';
|
||||
import {
|
||||
DEFAULT_ICON_COLOR,
|
||||
ICON_STYLE,
|
||||
PICKER_MODE,
|
||||
PICKER_TAB,
|
||||
isIconValue,
|
||||
} from './constants';
|
||||
import {
|
||||
buildEmojiSections,
|
||||
getEmojiTint,
|
||||
getRecentEmojis,
|
||||
addRecentEmoji,
|
||||
} from 'shared/components/emoji/pickerHelper';
|
||||
|
||||
const props = defineProps({
|
||||
// 'both' shows Icons + Emojis tabs; 'emoji' shows only the emoji panel.
|
||||
mode: {
|
||||
type: String,
|
||||
default: PICKER_MODE.BOTH,
|
||||
validator: value => Object.values(PICKER_MODE).includes(value),
|
||||
},
|
||||
// Saved value: an emoji char or an icon name (e.g. "rocket-line").
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// Current stored icon color (hex), used to preselect a swatch.
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showRemoveButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'remove', 'colorChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showTabs = computed(() => props.mode === PICKER_MODE.BOTH);
|
||||
const showHeader = computed(
|
||||
() => showTabs.value || (props.showRemoveButton && props.value)
|
||||
);
|
||||
|
||||
const activeTab = ref(
|
||||
props.value && !isIconValue(props.value)
|
||||
? PICKER_TAB.EMOJIS
|
||||
: PICKER_TAB.ICONS
|
||||
);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
id: PICKER_TAB.ICONS,
|
||||
label: t('EMOJI_ICON_PICKER.TABS.ICONS'),
|
||||
icon: 'i-lucide-shapes',
|
||||
},
|
||||
{
|
||||
id: PICKER_TAB.EMOJIS,
|
||||
label: t('EMOJI_ICON_PICKER.TABS.EMOJIS'),
|
||||
icon: 'i-lucide-smile',
|
||||
},
|
||||
]);
|
||||
|
||||
// Icons
|
||||
const iconSearch = ref('');
|
||||
const selectedColor = ref(props.color || DEFAULT_ICON_COLOR);
|
||||
// Preselect the outline/filled toggle from the saved icon's style suffix.
|
||||
const iconStyle = ref(
|
||||
props.value.endsWith(`-${ICON_STYLE.FILL}`)
|
||||
? ICON_STYLE.FILL
|
||||
: ICON_STYLE.LINE
|
||||
);
|
||||
|
||||
const filteredIcons = computed(() => {
|
||||
const term = iconSearch.value.trim().toLowerCase();
|
||||
if (!term) return CURATED_ICONS;
|
||||
return CURATED_ICONS.filter(
|
||||
icon => icon.name.includes(term) || icon.keywords.includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleIconStyle = () => {
|
||||
iconStyle.value =
|
||||
iconStyle.value === ICON_STYLE.LINE ? ICON_STYLE.FILL : ICON_STYLE.LINE;
|
||||
};
|
||||
|
||||
// Each icon button tints its hover with the selected color (low opacity).
|
||||
const iconHoverStyle = computed(() => ({
|
||||
'--ep-tint': `${selectedColor.value}24`,
|
||||
}));
|
||||
|
||||
const selectIcon = icon => {
|
||||
// Store the name with its style suffix, e.g. "rocket-fill".
|
||||
const value = `${icon.name}-${iconStyle.value}`;
|
||||
emit('select', {
|
||||
type: 'icon',
|
||||
value,
|
||||
icon: value,
|
||||
color: selectedColor.value,
|
||||
});
|
||||
};
|
||||
|
||||
watch(selectedColor, color => {
|
||||
if (isIconValue(props.value)) emit('colorChange', color);
|
||||
});
|
||||
|
||||
// Emojis
|
||||
const emojiSearch = ref('');
|
||||
const recentEmojis = ref([]);
|
||||
|
||||
onMounted(() => {
|
||||
recentEmojis.value = getRecentEmojis();
|
||||
});
|
||||
|
||||
const emojiSections = computed(() =>
|
||||
buildEmojiSections(
|
||||
emojiSearch.value,
|
||||
recentEmojis.value,
|
||||
t('EMOJI_ICON_PICKER.FREQUENTLY_USED')
|
||||
)
|
||||
);
|
||||
|
||||
// Tints an emoji button with a light shade of the emoji's own color on hover.
|
||||
const applyEmojiTint = (event, emoji) => {
|
||||
event.currentTarget.style.setProperty('--ep-tint', getEmojiTint(emoji));
|
||||
};
|
||||
|
||||
const selectEmoji = emoji => {
|
||||
recentEmojis.value = addRecentEmoji(emoji);
|
||||
emit('select', { type: 'emoji', value: emoji.emoji, emoji: emoji.emoji });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="dialog"
|
||||
class="absolute z-20 flex flex-col overflow-hidden shadow-xl w-[22rem] bg-n-surface-2 backdrop-blur-[100px] rounded-2xl outline outline-1 outline-n-weak dark:outline-n-strong/50"
|
||||
>
|
||||
<div v-if="showHeader" class="flex items-center justify-between gap-2 p-2">
|
||||
<div v-if="showTabs" class="flex gap-0.5 p-0.5 rounded-lg bg-n-alpha-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
v-tooltip.top="tab.label"
|
||||
type="button"
|
||||
class="flex items-center justify-center !p-0 transition-all rounded-md size-7 active:scale-[0.92]"
|
||||
:class="
|
||||
activeTab === tab.id
|
||||
? 'bg-n-surface-1 shadow-sm text-n-slate-12'
|
||||
: 'text-n-slate-10 hover:text-n-slate-12'
|
||||
"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<span :class="tab.icon" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
v-if="showRemoveButton && value"
|
||||
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-eraser"
|
||||
class="flex-shrink-0"
|
||||
@click="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Icons panel -->
|
||||
<div
|
||||
v-if="showTabs && activeTab === PICKER_TAB.ICONS"
|
||||
class="flex flex-col gap-1.5"
|
||||
:style="iconHoverStyle"
|
||||
>
|
||||
<ColorPalette v-model="selectedColor" />
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<Input
|
||||
v-model="iconSearch"
|
||||
size="md"
|
||||
class="flex-1"
|
||||
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_ICON')"
|
||||
custom-input-class="!ps-9 !bg-transparent"
|
||||
autofocus
|
||||
>
|
||||
<template #prefix>
|
||||
<span
|
||||
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
<button
|
||||
v-tooltip.top="
|
||||
iconStyle === ICON_STYLE.FILL
|
||||
? t('EMOJI_ICON_PICKER.STYLE.FILLED')
|
||||
: t('EMOJI_ICON_PICKER.STYLE.OUTLINE')
|
||||
"
|
||||
type="button"
|
||||
class="flex items-center justify-center flex-shrink-0 transition-all rounded-lg !p-2 size-9 text-n-slate-11 hover:bg-[var(--ep-tint)] active:scale-[0.92]"
|
||||
@click="toggleIconStyle"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
iconStyle === ICON_STYLE.FILL
|
||||
? 'i-ri-contrast-2-fill'
|
||||
: 'i-ri-contrast-2-line'
|
||||
"
|
||||
class="size-[1.2rem]"
|
||||
:style="{ color: selectedColor }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredIcons.length"
|
||||
class="grid grid-cols-10 h-52 gap-0.5 overflow-y-auto no-scrollbar content-start px-2 pb-2"
|
||||
>
|
||||
<button
|
||||
v-for="icon in filteredIcons"
|
||||
:key="icon.name"
|
||||
type="button"
|
||||
:title="icon.name"
|
||||
class="flex items-center justify-center !p-0 size-8 transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
|
||||
@click="selectIcon(icon)"
|
||||
>
|
||||
<span
|
||||
:class="icon[iconStyle]"
|
||||
class="size-[1.125rem]"
|
||||
:style="{ color: selectedColor }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center gap-2 h-52 text-n-slate-10"
|
||||
>
|
||||
<span class="i-lucide-search-x size-7" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ t('EMOJI_ICON_PICKER.NO_ICON') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emojis panel -->
|
||||
<div
|
||||
v-if="!showTabs || activeTab === PICKER_TAB.EMOJIS"
|
||||
class="flex flex-col gap-1.5"
|
||||
:class="{ 'pt-2': !showHeader }"
|
||||
>
|
||||
<div class="px-2">
|
||||
<Input
|
||||
v-model="emojiSearch"
|
||||
size="md"
|
||||
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_EMOJI')"
|
||||
custom-input-class="!ps-9 !bg-transparent"
|
||||
autofocus
|
||||
>
|
||||
<template #prefix>
|
||||
<span
|
||||
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div
|
||||
v-if="emojiSections.length"
|
||||
class="h-60 overflow-y-auto px-2 no-scrollbar pb-2"
|
||||
>
|
||||
<Virtualizer v-slot="{ item: section }" :data="emojiSections">
|
||||
<h5
|
||||
class="px-1 pt-2 pb-1 m-0 text-xs font-medium tracking-wide uppercase text-n-slate-10"
|
||||
>
|
||||
{{ section.name }}
|
||||
</h5>
|
||||
<div class="grid grid-cols-10 gap-0.5">
|
||||
<button
|
||||
v-for="emoji in section.emojis"
|
||||
:key="`${section.name}-${emoji.slug}`"
|
||||
type="button"
|
||||
:title="emoji.name"
|
||||
class="flex items-center justify-center !p-0 size-8 w-full max-w-[2rem] text-xl transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
|
||||
@mouseenter="applyEmojiTint($event, emoji.emoji)"
|
||||
@click="selectEmoji(emoji)"
|
||||
>
|
||||
{{ emoji.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</Virtualizer>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center gap-2 h-60 text-n-slate-10"
|
||||
>
|
||||
<span class="i-lucide-smile-plus size-7" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ t('EMOJI_ICON_PICKER.NO_EMOJI') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
// Prefix that turns a stored icon name (e.g. "rocket-line") into a class.
|
||||
// Swapping icon libraries means changing this and the curated set only.
|
||||
export const ICON_PREFIX = 'i-ri-';
|
||||
|
||||
export const ICON_STYLE = {
|
||||
LINE: 'line',
|
||||
FILL: 'fill',
|
||||
};
|
||||
|
||||
// Icon values are ascii names (e.g. "rocket-line"); emoji are non-ascii.
|
||||
export const isIconValue = value =>
|
||||
typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
|
||||
|
||||
export const iconClassFor = value =>
|
||||
value.startsWith(ICON_PREFIX) ? value : `${ICON_PREFIX}${value}`;
|
||||
|
||||
export const ICON_COLORS = [
|
||||
{ name: 'SLATE', value: '#64748B' },
|
||||
{ name: 'RED', value: '#EF4444' },
|
||||
{ name: 'ORANGE', value: '#F97316' },
|
||||
{ name: 'AMBER', value: '#F59E0B' },
|
||||
{ name: 'GREEN', value: '#22C55E' },
|
||||
{ name: 'TEAL', value: '#14B8A6' },
|
||||
{ name: 'BLUE', value: '#3B82F6' },
|
||||
{ name: 'INDIGO', value: '#6366F1' },
|
||||
{ name: 'VIOLET', value: '#8B5CF6' },
|
||||
{ name: 'PINK', value: '#EC4899' },
|
||||
];
|
||||
|
||||
export const DEFAULT_ICON_COLOR = '#3B82F6';
|
||||
|
||||
export const PICKER_MODE = {
|
||||
BOTH: 'both',
|
||||
EMOJI: 'emoji',
|
||||
};
|
||||
|
||||
export const PICKER_TAB = {
|
||||
ICONS: 'icons',
|
||||
EMOJIS: 'emojis',
|
||||
};
|
||||
@@ -0,0 +1,983 @@
|
||||
// Curated Remix (ri) icons, each with a `line` and `fill` class. The class
|
||||
// literals are kept here so Tailwind generates them. { name, keywords, line, fill }
|
||||
|
||||
export const CURATED_ICONS = [
|
||||
{
|
||||
name: 'home',
|
||||
keywords: 'home house main',
|
||||
line: 'i-ri-home-line',
|
||||
fill: 'i-ri-home-fill',
|
||||
},
|
||||
{
|
||||
name: 'star',
|
||||
keywords: 'favorite star',
|
||||
line: 'i-ri-star-line',
|
||||
fill: 'i-ri-star-fill',
|
||||
},
|
||||
{
|
||||
name: 'heart',
|
||||
keywords: 'like love',
|
||||
line: 'i-ri-heart-line',
|
||||
fill: 'i-ri-heart-fill',
|
||||
},
|
||||
{
|
||||
name: 'bookmark',
|
||||
keywords: 'save',
|
||||
line: 'i-ri-bookmark-line',
|
||||
fill: 'i-ri-bookmark-fill',
|
||||
},
|
||||
{
|
||||
name: 'flag-2',
|
||||
keywords: 'flag report',
|
||||
line: 'i-ri-flag-2-line',
|
||||
fill: 'i-ri-flag-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'price-tag-3',
|
||||
keywords: 'tag label',
|
||||
line: 'i-ri-price-tag-3-line',
|
||||
fill: 'i-ri-price-tag-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'map-pin',
|
||||
keywords: 'location place',
|
||||
line: 'i-ri-map-pin-line',
|
||||
fill: 'i-ri-map-pin-fill',
|
||||
},
|
||||
{
|
||||
name: 'sparkling-2',
|
||||
keywords: 'magic ai shine sparkle',
|
||||
line: 'i-ri-sparkling-2-line',
|
||||
fill: 'i-ri-sparkling-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'fire',
|
||||
keywords: 'flame hot trending',
|
||||
line: 'i-ri-fire-line',
|
||||
fill: 'i-ri-fire-fill',
|
||||
},
|
||||
{
|
||||
name: 'lightbulb',
|
||||
keywords: 'idea tip',
|
||||
line: 'i-ri-lightbulb-line',
|
||||
fill: 'i-ri-lightbulb-fill',
|
||||
},
|
||||
{
|
||||
name: 'rocket',
|
||||
keywords: 'launch startup ship',
|
||||
line: 'i-ri-rocket-line',
|
||||
fill: 'i-ri-rocket-fill',
|
||||
},
|
||||
{
|
||||
name: 'focus-3',
|
||||
keywords: 'target goal aim',
|
||||
line: 'i-ri-focus-3-line',
|
||||
fill: 'i-ri-focus-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'award',
|
||||
keywords: 'prize badge medal',
|
||||
line: 'i-ri-award-line',
|
||||
fill: 'i-ri-award-fill',
|
||||
},
|
||||
{
|
||||
name: 'trophy',
|
||||
keywords: 'win achievement',
|
||||
line: 'i-ri-trophy-line',
|
||||
fill: 'i-ri-trophy-fill',
|
||||
},
|
||||
{
|
||||
name: 'vip-crown-2',
|
||||
keywords: 'crown premium vip',
|
||||
line: 'i-ri-vip-crown-2-line',
|
||||
fill: 'i-ri-vip-crown-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'vip-diamond',
|
||||
keywords: 'gem diamond premium',
|
||||
line: 'i-ri-vip-diamond-line',
|
||||
fill: 'i-ri-vip-diamond-fill',
|
||||
},
|
||||
{
|
||||
name: 'gift',
|
||||
keywords: 'present reward',
|
||||
line: 'i-ri-gift-line',
|
||||
fill: 'i-ri-gift-fill',
|
||||
},
|
||||
{
|
||||
name: 'notification-3',
|
||||
keywords: 'bell alert',
|
||||
line: 'i-ri-notification-3-line',
|
||||
fill: 'i-ri-notification-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'global',
|
||||
keywords: 'globe world web',
|
||||
line: 'i-ri-global-line',
|
||||
fill: 'i-ri-global-fill',
|
||||
},
|
||||
{
|
||||
name: 'compass-3',
|
||||
keywords: 'explore discover',
|
||||
line: 'i-ri-compass-3-line',
|
||||
fill: 'i-ri-compass-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'puzzle-2',
|
||||
keywords: 'extension integration',
|
||||
line: 'i-ri-puzzle-2-line',
|
||||
fill: 'i-ri-puzzle-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'ticket-2',
|
||||
keywords: 'support coupon',
|
||||
line: 'i-ri-ticket-2-line',
|
||||
fill: 'i-ri-ticket-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'megaphone',
|
||||
keywords: 'announce broadcast marketing',
|
||||
line: 'i-ri-megaphone-line',
|
||||
fill: 'i-ri-megaphone-fill',
|
||||
},
|
||||
{
|
||||
name: 'briefcase-4',
|
||||
keywords: 'work job business',
|
||||
line: 'i-ri-briefcase-4-line',
|
||||
fill: 'i-ri-briefcase-4-fill',
|
||||
},
|
||||
{
|
||||
name: 'building-2',
|
||||
keywords: 'company office org',
|
||||
line: 'i-ri-building-2-line',
|
||||
fill: 'i-ri-building-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'store-2',
|
||||
keywords: 'shop commerce',
|
||||
line: 'i-ri-store-2-line',
|
||||
fill: 'i-ri-store-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'community',
|
||||
keywords: 'group team',
|
||||
line: 'i-ri-community-line',
|
||||
fill: 'i-ri-community-fill',
|
||||
},
|
||||
{
|
||||
name: 'folder',
|
||||
keywords: 'directory',
|
||||
line: 'i-ri-folder-line',
|
||||
fill: 'i-ri-folder-fill',
|
||||
},
|
||||
{
|
||||
name: 'folder-open',
|
||||
keywords: 'directory open',
|
||||
line: 'i-ri-folder-open-line',
|
||||
fill: 'i-ri-folder-open-fill',
|
||||
},
|
||||
{
|
||||
name: 'file',
|
||||
keywords: 'document',
|
||||
line: 'i-ri-file-line',
|
||||
fill: 'i-ri-file-fill',
|
||||
},
|
||||
{
|
||||
name: 'file-text',
|
||||
keywords: 'document doc',
|
||||
line: 'i-ri-file-text-line',
|
||||
fill: 'i-ri-file-text-fill',
|
||||
},
|
||||
{
|
||||
name: 'file-copy',
|
||||
keywords: 'documents files',
|
||||
line: 'i-ri-file-copy-line',
|
||||
fill: 'i-ri-file-copy-fill',
|
||||
},
|
||||
{
|
||||
name: 'clipboard',
|
||||
keywords: 'copy tasks',
|
||||
line: 'i-ri-clipboard-line',
|
||||
fill: 'i-ri-clipboard-fill',
|
||||
},
|
||||
{
|
||||
name: 'book-2',
|
||||
keywords: 'read guide book',
|
||||
line: 'i-ri-book-2-line',
|
||||
fill: 'i-ri-book-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'book-open',
|
||||
keywords: 'read guide docs',
|
||||
line: 'i-ri-book-open-line',
|
||||
fill: 'i-ri-book-open-fill',
|
||||
},
|
||||
{
|
||||
name: 'graduation-cap',
|
||||
keywords: 'learn course education',
|
||||
line: 'i-ri-graduation-cap-line',
|
||||
fill: 'i-ri-graduation-cap-fill',
|
||||
},
|
||||
{
|
||||
name: 'newspaper',
|
||||
keywords: 'news article blog',
|
||||
line: 'i-ri-newspaper-line',
|
||||
fill: 'i-ri-newspaper-fill',
|
||||
},
|
||||
{
|
||||
name: 'sticky-note',
|
||||
keywords: 'note memo',
|
||||
line: 'i-ri-sticky-note-line',
|
||||
fill: 'i-ri-sticky-note-fill',
|
||||
},
|
||||
{
|
||||
name: 'pencil',
|
||||
keywords: 'write edit',
|
||||
line: 'i-ri-pencil-line',
|
||||
fill: 'i-ri-pencil-fill',
|
||||
},
|
||||
{
|
||||
name: 'quill-pen',
|
||||
keywords: 'write author',
|
||||
line: 'i-ri-quill-pen-line',
|
||||
fill: 'i-ri-quill-pen-fill',
|
||||
},
|
||||
{
|
||||
name: 'mark-pen',
|
||||
keywords: 'highlight',
|
||||
line: 'i-ri-mark-pen-line',
|
||||
fill: 'i-ri-mark-pen-fill',
|
||||
},
|
||||
{
|
||||
name: 'calendar-2',
|
||||
keywords: 'date schedule calendar',
|
||||
line: 'i-ri-calendar-2-line',
|
||||
fill: 'i-ri-calendar-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'calendar-event',
|
||||
keywords: 'schedule event',
|
||||
line: 'i-ri-calendar-event-line',
|
||||
fill: 'i-ri-calendar-event-fill',
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
keywords: 'clock time',
|
||||
line: 'i-ri-time-line',
|
||||
fill: 'i-ri-time-fill',
|
||||
},
|
||||
{
|
||||
name: 'timer',
|
||||
keywords: 'countdown',
|
||||
line: 'i-ri-timer-line',
|
||||
fill: 'i-ri-timer-fill',
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
keywords: 'recent past',
|
||||
line: 'i-ri-history-line',
|
||||
fill: 'i-ri-history-fill',
|
||||
},
|
||||
{
|
||||
name: 'hourglass',
|
||||
keywords: 'wait time',
|
||||
line: 'i-ri-hourglass-line',
|
||||
fill: 'i-ri-hourglass-fill',
|
||||
},
|
||||
{
|
||||
name: 'shopping-cart-2',
|
||||
keywords: 'buy ecommerce cart',
|
||||
line: 'i-ri-shopping-cart-2-line',
|
||||
fill: 'i-ri-shopping-cart-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'shopping-bag-3',
|
||||
keywords: 'buy purchase bag',
|
||||
line: 'i-ri-shopping-bag-3-line',
|
||||
fill: 'i-ri-shopping-bag-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'box-3',
|
||||
keywords: 'package box shipping',
|
||||
line: 'i-ri-box-3-line',
|
||||
fill: 'i-ri-box-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'archive',
|
||||
keywords: 'store box archive',
|
||||
line: 'i-ri-archive-line',
|
||||
fill: 'i-ri-archive-fill',
|
||||
},
|
||||
{
|
||||
name: 'bank-card',
|
||||
keywords: 'pay billing card credit',
|
||||
line: 'i-ri-bank-card-line',
|
||||
fill: 'i-ri-bank-card-fill',
|
||||
},
|
||||
{
|
||||
name: 'wallet-3',
|
||||
keywords: 'money pay wallet',
|
||||
line: 'i-ri-wallet-3-line',
|
||||
fill: 'i-ri-wallet-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'money-dollar-circle',
|
||||
keywords: 'money price usd',
|
||||
line: 'i-ri-money-dollar-circle-line',
|
||||
fill: 'i-ri-money-dollar-circle-fill',
|
||||
},
|
||||
{
|
||||
name: 'copper-coin',
|
||||
keywords: 'coins money',
|
||||
line: 'i-ri-copper-coin-line',
|
||||
fill: 'i-ri-copper-coin-fill',
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
keywords: 'money exchange',
|
||||
line: 'i-ri-currency-line',
|
||||
fill: 'i-ri-currency-fill',
|
||||
},
|
||||
{
|
||||
name: 'receipt',
|
||||
keywords: 'invoice bill',
|
||||
line: 'i-ri-receipt-line',
|
||||
fill: 'i-ri-receipt-fill',
|
||||
},
|
||||
{
|
||||
name: 'percent',
|
||||
keywords: 'discount sale',
|
||||
line: 'i-ri-percent-line',
|
||||
fill: 'i-ri-percent-fill',
|
||||
},
|
||||
{
|
||||
name: 'calculator',
|
||||
keywords: 'math',
|
||||
line: 'i-ri-calculator-line',
|
||||
fill: 'i-ri-calculator-fill',
|
||||
},
|
||||
{
|
||||
name: 'mail',
|
||||
keywords: 'email message',
|
||||
line: 'i-ri-mail-line',
|
||||
fill: 'i-ri-mail-fill',
|
||||
},
|
||||
{
|
||||
name: 'send-plane',
|
||||
keywords: 'send submit',
|
||||
line: 'i-ri-send-plane-line',
|
||||
fill: 'i-ri-send-plane-fill',
|
||||
},
|
||||
{
|
||||
name: 'chat-3',
|
||||
keywords: 'chat talk message',
|
||||
line: 'i-ri-chat-3-line',
|
||||
fill: 'i-ri-chat-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'message-2',
|
||||
keywords: 'chat comment',
|
||||
line: 'i-ri-message-2-line',
|
||||
fill: 'i-ri-message-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'question-answer',
|
||||
keywords: 'chat conversation faq',
|
||||
line: 'i-ri-question-answer-line',
|
||||
fill: 'i-ri-question-answer-fill',
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
keywords: 'call',
|
||||
line: 'i-ri-phone-line',
|
||||
fill: 'i-ri-phone-fill',
|
||||
},
|
||||
{
|
||||
name: 'vidicon',
|
||||
keywords: 'video call meeting',
|
||||
line: 'i-ri-vidicon-line',
|
||||
fill: 'i-ri-vidicon-fill',
|
||||
},
|
||||
{
|
||||
name: 'mic',
|
||||
keywords: 'record voice audio',
|
||||
line: 'i-ri-mic-line',
|
||||
fill: 'i-ri-mic-fill',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
keywords: 'team people users',
|
||||
line: 'i-ri-group-line',
|
||||
fill: 'i-ri-group-fill',
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
keywords: 'person account',
|
||||
line: 'i-ri-user-line',
|
||||
fill: 'i-ri-user-fill',
|
||||
},
|
||||
{
|
||||
name: 'user-add',
|
||||
keywords: 'add member',
|
||||
line: 'i-ri-user-add-line',
|
||||
fill: 'i-ri-user-add-fill',
|
||||
},
|
||||
{
|
||||
name: 'contacts',
|
||||
keywords: 'person card contact',
|
||||
line: 'i-ri-contacts-line',
|
||||
fill: 'i-ri-contacts-fill',
|
||||
},
|
||||
{
|
||||
name: 'emotion-happy',
|
||||
keywords: 'happy smile emoji',
|
||||
line: 'i-ri-emotion-happy-line',
|
||||
fill: 'i-ri-emotion-happy-fill',
|
||||
},
|
||||
{
|
||||
name: 'thumb-up',
|
||||
keywords: 'like approve',
|
||||
line: 'i-ri-thumb-up-line',
|
||||
fill: 'i-ri-thumb-up-fill',
|
||||
},
|
||||
{
|
||||
name: 'hand-heart',
|
||||
keywords: 'care support',
|
||||
line: 'i-ri-hand-heart-line',
|
||||
fill: 'i-ri-hand-heart-fill',
|
||||
},
|
||||
{
|
||||
name: 'at',
|
||||
keywords: 'mention email at',
|
||||
line: 'i-ri-at-line',
|
||||
fill: 'i-ri-at-fill',
|
||||
},
|
||||
{
|
||||
name: 'image-2',
|
||||
keywords: 'picture photo image',
|
||||
line: 'i-ri-image-2-line',
|
||||
fill: 'i-ri-image-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'camera-3',
|
||||
keywords: 'photo camera',
|
||||
line: 'i-ri-camera-3-line',
|
||||
fill: 'i-ri-camera-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'film',
|
||||
keywords: 'movie video',
|
||||
line: 'i-ri-film-line',
|
||||
fill: 'i-ri-film-fill',
|
||||
},
|
||||
{
|
||||
name: 'music-2',
|
||||
keywords: 'audio song music',
|
||||
line: 'i-ri-music-2-line',
|
||||
fill: 'i-ri-music-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'headphone',
|
||||
keywords: 'audio support',
|
||||
line: 'i-ri-headphone-line',
|
||||
fill: 'i-ri-headphone-fill',
|
||||
},
|
||||
{
|
||||
name: 'palette',
|
||||
keywords: 'color design art',
|
||||
line: 'i-ri-palette-line',
|
||||
fill: 'i-ri-palette-fill',
|
||||
},
|
||||
{
|
||||
name: 'brush-3',
|
||||
keywords: 'paint design brush',
|
||||
line: 'i-ri-brush-3-line',
|
||||
fill: 'i-ri-brush-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'scissors-2',
|
||||
keywords: 'cut',
|
||||
line: 'i-ri-scissors-2-line',
|
||||
fill: 'i-ri-scissors-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'code-s-slash',
|
||||
keywords: 'develop code programming',
|
||||
line: 'i-ri-code-s-slash-line',
|
||||
fill: 'i-ri-code-s-slash-fill',
|
||||
},
|
||||
{
|
||||
name: 'terminal-box',
|
||||
keywords: 'console cli terminal',
|
||||
line: 'i-ri-terminal-box-line',
|
||||
fill: 'i-ri-terminal-box-fill',
|
||||
},
|
||||
{
|
||||
name: 'git-branch',
|
||||
keywords: 'version code git',
|
||||
line: 'i-ri-git-branch-line',
|
||||
fill: 'i-ri-git-branch-fill',
|
||||
},
|
||||
{
|
||||
name: 'github',
|
||||
keywords: 'git code',
|
||||
line: 'i-ri-github-line',
|
||||
fill: 'i-ri-github-fill',
|
||||
},
|
||||
{
|
||||
name: 'cpu',
|
||||
keywords: 'chip processor',
|
||||
line: 'i-ri-cpu-line',
|
||||
fill: 'i-ri-cpu-fill',
|
||||
},
|
||||
{
|
||||
name: 'database-2',
|
||||
keywords: 'storage data',
|
||||
line: 'i-ri-database-2-line',
|
||||
fill: 'i-ri-database-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'server',
|
||||
keywords: 'host backend',
|
||||
line: 'i-ri-server-line',
|
||||
fill: 'i-ri-server-fill',
|
||||
},
|
||||
{
|
||||
name: 'cloud',
|
||||
keywords: 'storage cloud',
|
||||
line: 'i-ri-cloud-line',
|
||||
fill: 'i-ri-cloud-fill',
|
||||
},
|
||||
{
|
||||
name: 'wifi',
|
||||
keywords: 'network internet',
|
||||
line: 'i-ri-wifi-line',
|
||||
fill: 'i-ri-wifi-fill',
|
||||
},
|
||||
{
|
||||
name: 'computer',
|
||||
keywords: 'screen desktop monitor',
|
||||
line: 'i-ri-computer-line',
|
||||
fill: 'i-ri-computer-fill',
|
||||
},
|
||||
{
|
||||
name: 'smartphone',
|
||||
keywords: 'mobile phone',
|
||||
line: 'i-ri-smartphone-line',
|
||||
fill: 'i-ri-smartphone-fill',
|
||||
},
|
||||
{
|
||||
name: 'macbook',
|
||||
keywords: 'laptop computer',
|
||||
line: 'i-ri-macbook-line',
|
||||
fill: 'i-ri-macbook-fill',
|
||||
},
|
||||
{
|
||||
name: 'bug',
|
||||
keywords: 'issue error bug',
|
||||
line: 'i-ri-bug-line',
|
||||
fill: 'i-ri-bug-fill',
|
||||
},
|
||||
{
|
||||
name: 'command',
|
||||
keywords: 'keyboard shortcut',
|
||||
line: 'i-ri-command-line',
|
||||
fill: 'i-ri-command-fill',
|
||||
},
|
||||
{
|
||||
name: 'settings-3',
|
||||
keywords: 'config gear options settings',
|
||||
line: 'i-ri-settings-3-line',
|
||||
fill: 'i-ri-settings-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'equalizer',
|
||||
keywords: 'controls adjust sliders filter',
|
||||
line: 'i-ri-equalizer-line',
|
||||
fill: 'i-ri-equalizer-fill',
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
keywords: 'fix tools config',
|
||||
line: 'i-ri-tools-line',
|
||||
fill: 'i-ri-tools-fill',
|
||||
},
|
||||
{
|
||||
name: 'hammer',
|
||||
keywords: 'build fix hammer',
|
||||
line: 'i-ri-hammer-line',
|
||||
fill: 'i-ri-hammer-fill',
|
||||
},
|
||||
{
|
||||
name: 'key-2',
|
||||
keywords: 'password access key',
|
||||
line: 'i-ri-key-2-line',
|
||||
fill: 'i-ri-key-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'lock',
|
||||
keywords: 'secure private lock',
|
||||
line: 'i-ri-lock-line',
|
||||
fill: 'i-ri-lock-fill',
|
||||
},
|
||||
{
|
||||
name: 'lock-unlock',
|
||||
keywords: 'open access',
|
||||
line: 'i-ri-lock-unlock-line',
|
||||
fill: 'i-ri-lock-unlock-fill',
|
||||
},
|
||||
{
|
||||
name: 'shield',
|
||||
keywords: 'security protect',
|
||||
line: 'i-ri-shield-line',
|
||||
fill: 'i-ri-shield-fill',
|
||||
},
|
||||
{
|
||||
name: 'shield-check',
|
||||
keywords: 'secure verified',
|
||||
line: 'i-ri-shield-check-line',
|
||||
fill: 'i-ri-shield-check-fill',
|
||||
},
|
||||
{
|
||||
name: 'eye',
|
||||
keywords: 'view visible preview',
|
||||
line: 'i-ri-eye-line',
|
||||
fill: 'i-ri-eye-fill',
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
keywords: 'find lookup search',
|
||||
line: 'i-ri-search-line',
|
||||
fill: 'i-ri-search-fill',
|
||||
},
|
||||
{
|
||||
name: 'filter-3',
|
||||
keywords: 'sort refine filter',
|
||||
line: 'i-ri-filter-3-line',
|
||||
fill: 'i-ri-filter-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'links',
|
||||
keywords: 'url chain link',
|
||||
line: 'i-ri-links-line',
|
||||
fill: 'i-ri-links-fill',
|
||||
},
|
||||
{
|
||||
name: 'share-forward',
|
||||
keywords: 'share send distribute',
|
||||
line: 'i-ri-share-forward-line',
|
||||
fill: 'i-ri-share-forward-fill',
|
||||
},
|
||||
{
|
||||
name: 'plug',
|
||||
keywords: 'connect integration',
|
||||
line: 'i-ri-plug-line',
|
||||
fill: 'i-ri-plug-fill',
|
||||
},
|
||||
{
|
||||
name: 'shut-down',
|
||||
keywords: 'power on off',
|
||||
line: 'i-ri-shut-down-line',
|
||||
fill: 'i-ri-shut-down-fill',
|
||||
},
|
||||
{
|
||||
name: 'qr-scan-2',
|
||||
keywords: 'qr scan',
|
||||
line: 'i-ri-qr-scan-2-line',
|
||||
fill: 'i-ri-qr-scan-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'fingerprint',
|
||||
keywords: 'identity auth fingerprint',
|
||||
line: 'i-ri-fingerprint-line',
|
||||
fill: 'i-ri-fingerprint-fill',
|
||||
},
|
||||
{
|
||||
name: 'bar-chart',
|
||||
keywords: 'analytics stats graph bar',
|
||||
line: 'i-ri-bar-chart-line',
|
||||
fill: 'i-ri-bar-chart-fill',
|
||||
},
|
||||
{
|
||||
name: 'pie-chart',
|
||||
keywords: 'analytics stats pie',
|
||||
line: 'i-ri-pie-chart-line',
|
||||
fill: 'i-ri-pie-chart-fill',
|
||||
},
|
||||
{
|
||||
name: 'line-chart',
|
||||
keywords: 'analytics trend graph line',
|
||||
line: 'i-ri-line-chart-line',
|
||||
fill: 'i-ri-line-chart-fill',
|
||||
},
|
||||
{
|
||||
name: 'stock',
|
||||
keywords: 'growth increase trending',
|
||||
line: 'i-ri-stock-line',
|
||||
fill: 'i-ri-stock-fill',
|
||||
},
|
||||
{
|
||||
name: 'pulse',
|
||||
keywords: 'activity health monitor',
|
||||
line: 'i-ri-pulse-line',
|
||||
fill: 'i-ri-pulse-fill',
|
||||
},
|
||||
{
|
||||
name: 'dashboard-3',
|
||||
keywords: 'speed performance dashboard gauge',
|
||||
line: 'i-ri-dashboard-3-line',
|
||||
fill: 'i-ri-dashboard-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'check',
|
||||
keywords: 'done complete ok check',
|
||||
line: 'i-ri-check-line',
|
||||
fill: 'i-ri-check-fill',
|
||||
},
|
||||
{
|
||||
name: 'checkbox-circle',
|
||||
keywords: 'done success check',
|
||||
line: 'i-ri-checkbox-circle-line',
|
||||
fill: 'i-ri-checkbox-circle-fill',
|
||||
},
|
||||
{
|
||||
name: 'close-circle',
|
||||
keywords: 'error fail close',
|
||||
line: 'i-ri-close-circle-line',
|
||||
fill: 'i-ri-close-circle-fill',
|
||||
},
|
||||
{
|
||||
name: 'information',
|
||||
keywords: 'info information',
|
||||
line: 'i-ri-information-line',
|
||||
fill: 'i-ri-information-fill',
|
||||
},
|
||||
{
|
||||
name: 'error-warning',
|
||||
keywords: 'warning alert',
|
||||
line: 'i-ri-error-warning-line',
|
||||
fill: 'i-ri-error-warning-fill',
|
||||
},
|
||||
{
|
||||
name: 'alert',
|
||||
keywords: 'warning caution',
|
||||
line: 'i-ri-alert-line',
|
||||
fill: 'i-ri-alert-fill',
|
||||
},
|
||||
{
|
||||
name: 'question',
|
||||
keywords: 'support faq help',
|
||||
line: 'i-ri-question-line',
|
||||
fill: 'i-ri-question-fill',
|
||||
},
|
||||
{
|
||||
name: 'layout-grid',
|
||||
keywords: 'dashboard apps grid',
|
||||
line: 'i-ri-layout-grid-line',
|
||||
fill: 'i-ri-layout-grid-fill',
|
||||
},
|
||||
{
|
||||
name: 'table',
|
||||
keywords: 'grid data table',
|
||||
line: 'i-ri-table-line',
|
||||
fill: 'i-ri-table-fill',
|
||||
},
|
||||
{
|
||||
name: 'function',
|
||||
keywords: 'kanban board',
|
||||
line: 'i-ri-function-line',
|
||||
fill: 'i-ri-function-fill',
|
||||
},
|
||||
{
|
||||
name: 'map-2',
|
||||
keywords: 'location navigate map',
|
||||
line: 'i-ri-map-2-line',
|
||||
fill: 'i-ri-map-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'navigation',
|
||||
keywords: 'direction gps',
|
||||
line: 'i-ri-navigation-line',
|
||||
fill: 'i-ri-navigation-fill',
|
||||
},
|
||||
{
|
||||
name: 'car',
|
||||
keywords: 'drive vehicle car',
|
||||
line: 'i-ri-car-line',
|
||||
fill: 'i-ri-car-fill',
|
||||
},
|
||||
{
|
||||
name: 'truck',
|
||||
keywords: 'delivery shipping',
|
||||
line: 'i-ri-truck-line',
|
||||
fill: 'i-ri-truck-fill',
|
||||
},
|
||||
{
|
||||
name: 'plane',
|
||||
keywords: 'travel flight plane',
|
||||
line: 'i-ri-plane-line',
|
||||
fill: 'i-ri-plane-fill',
|
||||
},
|
||||
{
|
||||
name: 'bike',
|
||||
keywords: 'cycle bike',
|
||||
line: 'i-ri-bike-line',
|
||||
fill: 'i-ri-bike-fill',
|
||||
},
|
||||
{
|
||||
name: 'anchor',
|
||||
keywords: 'ship marine anchor',
|
||||
line: 'i-ri-anchor-line',
|
||||
fill: 'i-ri-anchor-fill',
|
||||
},
|
||||
{
|
||||
name: 'leaf',
|
||||
keywords: 'eco nature green leaf',
|
||||
line: 'i-ri-leaf-line',
|
||||
fill: 'i-ri-leaf-fill',
|
||||
},
|
||||
{
|
||||
name: 'plant',
|
||||
keywords: 'nature plant grow',
|
||||
line: 'i-ri-plant-line',
|
||||
fill: 'i-ri-plant-fill',
|
||||
},
|
||||
{
|
||||
name: 'sun',
|
||||
keywords: 'day light weather sun',
|
||||
line: 'i-ri-sun-line',
|
||||
fill: 'i-ri-sun-fill',
|
||||
},
|
||||
{
|
||||
name: 'moon',
|
||||
keywords: 'night dark moon',
|
||||
line: 'i-ri-moon-line',
|
||||
fill: 'i-ri-moon-fill',
|
||||
},
|
||||
{
|
||||
name: 'rainy',
|
||||
keywords: 'weather rain',
|
||||
line: 'i-ri-rainy-line',
|
||||
fill: 'i-ri-rainy-fill',
|
||||
},
|
||||
{
|
||||
name: 'umbrella',
|
||||
keywords: 'rain protect umbrella',
|
||||
line: 'i-ri-umbrella-line',
|
||||
fill: 'i-ri-umbrella-fill',
|
||||
},
|
||||
{
|
||||
name: 'landscape',
|
||||
keywords: 'outdoor hike mountain',
|
||||
line: 'i-ri-landscape-line',
|
||||
fill: 'i-ri-landscape-fill',
|
||||
},
|
||||
{
|
||||
name: 'drop',
|
||||
keywords: 'water drop',
|
||||
line: 'i-ri-drop-line',
|
||||
fill: 'i-ri-drop-fill',
|
||||
},
|
||||
{
|
||||
name: 'cup',
|
||||
keywords: 'coffee drink cup',
|
||||
line: 'i-ri-cup-line',
|
||||
fill: 'i-ri-cup-fill',
|
||||
},
|
||||
{
|
||||
name: 'restaurant',
|
||||
keywords: 'food eat restaurant',
|
||||
line: 'i-ri-restaurant-line',
|
||||
fill: 'i-ri-restaurant-fill',
|
||||
},
|
||||
{
|
||||
name: 'cake-3',
|
||||
keywords: 'birthday celebrate cake',
|
||||
line: 'i-ri-cake-3-line',
|
||||
fill: 'i-ri-cake-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'goblet',
|
||||
keywords: 'drink wine bar',
|
||||
line: 'i-ri-goblet-line',
|
||||
fill: 'i-ri-goblet-fill',
|
||||
},
|
||||
{
|
||||
name: 'heart-pulse',
|
||||
keywords: 'health medical',
|
||||
line: 'i-ri-heart-pulse-line',
|
||||
fill: 'i-ri-heart-pulse-fill',
|
||||
},
|
||||
{
|
||||
name: 'stethoscope',
|
||||
keywords: 'health doctor medical',
|
||||
line: 'i-ri-stethoscope-line',
|
||||
fill: 'i-ri-stethoscope-fill',
|
||||
},
|
||||
{
|
||||
name: 'capsule',
|
||||
keywords: 'medicine pill health',
|
||||
line: 'i-ri-capsule-line',
|
||||
fill: 'i-ri-capsule-fill',
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
keywords: 'add new create plus',
|
||||
line: 'i-ri-add-line',
|
||||
fill: 'i-ri-add-fill',
|
||||
},
|
||||
{
|
||||
name: 'delete-bin',
|
||||
keywords: 'delete remove trash',
|
||||
line: 'i-ri-delete-bin-line',
|
||||
fill: 'i-ri-delete-bin-fill',
|
||||
},
|
||||
{
|
||||
name: 'download',
|
||||
keywords: 'save export download',
|
||||
line: 'i-ri-download-line',
|
||||
fill: 'i-ri-download-fill',
|
||||
},
|
||||
{
|
||||
name: 'upload',
|
||||
keywords: 'import upload',
|
||||
line: 'i-ri-upload-line',
|
||||
fill: 'i-ri-upload-fill',
|
||||
},
|
||||
{
|
||||
name: 'refresh',
|
||||
keywords: 'reload sync update refresh',
|
||||
line: 'i-ri-refresh-line',
|
||||
fill: 'i-ri-refresh-fill',
|
||||
},
|
||||
{
|
||||
name: 'external-link',
|
||||
keywords: 'open new external',
|
||||
line: 'i-ri-external-link-line',
|
||||
fill: 'i-ri-external-link-fill',
|
||||
},
|
||||
{
|
||||
name: 'logout-box',
|
||||
keywords: 'exit signout logout',
|
||||
line: 'i-ri-logout-box-line',
|
||||
fill: 'i-ri-logout-box-fill',
|
||||
},
|
||||
{
|
||||
name: 'fullscreen',
|
||||
keywords: 'expand maximize fullscreen',
|
||||
line: 'i-ri-fullscreen-line',
|
||||
fill: 'i-ri-fullscreen-fill',
|
||||
},
|
||||
{
|
||||
name: 'drag-move',
|
||||
keywords: 'drag reorder move',
|
||||
line: 'i-ri-drag-move-line',
|
||||
fill: 'i-ri-drag-move-fill',
|
||||
},
|
||||
{
|
||||
name: 'paint-brush',
|
||||
keywords: 'brush paint',
|
||||
line: 'i-ri-paint-brush-line',
|
||||
fill: 'i-ri-paint-brush-fill',
|
||||
},
|
||||
];
|
||||
@@ -147,8 +147,14 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const isCaptainMessage = computed(() => {
|
||||
const senderType = props.sender?.type ?? props.senderType;
|
||||
return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
||||
@@ -390,6 +396,10 @@ const contextMenuEnabledOptions = computed(() => {
|
||||
!props.private &&
|
||||
props.inboxSupportsReplyTo.outgoing &&
|
||||
!isFailedOrProcessing,
|
||||
report:
|
||||
isOnChatwootCloud.value &&
|
||||
isCaptainMessage.value &&
|
||||
!isMessageDeleted.value,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -78,6 +78,12 @@ export const MEDIA_TYPES = [
|
||||
ATTACHMENT_TYPES.IG_REEL,
|
||||
];
|
||||
|
||||
export const NON_FILE_TYPES = [
|
||||
ATTACHMENT_TYPES.LOCATION,
|
||||
ATTACHMENT_TYPES.FALLBACK,
|
||||
ATTACHMENT_TYPES.CONTACT,
|
||||
];
|
||||
|
||||
export const VOICE_CALL_STATUS = {
|
||||
IN_PROGRESS: 'in-progress',
|
||||
RINGING: 'ringing',
|
||||
|
||||
@@ -21,6 +21,12 @@ import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import {
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
getSidebarSortOptions,
|
||||
resolveSidebarSort,
|
||||
sortSidebarItems,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
|
||||
const props = defineProps({
|
||||
isMobileSidebarOpen: {
|
||||
@@ -50,6 +56,7 @@ const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUserId = useMapGetter('getCurrentUserID');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
@@ -79,6 +86,11 @@ const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||
store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
const fetchSidebarSortPreferences = ([currentAccountId, userId]) => {
|
||||
if (!currentAccountId || !userId) return;
|
||||
store.dispatch('sidebarSortPreferences/initialize');
|
||||
};
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -175,6 +187,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'
|
||||
);
|
||||
@@ -189,6 +204,9 @@ const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
'customViews/getConversationCustomViews'
|
||||
);
|
||||
const getSidebarSectionSort = useMapGetter(
|
||||
'sidebarSortPreferences/getSectionSort'
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('labels/get');
|
||||
@@ -204,44 +222,62 @@ watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const normalizeUnreadCount = count => {
|
||||
const unreadCount = Number(count);
|
||||
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
|
||||
};
|
||||
watch([accountId, currentUserId], fetchSidebarSortPreferences, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
|
||||
items.slice().sort((a, b) => {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(b)) -
|
||||
normalizeUnreadCount(unreadCountKey(a));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
|
||||
return labelKey(a).localeCompare(labelKey(b));
|
||||
const getSortOptionsForSection = section =>
|
||||
getSidebarSortOptions(section, {
|
||||
hasUnreadCounts: hasConversationUnreadCounts.value,
|
||||
});
|
||||
|
||||
const getSortForSection = section =>
|
||||
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
|
||||
hasUnreadCounts: hasConversationUnreadCounts.value,
|
||||
});
|
||||
|
||||
const updateSortPreference = (section, sortBy) => {
|
||||
store.dispatch('sidebarSortPreferences/setSectionSort', {
|
||||
section,
|
||||
sortBy,
|
||||
});
|
||||
};
|
||||
|
||||
const buildSortConfig = section => ({
|
||||
sortOptions: getSortOptionsForSection(section),
|
||||
activeSort: getSortForSection(section),
|
||||
onSortChange: sortBy => updateSortPreference(section, sortBy),
|
||||
});
|
||||
|
||||
const sortedFolders = computed(() =>
|
||||
sortSidebarItems(conversationCustomViews.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
|
||||
labelKey: view => view.name,
|
||||
})
|
||||
);
|
||||
|
||||
const sortedTeams = computed(() =>
|
||||
sortByUnreadCount(
|
||||
teams.value,
|
||||
team => team.name,
|
||||
team => getTeamUnreadCount.value(team.id)
|
||||
)
|
||||
sortSidebarItems(teams.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.TEAMS),
|
||||
labelKey: team => team.name,
|
||||
unreadCountKey: team => getTeamUnreadCount.value(team.id),
|
||||
})
|
||||
);
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
sortByUnreadCount(
|
||||
inboxes.value,
|
||||
inbox => inbox.name,
|
||||
inbox => getInboxUnreadCount.value(inbox.id)
|
||||
)
|
||||
sortSidebarItems(inboxes.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.CHANNELS),
|
||||
labelKey: inbox => inbox.name,
|
||||
unreadCountKey: inbox => getInboxUnreadCount.value(inbox.id),
|
||||
})
|
||||
);
|
||||
|
||||
const sortedLabels = computed(() =>
|
||||
sortByUnreadCount(
|
||||
labels.value,
|
||||
label => label.title,
|
||||
label => getLabelUnreadCount.value(label.id)
|
||||
)
|
||||
sortSidebarItems(labels.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.LABELS),
|
||||
labelKey: label => label.title,
|
||||
unreadCountKey: label => getLabelUnreadCount.value(label.id),
|
||||
})
|
||||
);
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
@@ -297,18 +333,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 +356,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,7 +364,10 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
|
||||
icon: 'i-lucide-folder',
|
||||
activeOn: ['conversations_through_folders'],
|
||||
children: conversationCustomViews.value.map(view => ({
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.FOLDERS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedFolders.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
to: accountScopedRoute('folder_conversations', { id: view.id }),
|
||||
@@ -334,6 +378,9 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: ['conversations_through_team'],
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.TEAMS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedTeams.value.map(team => ({
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
@@ -346,6 +393,9 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CHANNELS'),
|
||||
icon: 'i-lucide-mailbox',
|
||||
activeOn: ['conversation_through_inbox'],
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.CHANNELS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
@@ -366,6 +416,9 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tag',
|
||||
activeOn: ['conversations_through_label'],
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.LABELS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedLabels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
@@ -477,6 +530,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 +550,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,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
import SidebarSortMenu from './SidebarSortMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
@@ -14,7 +16,7 @@ const props = defineProps({
|
||||
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
|
||||
const emit = defineEmits(['close', 'mouseenter', 'mouseleave', 'sortToggle']);
|
||||
|
||||
const router = useRouter();
|
||||
const { isAllowed, sidebarWidth } = useSidebarContext();
|
||||
@@ -29,6 +31,14 @@ const toggleSubGroup = name => {
|
||||
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
|
||||
};
|
||||
|
||||
const handleSortToggle = isSortOpen => {
|
||||
emit('sortToggle', isSortOpen);
|
||||
};
|
||||
|
||||
onClickOutside(popoverRef, () => emit('close'), {
|
||||
ignore: ['[data-popover-content]'],
|
||||
});
|
||||
|
||||
const navigateAndClose = to => {
|
||||
router.push(to);
|
||||
emit('close');
|
||||
@@ -123,24 +133,44 @@ onMounted(async () => {
|
||||
>
|
||||
<template v-for="child in accessibleChildren" :key="child.name">
|
||||
<!-- SubGroup with children -->
|
||||
<li v-if="child.children" class="py-0.5">
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
|
||||
@click="toggleSubGroup(child.name)"
|
||||
<li v-if="child.children" class="group/sidebar-section py-0.5">
|
||||
<div
|
||||
class="flex items-center rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out"
|
||||
>
|
||||
<Icon
|
||||
v-if="child.icon"
|
||||
:icon="child.icon"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
|
||||
<span
|
||||
class="size-3 transition-transform i-lucide-chevron-down"
|
||||
:class="{
|
||||
'rotate-180': expandedSubGroup === child.name,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="flex flex-1 min-w-0 items-center gap-2 ps-2 py-1.5 text-left rtl:text-right"
|
||||
@click="toggleSubGroup(child.name)"
|
||||
>
|
||||
<Icon
|
||||
v-if="child.icon"
|
||||
:icon="child.icon"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
|
||||
</button>
|
||||
<div class="flex flex-shrink-0 items-center gap-1 pe-2">
|
||||
<SidebarSortMenu
|
||||
v-if="child.sortOptions?.length"
|
||||
:active-sort="child.activeSort"
|
||||
:options="child.sortOptions"
|
||||
:open-on-hover="false"
|
||||
@sort="child.onSortChange"
|
||||
@toggle="handleSortToggle"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-11 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
|
||||
@click.stop="toggleSubGroup(child.name)"
|
||||
>
|
||||
<span
|
||||
class="size-4 flex-shrink-0 transition-transform i-lucide-chevron-down"
|
||||
:class="{
|
||||
'rotate-180': expandedSubGroup === child.name,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Transition v-bind="transition">
|
||||
<ul
|
||||
v-if="expandedSubGroup === child.name"
|
||||
|
||||
@@ -55,6 +55,9 @@ const hasChildren = computed(
|
||||
const isPopoverOpen = computed(() => activePopover.value === props.name);
|
||||
const triggerRef = ref(null);
|
||||
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
|
||||
// The sort dropdown teleports outside the popover; keep the popover open while
|
||||
// it is showing so moving the cursor onto it does not close everything.
|
||||
const isSortMenuOpen = ref(false);
|
||||
|
||||
const openPopover = () => {
|
||||
if (triggerRef.value) {
|
||||
@@ -82,7 +85,7 @@ const handleMouseEnter = () => {
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!hasChildren.value) return;
|
||||
if (!hasChildren.value || isSortMenuOpen.value) return;
|
||||
scheduleClose(200);
|
||||
};
|
||||
|
||||
@@ -91,28 +94,53 @@ const handlePopoverMouseEnter = () => {
|
||||
};
|
||||
|
||||
const handlePopoverMouseLeave = () => {
|
||||
if (isSortMenuOpen.value) return;
|
||||
scheduleClose(100);
|
||||
};
|
||||
|
||||
const handleSortToggle = isOpen => {
|
||||
isSortMenuOpen.value = isOpen;
|
||||
cancelClose();
|
||||
};
|
||||
|
||||
// Close popover when mouse leaves the window
|
||||
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;
|
||||
@@ -254,6 +282,7 @@ watch(
|
||||
@close="closePopover"
|
||||
@mouseenter="handlePopoverMouseEnter"
|
||||
@mouseleave="handlePopoverMouseLeave"
|
||||
@sort-toggle="handleSortToggle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -274,16 +303,23 @@ 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"
|
||||
:sort-options="child.sortOptions"
|
||||
:active-sort="child.activeSort"
|
||||
@update-sort="child.onSortChange"
|
||||
/>
|
||||
<SidebarGroupLeaf
|
||||
v-else-if="isAllowed(child.to)"
|
||||
@@ -299,59 +335,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>
|
||||
|
||||
@@ -45,7 +45,9 @@ const count = computed(() =>
|
||||
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
|
||||
<div
|
||||
class="flex items-center gap-1.5 flex-grow justify-between min-w-0 flex-1"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
:class="{
|
||||
@@ -57,11 +59,7 @@ const count = computed(() =>
|
||||
</span>
|
||||
<span
|
||||
v-if="dynamicCount && !expandable"
|
||||
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
|
||||
:class="{
|
||||
'text-n-slate-12 outline-n-slate-6': isActive,
|
||||
'text-n-slate-11 outline-n-strong': !isActive,
|
||||
}"
|
||||
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
|
||||
@@ -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,
|
||||
}"
|
||||
|
||||
@@ -1,25 +1,104 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import SidebarSortMenu from './SidebarSortMenu.vue';
|
||||
|
||||
defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: [Object, String],
|
||||
default: '',
|
||||
},
|
||||
sortOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
endTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle', 'update-sort']);
|
||||
|
||||
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"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="relative min-w-0" :class="{ 'ms-5': collapsible }">
|
||||
<component
|
||||
:is="collapsible ? 'button' : 'div'"
|
||||
:type="collapsible ? 'button' : undefined"
|
||||
:aria-expanded="collapsible ? isExpanded : undefined"
|
||||
:title="label"
|
||||
class="relative flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-n-slate-10 select-none"
|
||||
:class="[
|
||||
showTreeLine && TREE_VERTICAL_LINE,
|
||||
showTreeLine &&
|
||||
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
|
||||
{
|
||||
'pointer-events-none': !collapsible,
|
||||
'cursor-pointer hover:bg-n-alpha-2': collapsible,
|
||||
'pe-14': collapsible && sortOptions.length,
|
||||
'pe-8': collapsible && !sortOptions.length,
|
||||
'pe-10': !collapsible && sortOptions.length,
|
||||
},
|
||||
]"
|
||||
@click.stop="collapsible ? emit('toggle') : undefined"
|
||||
>
|
||||
<div class="inline-flex min-w-0 items-center gap-2">
|
||||
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
|
||||
<span
|
||||
class="flex-grow truncate text-start text-sm font-medium leading-5"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</component>
|
||||
<div
|
||||
v-if="collapsible || sortOptions.length"
|
||||
class="absolute end-2 top-1/2 flex -translate-y-1/2 items-center gap-1"
|
||||
>
|
||||
<SidebarSortMenu
|
||||
v-if="sortOptions.length"
|
||||
:active-sort="activeSort"
|
||||
:options="sortOptions"
|
||||
@sort="sortBy => emit('update-sort', sortBy)"
|
||||
/>
|
||||
<button
|
||||
v-if="collapsible"
|
||||
type="button"
|
||||
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-10 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
|
||||
:aria-expanded="isExpanded"
|
||||
:aria-label="label"
|
||||
@click.stop="emit('toggle')"
|
||||
>
|
||||
<span
|
||||
class="size-3 flex-shrink-0"
|
||||
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import { SIDEBAR_SORT_KEYS } from 'dashboard/helper/sidebarSort';
|
||||
|
||||
const props = defineProps({
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
openOnHover: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sort', 'toggle']);
|
||||
|
||||
const SORT_OPTION_GROUPS = [
|
||||
{
|
||||
key: 'created',
|
||||
options: [SIDEBAR_SORT_KEYS.CREATED_DESC, SIDEBAR_SORT_KEYS.CREATED_ASC],
|
||||
},
|
||||
{
|
||||
key: 'alphabetical',
|
||||
options: [
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'unread_count',
|
||||
options: [
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { t } = useI18n();
|
||||
const isOpen = ref(false);
|
||||
const triggerRef = ref(null);
|
||||
const popoverRef = ref(null);
|
||||
let closeTimer;
|
||||
|
||||
const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
triggerRef,
|
||||
popoverRef,
|
||||
isOpen,
|
||||
{ align: 'start' }
|
||||
);
|
||||
|
||||
const getSortOptionLabel = option => {
|
||||
if (option === SIDEBAR_SORT_KEYS.CREATED_DESC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.CREATED_DESC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.CREATED_ASC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.CREATED_ASC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_ASC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_DESC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_DESC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_ASC');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const getSortGroupLabel = groupKey => {
|
||||
if (groupKey === 'created') {
|
||||
return t('SIDEBAR.SORT_GROUPS.CREATED');
|
||||
}
|
||||
|
||||
if (groupKey === 'alphabetical') {
|
||||
return t('SIDEBAR.SORT_GROUPS.ALPHABETICAL');
|
||||
}
|
||||
|
||||
if (groupKey === 'unread_count') {
|
||||
return t('SIDEBAR.SORT_GROUPS.UNREAD_COUNT');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const sortMenuSections = computed(() =>
|
||||
SORT_OPTION_GROUPS.map(group => ({
|
||||
title: getSortGroupLabel(group.key),
|
||||
items: group.options
|
||||
.filter(option => props.options.includes(option))
|
||||
.map(option => ({
|
||||
label: getSortOptionLabel(option),
|
||||
value: option,
|
||||
action: 'sort',
|
||||
isActive: option === props.activeSort,
|
||||
})),
|
||||
})).filter(section => section.items.length)
|
||||
);
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openMenu = async () => {
|
||||
clearCloseTimer();
|
||||
isOpen.value = true;
|
||||
emit('toggle', true);
|
||||
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
clearCloseTimer();
|
||||
isOpen.value = false;
|
||||
emit('toggle', false);
|
||||
};
|
||||
|
||||
const scheduleClose = () => {
|
||||
clearCloseTimer();
|
||||
closeTimer = setTimeout(closeMenu, 150);
|
||||
};
|
||||
|
||||
const handleTriggerEnter = () => {
|
||||
if (props.openOnHover) openMenu();
|
||||
};
|
||||
|
||||
const handleTriggerLeave = () => {
|
||||
if (props.openOnHover) scheduleClose();
|
||||
};
|
||||
|
||||
const handleClickOutside = event => {
|
||||
if (triggerRef.value?.contains(event.target)) return;
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
const handleSortChange = ({ value }) => {
|
||||
emit('sort', value);
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
onBeforeUnmount(clearCloseTimer);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="relative invisible flex-shrink-0 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/sidebar-section:visible group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:pointer-events-auto"
|
||||
:class="{ '!visible !opacity-100 !pointer-events-auto': isOpen }"
|
||||
@mouseenter="handleTriggerEnter"
|
||||
@mouseleave="handleTriggerLeave"
|
||||
>
|
||||
<Button
|
||||
:title="t('SIDEBAR.SORT_TOOLTIP')"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
class="!size-6 !text-n-slate-11 hover:!text-n-slate-12"
|
||||
:class="{ '!bg-n-alpha-2': isOpen }"
|
||||
@click.stop="openMenu"
|
||||
/>
|
||||
<TeleportWithDirection>
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
ref="popoverRef"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
data-popover-content
|
||||
:menu-sections="sortMenuSections"
|
||||
:class="fixedPosition.class"
|
||||
:style="fixedPosition.style"
|
||||
class="w-60 !fixed"
|
||||
@action="handleSortChange"
|
||||
@mouseenter="clearCloseTimer"
|
||||
@mouseleave="handleTriggerLeave"
|
||||
>
|
||||
<template #trailing-icon="{ item }">
|
||||
<span
|
||||
v-if="item.isActive"
|
||||
class="i-lucide-check ms-auto size-4 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</TeleportWithDirection>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +1,56 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
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';
|
||||
|
||||
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 },
|
||||
sortOptions: { type: Array, default: () => [] },
|
||||
activeSort: { type: String, default: '' },
|
||||
collapsible: { type: Boolean, default: false },
|
||||
showTreeLine: { type: Boolean, default: false },
|
||||
endTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update-sort']);
|
||||
|
||||
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 +63,120 @@ 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="group/sidebar-section 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"
|
||||
:sort-options="sortOptions"
|
||||
:active-sort="activeSort"
|
||||
class="my-1"
|
||||
@toggle="toggleSubGroup"
|
||||
@update-sort="sortBy => emit('update-sort', sortBy)"
|
||||
/>
|
||||
</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({});
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,7 @@ const showDivider = index => {
|
||||
<template v-for="(tab, index) in tabs" :key="index">
|
||||
<button
|
||||
:ref="el => (tabRefs[index] = el)"
|
||||
type="button"
|
||||
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
|
||||
:class="[
|
||||
activeTab === index
|
||||
|
||||
@@ -308,6 +308,15 @@ function filterByAssigneeTab(conversations) {
|
||||
return [...conversations];
|
||||
}
|
||||
|
||||
function sortByUnreadStatus(conversations) {
|
||||
return [...conversations].sort((a, b) => {
|
||||
const unreadCountDiff = (b.unread_count || 0) - (a.unread_count || 0);
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
|
||||
return (b.last_activity_at || 0) - (a.last_activity_at || 0);
|
||||
});
|
||||
}
|
||||
|
||||
const conversationList = computed(() => {
|
||||
let localConversationList = [];
|
||||
|
||||
@@ -337,6 +346,13 @@ const conversationList = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!hasAppliedFiltersOrActiveFolders.value &&
|
||||
activeSortBy.value === wootConstants.SORT_BY_TYPE.UNREAD
|
||||
) {
|
||||
localConversationList = sortByUnreadStatus(localConversationList);
|
||||
}
|
||||
|
||||
return localConversationList;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
sessions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['revoke', 'revokeAll', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const revokingId = ref(null);
|
||||
const revokingAll = ref(false);
|
||||
|
||||
const sortedSessions = computed(() =>
|
||||
[...props.sessions].sort(
|
||||
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||
)
|
||||
);
|
||||
|
||||
const formatDate = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return format(parseISO(dateStr), 'MMMM d, yyyy');
|
||||
};
|
||||
|
||||
const formatTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return format(parseISO(dateStr), 'hh:mma');
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) parts.push(session.browser_name);
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.sessions,
|
||||
() => {
|
||||
revokingId.value = null;
|
||||
revokingAll.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const handleRevoke = session => {
|
||||
revokingId.value = session.id;
|
||||
emit('revoke', session.id);
|
||||
};
|
||||
|
||||
const handleRevokeAll = () => {
|
||||
revokingAll.value = true;
|
||||
emit('revokeAll');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-lg mx-auto">
|
||||
<div
|
||||
class="bg-white shadow dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ $t('SESSION_LIMIT.TITLE') }}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11 mt-2">
|
||||
{{ $t('SESSION_LIMIT.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<NextButton
|
||||
type="button"
|
||||
faded
|
||||
sm
|
||||
class="flex-shrink-0 whitespace-nowrap"
|
||||
:label="$t('SESSION_LIMIT.END_ALL')"
|
||||
:is-loading="revokingAll"
|
||||
:disabled="revokingId !== null"
|
||||
@click="handleRevokeAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Session List -->
|
||||
<div class="flex flex-col gap-3 max-h-80 overflow-y-auto">
|
||||
<div
|
||||
v-for="session in sortedSessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Icon
|
||||
icon="i-lucide-monitor"
|
||||
class="size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{
|
||||
`${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 flex-shrink-0 disabled:opacity-50"
|
||||
:disabled="revokingId !== null || revokingAll"
|
||||
@click="handleRevoke(session)"
|
||||
>
|
||||
{{ $t('SESSION_LIMIT.END') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel -->
|
||||
<div class="text-center pt-4">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:label="$t('SESSION_LIMIT.CANCEL')"
|
||||
@click="() => emit('cancel')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,6 +78,10 @@ const chatSortOptions = computed(() => [
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_asc.TEXT'),
|
||||
value: 'created_at_asc',
|
||||
},
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.unread.TEXT'),
|
||||
value: 'unread',
|
||||
},
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc.TEXT'),
|
||||
value: 'priority_desc',
|
||||
|
||||
@@ -56,8 +56,9 @@ import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
const EmojiIconPicker = defineAsyncComponent(
|
||||
() =>
|
||||
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
|
||||
);
|
||||
|
||||
export default {
|
||||
@@ -66,7 +67,7 @@ export default {
|
||||
AttachmentPreview,
|
||||
AudioRecorder,
|
||||
ReplyBoxBanner,
|
||||
EmojiInput,
|
||||
EmojiIconPicker,
|
||||
MessageSignatureMissingAlert,
|
||||
ReplyBottomPanel,
|
||||
ReplyEmailHead,
|
||||
@@ -1284,13 +1285,15 @@ export default {
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<EmojiInput
|
||||
<EmojiIconPicker
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
mode="emoji"
|
||||
class="emoji-dialog"
|
||||
:class="{
|
||||
'emoji-dialog--expanded': isOnExpandedLayout,
|
||||
}"
|
||||
:on-click="addIntoEditor"
|
||||
@select="addIntoEditor($event.value)"
|
||||
/>
|
||||
<ReplyEmailHead
|
||||
v-if="showReplyHead && isDefaultEditorMode"
|
||||
|
||||
@@ -31,6 +31,7 @@ const mockUseMapGetter = (overrides = {}) => {
|
||||
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
|
||||
getCurrentAccountId: ref(1),
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
|
||||
'inboxAssignableAgents/getAssignableOwners': ref(() => []),
|
||||
};
|
||||
|
||||
const mergedGetters = { ...defaultGetters, ...overrides };
|
||||
@@ -75,6 +76,32 @@ describe('useAgentsList', () => {
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
});
|
||||
|
||||
it('includes agent bots when includeAgentBots is true', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableOwners': ref(() => [
|
||||
{ id: 2, name: 'Agent', assignee_type: 'User' },
|
||||
{
|
||||
id: 1,
|
||||
name: 'Bot',
|
||||
thumbnail: '',
|
||||
assignee_type: 'AgentBot',
|
||||
icon: 'i-lucide-bot',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const { agentsList } = useAgentsList(true, true);
|
||||
|
||||
expect(agentsList.value).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
name: 'Bot',
|
||||
assignee_type: 'AgentBot',
|
||||
icon: 'i-lucide-bot',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
|
||||
@@ -70,7 +70,8 @@ describe('useFacebookPageConnect', () => {
|
||||
ACCOUNT_ID
|
||||
);
|
||||
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
|
||||
scope: expect.stringContaining('pages_show_list'),
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,pages_show_list,pages_read_engagement',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,14 +10,21 @@ import {
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
*
|
||||
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
|
||||
* @param {boolean} [includeAgentBots=false] - Whether to include agent bots as assignment options.
|
||||
* @returns {Object} An object containing the agents list and assignable agents.
|
||||
*/
|
||||
export function useAgentsList(includeNoneAgent = true) {
|
||||
export function useAgentsList(
|
||||
includeNoneAgent = true,
|
||||
includeAgentBots = false
|
||||
) {
|
||||
const { t } = useI18n();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
|
||||
const assignableOwners = useMapGetter(
|
||||
'inboxAssignableAgents/getAssignableOwners'
|
||||
);
|
||||
|
||||
const inboxId = computed(() => currentChat.value?.inbox_id);
|
||||
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
|
||||
@@ -42,11 +49,22 @@ export function useAgentsList(includeNoneAgent = true) {
|
||||
return inboxId.value ? assignable.value(inboxId.value) : [];
|
||||
});
|
||||
|
||||
const owners = computed(() => {
|
||||
return includeAgentBots && inboxId.value
|
||||
? assignableOwners.value(inboxId.value)
|
||||
: [];
|
||||
});
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = assignableAgents.value || [];
|
||||
const agents = includeAgentBots
|
||||
? owners.value.filter(owner => owner.assignee_type === 'User')
|
||||
: assignableAgents.value || [];
|
||||
const bots = owners.value.filter(
|
||||
owner => owner.assignee_type === 'AgentBot'
|
||||
);
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
@@ -60,6 +78,7 @@ export function useAgentsList(includeNoneAgent = true) {
|
||||
return [
|
||||
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
|
||||
...filteredAgentsByAvailability,
|
||||
...bots,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
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';
|
||||
|
||||
// Page-management + messaging scopes required to list pages and create a
|
||||
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
|
||||
const FB_PAGE_SCOPES =
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
|
||||
|
||||
// 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
|
||||
@@ -51,7 +47,7 @@ export function useFacebookPageConnect() {
|
||||
: null
|
||||
);
|
||||
},
|
||||
{ scope: FB_PAGE_SCOPES }
|
||||
{ scope: buildFacebookLoginScopes() }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export default {
|
||||
WAITING_SINCE_ASC: 'waiting_since_asc',
|
||||
WAITING_SINCE_DESC: 'waiting_since_desc',
|
||||
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
|
||||
UNREAD: 'unread',
|
||||
},
|
||||
ARTICLE_STATUS_TYPES: {
|
||||
DRAFT: 0,
|
||||
|
||||
@@ -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,11 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const SESSION_EVENTS = Object.freeze({
|
||||
LIMIT_HIT: 'Session limit reached at login',
|
||||
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',
|
||||
|
||||
@@ -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(',');
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
export const SIDEBAR_SORT_KEYS = Object.freeze({
|
||||
CREATED_DESC: 'created_at_desc',
|
||||
CREATED_ASC: 'created_at_asc',
|
||||
ALPHABETICAL_ASC: 'alphabetical_asc',
|
||||
ALPHABETICAL_DESC: 'alphabetical_desc',
|
||||
UNREAD_COUNT_DESC: 'unread_count_desc',
|
||||
UNREAD_COUNT_ASC: 'unread_count_asc',
|
||||
});
|
||||
|
||||
export const SIDEBAR_SORT_SECTIONS = Object.freeze({
|
||||
FOLDERS: 'folders',
|
||||
TEAMS: 'teams',
|
||||
CHANNELS: 'channels',
|
||||
LABELS: 'labels',
|
||||
});
|
||||
|
||||
export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
],
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
[SIDEBAR_SORT_SECTIONS.CHANNELS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
[SIDEBAR_SORT_SECTIONS.LABELS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
});
|
||||
|
||||
const UNREAD_COUNT_SORT_OPTIONS = [
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
];
|
||||
|
||||
export const DEFAULT_SIDEBAR_SORT_PREFERENCES = Object.freeze({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.CHANNELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.LABELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
});
|
||||
|
||||
export const isValidSidebarSort = (section, sortBy) => {
|
||||
return SIDEBAR_SORT_OPTIONS_BY_SECTION[section]?.includes(sortBy);
|
||||
};
|
||||
|
||||
const isUnreadCountSort = sortBy => UNREAD_COUNT_SORT_OPTIONS.includes(sortBy);
|
||||
|
||||
export const getSidebarSortOptions = (
|
||||
section,
|
||||
{ hasUnreadCounts = true } = {}
|
||||
) => {
|
||||
const options = SIDEBAR_SORT_OPTIONS_BY_SECTION[section] || [];
|
||||
|
||||
if (hasUnreadCounts) return options;
|
||||
|
||||
return options.filter(option => !isUnreadCountSort(option));
|
||||
};
|
||||
|
||||
export const resolveSidebarSort = (
|
||||
section,
|
||||
sortBy,
|
||||
{ hasUnreadCounts = true } = {}
|
||||
) => {
|
||||
const options = getSidebarSortOptions(section, { hasUnreadCounts });
|
||||
|
||||
if (options.includes(sortBy)) return sortBy;
|
||||
if (!hasUnreadCounts && isUnreadCountSort(sortBy)) {
|
||||
return SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
|
||||
}
|
||||
|
||||
return options[0] || SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
|
||||
};
|
||||
|
||||
export const normalizeSidebarSortPreferences = (preferences = {}) => {
|
||||
const savedPreferences = preferences || {};
|
||||
|
||||
return Object.keys(DEFAULT_SIDEBAR_SORT_PREFERENCES).reduce(
|
||||
(result, section) => {
|
||||
const sortBy = savedPreferences[section];
|
||||
result[section] = isValidSidebarSort(section, sortBy)
|
||||
? sortBy
|
||||
: DEFAULT_SIDEBAR_SORT_PREFERENCES[section];
|
||||
return result;
|
||||
},
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeUnreadCount = count => {
|
||||
const unreadCount = Number(count);
|
||||
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
|
||||
};
|
||||
|
||||
const getCreatedValue = item => {
|
||||
const createdAt = item.created_at || item.createdAt;
|
||||
|
||||
if (typeof createdAt === 'number') return createdAt;
|
||||
|
||||
if (createdAt) {
|
||||
const timestamp = Date.parse(createdAt);
|
||||
if (Number.isFinite(timestamp)) return timestamp;
|
||||
}
|
||||
|
||||
const id = Number(item.id);
|
||||
return Number.isFinite(id) ? id : 0;
|
||||
};
|
||||
|
||||
const getLabelValue = (item, labelKey) => {
|
||||
return String(labelKey(item) || '');
|
||||
};
|
||||
|
||||
const compareAlphabetically = (a, b, labelKey) => {
|
||||
return getLabelValue(a, labelKey).localeCompare(
|
||||
getLabelValue(b, labelKey),
|
||||
undefined,
|
||||
{
|
||||
sensitivity: 'base',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const sortSidebarItems = (
|
||||
items,
|
||||
{ sortBy, labelKey, unreadCountKey = () => 0 }
|
||||
) => {
|
||||
return (items || []).slice().sort((a, b) => {
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_DESC) {
|
||||
const createdDiff = getCreatedValue(b) - getCreatedValue(a);
|
||||
if (createdDiff !== 0) return createdDiff;
|
||||
return compareAlphabetically(a, b, labelKey);
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_ASC) {
|
||||
const createdDiff = getCreatedValue(a) - getCreatedValue(b);
|
||||
if (createdDiff !== 0) return createdDiff;
|
||||
return compareAlphabetically(a, b, labelKey);
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
|
||||
return compareAlphabetically(b, a, labelKey);
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(b)) -
|
||||
normalizeUnreadCount(unreadCountKey(a));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(a)) -
|
||||
normalizeUnreadCount(unreadCountKey(b));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
}
|
||||
|
||||
return compareAlphabetically(a, b, labelKey);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
SIDEBAR_SORT_KEYS,
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
getSidebarSortOptions,
|
||||
normalizeSidebarSortPreferences,
|
||||
resolveSidebarSort,
|
||||
sortSidebarItems,
|
||||
} from '../sidebarSort';
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Billing',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Accounts',
|
||||
created_at: '2024-03-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Support',
|
||||
created_at: '2024-02-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
describe('#sortSidebarItems', () => {
|
||||
it('sorts by created date descending', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Accounts',
|
||||
'Support',
|
||||
'Billing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by created date ascending', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Billing',
|
||||
'Support',
|
||||
'Accounts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to id when created date is not present', () => {
|
||||
const sortedItems = sortSidebarItems(
|
||||
items.map(({ created_at: _createdAt, ...item }) => item),
|
||||
{
|
||||
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
labelKey: item => item.name,
|
||||
}
|
||||
);
|
||||
|
||||
expect(sortedItems.map(item => item.id)).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically from A to Z', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Accounts',
|
||||
'Billing',
|
||||
'Support',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically from Z to A', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Support',
|
||||
'Billing',
|
||||
'Accounts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by unread count descending and falls back to alphabetical order', () => {
|
||||
const unreadCounts = {
|
||||
1: 3,
|
||||
2: 3,
|
||||
3: 7,
|
||||
};
|
||||
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
labelKey: item => item.name,
|
||||
unreadCountKey: item => unreadCounts[item.id],
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Accounts',
|
||||
'Billing',
|
||||
'Support',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by unread count ascending and falls back to alphabetical order', () => {
|
||||
const unreadCounts = {
|
||||
1: 3,
|
||||
2: 3,
|
||||
3: 7,
|
||||
};
|
||||
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
labelKey: item => item.name,
|
||||
unreadCountKey: item => unreadCounts[item.id],
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Billing',
|
||||
'Support',
|
||||
'Accounts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#normalizeSidebarSortPreferences', () => {
|
||||
it('keeps valid preferences', () => {
|
||||
const preferences = normalizeSidebarSortPreferences({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
});
|
||||
|
||||
expect(preferences).toEqual({
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to defaults for unsupported preferences', () => {
|
||||
const preferences = normalizeSidebarSortPreferences({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
});
|
||||
|
||||
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
|
||||
});
|
||||
|
||||
it('falls back to defaults when stored preferences are null', () => {
|
||||
expect(normalizeSidebarSortPreferences(null)).toEqual(
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getSidebarSortOptions', () => {
|
||||
it('keeps unread count options when unread counts are enabled', () => {
|
||||
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
|
||||
hasUnreadCounts: true,
|
||||
});
|
||||
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
|
||||
});
|
||||
|
||||
it('removes unread count options when unread counts are disabled', () => {
|
||||
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
|
||||
hasUnreadCounts: false,
|
||||
});
|
||||
|
||||
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveSidebarSort', () => {
|
||||
it('keeps unread count sort when unread counts are enabled', () => {
|
||||
const sortBy = resolveSidebarSort(
|
||||
SIDEBAR_SORT_SECTIONS.TEAMS,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
{ hasUnreadCounts: true }
|
||||
);
|
||||
|
||||
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
});
|
||||
|
||||
it('falls back to alphabetical sort when unread counts are disabled', () => {
|
||||
const sortBy = resolveSidebarSort(
|
||||
SIDEBAR_SORT_SECTIONS.TEAMS,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
{ hasUnreadCounts: false }
|
||||
);
|
||||
|
||||
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,9 @@
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
},
|
||||
"unread": {
|
||||
"TEXT": "Unread Count: Highest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"ACTIONS": {
|
||||
"CREATE": "Add company"
|
||||
},
|
||||
"SELECTOR": {
|
||||
"PLACEHOLDER": "Select company",
|
||||
"CREATE_OPTION": "Add \"{name}\""
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Add company details",
|
||||
"ACTIONS": {
|
||||
|
||||
@@ -511,6 +511,7 @@
|
||||
"ATTRIBUTES": "Attributes",
|
||||
"HISTORY": "History",
|
||||
"NOTES": "Notes",
|
||||
"MEDIA": "Media",
|
||||
"MERGE": "Merge"
|
||||
},
|
||||
"HISTORY": {
|
||||
|
||||
@@ -304,6 +304,25 @@
|
||||
"MESSAGE": "You cannot undo this action",
|
||||
"DELETE": "Delete",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"REPORT_MESSAGE": {
|
||||
"LABEL": "Report message",
|
||||
"TITLE": "Report Captain message",
|
||||
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
|
||||
"PROBLEM_TYPE": "Problem type",
|
||||
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
|
||||
"DESCRIPTION_LABEL": "Description",
|
||||
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
|
||||
"SUBMIT": "Report",
|
||||
"SUCCESS": "Thanks for reporting. Our team will take a look.",
|
||||
"ERROR": "Could not report this message. Please try again.",
|
||||
"REASONS": {
|
||||
"incorrect_information": "Incorrect information",
|
||||
"inappropriate_response": "Inappropriate response",
|
||||
"incomplete_response": "Incomplete response",
|
||||
"outdated_information": "Outdated information",
|
||||
"other": "Other"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
@@ -401,7 +420,8 @@
|
||||
"VIEW_ALL": "View all",
|
||||
"SHOW_LESS": "Show less",
|
||||
"MORE_COUNT": "+{count}",
|
||||
"UNTITLED_FILE": "Untitled file"
|
||||
"UNTITLED_FILE": "Untitled file",
|
||||
"JUMP_TO_MESSAGE": "Jump to message"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
"ORDER_ID": "Order #{id}",
|
||||
|
||||
@@ -3,5 +3,33 @@
|
||||
"PLACEHOLDER": "Search emojis",
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"REMOVE": "Remove"
|
||||
},
|
||||
"EMOJI_ICON_PICKER": {
|
||||
"TABS": {
|
||||
"ICONS": "Icons",
|
||||
"EMOJIS": "Emojis"
|
||||
},
|
||||
"SEARCH_EMOJI": "Search emoji…",
|
||||
"SEARCH_ICON": "Search icons…",
|
||||
"FREQUENTLY_USED": "Frequently used",
|
||||
"NO_EMOJI": "No emoji match your search",
|
||||
"NO_ICON": "No icons match your search",
|
||||
"REMOVE": "Remove",
|
||||
"STYLE": {
|
||||
"OUTLINE": "Outline icons",
|
||||
"FILLED": "Filled icons"
|
||||
},
|
||||
"COLORS": {
|
||||
"SLATE": "Slate",
|
||||
"RED": "Red",
|
||||
"ORANGE": "Orange",
|
||||
"AMBER": "Amber",
|
||||
"GREEN": "Green",
|
||||
"TEAL": "Teal",
|
||||
"BLUE": "Blue",
|
||||
"INDIGO": "Indigo",
|
||||
"VIOLET": "Violet",
|
||||
"PINK": "Pink"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,6 +552,7 @@
|
||||
"LOCALE": {
|
||||
"ALL": "All locales"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search articles...",
|
||||
"NEW_ARTICLE": "New article"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
@@ -579,6 +580,10 @@
|
||||
"CATEGORY": {
|
||||
"TITLE": "There are no articles in this category",
|
||||
"SUBTITLE": "Articles in this category will appear here"
|
||||
},
|
||||
"SEARCH": {
|
||||
"TITLE": "No matching articles",
|
||||
"SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
|
||||
}
|
||||
},
|
||||
"BULK_TRANSLATE": {
|
||||
@@ -624,6 +629,7 @@
|
||||
"CATEGORY_HEADER": {
|
||||
"NEW_CATEGORY": "New category",
|
||||
"EDIT_CATEGORY": "Edit category",
|
||||
"SEARCH_PLACEHOLDER": "Search categories...",
|
||||
"CATEGORIES_COUNT": "{n} category | {n} categories",
|
||||
"BREADCRUMB": {
|
||||
"CATEGORY_LOCALE": "Categories ({localeCode})",
|
||||
@@ -634,6 +640,10 @@
|
||||
"TITLE": "No categories found",
|
||||
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
|
||||
},
|
||||
"SEARCH_EMPTY_STATE": {
|
||||
"TITLE": "No matching categories",
|
||||
"SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
|
||||
},
|
||||
"CATEGORY_CARD": {
|
||||
"ARTICLES_COUNT": "{count} article | {count} articles"
|
||||
},
|
||||
@@ -691,6 +701,11 @@
|
||||
"LOCALES_PAGE": {
|
||||
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
|
||||
"NEW_LOCALE_BUTTON_TEXT": "New locale",
|
||||
"SEARCH_PLACEHOLDER": "Search locales...",
|
||||
"SEARCH_EMPTY_STATE": {
|
||||
"TITLE": "No matching locales",
|
||||
"SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
|
||||
},
|
||||
"LOCALE_CARD": {
|
||||
"ARTICLES_COUNT": "{count} article | {count} articles",
|
||||
"CATEGORIES_COUNT": "{count} category | {count} categories",
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
|
||||
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
|
||||
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
|
||||
"ERROR_AUTH": "There was an error connecting to TikTok, please try again",
|
||||
"NORTH_AMERICA_WARNING": "TikTok connections for North American accounts are temporarily unavailable while we wait for an update from the TikTok team."
|
||||
},
|
||||
"TWITTER": {
|
||||
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
||||
@@ -653,6 +654,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": {
|
||||
|
||||
@@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
import sessionLimit from './sessionLimit.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
@@ -85,5 +86,6 @@ export default {
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...sessionLimit,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"SESSION_LIMIT": {
|
||||
"TITLE": "Active session limit reached",
|
||||
"DESCRIPTION": "You have reached your limit of active sessions. Please end a session before logging in.",
|
||||
"END": "End",
|
||||
"END_ALL": "End all sessions",
|
||||
"LOG_IN": "Log in",
|
||||
"CANCEL": "Back to login",
|
||||
"UNKNOWN_DEVICE": "Unknown device",
|
||||
"STARTED": "Started"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -379,6 +390,21 @@
|
||||
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
|
||||
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
||||
},
|
||||
"SORT_TOOLTIP": "Sort",
|
||||
"SORT_BY": "Sort by",
|
||||
"SORT_GROUPS": {
|
||||
"CREATED": "Sort by date of creation",
|
||||
"ALPHABETICAL": "Sort in alphabetical order",
|
||||
"UNREAD_COUNT": "Sort by unread count"
|
||||
},
|
||||
"SORT_OPTIONS": {
|
||||
"CREATED_DESC": "Newest first",
|
||||
"CREATED_ASC": "Oldest first",
|
||||
"ALPHABETICAL_ASC": "Ascending (A - Z)",
|
||||
"ALPHABETICAL_DESC": "Descending (Z - A)",
|
||||
"UNREAD_COUNT_DESC": "Highest first",
|
||||
"UNREAD_COUNT_ASC": "Lowest first"
|
||||
},
|
||||
"DOCS": "Read docs",
|
||||
"SECURITY": "Security",
|
||||
"CAPTAIN_AI": "Captain",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ReportCaptainMessageDialog from './ReportCaptainMessageDialog.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -21,6 +22,7 @@ export default {
|
||||
MenuItem,
|
||||
ContextMenu,
|
||||
NextButton,
|
||||
ReportCaptainMessageDialog,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
@@ -152,6 +154,10 @@ export default {
|
||||
closeDeleteModal() {
|
||||
this.showDeleteModal = false;
|
||||
},
|
||||
openReportDialog() {
|
||||
this.handleClose();
|
||||
this.$refs.reportDialog?.open();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -243,6 +249,16 @@ export default {
|
||||
variant="icon"
|
||||
@click.stop="showCannedResponseModal"
|
||||
/>
|
||||
<hr v-if="enabledOptions['report']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['report']"
|
||||
:option="{
|
||||
icon: 'warning',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.LABEL'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="openReportDialog"
|
||||
/>
|
||||
<hr v-if="enabledOptions['delete']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['delete']"
|
||||
@@ -255,6 +271,11 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
<ReportCaptainMessageDialog
|
||||
v-if="enabledOptions['report']"
|
||||
ref="reportDialog"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import MessageReportsAPI from 'dashboard/api/captain/messageReports';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
messageId: { type: [Number, String], required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const REPORT_REASONS = [
|
||||
'incorrect_information',
|
||||
'inappropriate_response',
|
||||
'incomplete_response',
|
||||
'outdated_information',
|
||||
'other',
|
||||
];
|
||||
|
||||
const reasonOptions = computed(() =>
|
||||
REPORT_REASONS.map(value => ({
|
||||
value,
|
||||
label: t(`CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.REASONS.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const form = reactive({ reportReason: '', description: '' });
|
||||
|
||||
const isFormInvalid = computed(() => !form.reportReason);
|
||||
|
||||
const resetForm = () => {
|
||||
form.reportReason = '';
|
||||
form.description = '';
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
resetForm();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isFormInvalid.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await MessageReportsAPI.create({
|
||||
message_id: props.messageId,
|
||||
report_reason: form.reportReason,
|
||||
description: form.description.trim() || null,
|
||||
});
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUCCESS'));
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.ERROR'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.TITLE')"
|
||||
:description="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION')"
|
||||
:confirm-button-label="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUBMIT')"
|
||||
:is-loading="isLoading"
|
||||
:disable-confirm-button="isFormInvalid"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<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('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.reportReason"
|
||||
class="!w-full [&>select]:w-full"
|
||||
:options="reasonOptions"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_LABEL') }}
|
||||
</label>
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:max-length="500"
|
||||
show-character-count
|
||||
auto-height
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user