Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f2e5abdda |
@@ -16,7 +16,7 @@ on:
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
|
||||
- name: get installer
|
||||
|
||||
@@ -283,4 +283,3 @@ exclude:
|
||||
- 'app/javascript/widget/assets/scss/sdk.css'
|
||||
- 'app/assets/stylesheets/administrate/reset/_normalize.scss'
|
||||
- 'app/javascript/shared/assets/stylesheets/*.scss'
|
||||
- 'app/javascript/dashboard/assets/scss/_woot.scss'
|
||||
|
||||
@@ -175,8 +175,6 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'ruby-openai'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
+2
-20
@@ -352,7 +352,6 @@ GEM
|
||||
ruby2ruby (~> 2.4)
|
||||
ruby_parser (~> 3.10)
|
||||
hana (1.3.7)
|
||||
hash_diff (1.1.1)
|
||||
hashdiff (1.1.0)
|
||||
hashie (5.0.0)
|
||||
html2text (0.4.0)
|
||||
@@ -521,9 +520,6 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.2)
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
@@ -565,7 +561,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.13)
|
||||
rack (2.2.11)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -715,7 +711,6 @@ GEM
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
securerandom (0.4.1)
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
activesupport (>= 4)
|
||||
@@ -730,17 +725,6 @@ GEM
|
||||
sentry-ruby (~> 5.19.0)
|
||||
sidekiq (>= 3.0)
|
||||
sexp_processor (4.17.0)
|
||||
shopify_api (14.8.0)
|
||||
activesupport
|
||||
concurrent-ruby
|
||||
hash_diff
|
||||
httparty
|
||||
jwt
|
||||
oj
|
||||
openssl
|
||||
securerandom
|
||||
sorbet-runtime
|
||||
zeitwerk (~> 2.5)
|
||||
shoulda-matchers (5.3.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.1)
|
||||
@@ -773,7 +757,6 @@ GEM
|
||||
snaky_hash (2.0.1)
|
||||
hashie
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
sorbet-runtime (0.5.11934)
|
||||
spring (4.1.1)
|
||||
spring-watcher-listen (2.1.0)
|
||||
listen (>= 2.7, < 4.0)
|
||||
@@ -816,7 +799,7 @@ GEM
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (2.4.2)
|
||||
uniform_notifier (1.16.0)
|
||||
uri (1.0.3)
|
||||
uri (0.13.0)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
@@ -967,7 +950,6 @@ DEPENDENCIES
|
||||
sentry-rails (>= 5.19.0)
|
||||
sentry-ruby
|
||||
sentry-sidekiq (>= 5.19.0)
|
||||
shopify_api
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
button:not(.reset-base),
|
||||
input[type='button']:not(.reset-base),
|
||||
input[type='reset']:not(.reset-base),
|
||||
input[type='submit']:not(.reset-base),
|
||||
.button:not(.reset-base) {
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="reset"],
|
||||
input[type="submit"],
|
||||
.button {
|
||||
appearance: none;
|
||||
background-color: $color-woot;
|
||||
border: 0;
|
||||
border-radius: $base-border-radius;
|
||||
color: $white;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
display: inline-block;
|
||||
font-size: $font-size-small;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-weight: $font-weight-medium;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
.icon-container {
|
||||
margin-right: 2px;
|
||||
|
||||
}
|
||||
|
||||
.value-container {
|
||||
|
||||
@@ -2,9 +2,10 @@ class RoomChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
# TODO: should we only do ensure stream if current account is present?
|
||||
# for now going ahead with guard clauses in update_subscription and broadcast_presence
|
||||
|
||||
ensure_stream
|
||||
current_user
|
||||
current_account
|
||||
ensure_stream
|
||||
update_subscription
|
||||
broadcast_presence
|
||||
end
|
||||
@@ -21,12 +22,12 @@ class RoomChannel < ApplicationCable::Channel
|
||||
|
||||
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
|
||||
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
|
||||
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
|
||||
ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
|
||||
end
|
||||
|
||||
def ensure_stream
|
||||
stream_from pubsub_token
|
||||
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
|
||||
@pubsub_token = params[:pubsub_token]
|
||||
stream_from @pubsub_token
|
||||
end
|
||||
|
||||
def update_subscription
|
||||
@@ -35,15 +36,11 @@ class RoomChannel < ApplicationCable::Channel
|
||||
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
|
||||
end
|
||||
|
||||
def pubsub_token
|
||||
@pubsub_token ||= params[:pubsub_token]
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= if params[:user_id].blank?
|
||||
ContactInbox.find_by!(pubsub_token: pubsub_token).contact
|
||||
ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
|
||||
else
|
||||
User.find_by!(pubsub_token: pubsub_token, id: params[:user_id])
|
||||
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
|
||||
before_action :inbox, :contact, :contact_inbox, only: [:create]
|
||||
|
||||
ATTACHMENT_RESULTS_PER_PAGE = 100
|
||||
|
||||
def index
|
||||
result = conversation_finder.perform
|
||||
@conversations = result[:conversations]
|
||||
@@ -26,12 +24,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def attachments
|
||||
@attachments_count = @conversation.attachments.count
|
||||
@attachments = @conversation.attachments
|
||||
.includes(:message)
|
||||
.order(created_at: :desc)
|
||||
.page(attachment_params[:page])
|
||||
.per(ATTACHMENT_RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -131,10 +124,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
params.permit(:priority)
|
||||
end
|
||||
|
||||
def attachment_params
|
||||
params.permit(:page)
|
||||
end
|
||||
|
||||
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_column(:agent_last_seen_at, last_seen_at)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||
include Shopify::IntegrationHelper
|
||||
before_action :setup_shopify_context, only: [:orders]
|
||||
before_action :fetch_hook, except: [:auth]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
shop_domain = params[:shop_domain]
|
||||
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
|
||||
|
||||
state = generate_shopify_token(Current.account.id)
|
||||
|
||||
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
|
||||
auth_url += URI.encode_www_form(
|
||||
client_id: client_id,
|
||||
scope: REQUIRED_SCOPES.join(','),
|
||||
redirect_uri: redirect_uri,
|
||||
state: state
|
||||
)
|
||||
|
||||
render json: { redirect_url: auth_url }
|
||||
end
|
||||
|
||||
def orders
|
||||
customers = fetch_customers
|
||||
return render json: { orders: [] } if customers.empty?
|
||||
|
||||
orders = fetch_orders(customers.first['id'])
|
||||
render json: { orders: orders }
|
||||
rescue ShopifyAPI::Errors::HttpResponseError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
|
||||
end
|
||||
|
||||
def contact
|
||||
@contact ||= Current.account.contacts.find_by(id: params[:contact_id])
|
||||
end
|
||||
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify')
|
||||
end
|
||||
|
||||
def fetch_customers
|
||||
query = []
|
||||
query << "email:#{contact.email}" if contact.email.present?
|
||||
query << "phone:#{contact.phone_number}" if contact.phone_number.present?
|
||||
|
||||
shopify_client.get(
|
||||
path: 'customers/search.json',
|
||||
query: {
|
||||
query: query.join(' OR '),
|
||||
fields: 'id,email,phone'
|
||||
}
|
||||
).body['customers'] || []
|
||||
end
|
||||
|
||||
def fetch_orders(customer_id)
|
||||
orders = shopify_client.get(
|
||||
path: 'orders.json',
|
||||
query: {
|
||||
customer_id: customer_id,
|
||||
status: 'any',
|
||||
fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status'
|
||||
}
|
||||
).body['orders'] || []
|
||||
|
||||
orders.map do |order|
|
||||
order.merge('admin_url' => "https://#{@hook.reference_id}/admin/orders/#{order['id']}")
|
||||
end
|
||||
end
|
||||
|
||||
def setup_shopify_context
|
||||
return if client_id.blank? || client_secret.blank?
|
||||
|
||||
ShopifyAPI::Context.setup(
|
||||
api_key: client_id,
|
||||
api_secret_key: client_secret,
|
||||
api_version: '2025-01'.freeze,
|
||||
scope: REQUIRED_SCOPES.join(','),
|
||||
is_embedded: true,
|
||||
is_private: false
|
||||
)
|
||||
end
|
||||
|
||||
def shopify_session
|
||||
ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token)
|
||||
end
|
||||
|
||||
def shopify_client
|
||||
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
|
||||
end
|
||||
|
||||
def validate_contact
|
||||
return unless contact.blank? || (contact.email.blank? && contact.phone_number.blank?)
|
||||
|
||||
render json: { error: 'Contact information missing' },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -2,10 +2,6 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
|
||||
skip_before_action :set_contact
|
||||
|
||||
def index
|
||||
@campaigns = @web_widget
|
||||
.inbox
|
||||
.campaigns
|
||||
.where(enabled: true, account_id: @web_widget.inbox.account_id)
|
||||
.includes(:sender)
|
||||
@campaigns = @web_widget.inbox.campaigns.where(enabled: true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,6 @@ class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController
|
||||
skip_before_action :set_contact
|
||||
|
||||
def index
|
||||
@inbox_members = @web_widget.inbox.inbox_members.includes(user: { avatar_attachment: :blob })
|
||||
@inbox_members = @web_widget.inbox.inbox_members.includes(:user)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
module Database::ConnectionDiagnostics
|
||||
extend ActiveSupport::Concern
|
||||
include Database::QueryInspection
|
||||
include Database::QueryLogging
|
||||
|
||||
private
|
||||
|
||||
def log_connection_pool_stats(connection_pool)
|
||||
{
|
||||
pool_size: connection_pool.size,
|
||||
active_connections: connection_pool.connections.count(&:in_use?),
|
||||
total_connections: connection_pool.connections.count,
|
||||
waiting_threads: connection_pool.num_waiting_in_queue,
|
||||
checkout_timeout: connection_pool.checkout_timeout
|
||||
}
|
||||
end
|
||||
|
||||
def connection_status_sql
|
||||
<<~SQL.squish
|
||||
SELECT count(*) as connection_count, state#{' '}
|
||||
FROM pg_stat_activity#{' '}
|
||||
GROUP BY state;
|
||||
SQL
|
||||
end
|
||||
|
||||
def fetch_connection_diagnostics
|
||||
{
|
||||
active_queries: fetch_active_queries,
|
||||
locked_queries: fetch_locked_queries,
|
||||
connection_status: ActiveRecord::Base.connection.execute(connection_status_sql).to_a
|
||||
}
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching active query data: #{e.message}"
|
||||
{ active_queries: [], locked_queries: [] }
|
||||
end
|
||||
|
||||
def caller_info(exception)
|
||||
{
|
||||
process_id: Process.pid,
|
||||
thread_id: Thread.current.object_id,
|
||||
backtrace: exception.backtrace&.first(15) || []
|
||||
}
|
||||
end
|
||||
|
||||
def log_timeout_error(exception, connection_info)
|
||||
Rails.logger.error "ActiveRecord::ConnectionTimeoutError: #{exception.message}"
|
||||
Rails.logger.error "Connection Pool Stats: #{connection_info.except(:active_queries, :locked_queries).inspect}"
|
||||
log_active_queries(connection_info[:active_queries])
|
||||
log_locked_queries(connection_info[:locked_queries])
|
||||
end
|
||||
end
|
||||
@@ -1,91 +0,0 @@
|
||||
module Database::QueryInspection
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def active_queries_sql
|
||||
<<~SQL.squish
|
||||
SELECT pid,#{' '}
|
||||
now() - pg_stat_activity.query_start AS duration,
|
||||
query,
|
||||
state,
|
||||
wait_event_type,
|
||||
wait_event,
|
||||
backend_type,
|
||||
application_name,
|
||||
client_addr,
|
||||
usename
|
||||
FROM pg_stat_activity#{' '}
|
||||
WHERE state <> 'idle'
|
||||
AND query NOT ILIKE '%pg_stat_activity%'
|
||||
ORDER BY duration DESC;
|
||||
SQL
|
||||
end
|
||||
|
||||
def process_active_query_row(row)
|
||||
{
|
||||
pid: row['pid'],
|
||||
duration: row['duration'].to_s,
|
||||
state: row['state'],
|
||||
query: row['query'],
|
||||
wait_event_type: row['wait_event_type'],
|
||||
wait_event: row['wait_event'],
|
||||
backend_type: row['backend_type'],
|
||||
application_name: row['application_name'],
|
||||
client_addr: row['client_addr'],
|
||||
username: row['usename']
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_active_queries
|
||||
query_data = ActiveRecord::Base.connection.execute(active_queries_sql)
|
||||
query_data.map { |row| process_active_query_row(row) }
|
||||
end
|
||||
|
||||
def locked_queries_sql
|
||||
<<~SQL.squish
|
||||
SELECT blocked_locks.pid AS blocked_pid,
|
||||
blocked_activity.usename AS blocked_user,
|
||||
blocking_locks.pid AS blocking_pid,
|
||||
blocking_activity.usename AS blocking_user,
|
||||
blocked_activity.query AS blocked_statement,
|
||||
blocking_activity.query AS blocking_statement,
|
||||
now() - blocking_activity.query_start AS blocking_duration
|
||||
FROM pg_catalog.pg_locks blocked_locks
|
||||
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
|
||||
JOIN pg_catalog.pg_locks blocking_locks#{' '}
|
||||
ON blocking_locks.locktype = blocked_locks.locktype
|
||||
AND blocking_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE
|
||||
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
|
||||
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
|
||||
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
|
||||
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
|
||||
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
|
||||
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
|
||||
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
|
||||
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
|
||||
AND blocking_locks.pid != blocked_locks.pid
|
||||
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
|
||||
WHERE NOT blocked_locks.granted;
|
||||
SQL
|
||||
end
|
||||
|
||||
def process_lock_row(row)
|
||||
{
|
||||
blocked_pid: row['blocked_pid'],
|
||||
blocked_user: row['blocked_user'],
|
||||
blocking_pid: row['blocking_pid'],
|
||||
blocking_user: row['blocking_user'],
|
||||
blocked_statement: row['blocked_statement'],
|
||||
blocking_statement: row['blocking_statement'],
|
||||
blocking_duration: row['blocking_duration'].to_s
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_locked_queries
|
||||
lock_data = ActiveRecord::Base.connection.execute(locked_queries_sql)
|
||||
return [] if lock_data.count.zero?
|
||||
|
||||
lock_data.map { |row| process_lock_row(row) }
|
||||
end
|
||||
end
|
||||
@@ -1,33 +0,0 @@
|
||||
module Database::QueryLogging
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def log_query_details(query_info, index)
|
||||
Rails.logger.error "Query ##{index + 1} [PID: #{query_info[:pid]}] [Duration: #{query_info[:duration]}] [State: #{query_info[:state]}]:"
|
||||
Rails.logger.error "App: #{query_info[:application_name]} User: #{query_info[:username]} Client: #{query_info[:client_addr]}"
|
||||
Rails.logger.error "Waiting: #{query_info[:wait_event_type]} / #{query_info[:wait_event]}" if query_info[:wait_event_type].present?
|
||||
Rails.logger.error query_info[:query]
|
||||
end
|
||||
|
||||
def log_active_queries(active_queries)
|
||||
if active_queries.any?
|
||||
Rails.logger.error "Active Database Queries (#{active_queries.count}):"
|
||||
active_queries.each_with_index { |query_info, index| log_query_details(query_info, index) }
|
||||
else
|
||||
Rails.logger.error 'No active queries found or unable to retrieve query information'
|
||||
end
|
||||
end
|
||||
|
||||
def log_locked_queries(locked_queries)
|
||||
return if locked_queries.blank?
|
||||
|
||||
Rails.logger.error "Locked Queries (#{locked_queries.count}):"
|
||||
locked_queries.each_with_index do |lock_info, index|
|
||||
Rails.logger.error "Lock ##{index + 1}: PID #{lock_info[:blocked_pid]} blocked by PID #{lock_info[:blocking_pid]}"
|
||||
Rails.logger.error "Duration: #{lock_info[:blocking_duration]}"
|
||||
Rails.logger.error "Blocked query: #{lock_info[:blocked_statement]}"
|
||||
Rails.logger.error "Blocking query: #{lock_info[:blocking_statement]}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,8 @@
|
||||
module RequestExceptionHandler
|
||||
extend ActiveSupport::Concern
|
||||
include Database::ConnectionDiagnostics
|
||||
|
||||
included do
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||
rescue_from ActiveRecord::ConnectionTimeoutError, with: :handle_connection_timeout
|
||||
end
|
||||
|
||||
private
|
||||
@@ -20,36 +18,11 @@ module RequestExceptionHandler
|
||||
rescue ActionController::ParameterMissing => e
|
||||
log_handled_error(e)
|
||||
render_could_not_create_error(e.message)
|
||||
rescue ActiveRecord::ConnectionTimeoutError => e
|
||||
handle_connection_timeout(e)
|
||||
ensure
|
||||
# to address the thread variable leak issues in Puma/Thin webserver
|
||||
Current.reset
|
||||
end
|
||||
|
||||
def handle_connection_timeout(exception)
|
||||
connection_pool = ActiveRecord::Base.connection_pool
|
||||
connection_info = log_connection_pool_stats(connection_pool)
|
||||
|
||||
# Gather diagnostic info
|
||||
diagnostics = fetch_connection_diagnostics
|
||||
connection_info.merge!(diagnostics)
|
||||
connection_info[:caller_info] = caller_info(exception)
|
||||
|
||||
# Log error details
|
||||
log_timeout_error(exception, connection_info)
|
||||
|
||||
# Report to exception tracker
|
||||
ChatwootExceptionTracker.new(
|
||||
exception,
|
||||
user: Current.user,
|
||||
account: Current.account,
|
||||
additional_context: { connection_info: connection_info }
|
||||
).capture_exception
|
||||
|
||||
render_service_unavailable('Database connection timeout. Please try again later.')
|
||||
end
|
||||
|
||||
def render_unauthorized(message)
|
||||
render json: { error: message }, status: :unauthorized
|
||||
end
|
||||
@@ -70,10 +43,6 @@ module RequestExceptionHandler
|
||||
render json: { error: message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def render_service_unavailable(message)
|
||||
render json: { error: message }, status: :service_unavailable
|
||||
end
|
||||
|
||||
def render_record_invalid(exception)
|
||||
log_handled_error(exception)
|
||||
render json: {
|
||||
|
||||
@@ -5,11 +5,10 @@ module SwitchLocale
|
||||
|
||||
def switch_locale(&)
|
||||
# priority is for locale set in query string (mostly for widget/from js sdk)
|
||||
locale ||= params[:locale]
|
||||
|
||||
locale ||= locale_from_params
|
||||
locale ||= locale_from_custom_domain
|
||||
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
|
||||
locale ||= ENV.fetch('DEFAULT_LOCALE', nil)
|
||||
locale ||= locale_from_env_variable
|
||||
set_locale(locale, &)
|
||||
end
|
||||
|
||||
@@ -33,30 +32,26 @@ module SwitchLocale
|
||||
end
|
||||
|
||||
def set_locale(locale, &)
|
||||
safe_locale = validate_and_get_locale(locale)
|
||||
# if locale is empty, use default_locale
|
||||
locale ||= I18n.default_locale
|
||||
# Ensure locale won't bleed into other requests
|
||||
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
|
||||
I18n.with_locale(safe_locale, &)
|
||||
I18n.with_locale(locale, &)
|
||||
end
|
||||
|
||||
def validate_and_get_locale(locale)
|
||||
return I18n.default_locale.to_s if locale.blank?
|
||||
|
||||
available_locales = I18n.available_locales.map(&:to_s)
|
||||
locale_without_variant = locale.split('_')[0]
|
||||
|
||||
if available_locales.include?(locale)
|
||||
locale
|
||||
elsif available_locales.include?(locale_without_variant)
|
||||
locale_without_variant
|
||||
else
|
||||
I18n.default_locale.to_s
|
||||
end
|
||||
def locale_from_params
|
||||
I18n.available_locales.map(&:to_s).include?(params[:locale]) ? params[:locale] : nil
|
||||
end
|
||||
|
||||
def locale_from_account(account)
|
||||
return unless account
|
||||
|
||||
account.locale
|
||||
I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil
|
||||
end
|
||||
|
||||
def locale_from_env_variable
|
||||
return unless ENV.fetch('DEFAULT_LOCALE', nil)
|
||||
|
||||
I18n.available_locales.map(&:to_s).include?(ENV.fetch('DEFAULT_LOCALE')) ? ENV.fetch('DEFAULT_LOCALE') : nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,25 +6,17 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@articles = @portal.articles.published.includes(:category, :author)
|
||||
@articles = @portal.articles.published
|
||||
@articles_count = @articles.count
|
||||
search_articles
|
||||
order_by_sort_param
|
||||
limit_results
|
||||
@articles = @articles.page(list_params[:page]) if list_params[:page].present?
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
private
|
||||
|
||||
def limit_results
|
||||
return if list_params[:per_page].blank?
|
||||
|
||||
per_page = [list_params[:per_page].to_i, 100].min
|
||||
per_page = 25 if per_page < 1
|
||||
@articles = @articles.page(list_params[:page]).per(per_page)
|
||||
end
|
||||
|
||||
def search_articles
|
||||
@articles = @articles.search(list_params) if list_params.present?
|
||||
end
|
||||
@@ -53,7 +45,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.permit(:query, :locale, :sort, :status, :page, :per_page)
|
||||
params.permit(:query, :locale, :sort, :status, :page)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Public::Api::V1::Portals::BaseController < PublicController
|
||||
include SwitchLocale
|
||||
|
||||
before_action :show_plain_layout
|
||||
before_action :set_color_scheme
|
||||
before_action :set_global_config
|
||||
@@ -29,7 +27,14 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
end
|
||||
|
||||
def switch_locale_with_portal(&)
|
||||
@locale = validate_and_get_locale(params[:locale])
|
||||
locale_without_variant = params[:locale].split('_')[0]
|
||||
is_locale_available = I18n.available_locales.map(&:to_s).include?(params[:locale])
|
||||
is_locale_variant_available = I18n.available_locales.map(&:to_s).include?(locale_without_variant)
|
||||
if is_locale_available
|
||||
@locale = params[:locale]
|
||||
elsif is_locale_variant_available
|
||||
@locale = locale_without_variant
|
||||
end
|
||||
|
||||
I18n.with_locale(@locale, &)
|
||||
end
|
||||
@@ -39,12 +44,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
|
||||
render_404 && return if article.blank?
|
||||
|
||||
article_locale = if article.category.present?
|
||||
article.category.locale
|
||||
else
|
||||
article.portal.default_locale
|
||||
end
|
||||
@locale = validate_and_get_locale(article_locale)
|
||||
@locale = if article.category.present?
|
||||
article.category.locale
|
||||
else
|
||||
article.portal.default_locale
|
||||
end
|
||||
|
||||
I18n.with_locale(@locale, &)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
class Shopify::CallbacksController < ApplicationController
|
||||
include Shopify::IntegrationHelper
|
||||
|
||||
def show
|
||||
verify_account!
|
||||
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: '/shopify/callback'
|
||||
)
|
||||
|
||||
handle_response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Shopify callback error: #{e.message}")
|
||||
redirect_to "#{redirect_uri}?error=true"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify_account!
|
||||
@account_id = verify_shopify_token(params[:state])
|
||||
raise StandardError, 'Invalid state parameter' if account.blank?
|
||||
end
|
||||
|
||||
def handle_response
|
||||
account.hooks.create!(
|
||||
app_id: 'shopify',
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
reference_id: params[:shop],
|
||||
settings: {
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
)
|
||||
|
||||
redirect_to shopify_integration_url
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def oauth_client
|
||||
OAuth2::Client.new(
|
||||
client_id,
|
||||
client_secret,
|
||||
{
|
||||
site: "https://#{params[:shop]}",
|
||||
authorize_url: '/admin/oauth/authorize',
|
||||
token_url: '/admin/oauth/access_token'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(@account_id)
|
||||
end
|
||||
|
||||
def account_id
|
||||
@account_id ||= params[:state].split('_').first
|
||||
end
|
||||
|
||||
def shopify_integration_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
|
||||
end
|
||||
|
||||
def redirect_uri
|
||||
return shopify_integration_url if account
|
||||
|
||||
ENV.fetch('FRONTEND_URL', nil)
|
||||
end
|
||||
end
|
||||
@@ -35,8 +35,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
@allowed_configs = case @config
|
||||
when 'facebook'
|
||||
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
|
||||
when 'shopify'
|
||||
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
|
||||
when 'microsoft'
|
||||
%w[AZURE_APP_ID AZURE_APP_SECRET]
|
||||
when 'email'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Twilio::CallbackController < ApplicationController
|
||||
def create
|
||||
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
|
||||
::Twilio::IncomingMessageService.new(params: permitted_params).perform
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Twilio::DeliveryStatusController < ApplicationController
|
||||
def create
|
||||
Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
|
||||
::Twilio::DeliveryStatusService.new(params: permitted_params).perform
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
@@ -2,12 +2,6 @@ class Webhooks::WhatsappController < ActionController::API
|
||||
include MetaTokenVerifyConcern
|
||||
|
||||
def process_payload
|
||||
if inactive_whatsapp_number?
|
||||
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
|
||||
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
|
||||
head :ok
|
||||
end
|
||||
@@ -19,15 +13,4 @@ class Webhooks::WhatsappController < ActionController::API
|
||||
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
|
||||
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
|
||||
end
|
||||
|
||||
def inactive_whatsapp_number?
|
||||
phone_number = params[:phone_number]
|
||||
return false if phone_number.blank?
|
||||
|
||||
inactive_numbers = GlobalConfig.get_value('INACTIVE_WHATSAPP_NUMBERS').to_s
|
||||
return false if inactive_numbers.blank?
|
||||
|
||||
inactive_numbers_array = inactive_numbers.split(',').map(&:strip)
|
||||
inactive_numbers_array.include?(phone_number)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -78,11 +78,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.freeze
|
||||
COLLECTION_FILTERS = {
|
||||
active: ->(resources) { resources.where(status: :active) },
|
||||
suspended: ->(resources) { resources.where(status: :suspended) },
|
||||
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
|
||||
}.freeze
|
||||
COLLECTION_FILTERS = {}.freeze
|
||||
|
||||
# Overwrite this method to customize how accounts are displayed
|
||||
# across all pages of the admin dashboard.
|
||||
|
||||
@@ -94,12 +94,7 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.freeze
|
||||
COLLECTION_FILTERS = {
|
||||
super_admin: ->(resources) { resources.where(type: 'SuperAdmin') },
|
||||
confirmed: ->(resources) { resources.where.not(confirmed_at: nil) },
|
||||
unconfirmed: ->(resources) { resources.where(confirmed_at: nil) },
|
||||
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
|
||||
}.freeze
|
||||
COLLECTION_FILTERS = {}.freeze
|
||||
|
||||
# Overwrite this method to customize how users are displayed
|
||||
# across all pages of the admin dashboard.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module Filters::FilterHelper
|
||||
module FilterHelper
|
||||
def build_condition_query(model_filters, query_hash, current_index)
|
||||
current_filter = model_filters[query_hash['attribute_key']]
|
||||
|
||||
@@ -89,18 +89,4 @@ module Filters::FilterHelper
|
||||
operator = condition['query_operator'].upcase
|
||||
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
|
||||
end
|
||||
|
||||
def conversation_status_values(values)
|
||||
return Conversation.statuses.values if values.include?('all')
|
||||
|
||||
values.map { |x| Conversation.statuses[x.to_sym] }
|
||||
end
|
||||
|
||||
def conversation_priority_values(values)
|
||||
values.map { |x| Conversation.priorities[x.to_sym] }
|
||||
end
|
||||
|
||||
def message_type_values(values)
|
||||
values.map { |x| Message.message_types[x.to_sym] }
|
||||
end
|
||||
end
|
||||
@@ -1,58 +0,0 @@
|
||||
module Shopify::IntegrationHelper
|
||||
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze
|
||||
|
||||
# Generates a signed JWT token for Shopify integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_shopify_token(account_id)
|
||||
return if client_secret.blank?
|
||||
|
||||
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate Shopify token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
# Verifies and decodes a Shopify JWT token
|
||||
#
|
||||
# @param token [String] The JWT token to verify
|
||||
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||
def verify_shopify_token(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client_id
|
||||
@client_id ||= GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil)
|
||||
end
|
||||
|
||||
def client_secret
|
||||
@client_secret ||= GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
|
||||
end
|
||||
|
||||
def decode_token(token, secret)
|
||||
JWT.decode(
|
||||
token,
|
||||
secret,
|
||||
true,
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}
|
||||
).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Shopify token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -32,12 +32,6 @@ class IntegrationsAPI extends ApiClient {
|
||||
deleteHook(hookId) {
|
||||
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
|
||||
}
|
||||
|
||||
connectShopify({ shopDomain }) {
|
||||
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
|
||||
shop_domain: shopDomain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new IntegrationsAPI();
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class ShopifyAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('integrations/shopify', { accountScoped: true });
|
||||
}
|
||||
|
||||
getOrders(contactId) {
|
||||
return axios.get(`${this.url}/orders`, {
|
||||
params: { contact_id: contactId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ShopifyAPI();
|
||||
@@ -1,20 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class LiveReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('live_reports', { accountScoped: true, apiVersion: 'v2' });
|
||||
}
|
||||
|
||||
getConversationMetric(params = {}) {
|
||||
return axios.get(`${this.url}/conversation_metrics`, { params });
|
||||
}
|
||||
|
||||
getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) {
|
||||
return axios.get(`${this.url}/grouped_conversation_metrics`, {
|
||||
params: { group_by: groupBy },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new LiveReportsAPI();
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
&.no-margin {
|
||||
.mx-input {
|
||||
margin-bottom: 0 !important;
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
// scss-lint:disable PropertySortOrder
|
||||
@layer base {
|
||||
// NEXT COLORS START
|
||||
:root {
|
||||
// slate
|
||||
--slate-1: 252 252 253;
|
||||
--slate-2: 249 249 251;
|
||||
--slate-3: 240 240 243;
|
||||
--slate-4: 232 232 236;
|
||||
--slate-5: 224 225 230;
|
||||
--slate-6: 217 217 224;
|
||||
--slate-7: 205 206 214;
|
||||
--slate-8: 185 187 198;
|
||||
--slate-9: 139 141 152;
|
||||
--slate-10: 128 131 141;
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
--iris-1: 253 253 255;
|
||||
--iris-2: 248 248 255;
|
||||
--iris-3: 240 241 254;
|
||||
--iris-4: 230 231 255;
|
||||
--iris-5: 218 220 255;
|
||||
--iris-6: 203 205 255;
|
||||
--iris-7: 184 186 248;
|
||||
--iris-8: 155 158 240;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 81 81 205;
|
||||
--iris-11: 87 83 198;
|
||||
--iris-12: 39 41 98;
|
||||
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
--ruby-4: 255 220 225;
|
||||
--ruby-5: 255 206 214;
|
||||
--ruby-6: 248 191 200;
|
||||
--ruby-7: 239 172 184;
|
||||
--ruby-8: 229 146 163;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 220 59 93;
|
||||
--ruby-11: 202 36 77;
|
||||
--ruby-12: 100 23 43;
|
||||
|
||||
--amber-1: 254 253 251;
|
||||
--amber-2: 254 251 233;
|
||||
--amber-3: 255 247 194;
|
||||
--amber-4: 255 238 156;
|
||||
--amber-5: 251 229 119;
|
||||
--amber-6: 243 214 115;
|
||||
--amber-7: 233 193 98;
|
||||
--amber-8: 226 163 54;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 186 24;
|
||||
--amber-11: 171 100 0;
|
||||
--amber-12: 79 52 34;
|
||||
|
||||
--teal-1: 250 254 253;
|
||||
--teal-2: 243 251 249;
|
||||
--teal-3: 224 248 243;
|
||||
--teal-4: 204 243 234;
|
||||
--teal-5: 184 234 224;
|
||||
--teal-6: 161 222 210;
|
||||
--teal-7: 131 205 193;
|
||||
--teal-8: 83 185 171;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 13 155 138;
|
||||
--teal-11: 0 133 115;
|
||||
--teal-12: 13 61 56;
|
||||
|
||||
--gray-1: 252 252 252;
|
||||
--gray-2: 249 249 249;
|
||||
--gray-3: 240 240 240;
|
||||
--gray-4: 232 232 232;
|
||||
--gray-5: 224 224 224;
|
||||
--gray-6: 217 217 217;
|
||||
--gray-7: 206 206 206;
|
||||
--gray-8: 187 187 187;
|
||||
--gray-9: 141 141 141;
|
||||
--gray-10: 131 131 131;
|
||||
--gray-11: 100 100 100;
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
--border-strong: 235 235 235;
|
||||
--border-weak: 234 234 234;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--solid-iris: 230 231 255;
|
||||
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
}
|
||||
|
||||
.dark {
|
||||
// slate
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
--slate-3: 33 34 37;
|
||||
--slate-4: 39 42 45;
|
||||
--slate-5: 46 49 53;
|
||||
--slate-6: 54 58 63;
|
||||
--slate-7: 67 72 78;
|
||||
--slate-8: 90 97 105;
|
||||
--slate-9: 105 110 119;
|
||||
--slate-10: 119 123 132;
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
--iris-1: 19 19 30;
|
||||
--iris-2: 23 22 37;
|
||||
--iris-3: 32 34 72;
|
||||
--iris-4: 38 42 101;
|
||||
--iris-5: 48 51 116;
|
||||
--iris-6: 61 62 130;
|
||||
--iris-7: 74 74 149;
|
||||
--iris-8: 89 88 177;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 84 114 228;
|
||||
--iris-11: 158 177 255;
|
||||
--iris-12: 224 223 254;
|
||||
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
--ruby-4: 78 19 37;
|
||||
--ruby-5: 94 26 46;
|
||||
--ruby-6: 111 37 57;
|
||||
--ruby-7: 136 52 71;
|
||||
--ruby-8: 179 68 90;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 236 90 114;
|
||||
--ruby-11: 255 148 157;
|
||||
--ruby-12: 254 210 225;
|
||||
|
||||
--amber-1: 22 18 12;
|
||||
--amber-2: 29 24 15;
|
||||
--amber-3: 48 32 8;
|
||||
--amber-4: 63 39 0;
|
||||
--amber-5: 77 48 0;
|
||||
--amber-6: 92 61 5;
|
||||
--amber-7: 113 79 25;
|
||||
--amber-8: 143 100 36;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 214 10;
|
||||
--amber-11: 255 202 22;
|
||||
--amber-12: 255 231 179;
|
||||
|
||||
--teal-1: 13 21 20;
|
||||
--teal-2: 17 28 27;
|
||||
--teal-3: 13 45 42;
|
||||
--teal-4: 2 59 55;
|
||||
--teal-5: 8 72 67;
|
||||
--teal-6: 20 87 80;
|
||||
--teal-7: 28 105 97;
|
||||
--teal-8: 32 126 115;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 14 179 158;
|
||||
--teal-11: 11 216 182;
|
||||
--teal-12: 173 240 221;
|
||||
|
||||
--gray-1: 17 17 17;
|
||||
--gray-2: 25 25 25;
|
||||
--gray-3: 34 34 34;
|
||||
--gray-4: 42 42 42;
|
||||
--gray-5: 49 49 49;
|
||||
--gray-6: 58 58 58;
|
||||
--gray-7: 72 72 72;
|
||||
--gray-8: 96 96 96;
|
||||
--gray-9: 110 110 110;
|
||||
--gray-10: 123 123 123;
|
||||
--gray-11: 180 180 180;
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 44 45 54;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-iris: 38 42 101;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
--alpha-2: 139, 147, 182, 0.15;
|
||||
--alpha-3: 36, 38, 45, 0.9;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 236, 236, 236, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
}
|
||||
// NEXT COLORS END
|
||||
@@ -5,9 +5,6 @@
|
||||
@import 'shared/assets/fonts/InterDisplay/inter-display';
|
||||
@import 'shared/assets/fonts/inter';
|
||||
|
||||
// Next Colors
|
||||
@import 'next-colors';
|
||||
|
||||
@import 'shared/assets/stylesheets/animations';
|
||||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
@@ -57,19 +54,214 @@
|
||||
@apply text-n-blue-text;
|
||||
}
|
||||
|
||||
.custom-dashed-border {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.dark .custom-dashed-border {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
// scss-lint:disable PropertySortOrder
|
||||
@layer base {
|
||||
/* NEXT COLORS START */
|
||||
:root {
|
||||
/* slate */
|
||||
--slate-1: 252 252 253;
|
||||
--slate-2: 249 249 251;
|
||||
--slate-3: 240 240 243;
|
||||
--slate-4: 232 232 236;
|
||||
--slate-5: 224 225 230;
|
||||
--slate-6: 217 217 224;
|
||||
--slate-7: 205 206 214;
|
||||
--slate-8: 185 187 198;
|
||||
--slate-9: 139 141 152;
|
||||
--slate-10: 128 131 141;
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
--iris-1: 253 253 255;
|
||||
--iris-2: 248 248 255;
|
||||
--iris-3: 240 241 254;
|
||||
--iris-4: 230 231 255;
|
||||
--iris-5: 218 220 255;
|
||||
--iris-6: 203 205 255;
|
||||
--iris-7: 184 186 248;
|
||||
--iris-8: 155 158 240;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 81 81 205;
|
||||
--iris-11: 87 83 198;
|
||||
--iris-12: 39 41 98;
|
||||
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
--ruby-4: 255 220 225;
|
||||
--ruby-5: 255 206 214;
|
||||
--ruby-6: 248 191 200;
|
||||
--ruby-7: 239 172 184;
|
||||
--ruby-8: 229 146 163;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 220 59 93;
|
||||
--ruby-11: 202 36 77;
|
||||
--ruby-12: 100 23 43;
|
||||
|
||||
--amber-1: 254 253 251;
|
||||
--amber-2: 254 251 233;
|
||||
--amber-3: 255 247 194;
|
||||
--amber-4: 255 238 156;
|
||||
--amber-5: 251 229 119;
|
||||
--amber-6: 243 214 115;
|
||||
--amber-7: 233 193 98;
|
||||
--amber-8: 226 163 54;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 186 24;
|
||||
--amber-11: 171 100 0;
|
||||
--amber-12: 79 52 34;
|
||||
|
||||
--teal-1: 250 254 253;
|
||||
--teal-2: 243 251 249;
|
||||
--teal-3: 224 248 243;
|
||||
--teal-4: 204 243 234;
|
||||
--teal-5: 184 234 224;
|
||||
--teal-6: 161 222 210;
|
||||
--teal-7: 131 205 193;
|
||||
--teal-8: 83 185 171;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 13 155 138;
|
||||
--teal-11: 0 133 115;
|
||||
--teal-12: 13 61 56;
|
||||
|
||||
--gray-1: 252 252 252;
|
||||
--gray-2: 249 249 249;
|
||||
--gray-3: 240 240 240;
|
||||
--gray-4: 232 232 232;
|
||||
--gray-5: 224 224 224;
|
||||
--gray-6: 217 217 217;
|
||||
--gray-7: 206 206 206;
|
||||
--gray-8: 187 187 187;
|
||||
--gray-9: 141 141 141;
|
||||
--gray-10: 131 131 131;
|
||||
--gray-11: 100 100 100;
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
--border-strong: 235 235 235;
|
||||
--border-weak: 234 234 234;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--solid-iris: 230 231 255;
|
||||
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* slate */
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
--slate-3: 33 34 37;
|
||||
--slate-4: 39 42 45;
|
||||
--slate-5: 46 49 53;
|
||||
--slate-6: 54 58 63;
|
||||
--slate-7: 67 72 78;
|
||||
--slate-8: 90 97 105;
|
||||
--slate-9: 105 110 119;
|
||||
--slate-10: 119 123 132;
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
--iris-1: 19 19 30;
|
||||
--iris-2: 23 22 37;
|
||||
--iris-3: 32 34 72;
|
||||
--iris-4: 38 42 101;
|
||||
--iris-5: 48 51 116;
|
||||
--iris-6: 61 62 130;
|
||||
--iris-7: 74 74 149;
|
||||
--iris-8: 89 88 177;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 84 114 228;
|
||||
--iris-11: 158 177 255;
|
||||
--iris-12: 224 223 254;
|
||||
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
--ruby-4: 78 19 37;
|
||||
--ruby-5: 94 26 46;
|
||||
--ruby-6: 111 37 57;
|
||||
--ruby-7: 136 52 71;
|
||||
--ruby-8: 179 68 90;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 236 90 114;
|
||||
--ruby-11: 255 148 157;
|
||||
--ruby-12: 254 210 225;
|
||||
|
||||
--amber-1: 22 18 12;
|
||||
--amber-2: 29 24 15;
|
||||
--amber-3: 48 32 8;
|
||||
--amber-4: 63 39 0;
|
||||
--amber-5: 77 48 0;
|
||||
--amber-6: 92 61 5;
|
||||
--amber-7: 113 79 25;
|
||||
--amber-8: 143 100 36;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 214 10;
|
||||
--amber-11: 255 202 22;
|
||||
--amber-12: 255 231 179;
|
||||
|
||||
--teal-1: 13 21 20;
|
||||
--teal-2: 17 28 27;
|
||||
--teal-3: 13 45 42;
|
||||
--teal-4: 2 59 55;
|
||||
--teal-5: 8 72 67;
|
||||
--teal-6: 20 87 80;
|
||||
--teal-7: 28 105 97;
|
||||
--teal-8: 32 126 115;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 14 179 158;
|
||||
--teal-11: 11 216 182;
|
||||
--teal-12: 173 240 221;
|
||||
|
||||
--gray-1: 17 17 17;
|
||||
--gray-2: 25 25 25;
|
||||
--gray-3: 34 34 34;
|
||||
--gray-4: 42 42 42;
|
||||
--gray-5: 49 49 49;
|
||||
--gray-6: 58 58 58;
|
||||
--gray-7: 72 72 72;
|
||||
--gray-8: 96 96 96;
|
||||
--gray-9: 110 110 110;
|
||||
--gray-10: 123 123 123;
|
||||
--gray-11: 180 180 180;
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 44 45 54;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-iris: 38 42 101;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
--alpha-2: 139, 147, 182, 0.15;
|
||||
--alpha-3: 36, 38, 45, 0.9;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 236, 236, 236, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
/* NEXT COLORS END */
|
||||
|
||||
:root {
|
||||
--color-amber-25: 254 253 251;
|
||||
--color-amber-50: 255 249 237;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@mixin label-multiselect-hover {
|
||||
&::after {
|
||||
@apply text-n-brand;
|
||||
@apply text-woot-600 dark:text-woot-600;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-n-slate-3;
|
||||
@apply bg-slate-50 dark:bg-slate-700;
|
||||
|
||||
&::after {
|
||||
@apply text-n-blue-text;
|
||||
@apply text-woot-500 dark:text-woot-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,16 +18,20 @@
|
||||
}
|
||||
|
||||
&.multiselect--disabled {
|
||||
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
|
||||
@apply opacity-50 border border-slate-200 dark:border-slate-600 rounded-md cursor-not-allowed;
|
||||
|
||||
.multiselect__select {
|
||||
@apply cursor-not-allowed bg-transparent rounded-lg;
|
||||
@apply cursor-not-allowed bg-white dark:bg-slate-900 rounded-md;
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@apply border-0;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--active {
|
||||
> .multiselect__tags {
|
||||
@apply outline-n-blue-border;
|
||||
@apply border-woot-500 dark:border-woot-500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +44,7 @@
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
|
||||
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
|
||||
.multiselect__content {
|
||||
@@ -57,19 +61,15 @@
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply bottom-0 flex items-center justify-center text-center;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight {
|
||||
@apply bg-n-alpha-black2 text-n-slate-12;
|
||||
@apply bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight:hover {
|
||||
@apply bg-n-brand/10 text-n-slate-12;
|
||||
@apply bg-woot-50 dark:bg-woot-600 text-slate-800 dark:text-slate-100;
|
||||
|
||||
&::after {
|
||||
@apply bg-transparent text-center text-n-slate-12;
|
||||
@apply bg-woot-50 dark:bg-woot-600 text-slate-600 dark:text-slate-200;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,17 +78,17 @@
|
||||
}
|
||||
|
||||
&.multiselect__option--selected {
|
||||
@apply bg-n-brand/20 text-n-slate-12;
|
||||
|
||||
&::after {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
@apply bg-woot-50 dark:bg-woot-600 text-slate-800 dark:text-slate-100;
|
||||
|
||||
&.multiselect__option--highlight:hover {
|
||||
@apply bg-transparent;
|
||||
@apply bg-woot-75 dark:bg-woot-600;
|
||||
|
||||
&::after {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
&::after:hover {
|
||||
@apply text-n-slate-12 bg-transparent;
|
||||
@apply text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,27 +96,23 @@
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
|
||||
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 m-0 min-h-[2.875rem] pt-0;
|
||||
|
||||
input {
|
||||
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
|
||||
@apply border-0 border-none;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__spinner {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.multiselect__tags-wrap {
|
||||
@apply inline-block leading-none mt-1;
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@apply text-n-slate-10 font-normal pt-3;
|
||||
@apply text-slate-400 dark:text-slate-400 font-normal pt-3;
|
||||
}
|
||||
|
||||
.multiselect__tag {
|
||||
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
|
||||
@apply bg-slate-50 dark:bg-slate-800 mt-1 text-slate-800 dark:text-slate-100 pr-6 pl-2.5 py-1.5;
|
||||
}
|
||||
|
||||
.multiselect__tag-icon {
|
||||
@@ -129,7 +125,7 @@
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
|
||||
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,11 +143,11 @@
|
||||
}
|
||||
|
||||
> .multiselect__tags {
|
||||
@apply outline-transparent;
|
||||
@apply border-transparent;
|
||||
}
|
||||
|
||||
&.multiselect--active > .multiselect__tags {
|
||||
@apply outline-n-blue-border;
|
||||
@apply border-woot-500 dark:border-woot-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,7 +179,7 @@
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
|
||||
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem];
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@@ -191,7 +187,7 @@
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
|
||||
@apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-3 px-0.5;
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@@ -212,24 +208,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--disabled {
|
||||
background-color: rgba(var(--black-alpha-2)) !important;
|
||||
|
||||
.multiselect__tags {
|
||||
@apply hover:outline-n-weak;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--active {
|
||||
.multiselect__select::before {
|
||||
@apply top-[62%];
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__select::before {
|
||||
top: 60% !important;
|
||||
}
|
||||
|
||||
.multiselect-wrap--medium {
|
||||
.multiselect__tags,
|
||||
.multiselect__input {
|
||||
@@ -239,16 +217,16 @@
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
|
||||
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 text-sm h-12 min-h-[3rem];
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@apply h-[2.875rem] min-h-[2.875rem];
|
||||
margin-bottom: 0 !important;
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
|
||||
@apply items-center flex m-0 text-sm py-1 px-0.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900;
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
// scss-lint:disable QualifyingElement
|
||||
|
||||
// Base typography
|
||||
// -------------------------
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-medium text-n-slate-12;
|
||||
@apply font-medium text-slate-800 dark:text-slate-50;
|
||||
}
|
||||
|
||||
p {
|
||||
text-rendering: optimizeLegibility;
|
||||
|
||||
@apply mb-2 leading-[1.65] text-sm;
|
||||
|
||||
a {
|
||||
@apply text-n-brand dark:text-n-brand cursor-pointer;
|
||||
@apply text-woot-500 dark:text-woot-500 cursor-pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,82 +41,64 @@ dl:not(.reset-base) {
|
||||
}
|
||||
|
||||
// Form elements
|
||||
// -------------------------
|
||||
label {
|
||||
@apply text-n-slate-12 block m-0 leading-7 text-sm font-medium;
|
||||
@apply text-slate-800 dark:text-slate-200 block m-0 leading-7 text-sm font-medium;
|
||||
|
||||
&.error {
|
||||
input {
|
||||
@apply mb-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrap,
|
||||
.help-text {
|
||||
@apply text-n-slate-11 text-sm font-medium;
|
||||
@apply text-slate-800 dark:text-slate-100 text-sm font-medium;
|
||||
|
||||
.help-text {
|
||||
@apply font-normal text-slate-600 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus outline removal
|
||||
.button,
|
||||
textarea {
|
||||
textarea,
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Field base styles (Input, TextArea, Select)
|
||||
@layer components {
|
||||
.field-base {
|
||||
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
|
||||
}
|
||||
|
||||
.field-disabled {
|
||||
@apply opacity-50 outline-n-weak dark:outline-n-weak cursor-not-allowed;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
@apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
|
||||
}
|
||||
}
|
||||
|
||||
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
|
||||
|
||||
#{$form-input-selector} {
|
||||
@apply field-base h-10;
|
||||
// Inputs
|
||||
input[type='text']:not(.reset-base),
|
||||
input[type='number']:not(.reset-base),
|
||||
input[type='password']:not(.reset-base),
|
||||
input[type='date']:not(.reset-base),
|
||||
input[type='email']:not(.reset-base),
|
||||
input[type='url']:not(.reset-base) {
|
||||
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
|
||||
|
||||
&[disabled] {
|
||||
@apply field-disabled;
|
||||
}
|
||||
|
||||
&.error {
|
||||
@apply field-error mb-1;
|
||||
@apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='file']:not(.multiselect__input) {
|
||||
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
|
||||
input[type='file'] {
|
||||
@apply bg-white dark:bg-n-solid-1 leading-[1.15] mb-4;
|
||||
}
|
||||
|
||||
// Select
|
||||
select {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
|
||||
background-position: right -1rem center;
|
||||
background-size: 9px 6px;
|
||||
|
||||
@apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
|
||||
|
||||
&[disabled] {
|
||||
@apply field-disabled;
|
||||
}
|
||||
@apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2 rounded-md w-full text-base font-normal appearance-none transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
|
||||
}
|
||||
|
||||
// Textarea
|
||||
textarea {
|
||||
@apply field-base h-16;
|
||||
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-16 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
|
||||
|
||||
&[disabled] {
|
||||
@apply field-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Add mb-1 when .help-text exists within the same label container
|
||||
label:has(.help-text) {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
margin-bottom: 0.25rem !important;
|
||||
@apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,41 +109,18 @@ label:has(.help-text) {
|
||||
}
|
||||
}
|
||||
|
||||
// FormKit support
|
||||
.formkit-outer[data-invalid='true'] {
|
||||
#{$form-input-selector},
|
||||
textarea,
|
||||
select {
|
||||
@apply field-error;
|
||||
}
|
||||
|
||||
.formkit-message {
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
#{$form-input-selector},
|
||||
input,
|
||||
input:not([type]),
|
||||
textarea,
|
||||
select,
|
||||
.multiselect > .multiselect__tags,
|
||||
.multiselect:not(.no-margin) {
|
||||
@apply field-error;
|
||||
}
|
||||
|
||||
// Only add mb-1 when .message exists within the same .error container
|
||||
// And exclude no-margin from the margin-bottom
|
||||
&:has(.message) {
|
||||
input:not(.no-margin),
|
||||
textarea,
|
||||
select {
|
||||
margin-bottom: 0.25rem !important;
|
||||
}
|
||||
@apply border border-solid border-red-400 dark:border-red-400 mb-1;
|
||||
}
|
||||
|
||||
.message {
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full;
|
||||
@apply text-red-400 dark:text-red-400 block text-sm mb-2.5 w-full;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +130,7 @@ label:has(.help-text) {
|
||||
}
|
||||
|
||||
.error {
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9;
|
||||
@apply border-red-400 dark:border-red-400;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,11 +141,11 @@ code {
|
||||
@apply text-xs border-0;
|
||||
|
||||
&.hljs {
|
||||
@apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12 rounded-lg p-5;
|
||||
@apply bg-n-slate-3 dark:bg-n-solid-3 text-slate-800 dark:text-slate-50 rounded-lg p-5;
|
||||
|
||||
.hljs-number,
|
||||
.hljs-string {
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9;
|
||||
@apply text-red-800 dark:text-red-400;
|
||||
}
|
||||
|
||||
.hljs-name,
|
||||
|
||||
@@ -42,7 +42,7 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(.success):not(.alert):not(.warning):not(
|
||||
&:hover:not(.secondary):not(.success):not(.alert):not(.warning):not(
|
||||
.clear
|
||||
):not(.smooth):not(.hollow) {
|
||||
@apply bg-n-brand/80 dark:bg-n-brand/80;
|
||||
@@ -117,63 +117,63 @@ button {
|
||||
}
|
||||
|
||||
&.hollow {
|
||||
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:enabled:bg-n-brand/20;
|
||||
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:bg-n-brand/20;
|
||||
|
||||
&.secondary {
|
||||
@apply text-n-slate-12 border-n-slate-5 hover:enabled:bg-n-slate-5;
|
||||
@apply text-n-slate-12 border-n-slate-5 hover:bg-n-slate-5;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply text-n-teal-9 border-n-teal-8 hover:enabled:bg-n-teal-5;
|
||||
@apply text-n-teal-9 border-n-teal-8 hover:bg-n-teal-5;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply text-n-ruby-9 border-n-ruby-8 hover:enabled:bg-n-ruby-5;
|
||||
@apply text-n-ruby-9 border-n-ruby-8 hover:bg-n-ruby-5;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply text-n-amber-9 border-n-amber-8 hover:enabled:bg-n-amber-5;
|
||||
@apply text-n-amber-9 border-n-amber-8 hover:bg-n-amber-5;
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth style
|
||||
&.smooth {
|
||||
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:enabled:bg-n-brand/20 dark:hover:enabled:bg-n-brand/40;
|
||||
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:bg-n-brand/20 dark:hover:bg-n-brand/40;
|
||||
|
||||
&.secondary {
|
||||
@apply bg-n-slate-4 text-n-slate-11 hover:enabled:text-n-slate-11 hover:enabled:bg-n-slate-5;
|
||||
@apply bg-n-slate-4 text-n-slate-11 hover:text-n-slate-11 hover:bg-n-slate-5;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply bg-n-teal-4 text-n-teal-11 hover:enabled:text-n-teal-11 hover:enabled:bg-n-teal-5;
|
||||
@apply bg-n-teal-4 text-n-teal-11 hover:text-n-teal-11 hover:bg-n-teal-5;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply bg-n-ruby-4 text-n-ruby-11 hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-5;
|
||||
@apply bg-n-ruby-4 text-n-ruby-11 hover:text-n-ruby-11 hover:bg-n-ruby-5;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply bg-n-amber-4 text-n-amber-11 hover:enabled:text-n-amber-11 hover:enabled:bg-n-amber-5;
|
||||
@apply bg-n-amber-4 text-n-amber-11 hover:text-n-amber-11 hover:bg-n-amber-5;
|
||||
}
|
||||
}
|
||||
|
||||
&.clear {
|
||||
@apply text-n-blue-text hover:enabled:bg-n-brand/10 dark:hover:enabled:bg-n-brand/30;
|
||||
@apply text-n-blue-text hover:bg-n-brand/10 dark:hover:bg-n-brand/30;
|
||||
|
||||
&.secondary {
|
||||
@apply text-n-slate-12 hover:enabled:bg-n-slate-4;
|
||||
@apply text-n-slate-12 hover:bg-n-slate-4;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply text-n-teal-10 hover:enabled:bg-n-teal-4;
|
||||
@apply text-n-teal-10 hover:bg-n-teal-4;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply text-n-ruby-11 hover:enabled:bg-n-ruby-4;
|
||||
@apply text-n-ruby-11 hover:bg-n-ruby-4;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply text-n-amber-11 hover:enabled:bg-n-amber-4;
|
||||
@apply text-n-amber-11 hover:bg-n-amber-4;
|
||||
}
|
||||
|
||||
&:active {
|
||||
|
||||
@@ -247,9 +247,10 @@ defineExpose({
|
||||
:placeholder="item.placeholder"
|
||||
class="[&>div>button]:h-8"
|
||||
:class="{
|
||||
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
|
||||
'[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent':
|
||||
!isDetailsView,
|
||||
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
|
||||
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
|
||||
isDetailsView,
|
||||
}"
|
||||
@update:model-value="handleCountrySelection"
|
||||
/>
|
||||
@@ -265,9 +266,7 @@ defineExpose({
|
||||
:placeholder="item.placeholder"
|
||||
:message-type="getMessageType(item.key)"
|
||||
:custom-input-class="`h-8 !pt-1 !pb-1 ${
|
||||
!isDetailsView
|
||||
? '[&:not(.error,.focus)]:!outline-transparent'
|
||||
: ''
|
||||
!isDetailsView ? '[&:not(.error,.focus)]:!border-transparent' : ''
|
||||
}`"
|
||||
class="w-full"
|
||||
@input="
|
||||
@@ -304,7 +303,7 @@ defineExpose({
|
||||
v-model="
|
||||
state.additionalAttributes.socialProfiles[item.key.toLowerCase()]
|
||||
"
|
||||
class="w-auto min-w-[100px] text-sm bg-transparent outline-none reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
|
||||
class="w-auto min-w-[100px] text-sm bg-transparent reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
|
||||
:placeholder="item.placeholder"
|
||||
:size="item.placeholder.length"
|
||||
@input="emit('update', state)"
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
:placeholder="
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+2
-2
@@ -75,7 +75,7 @@ onMounted(() => {
|
||||
<div>
|
||||
<div class="flex justify-between w-full gap-4 py-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-n-slate-12"
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
@@ -113,7 +113,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="flex justify-between w-full gap-3 py-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-n-slate-12"
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{{
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ defineExpose({ dialogRef });
|
||||
:placeholder="
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
+1
-1
@@ -303,7 +303,7 @@ const handleAvatarDelete = () => {
|
||||
:message="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
||||
class="[&>div>button]:!outline-n-weak"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
|
||||
@@ -27,14 +27,8 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
isModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -67,8 +61,6 @@ const directUploadsEnabled = computed(
|
||||
const activeContact = computed(() => contactById.value(props.contactId));
|
||||
|
||||
const composePopoverClass = computed(() => {
|
||||
if (props.isModal) return '';
|
||||
|
||||
return props.alignPosition === 'right'
|
||||
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
||||
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
|
||||
@@ -139,14 +131,9 @@ const clearSelectedContact = () => {
|
||||
|
||||
const closeCompose = () => {
|
||||
showComposeNewConversation.value = false;
|
||||
if (!props.contactId) {
|
||||
// If contactId is passed as prop
|
||||
// Then don't allow to remove the selected contact
|
||||
selectedContact.value = null;
|
||||
}
|
||||
selectedContact.value = null;
|
||||
targetInbox.value = null;
|
||||
resetContacts();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
@@ -195,15 +182,7 @@ watch(
|
||||
);
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (!showComposeNewConversation.value) return;
|
||||
|
||||
showComposeNewConversation.value = false;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onModalBackdropClick = () => {
|
||||
if (!props.isModal) return;
|
||||
handleClickOutside();
|
||||
};
|
||||
|
||||
onMounted(() => resetContacts());
|
||||
@@ -226,7 +205,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
v-on-click-outside="[
|
||||
handleClickOutside,
|
||||
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open.
|
||||
{ ignore: ['div.ProseMirror-prompt'] },
|
||||
]"
|
||||
class="relative"
|
||||
@@ -239,37 +218,29 @@ useKeyboardEvents(keyboardEvents);
|
||||
:is-open="showComposeNewConversation"
|
||||
:toggle="toggle"
|
||||
/>
|
||||
<div
|
||||
<ComposeNewConversationForm
|
||||
v-if="showComposeNewConversation"
|
||||
:class="{
|
||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
||||
isModal,
|
||||
}"
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<ComposeNewConversationForm
|
||||
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
:current-user="currentUser"
|
||||
:selected-contact="selectedContact"
|
||||
:target-inbox="targetInbox"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
:is-direct-uploads-enabled="directUploadsEnabled"
|
||||
:contact-conversations-ui-flags="uiFlags"
|
||||
:contacts-ui-flags="contactsUiFlags"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@search-contacts="onContactSearch"
|
||||
@reset-contact-search="resetContacts"
|
||||
@update-selected-contact="handleSelectedContact"
|
||||
@update-target-inbox="handleTargetInbox"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@create-conversation="createConversation"
|
||||
@discard="closeCompose"
|
||||
/>
|
||||
</div>
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
:current-user="currentUser"
|
||||
:selected-contact="selectedContact"
|
||||
:target-inbox="targetInbox"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
:is-direct-uploads-enabled="directUploadsEnabled"
|
||||
:contact-conversations-ui-flags="uiFlags"
|
||||
:contacts-ui-flags="contactsUiFlags"
|
||||
:class="composePopoverClass"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@search-contacts="onContactSearch"
|
||||
@reset-contact-search="resetContacts"
|
||||
@update-selected-contact="handleSelectedContact"
|
||||
@update-target-inbox="handleTargetInbox"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@create-conversation="createConversation"
|
||||
@discard="closeCompose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -232,20 +232,4 @@ useKeyboardEvents(keyboardEvents);
|
||||
.emoji-dialog::before {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
// The <label> tag inside the file-upload component overlaps the button due to its position.
|
||||
// This causes the button's hover state to not work, as it's positioned below the label (z-index).
|
||||
// Increasing the button's z-index would break the file upload functionality.
|
||||
// This style ensures the label remains clickable while preserving the button's hover effect.
|
||||
:deep() {
|
||||
.file-uploads.file-uploads-html5 {
|
||||
label {
|
||||
@apply hover:cursor-pointer;
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
@apply dark:bg-n-solid-2 bg-n-alpha-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
||||
class="w-[42rem] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
||||
>
|
||||
<ContactSelector
|
||||
:contacts="contacts"
|
||||
|
||||
+1
-3
@@ -113,8 +113,7 @@ const handleInput = value => {
|
||||
</div>
|
||||
<div
|
||||
v-else-if="selectedContact"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 min-h-7 min-w-0"
|
||||
:class="!contactId ? 'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1' : 'px-3'"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
|
||||
>
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{
|
||||
@@ -124,7 +123,6 @@ const handleInput = value => {
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
v-if="!contactId"
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
color="slate"
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ const targetInboxLabel = computed(() => {
|
||||
</label>
|
||||
<div
|
||||
v-if="targetInbox"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0"
|
||||
>
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{ targetInboxLabel }}
|
||||
|
||||
+2
-2
@@ -84,7 +84,7 @@ const handleSendMessage = template => {
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[350px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
@@ -96,7 +96,7 @@ const handleSendMessage = template => {
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
class="absolute top-full mt-1.5 max-h-[500px] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[460px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{
|
||||
|
||||
@@ -30,11 +30,11 @@ const bannerClass = computed(() => {
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
const classMap = {
|
||||
slate: 'bg-n-slate-4 hover:bg-n-slate-5 text-n-slate-11',
|
||||
amber: 'bg-n-amber-4 hover:bg-n-amber-5 text-n-amber-11',
|
||||
teal: 'bg-n-teal-4 hover:bg-n-teal-5 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-4 hover:bg-n-ruby-5 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-4 hover:bg-n-blue-5 text-n-blue-11',
|
||||
slate: 'bg-n-slate-4 text-n-slate-11',
|
||||
amber: 'bg-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-4 text-n-blue-11',
|
||||
};
|
||||
|
||||
return classMap[props.color];
|
||||
|
||||
@@ -100,57 +100,49 @@ const STYLE_CONFIG = {
|
||||
colors: {
|
||||
blue: {
|
||||
solid:
|
||||
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
|
||||
'bg-n-brand text-white hover:enabled:brightness-110 outline-transparent',
|
||||
faded:
|
||||
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
|
||||
outline: 'text-n-blue-text outline-n-brand',
|
||||
ghost:
|
||||
'text-n-blue-text hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-blue-text hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 outline-transparent',
|
||||
outline: 'text-n-blue-text outline-n-blue-border',
|
||||
ghost: 'text-n-blue-text hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-blue-text hover:enabled:underline outline-transparent',
|
||||
},
|
||||
ruby: {
|
||||
solid:
|
||||
'bg-n-ruby-9 text-white hover:enabled:bg-n-ruby-10 focus-visible:bg-n-ruby-10 outline-transparent',
|
||||
'bg-n-ruby-9 text-white hover:enabled:bg-n-ruby-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-ruby-9/10 text-n-ruby-11 hover:enabled:bg-n-ruby-9/20 focus-visible:bg-n-ruby-9/20 outline-transparent',
|
||||
outline:
|
||||
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
|
||||
ghost:
|
||||
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-ruby-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
'bg-n-ruby-9/10 text-n-ruby-11 hover:enabled:bg-n-ruby-9/20 outline-transparent',
|
||||
outline: 'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 outline-n-ruby-8',
|
||||
ghost: 'text-n-ruby-11 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-ruby-9 hover:enabled:underline outline-transparent',
|
||||
},
|
||||
amber: {
|
||||
solid:
|
||||
'bg-n-amber-9 text-white hover:enabled:bg-n-amber-10 focus-visible:bg-n-amber-10 outline-transparent',
|
||||
'bg-n-amber-9 text-white hover:enabled:bg-n-amber-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-amber-9/10 text-n-slate-12 hover:enabled:bg-n-amber-9/20 focus-visible:bg-n-amber-9/20 outline-transparent',
|
||||
'bg-n-amber-9/10 text-n-slate-12 hover:enabled:bg-n-amber-9/20 outline-transparent',
|
||||
outline:
|
||||
'text-n-amber-11 hover:enabled:bg-n-amber-9/10 focus-visible:bg-n-amber-9/10 outline-n-amber-9',
|
||||
link: 'text-n-amber-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
ghost:
|
||||
'text-n-amber-9 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
'text-n-amber-11 hover:enabled:bg-n-amber-9/10 outline-n-amber-9',
|
||||
link: 'text-n-amber-9 hover:enabled:underline outline-transparent',
|
||||
ghost: 'text-n-amber-9 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
slate: {
|
||||
solid:
|
||||
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
||||
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 hover:enabled:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
||||
faded:
|
||||
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent',
|
||||
outline:
|
||||
'text-n-slate-11 outline-n-strong hover:enabled:bg-n-slate-9/10 focus-visible:bg-n-slate-9/10',
|
||||
link: 'text-n-slate-11 hover:enabled:text-n-slate-12 focus-visible:text-n-slate-12 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
ghost:
|
||||
'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 outline-transparent',
|
||||
outline: 'text-n-slate-11 outline-n-strong hover:enabled:bg-n-slate-9/10',
|
||||
link: 'text-n-slate-11 hover:enabled:text-n-slate-12 hover:enabled:underline outline-transparent',
|
||||
ghost: 'text-n-slate-12 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
teal: {
|
||||
solid:
|
||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
||||
outline:
|
||||
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
||||
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
ghost:
|
||||
'text-n-teal-9 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 outline-transparent',
|
||||
outline: 'text-n-teal-11 hover:enabled:bg-n-teal-9/10 outline-n-teal-9',
|
||||
link: 'text-n-teal-9 hover:enabled:underline outline-transparent',
|
||||
ghost: 'text-n-teal-9 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
},
|
||||
sizes: {
|
||||
|
||||
@@ -72,18 +72,10 @@ const handlePageChange = event => {
|
||||
<div
|
||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||
>
|
||||
<div class="flex gap-4 items-center">
|
||||
<slot name="headerTitle">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
</slot>
|
||||
<div v-if="!isEmpty" class="flex items-center gap-2">
|
||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
||||
<slot name="knowMore" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
<slot name="headerTitle" />
|
||||
</span>
|
||||
<div
|
||||
v-if="!showPaywall"
|
||||
v-on-clickaway="() => emit('close')"
|
||||
|
||||
-9
@@ -2,7 +2,6 @@
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
@@ -13,14 +12,6 @@ const onClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-assistant"
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.SUBTITLE')"
|
||||
|
||||
-9
@@ -2,7 +2,6 @@
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
@@ -13,14 +12,6 @@ const onClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-document"
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.SUBTITLE')"
|
||||
|
||||
-9
@@ -2,7 +2,6 @@
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
@@ -13,14 +12,6 @@ const onClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-faq"
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
|
||||
|
||||
@@ -96,7 +96,7 @@ watch(
|
||||
:label="selectedLabel"
|
||||
trailing-icon
|
||||
:disabled="disabled"
|
||||
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 [&:not(.focused)]:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:outline-n-weak focus:outline-n-brand"
|
||||
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6"
|
||||
:class="{ focused: open }"
|
||||
:icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
@click="toggleDropdown"
|
||||
|
||||
@@ -69,7 +69,7 @@ defineExpose({
|
||||
:value="searchValue"
|
||||
type="search"
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
class="reset-base w-full py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
|
||||
class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
|
||||
@input="onInputSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -80,12 +80,10 @@ const maxWidthClass = computed(() => {
|
||||
const open = () => {
|
||||
dialogRef.value?.showModal();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
@@ -106,10 +104,9 @@ defineExpose({ open, close });
|
||||
@close="close"
|
||||
>
|
||||
<OnClickOutside @trigger="close">
|
||||
<form
|
||||
<div
|
||||
ref="dialogContentRef"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
@submit.prevent="confirm"
|
||||
@click.stop
|
||||
>
|
||||
<div v-if="title || description" class="flex flex-col gap-2">
|
||||
@@ -132,7 +129,6 @@ defineExpose({ open, close });
|
||||
color="slate"
|
||||
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
|
||||
class="w-full"
|
||||
type="button"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
@@ -142,11 +138,11 @@ defineExpose({ open, close });
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
:disabled="disableConfirmButton || isLoading"
|
||||
type="submit"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</slot>
|
||||
</form>
|
||||
</div>
|
||||
</OnClickOutside>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
|
||||
@@ -29,10 +29,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
labelClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
@@ -75,7 +71,7 @@ onMounted(() => {
|
||||
:placeholder="
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -101,13 +97,9 @@ onMounted(() => {
|
||||
</slot>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
|
||||
item.label
|
||||
}}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="filteredMenuItems.length === 0"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<script setup>
|
||||
import FeatureSpotlight from './FeatureSpotlight.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/FeatureSpotlight/Default"
|
||||
:layout="{ type: 'grid', width: '1000px' }"
|
||||
>
|
||||
<Variant title="Default with learn more URL">
|
||||
<div class="p-6 bg-n-background">
|
||||
<FeatureSpotlight
|
||||
title="Captain Assistant"
|
||||
note="Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
|
||||
video-url=""
|
||||
thumbnail=""
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://www.chatwoot.com/hc/user-guide/articles/1738101547-creating-an-assistant-with-captain"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Video URL and Thumbnail">
|
||||
<div class="p-6 bg-n-background">
|
||||
<FeatureSpotlight
|
||||
title="Captain Assistant"
|
||||
note="Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
|
||||
video-url="https://www.youtube.com/watch?v=E4xUHyAAktY"
|
||||
thumbnail="https://i.ytimg.com/an_webp/E4xUHyAAktY/mqdefault_6s.webp?du=3000&sqp=CJaKmL4G&rs=AOn4CLCmfy1TMOcW4UsjQTgyKRp4TSGZgg"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://www.chatwoot.com/hc/user-guide/articles/1738101547-creating-an-assistant-with-captain"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
note: { type: String, default: '' },
|
||||
videoUrl: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
fallbackThumbnail: { type: String, default: '' },
|
||||
fallbackThumbnailDark: { type: String, default: '' },
|
||||
learnMoreUrl: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const imageError = ref(false);
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true;
|
||||
};
|
||||
|
||||
const openLink = link => {
|
||||
if (link) {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="custom-dashed-border rounded-2xl py-5 px-6">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||
<div
|
||||
class="flex-shrink-0 bg-gray-800 w-[7.5rem] h-[6.5rem] rounded-lg flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
v-if="!imageError && thumbnail"
|
||||
:src="thumbnail"
|
||||
:alt="title"
|
||||
draggable="false"
|
||||
class="w-full h-full object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<img
|
||||
v-if="fallbackThumbnailDark"
|
||||
:src="fallbackThumbnailDark"
|
||||
:alt="title"
|
||||
draggable="false"
|
||||
class="w-full h-full object-cover hidden dark:block rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<img
|
||||
v-if="fallbackThumbnail"
|
||||
:src="fallbackThumbnail"
|
||||
:alt="title"
|
||||
draggable="false"
|
||||
class="w-full h-full object-cover block dark:hidden rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col flex-1 gap-3 ltr:pr-8 rtl:pl-8">
|
||||
<p v-if="note" class="text-n-slate-12 text-sm mb-0">{{ note }}</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<slot name="actions">
|
||||
<Button
|
||||
v-if="videoUrl"
|
||||
:label="$t('FEATURE_SPOTLIGHT.WATCH_VIDEO')"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-circle-play"
|
||||
@click="openLink(videoUrl)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="learnMoreUrl"
|
||||
:label="$t('FEATURE_SPOTLIGHT.LEARN_MORE')"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
trailing-icon
|
||||
icon="i-lucide-arrow-up-right"
|
||||
@click="openLink(learnMoreUrl)"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
<script setup>
|
||||
import FeatureSpotlightPopover from './FeatureSpotlightPopover.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/FeatureSpotlight/Popup"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Default with learn more URL">
|
||||
<div class="p-6 h-[450px] bg-n-background">
|
||||
<div class="flex gap-8">
|
||||
<FeatureSpotlightPopover
|
||||
button-label="Learn about Assistant"
|
||||
title="Captain Assistant"
|
||||
note="Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
|
||||
video-url=""
|
||||
thumbnail=""
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-popover-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-popover-dark.svg"
|
||||
learn-more-url="https://www.chatwoot.com/hc/user-guide/articles/1738101547-creating-an-assistant-with-captain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Video Thumbnail and URL">
|
||||
<div class="p-6 h-[450px] bg-n-background">
|
||||
<div class="flex gap-8">
|
||||
<FeatureSpotlightPopover
|
||||
button-label="Learn about Assistant"
|
||||
title="Captain Assistant"
|
||||
note="Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
|
||||
video-url="https://www.youtube.com/watch?v=E4xUHyAAktY"
|
||||
thumbnail="https://i.ytimg.com/an_webp/E4xUHyAAktY/mqdefault_6s.webp?du=3000&sqp=CJaKmL4G&rs=AOn4CLCmfy1TMOcW4UsjQTgyKRp4TSGZgg"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-popover-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-popover-dark.svg"
|
||||
learn-more-url="https://www.chatwoot.com/hc/user-guide/articles/1738101547-creating-an-assistant-with-captain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
buttonLabel: { type: String, default: '' },
|
||||
title: { type: String, default: '' },
|
||||
note: { type: String, default: '' },
|
||||
videoUrl: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
fallbackThumbnail: { type: String, default: '' },
|
||||
fallbackThumbnailDark: { type: String, default: '' },
|
||||
learnMoreUrl: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const imageError = ref(false);
|
||||
const [isPopupVisible, togglePopup] = useToggle();
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true;
|
||||
};
|
||||
|
||||
const openLink = link => {
|
||||
if (link) {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
id="togglePopup"
|
||||
:label="buttonLabel"
|
||||
slate
|
||||
ghost
|
||||
sm
|
||||
:class="{ 'bg-n-alpha-2': isPopupVisible }"
|
||||
@click="togglePopup(!isPopupVisible)"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isPopupVisible"
|
||||
v-on-click-outside="[
|
||||
() => isPopupVisible && (isPopupVisible = false),
|
||||
{ ignore: ['#togglePopup'] },
|
||||
]"
|
||||
>
|
||||
<section
|
||||
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80"
|
||||
>
|
||||
<div
|
||||
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"
|
||||
/>
|
||||
|
||||
<div class="relative flex flex-col items-start gap-4 z-20">
|
||||
<div class="flex-shrink-0 bg-gray-800 w-full h-[7.5rem] rounded-lg">
|
||||
<img
|
||||
v-if="!imageError && thumbnail"
|
||||
:src="thumbnail"
|
||||
:alt="title"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover rounded-lg"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<img
|
||||
v-if="fallbackThumbnailDark"
|
||||
:src="fallbackThumbnailDark"
|
||||
:alt="title"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover hidden dark:block"
|
||||
/>
|
||||
|
||||
<img
|
||||
v-if="fallbackThumbnail"
|
||||
:src="fallbackThumbnail"
|
||||
:alt="title"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover block dark:hidden"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="note" class="text-n-slate-12 text-start text-sm mb-0">
|
||||
{{ note }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3 justify-between w-full">
|
||||
<slot name="actions">
|
||||
<Button
|
||||
v-if="videoUrl"
|
||||
:label="$t('FEATURE_SPOTLIGHT.WATCH_VIDEO')"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-circle-play"
|
||||
class="w-full"
|
||||
@click="openLink(videoUrl)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="learnMoreUrl"
|
||||
:label="$t('FEATURE_SPOTLIGHT.LEARN_MORE')"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
trailing-icon
|
||||
class="w-full"
|
||||
icon="i-lucide-arrow-up-right"
|
||||
@click="openLink(learnMoreUrl)"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,10 +2,7 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useOperators } from './operators';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import {
|
||||
buildAttributesFilterTypes,
|
||||
CONTACT_ATTRIBUTES,
|
||||
} from './helper/filterHelper.js';
|
||||
import { buildAttributesFilterTypes } from './helper/filterHelper.js';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
|
||||
/**
|
||||
@@ -62,11 +59,7 @@ export function useContactFilterContext() {
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
const customFilterTypes = computed(() =>
|
||||
buildAttributesFilterTypes(
|
||||
contactAttributes.value,
|
||||
getOperatorTypes,
|
||||
'contact'
|
||||
)
|
||||
buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -74,8 +67,8 @@ export function useContactFilterContext() {
|
||||
*/
|
||||
const filterTypes = computed(() => [
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.NAME,
|
||||
value: CONTACT_ATTRIBUTES.NAME,
|
||||
attributeKey: 'name',
|
||||
value: 'name',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
||||
inputType: 'plainText',
|
||||
@@ -84,8 +77,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.EMAIL,
|
||||
value: CONTACT_ATTRIBUTES.EMAIL,
|
||||
attributeKey: 'email',
|
||||
value: 'email',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
||||
inputType: 'plainText',
|
||||
@@ -94,8 +87,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.PHONE_NUMBER,
|
||||
value: CONTACT_ATTRIBUTES.PHONE_NUMBER,
|
||||
attributeKey: 'phone_number',
|
||||
value: 'phone_number',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
||||
inputType: 'plainText',
|
||||
@@ -104,8 +97,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.IDENTIFIER,
|
||||
value: CONTACT_ATTRIBUTES.IDENTIFIER,
|
||||
attributeKey: 'identifier',
|
||||
value: 'identifier',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
||||
inputType: 'plainText',
|
||||
@@ -114,8 +107,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.COUNTRY_CODE,
|
||||
value: CONTACT_ATTRIBUTES.COUNTRY_CODE,
|
||||
attributeKey: 'country_code',
|
||||
value: 'country_code',
|
||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -125,8 +118,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.CITY,
|
||||
value: CONTACT_ATTRIBUTES.CITY,
|
||||
attributeKey: 'city',
|
||||
value: 'city',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
||||
inputType: 'plainText',
|
||||
@@ -135,8 +128,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
value: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
attributeKey: 'created_at',
|
||||
value: 'created_at',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
||||
inputType: 'date',
|
||||
@@ -145,8 +138,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
value: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
attributeKey: 'last_activity_at',
|
||||
value: 'last_activity_at',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
||||
inputType: 'date',
|
||||
@@ -155,8 +148,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.REFERER,
|
||||
value: CONTACT_ATTRIBUTES.REFERER,
|
||||
attributeKey: 'referer',
|
||||
value: 'referer',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
inputType: 'plainText',
|
||||
@@ -165,8 +158,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.BLOCKED,
|
||||
value: CONTACT_ATTRIBUTES.BLOCKED,
|
||||
attributeKey: 'blocked',
|
||||
value: 'blocked',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
||||
inputType: 'searchSelect',
|
||||
|
||||
@@ -1,35 +1,3 @@
|
||||
/**
|
||||
* Standard attributes of the conversation model
|
||||
*/
|
||||
export const CONVERSATION_ATTRIBUTES = {
|
||||
STATUS: 'status',
|
||||
PRIORITY: 'priority',
|
||||
ASSIGNEE_ID: 'assignee_id',
|
||||
INBOX_ID: 'inbox_id',
|
||||
TEAM_ID: 'team_id',
|
||||
DISPLAY_ID: 'display_id',
|
||||
CAMPAIGN_ID: 'campaign_id',
|
||||
LABELS: 'labels',
|
||||
BROWSER_LANGUAGE: 'browser_language',
|
||||
COUNTRY_CODE: 'country_code',
|
||||
REFERER: 'referer',
|
||||
CREATED_AT: 'created_at',
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
};
|
||||
|
||||
export const CONTACT_ATTRIBUTES = {
|
||||
NAME: 'name',
|
||||
EMAIL: 'email',
|
||||
PHONE_NUMBER: 'phone_number',
|
||||
IDENTIFIER: 'identifier',
|
||||
COUNTRY_CODE: 'country_code',
|
||||
CITY: 'city',
|
||||
CREATED_AT: 'created_at',
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
REFERER: 'referer',
|
||||
BLOCKED: 'blocked',
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the input type for a custom attribute based on its key
|
||||
* @param {string} key - The attribute display type key
|
||||
@@ -52,37 +20,24 @@ export const getCustomAttributeInputType = key => {
|
||||
|
||||
/**
|
||||
* Builds filter types for custom attributes
|
||||
* This also removes any conflicting attributes
|
||||
* @param {Array} attributes - The attributes array
|
||||
* @param {Function} getOperatorTypes - Function to get operator types
|
||||
* @returns {Array} Array of filter types
|
||||
*/
|
||||
export const buildAttributesFilterTypes = (
|
||||
attributes,
|
||||
getOperatorTypes,
|
||||
filterModel = 'conversation'
|
||||
) => {
|
||||
const standardAttributes = Object.values(
|
||||
filterModel === 'conversation'
|
||||
? CONVERSATION_ATTRIBUTES
|
||||
: CONTACT_ATTRIBUTES
|
||||
);
|
||||
|
||||
return attributes
|
||||
.filter(attr => !standardAttributes.includes(attr.attributeKey))
|
||||
.map(attr => ({
|
||||
attributeKey: attr.attributeKey,
|
||||
value: attr.attributeKey,
|
||||
attributeName: attr.attributeDisplayName,
|
||||
label: attr.attributeDisplayName,
|
||||
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
|
||||
filterOperators: getOperatorTypes(attr.attributeDisplayType),
|
||||
options:
|
||||
attr.attributeDisplayType === 'list'
|
||||
? attr.attributeValues.map(item => ({ id: item, name: item }))
|
||||
: [],
|
||||
attributeModel: 'customAttributes',
|
||||
}));
|
||||
export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => {
|
||||
return attributes.map(attr => ({
|
||||
attributeKey: attr.attributeKey,
|
||||
value: attr.attributeKey,
|
||||
attributeName: attr.attributeDisplayName,
|
||||
label: attr.attributeDisplayName,
|
||||
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
|
||||
filterOperators: getOperatorTypes(attr.attributeDisplayType),
|
||||
options:
|
||||
attr.attributeDisplayType === 'list'
|
||||
? attr.attributeValues.map(item => ({ id: item, name: item }))
|
||||
: [],
|
||||
attributeModel: 'customAttributes',
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,10 +3,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useOperators } from './operators';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useChannelIcon } from 'next/icon/provider';
|
||||
import {
|
||||
buildAttributesFilterTypes,
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
} from './helper/filterHelper';
|
||||
import { buildAttributesFilterTypes } from './helper/filterHelper';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
@@ -73,11 +70,7 @@ export function useConversationFilterContext() {
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
const customFilterTypes = computed(() =>
|
||||
buildAttributesFilterTypes(
|
||||
conversationAttributes.value,
|
||||
getOperatorTypes,
|
||||
'conversation'
|
||||
)
|
||||
buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -85,8 +78,8 @@ export function useConversationFilterContext() {
|
||||
*/
|
||||
const filterTypes = computed(() => [
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.STATUS,
|
||||
value: CONVERSATION_ATTRIBUTES.STATUS,
|
||||
attributeKey: 'status',
|
||||
value: 'status',
|
||||
attributeName: t('FILTER.ATTRIBUTES.STATUS'),
|
||||
label: t('FILTER.ATTRIBUTES.STATUS'),
|
||||
inputType: 'multiSelect',
|
||||
@@ -101,24 +94,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.PRIORITY,
|
||||
value: CONVERSATION_ATTRIBUTES.PRIORITY,
|
||||
attributeName: t('FILTER.ATTRIBUTES.PRIORITY'),
|
||||
label: t('FILTER.ATTRIBUTES.PRIORITY'),
|
||||
inputType: 'multiSelect',
|
||||
options: ['low', 'medium', 'high', 'urgent'].map(id => {
|
||||
return {
|
||||
id,
|
||||
name: t(`CONVERSATION.PRIORITY.OPTIONS.${id.toUpperCase()}`),
|
||||
};
|
||||
}),
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.ASSIGNEE_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.ASSIGNEE_ID,
|
||||
attributeKey: 'assignee_id',
|
||||
value: 'assignee_id',
|
||||
attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -133,8 +110,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.INBOX_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.INBOX_ID,
|
||||
attributeKey: 'inbox_id',
|
||||
value: 'inbox_id',
|
||||
attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.INBOX_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -149,8 +126,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.TEAM_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.TEAM_ID,
|
||||
attributeKey: 'team_id',
|
||||
value: 'team_id',
|
||||
attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -160,8 +137,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
attributeKey: 'display_id',
|
||||
value: 'display_id',
|
||||
attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
|
||||
label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
|
||||
inputType: 'plainText',
|
||||
@@ -170,8 +147,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
|
||||
attributeKey: 'campaign_id',
|
||||
value: 'campaign_id',
|
||||
attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -184,8 +161,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.LABELS,
|
||||
value: CONVERSATION_ATTRIBUTES.LABELS,
|
||||
attributeKey: 'labels',
|
||||
value: 'labels',
|
||||
attributeName: t('FILTER.ATTRIBUTES.LABELS'),
|
||||
label: t('FILTER.ATTRIBUTES.LABELS'),
|
||||
inputType: 'multiSelect',
|
||||
@@ -208,8 +185,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
|
||||
value: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
|
||||
attributeKey: 'browser_language',
|
||||
value: 'browser_language',
|
||||
attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
|
||||
label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -219,8 +196,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||
attributeKey: 'country_code',
|
||||
value: 'country_code',
|
||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -230,8 +207,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
|
||||
value: CONVERSATION_ATTRIBUTES.REFERER,
|
||||
attributeKey: 'referer',
|
||||
value: 'referer',
|
||||
attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'),
|
||||
label: t('FILTER.ATTRIBUTES.REFERER_LINK'),
|
||||
inputType: 'plainText',
|
||||
@@ -240,8 +217,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.CREATED_AT,
|
||||
value: CONVERSATION_ATTRIBUTES.CREATED_AT,
|
||||
attributeKey: 'created_at',
|
||||
value: 'created_at',
|
||||
attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'),
|
||||
label: t('FILTER.ATTRIBUTES.CREATED_AT'),
|
||||
inputType: 'date',
|
||||
@@ -250,8 +227,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
value: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
attributeKey: 'last_activity_at',
|
||||
value: 'last_activity_at',
|
||||
attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
|
||||
label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
|
||||
inputType: 'date',
|
||||
|
||||
@@ -83,7 +83,7 @@ defineExpose({
|
||||
v-if="label"
|
||||
:for="id"
|
||||
:class="customLabelClass"
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
@@ -97,7 +97,7 @@ defineExpose({
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="customInputClass"
|
||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
@@ -70,12 +70,12 @@ const messageClass = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const inputOutlineClass = computed(() => {
|
||||
const inputBorderClass = computed(() => {
|
||||
switch (props.messageType) {
|
||||
case 'error':
|
||||
return 'outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8';
|
||||
return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
|
||||
default:
|
||||
return 'outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 disabled:outline-n-weak dark:disabled:outline-n-weak focus:outline-n-brand dark:focus:outline-n-brand';
|
||||
return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak focus:border-n-brand dark:focus:border-n-brand';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -124,7 +124,7 @@ onMounted(() => {
|
||||
:value="modelValue"
|
||||
:class="[
|
||||
customInputClass,
|
||||
inputOutlineClass,
|
||||
inputBorderClass,
|
||||
{
|
||||
error: messageType === 'error',
|
||||
focus: isFocused,
|
||||
@@ -134,7 +134,7 @@ onMounted(() => {
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
|
||||
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
@@ -26,7 +26,7 @@ const { t } = useI18n();
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bg-n-alpha-3 px-4 py-3 border rounded-xl border-n-strong text-n-slate-12 bottom-6 w-52 text-xs backdrop-blur-[100px] shadow-[0px_0px_24px_0px_rgba(0,0,0,0.12)] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all break-all"
|
||||
class="absolute bg-n-alpha-3 px-4 py-3 border rounded-xl border-n-strong text-n-slate-12 bottom-6 w-52 text-xs backdrop-blur-[100px] shadow-[0px_0px_24px_0px_rgba(0,0,0,0.12)] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all"
|
||||
:class="{
|
||||
'ltr:left-0 rtl:right-0': orientation === ORIENTATION.LEFT,
|
||||
'ltr:right-0 rtl:left-0': orientation === ORIENTATION.RIGHT,
|
||||
|
||||
@@ -49,7 +49,7 @@ const isSent = computed(() => {
|
||||
isASmsInbox.value ||
|
||||
isATelegramChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
return status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
|
||||
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||
@@ -67,7 +67,7 @@ const isDelivered = computed(() => {
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
return status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
@@ -88,7 +88,7 @@ const isRead = computed(() => {
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||
return status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
@@ -102,7 +102,6 @@ const statusToShow = computed(() => {
|
||||
if (isRead.value) return MESSAGE_STATUS.READ;
|
||||
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
|
||||
if (isSent.value) return MESSAGE_STATUS.SENT;
|
||||
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
|
||||
|
||||
return MESSAGE_STATUS.PROGRESS;
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ const hasQuotedMessage = computed(() => {
|
||||
<template v-else>
|
||||
<Letter
|
||||
v-if="showQuotedMessage"
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
class-name="prose prose-bubble !max-w-none"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
'transform',
|
||||
@@ -104,7 +104,7 @@ const hasQuotedMessage = computed(() => {
|
||||
/>
|
||||
<Letter
|
||||
v-else
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
class-name="prose prose-bubble !max-w-none"
|
||||
:html="unquotedHTML"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
@@ -143,21 +143,3 @@ const hasQuotedMessage = computed(() => {
|
||||
</section>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
// Tailwind resets break the rendering of google drive link in Gmail messages
|
||||
// This fixes it using https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors
|
||||
|
||||
.letter-render [class*='gmail_drive_chip'] {
|
||||
box-sizing: initial;
|
||||
@apply bg-n-slate-4 border-n-slate-6 rounded-md !important;
|
||||
|
||||
a {
|
||||
@apply text-n-slate-12 !important;
|
||||
|
||||
img {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from './FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
@@ -9,26 +9,6 @@ import { useMessageContext } from '../../provider.js';
|
||||
const { content, attachments, contentAttributes, messageType } =
|
||||
useMessageContext();
|
||||
|
||||
const hasTranslations = computed(() => {
|
||||
const { translations = {} } = contentAttributes.value;
|
||||
return Object.keys(translations || {}).length > 0;
|
||||
});
|
||||
|
||||
const renderOriginal = ref(false);
|
||||
|
||||
const renderContent = computed(() => {
|
||||
if (renderOriginal.value) {
|
||||
return content.value;
|
||||
}
|
||||
|
||||
if (hasTranslations.value) {
|
||||
const translations = contentAttributes.value.translations;
|
||||
return translations[Object.keys(translations)[0]];
|
||||
}
|
||||
|
||||
return content.value;
|
||||
});
|
||||
|
||||
const isTemplate = computed(() => {
|
||||
return messageType.value === MESSAGE_TYPES.TEMPLATE;
|
||||
});
|
||||
@@ -36,16 +16,6 @@ const isTemplate = computed(() => {
|
||||
const isEmpty = computed(() => {
|
||||
return !content.value && !attachments.value?.length;
|
||||
});
|
||||
|
||||
const viewToggleKey = computed(() => {
|
||||
return renderOriginal.value
|
||||
? 'CONVERSATION.VIEW_TRANSLATED'
|
||||
: 'CONVERSATION.VIEW_ORIGINAL';
|
||||
});
|
||||
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -54,16 +24,7 @@ const handleSeeOriginal = () => {
|
||||
<span v-if="isEmpty" class="text-n-slate-11">
|
||||
{{ $t('CONVERSATION.NO_CONTENT') }}
|
||||
</span>
|
||||
<FormattedContent v-if="renderContent" :content="renderContent" />
|
||||
<span class="-mt-3">
|
||||
<span
|
||||
v-if="hasTranslations"
|
||||
class="text-xs text-n-slate-11 cursor-pointer hover:underline"
|
||||
@click="handleSeeOriginal"
|
||||
>
|
||||
{{ $t(viewToggleKey) }}
|
||||
</span>
|
||||
</span>
|
||||
<FormattedContent v-if="content" :content="content" />
|
||||
<AttachmentChips :attachments="attachments" class="gap-2" />
|
||||
<template v-if="isTemplate">
|
||||
<div
|
||||
|
||||
@@ -90,19 +90,19 @@ const activeCountry = computed(() =>
|
||||
|
||||
const inputBorderClass = computed(() => {
|
||||
const errorClass =
|
||||
'outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8';
|
||||
'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
|
||||
const focusClass =
|
||||
'has-[:focus]:outline-n-brand dark:has-[:focus]:outline-n-brand';
|
||||
'has-[:focus]:border-n-brand dark:has-[:focus]:border-n-brand';
|
||||
|
||||
if (!props.showBorder) {
|
||||
if (hasError.value) return errorClass;
|
||||
return `outline-transparent ${focusClass}`;
|
||||
return `border-transparent ${focusClass}`;
|
||||
}
|
||||
|
||||
if (hasError.value) {
|
||||
return errorClass;
|
||||
}
|
||||
return `${focusClass} outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 disabled:outline-n-weak dark:disabled:outline-n-weak`;
|
||||
return `${focusClass} border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak`;
|
||||
});
|
||||
|
||||
const phoneNumberError = computed(() => {
|
||||
@@ -163,7 +163,7 @@ watch(
|
||||
<div>
|
||||
<div
|
||||
v-on-clickaway="() => closeCountryDropdown()"
|
||||
class="relative flex items-center h-8 transition-all duration-500 ease-in-out outline outline-1 outline-offset-[-1px] rounded-lg bg-n-alpha-black2"
|
||||
class="relative flex items-center h-8 transition-all duration-500 ease-in-out border rounded-lg bg-n-alpha-black2"
|
||||
:class="[inputBorderClass, { 'cursor-not-allowed opacity-50': disabled }]"
|
||||
>
|
||||
<Input
|
||||
@@ -171,7 +171,7 @@ watch(
|
||||
type="tel"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
custom-input-class="!border-0 !outline-none h-8 !py-0.5 !bg-transparent ltr:!pl-1 rtl:!pr-1"
|
||||
custom-input-class="!border-0 h-8 !py-0.5 !bg-transparent ltr:!pl-1 rtl:!pr-1"
|
||||
class="w-full !flex-row"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -185,7 +185,7 @@ watch(
|
||||
"
|
||||
trailing-icon
|
||||
:disabled="disabled"
|
||||
class="!h-[1.875rem] top-1 ltr:ml-px rtl:mr-px !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
class="!h-[1.875rem] top-1 !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="toggleCountryDropdown"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -90,7 +90,7 @@ const sortedInboxes = computed(() =>
|
||||
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
const newReportRoutes = () => [
|
||||
const newReportRoutes = [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
@@ -116,7 +116,7 @@ const newReportRoutes = () => [
|
||||
},
|
||||
];
|
||||
|
||||
const oldReportRoutes = () => [
|
||||
const oldReportRoutes = [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
@@ -140,7 +140,7 @@ const oldReportRoutes = () => [
|
||||
];
|
||||
|
||||
const reportRoutes = computed(() =>
|
||||
showV4Routes.value ? newReportRoutes() : oldReportRoutes()
|
||||
showV4Routes.value ? newReportRoutes : oldReportRoutes
|
||||
);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
|
||||
@@ -179,7 +179,7 @@ onMounted(() => {
|
||||
}"
|
||||
:disabled="disabled"
|
||||
rows="1"
|
||||
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !outline-0 !mb-0 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
|
||||
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
@@ -28,8 +28,8 @@ export default {
|
||||
'border-b border-solid border-n-weak/60 dark:border-n-weak': showBorder,
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-8 gap-6">
|
||||
<div class="col-span-2">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-6 gap-6">
|
||||
<div class="col-span-2 xl:col-span-1">
|
||||
<p
|
||||
v-if="title"
|
||||
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
|
||||
@@ -48,7 +48,7 @@ export default {
|
||||
{{ note }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-6 xl:col-span-5">
|
||||
<div class="col-span-4 lg:col-span-4 2xl:col-span-3">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
defaultPageSize: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
showPageSizeSelector: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pageSizeChange']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const pageSizeOptions = [
|
||||
{
|
||||
label: `${t('REPORT.PAGINATION.PER_PAGE_TEMPLATE', { size: 10 })}`,
|
||||
value: 10,
|
||||
},
|
||||
{
|
||||
label: `${t('REPORT.PAGINATION.PER_PAGE_TEMPLATE', { size: 20 })}`,
|
||||
value: 20,
|
||||
},
|
||||
{
|
||||
label: `${t('REPORT.PAGINATION.PER_PAGE_TEMPLATE', { size: 50 })}`,
|
||||
value: 50,
|
||||
},
|
||||
{
|
||||
label: `${t('REPORT.PAGINATION.PER_PAGE_TEMPLATE', { size: 100 })}`,
|
||||
value: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const getFormattedPages = (start, end) => {
|
||||
const formatter = new Intl.NumberFormat(navigator.language);
|
||||
return Array.from({ length: end - start + 1 }, (_, i) =>
|
||||
@@ -83,98 +48,70 @@ const end = computed(() => {
|
||||
total.value
|
||||
);
|
||||
});
|
||||
|
||||
const currentPageSize = computed({
|
||||
get() {
|
||||
return props.table.getState().pagination.pageSize;
|
||||
},
|
||||
set(newValue) {
|
||||
props.table.setPageSize(Number(newValue));
|
||||
emit('pageSizeChange', Number(newValue));
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
props.showPageSizeSelector &&
|
||||
props.defaultPageSize &&
|
||||
props.defaultPageSize !== 10
|
||||
) {
|
||||
props.table.setPageSize(props.defaultPageSize);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-1 items-center gap-2 justify-between">
|
||||
<p class="text-sm truncate text-n-slate-11 mb-0">
|
||||
{{ $t('REPORT.PAGINATION.RESULTS', { start, end, total }) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<FilterSelect
|
||||
v-if="showPageSizeSelector"
|
||||
v-model="currentPageSize"
|
||||
variant="outline"
|
||||
hide-icon
|
||||
class="[&>button]:text-n-slate-11 [&>button]:hover:text-n-slate-12 [&>button]:h-6"
|
||||
:options="pageSizeOptions"
|
||||
/>
|
||||
<nav class="isolate inline-flex items-center gap-1.5">
|
||||
<Button
|
||||
icon="i-lucide-chevrons-left"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!size-6"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.setPageIndex(0)"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-chevron-left"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!size-6"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
/>
|
||||
<Button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
xs
|
||||
outline
|
||||
:color="page == currentPage ? 'blue' : 'slate'"
|
||||
class="!h-6 min-w-6"
|
||||
@click="table.setPageIndex(page - 1)"
|
||||
>
|
||||
<span
|
||||
class="text-center"
|
||||
:class="{ 'text-n-brand': page == currentPage }"
|
||||
>
|
||||
{{ page }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
icon="i-lucide-chevron-right"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!size-6"
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.nextPage()"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-chevrons-right"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!size-6"
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||
/>
|
||||
</nav>
|
||||
<div class="flex flex-1 items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-n-slate-11 mb-0">
|
||||
{{ $t('REPORT.PAGINATION.RESULTS', { start, end, total }) }}
|
||||
</p>
|
||||
</div>
|
||||
<nav class="isolate inline-flex gap-1">
|
||||
<woot-button
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
variant="clear"
|
||||
class="h-8 border-0 flex items-center"
|
||||
color-scheme="secondary"
|
||||
@click="table.setPageIndex(0)"
|
||||
>
|
||||
<span class="i-lucide-chevrons-left size-3" aria-hidden="true" />
|
||||
</woot-button>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
class="h-8 border-0 flex items-center"
|
||||
color-scheme="secondary"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
>
|
||||
<span class="i-lucide-chevron-left size-3" aria-hidden="true" />
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
variant="clear"
|
||||
class="h-8 flex items-center justify-center text-xs leading-none text-center"
|
||||
:class="page == currentPage ? 'border-n-brand' : 'border-slate-50'"
|
||||
color-scheme="secondary"
|
||||
@click="table.setPageIndex(page - 1)"
|
||||
>
|
||||
<span
|
||||
class="text-center"
|
||||
:class="{ 'text-n-brand': page == currentPage }"
|
||||
>
|
||||
{{ page }}
|
||||
</span>
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:disabled="!table.getCanNextPage()"
|
||||
variant="clear"
|
||||
class="h-8 border-0 flex items-center"
|
||||
color-scheme="secondary"
|
||||
@click="table.nextPage()"
|
||||
>
|
||||
<span class="i-lucide-chevron-right size-3" aria-hidden="true" />
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:disabled="!table.getCanNextPage()"
|
||||
variant="clear"
|
||||
class="h-8 border-0 flex items-center"
|
||||
color-scheme="secondary"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||
>
|
||||
<span class="i-lucide-chevrons-right size-3" aria-hidden="true" />
|
||||
</woot-button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -68,7 +68,7 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.wizard-box {
|
||||
.item {
|
||||
@apply cursor-pointer after:bg-slate-75 before:bg-slate-75 dark:after:bg-slate-600 dark:before:bg-slate-600 py-4 ltr:pr-4 rtl:pl-4 ltr:pl-6 rtl:pr-6 relative before:h-4 before:top-0 last:before:h-0 first:before:h-0 last:after:h-0 before:content-[''] before:absolute before:w-0.5 after:content-[''] after:h-full after:absolute after:top-5 after:w-0.5 rtl:after:left-6 rtl:before:left-6;
|
||||
@apply cursor-pointer after:bg-slate-75 before:bg-slate-75 dark:after:bg-slate-600 dark:before:bg-slate-600 py-4 pr-4 pl-6 relative before:h-4 before:top-0 last:before:h-0 first:before:h-0 last:after:h-0 before:content-[''] before:absolute before:w-0.5 after:content-[''] after:h-full after:absolute after:top-5 after:w-0.5;
|
||||
|
||||
&.active {
|
||||
h3 {
|
||||
@@ -97,7 +97,7 @@ export default {
|
||||
}
|
||||
|
||||
.step {
|
||||
@apply bg-slate-75 dark:bg-slate-600 rounded-2xl font-medium w-4 left-4 leading-4 z-10 absolute text-center text-white dark:text-white text-xxs top-5;
|
||||
@apply bg-slate-75 dark:bg-slate-600 rounded-2xl font-medium w-4 left-4 leading-4 z-[999] absolute text-center text-white dark:text-white text-xxs top-5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,6 @@ export default {
|
||||
icon="dismiss"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
class="flex-shrink-0"
|
||||
@click="removeAction"
|
||||
/>
|
||||
</div>
|
||||
@@ -202,10 +201,10 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter {
|
||||
@apply bg-n-background p-2 border border-solid border-n-strong dark:border-n-strong rounded-lg mb-2;
|
||||
@apply bg-n-slate-3 dark:bg-n-solid-3 p-2 border border-solid border-n-strong dark:border-n-strong rounded-md mb-2;
|
||||
|
||||
&.is-a-macro {
|
||||
@apply mb-0 bg-n-background dark:bg-n-solid-1 p-0 border-0 rounded-none;
|
||||
@apply mb-0 bg-n-slate-2 dark:bg-n-solid-3 p-0 border-0 rounded-none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,19 +213,15 @@ export default {
|
||||
}
|
||||
|
||||
.filter.has-error {
|
||||
@apply bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5;
|
||||
|
||||
&.is-a-macro {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
@apply bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50;
|
||||
}
|
||||
|
||||
.filter-inputs {
|
||||
@apply flex gap-1;
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.filter-error {
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
|
||||
@apply text-red-500 dark:text-red-200 block my-1 mx-0;
|
||||
}
|
||||
|
||||
.action__question,
|
||||
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="multiselect-wrap--small flex flex-col gap-1 mt-1">
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedTeams"
|
||||
track-by="id"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import router from '../../routes/index';
|
||||
const props = defineProps({
|
||||
backUrl: {
|
||||
@@ -23,16 +24,24 @@ const goBack = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const buttonStyleClass = props.compact ? 'text-sm' : 'text-base';
|
||||
const buttonStyleClass = props.compact
|
||||
? 'text-sm text-n-slate-11'
|
||||
: 'text-base text-n-blue-text';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center p-0 font-normal cursor-pointer text-n-slate-11"
|
||||
class="flex items-center p-0 font-normal cursor-pointer gap-1"
|
||||
:class="buttonStyleClass"
|
||||
@click.capture="goBack"
|
||||
>
|
||||
<fluent-icon icon="chevron-left" class="-ml-1" />
|
||||
<Icon
|
||||
icon="i-lucide-chevron-left"
|
||||
class="ltr:-ml-1 rtl:-mr-1"
|
||||
:class="
|
||||
props.compact ? 'text-n-slate-11 size-4' : 'text-n-blue-text size-5'
|
||||
"
|
||||
/>
|
||||
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -11,13 +11,13 @@ export default {
|
||||
<div class="empty-state py-16 px-1 ml-0 mr-0">
|
||||
<h3
|
||||
v-if="title"
|
||||
class="text-n-slate-12 block text-center w-full text-xl font-medium"
|
||||
class="text-slate-700 dark:text-slate-200 block text-center w-full text-xl font-thin"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="message"
|
||||
class="block text-center text-n-slate-11 dark:text-slate-400 my-4 mx-auto w-[90%]"
|
||||
class="block text-center text-slate-500 dark:text-slate-400 my-4 mx-auto w-[90%]"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
@@ -127,8 +127,8 @@ export default {
|
||||
},
|
||||
getInputErrorClass(errorMessage) {
|
||||
return errorMessage
|
||||
? 'bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5'
|
||||
: 'bg-n-background border-n-weak dark:border-n-weak';
|
||||
? 'bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50'
|
||||
: 'bg-n-slate-3 dark:bg-n-solid-3 border-n-strong dark:border-n-strong';
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -138,14 +138,14 @@ export default {
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="p-2 border border-solid rounded-lg"
|
||||
class="p-2 border border-solid rounded-md"
|
||||
:class="getInputErrorClass(errorMessage)"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<div class="flex">
|
||||
<select
|
||||
v-if="groupedFilters"
|
||||
v-model="attributeKey"
|
||||
class="max-w-[30%] mb-0 mr-1"
|
||||
class="bg-white max-w-[30%] dark:bg-slate-900 mb-0 mr-1 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
|
||||
@change="resetFilter()"
|
||||
>
|
||||
<optgroup
|
||||
@@ -166,7 +166,7 @@ export default {
|
||||
<select
|
||||
v-else
|
||||
v-model="attributeKey"
|
||||
class="max-w-[30%] mb-0 mr-1"
|
||||
class="bg-white max-w-[30%] dark:bg-slate-900 mb-0 mr-1 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
|
||||
@change="resetFilter()"
|
||||
>
|
||||
<option
|
||||
@@ -179,7 +179,10 @@ export default {
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-model="filterOperator" class="max-w-[20%] mb-0 mr-1">
|
||||
<select
|
||||
v-model="filterOperator"
|
||||
class="bg-white dark:bg-slate-900 max-w-[20%] mb-0 mr-1 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
|
||||
>
|
||||
<option
|
||||
v-for="(operator, o) in operators"
|
||||
:key="o"
|
||||
@@ -231,14 +234,14 @@ export default {
|
||||
v-model="values"
|
||||
type="date"
|
||||
:editable="false"
|
||||
class="!mb-0 datepicker"
|
||||
class="mb-0 datepicker"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
v-model="values"
|
||||
type="text"
|
||||
class="!mb-0"
|
||||
class="mb-0"
|
||||
:placeholder="$t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
@@ -284,7 +287,7 @@ export default {
|
||||
}
|
||||
|
||||
.filter-error {
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
|
||||
@apply text-red-500 dark:text-red-200 block my-1 mx-0;
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<script>
|
||||
import {
|
||||
getInboxClassByType,
|
||||
getReadableInboxByType,
|
||||
} from 'dashboard/helper/inbox';
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inboxIdentifier: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
channelType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
computedInboxIcon() {
|
||||
if (!this.channelType) return 'chat';
|
||||
const classByType = getInboxClassByType(
|
||||
this.channelType,
|
||||
this.inboxIdentifier
|
||||
);
|
||||
return classByType;
|
||||
},
|
||||
computedInboxType() {
|
||||
if (!this.channelType) return 'chat';
|
||||
const classByType = getReadableInboxByType(
|
||||
this.channelType,
|
||||
this.inboxIdentifier
|
||||
);
|
||||
return classByType;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center h-[2.375rem] min-w-0 py-0 px-1">
|
||||
<span
|
||||
class="inline-flex rounded mr-1 rtl:ml-1 rtl:mr-0 bg-slate-25 dark:bg-slate-700 p-0.5 items-center flex-shrink-0 justify-center w-6 h-6"
|
||||
>
|
||||
<fluent-icon
|
||||
:icon="computedInboxIcon"
|
||||
size="14"
|
||||
class="text-slate-800 dark:text-slate-200"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex flex-col w-full min-w-0 ml-1 mr-1">
|
||||
<h5 class="option__title">
|
||||
{{ name }}
|
||||
</h5>
|
||||
<p
|
||||
class="option__body overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
:title="inboxIdentifier"
|
||||
>
|
||||
{{ inboxIdentifier || computedInboxType }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.option__body {
|
||||
@apply inline-block text-slate-600 dark:text-slate-200 leading-[1.3] min-w-0 m-0;
|
||||
}
|
||||
.option__title {
|
||||
@apply leading-[1.1] text-xs m-0 text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
|
||||
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
|
||||
|
||||
const props = defineProps({
|
||||
allLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
savedLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add', 'remove']);
|
||||
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const showSearchDropdownLabel = ref(false);
|
||||
|
||||
const selectedLabels = computed(() => {
|
||||
return props.savedLabels.map(label => label.title);
|
||||
});
|
||||
|
||||
const addItem = label => {
|
||||
emit('add', label);
|
||||
};
|
||||
|
||||
const removeItem = label => {
|
||||
emit('remove', label);
|
||||
};
|
||||
|
||||
const toggleLabels = () => {
|
||||
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
|
||||
};
|
||||
|
||||
const closeDropdownLabel = () => {
|
||||
showSearchDropdownLabel.value = false;
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
KeyL: {
|
||||
action: e => {
|
||||
toggleLabels();
|
||||
e.preventDefault();
|
||||
},
|
||||
},
|
||||
Escape: {
|
||||
action: () => closeDropdownLabel(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeDropdownLabel" class="relative leading-6">
|
||||
<AddLabel @add="toggleLabels" />
|
||||
<woot-label
|
||||
v-for="label in savedLabels"
|
||||
:key="label.id"
|
||||
:title="label.title"
|
||||
:description="label.description"
|
||||
show-close
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
@remove="removeItem"
|
||||
/>
|
||||
<div class="absolute w-full top-7">
|
||||
<div
|
||||
:class="{ 'dropdown-pane--open': showSearchDropdownLabel }"
|
||||
class="!box-border !w-full dropdown-pane"
|
||||
>
|
||||
<LabelDropdown
|
||||
v-if="showSearchDropdownLabel"
|
||||
:account-labels="allLabels"
|
||||
:selected-labels="selectedLabels"
|
||||
:allow-creation="isAdmin"
|
||||
@add="addItem"
|
||||
@remove="removeItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,18 +14,16 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-solid border-n-weak/60">
|
||||
<div class="max-w-6xl w-full mx-auto pt-4 pb-0 px-8">
|
||||
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="headerContent"
|
||||
class="w-full text-slate-600 dark:text-slate-300 text-sm mb-2"
|
||||
>
|
||||
{{ headerContent }}
|
||||
</p>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="pt-4 pb-0 px-8 border-b border-solid border-n-weak/60">
|
||||
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="headerContent"
|
||||
class="w-full text-slate-600 dark:text-slate-300 text-sm mb-2"
|
||||
>
|
||||
{{ headerContent }}
|
||||
</p>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -783,15 +783,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
|
||||
.is-private {
|
||||
.prosemirror-mention-node {
|
||||
@apply font-medium bg-n-amber-2/80 dark:bg-n-amber-2/80 text-n-slate-12 py-0 px-1;
|
||||
@apply font-medium bg-n-amber-2/80 dark:bg-n-amber-2/80 text-slate-900 dark:text-slate-25 py-0 px-1;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
> .ProseMirror {
|
||||
@apply text-n-slate-12;
|
||||
@apply text-slate-800 dark:text-slate-25;
|
||||
|
||||
p {
|
||||
@apply text-n-slate-12;
|
||||
@apply text-slate-800 dark:text-slate-25;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -802,11 +802,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
}
|
||||
|
||||
.message-editor {
|
||||
@apply rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 bg-n-alpha-black2 py-0 px-1 mb-0;
|
||||
@apply border border-solid border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-900 rounded-md py-0 px-1 mb-0;
|
||||
}
|
||||
|
||||
.editor_warning {
|
||||
@apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9;
|
||||
@apply border border-solid border-red-400 dark:border-red-400;
|
||||
}
|
||||
|
||||
.editor-warning__message {
|
||||
|
||||
@@ -1253,7 +1253,7 @@ export default {
|
||||
@apply relative py-0 px-4 -mt-px;
|
||||
|
||||
textarea {
|
||||
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
|
||||
@apply shadow-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="v$.toEmailsVal.$model"
|
||||
type="text"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
|
||||
:class="{ error: v$.toEmailsVal.$error }"
|
||||
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
|
||||
@blur="onBlur"
|
||||
@@ -113,7 +113,7 @@ export default {
|
||||
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
|
||||
<woot-input
|
||||
v-model="v$.ccEmailsVal.$model"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
|
||||
type="text"
|
||||
:class="{ error: v$.ccEmailsVal.$error }"
|
||||
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
|
||||
@@ -142,7 +142,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="v$.bccEmailsVal.$model"
|
||||
type="text"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
|
||||
:class="{ error: v$.bccEmailsVal.$error }"
|
||||
:placeholder="
|
||||
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formatDate = dateString => {
|
||||
return format(new Date(dateString), 'MMM d, yyyy');
|
||||
};
|
||||
|
||||
const formatCurrency = (amount, currency) => {
|
||||
return new Intl.NumberFormat('en', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getStatusClass = status => {
|
||||
const classes = {
|
||||
paid: 'bg-n-teal-5 text-n-teal-12',
|
||||
};
|
||||
return classes[status] || 'bg-slate-50 text-slate-700';
|
||||
};
|
||||
|
||||
const getStatusI18nKey = (type, status = '') => {
|
||||
return `CONVERSATION_SIDEBAR.SHOPIFY.${type.toUpperCase()}_STATUS.${status.toUpperCase()}`;
|
||||
};
|
||||
|
||||
const fulfillmentStatus = computed(() => {
|
||||
const { fulfillment_status: status } = props.order;
|
||||
if (!status) {
|
||||
return '';
|
||||
}
|
||||
return t(getStatusI18nKey('FULFILLMENT', status));
|
||||
});
|
||||
|
||||
const financialStatus = computed(() => {
|
||||
const { financial_status: status } = props.order;
|
||||
if (!status) {
|
||||
return '';
|
||||
}
|
||||
return t(getStatusI18nKey('FINANCIAL', status));
|
||||
});
|
||||
|
||||
const getFulfillmentClass = status => {
|
||||
const classes = {
|
||||
fulfilled: 'text-green-600',
|
||||
partial: 'text-yellow-600',
|
||||
unfulfilled: 'text-red-600',
|
||||
};
|
||||
return classes[status] || 'text-slate-600';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="py-3 border-b border-n-weak last:border-b-0 flex flex-col gap-1.5"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="font-medium flex">
|
||||
<a
|
||||
:href="order.admin_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:underline text-n-slate-12 cursor-pointer truncate"
|
||||
>
|
||||
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.ORDER_ID', { id: order.id }) }}
|
||||
<i class="i-lucide-external-link pl-5" />
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
:class="getStatusClass(order.financial_status)"
|
||||
class="text-xs px-2 py-1 rounded capitalize truncate"
|
||||
:title="financialStatus"
|
||||
>
|
||||
{{ financialStatus }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-n-slate-12">
|
||||
<span class="text-n-slate-11 border-r border-n-weak pr-2">
|
||||
{{ formatDate(order.created_at) }}
|
||||
</span>
|
||||
<span class="text-n-slate-11 pl-2">
|
||||
{{ formatCurrency(order.total_price, order.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="fulfillmentStatus">
|
||||
<span
|
||||
:class="getFulfillmentClass(order.fulfillment_status)"
|
||||
class="capitalize font-medium"
|
||||
:title="fulfillmentStatus"
|
||||
>
|
||||
{{ fulfillmentStatus }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,71 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ShopifyAPI from '../../../api/integrations/shopify';
|
||||
import ShopifyOrderItem from './ShopifyOrderItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const contact = useFunctionGetter('contacts/getContact', props.contactId);
|
||||
|
||||
const hasSearchableInfo = computed(
|
||||
() => !!contact.value?.email || !!contact.value?.phone_number
|
||||
);
|
||||
|
||||
const orders = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await ShopifyAPI.getOrders(props.contactId);
|
||||
orders.value = response.data.orders;
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e.response?.data?.error || 'CONVERSATION_SIDEBAR.SHOPIFY.ERROR';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.contactId,
|
||||
() => {
|
||||
if (hasSearchableInfo.value) {
|
||||
fetchOrders();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-2 text-n-slate-12">
|
||||
<div v-if="!hasSearchableInfo" class="text-center text-n-slate-12">
|
||||
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
|
||||
</div>
|
||||
<div v-else-if="loading" class="flex justify-center items-center p-4">
|
||||
<Spinner size="32" class="text-n-brand" />
|
||||
</div>
|
||||
<div v-else-if="error" class="text-center text-n-ruby-12">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else-if="!orders.length" class="text-center text-n-slate-12">
|
||||
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<ShopifyOrderItem
|
||||
v-for="order in orders"
|
||||
:key="order.id"
|
||||
:order="order"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
contentAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
computed: {
|
||||
translationsAvailable() {
|
||||
return !!Object.keys(this.translations).length;
|
||||
},
|
||||
translations() {
|
||||
return this.contentAttributes.translations || {};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal
|
||||
modal-type="right-aligned"
|
||||
class="text-left"
|
||||
show
|
||||
:on-close="onClose"
|
||||
>
|
||||
<div class="content">
|
||||
<p>
|
||||
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
|
||||
</p>
|
||||
<p v-dompurify-html="content" class="mb-0" />
|
||||
<br />
|
||||
<hr />
|
||||
<div v-if="translationsAvailable">
|
||||
<p>
|
||||
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
|
||||
</p>
|
||||
<div v-for="(translation, language) in translations" :key="language">
|
||||
<p>
|
||||
<strong>{{ language }}:</strong>
|
||||
</p>
|
||||
<p v-dompurify-html="translation" />
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>
|
||||
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
+150
-107
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, useTemplateRef } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useImageZoom } from 'dashboard/composables/useImageZoom';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
|
||||
@@ -27,6 +26,7 @@ const emit = defineEmits(['close']);
|
||||
const show = defineModel('show', { type: Boolean, default: false });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
@@ -36,7 +36,11 @@ const ALLOWED_FILE_TYPES = {
|
||||
AUDIO: 'audio',
|
||||
};
|
||||
|
||||
const MAX_ZOOM_LEVEL = 2;
|
||||
const MIN_ZOOM_LEVEL = 1;
|
||||
|
||||
const isDownloading = ref(false);
|
||||
const zoomScale = ref(1);
|
||||
const activeAttachment = ref({});
|
||||
const activeFileType = ref('');
|
||||
const activeImageIndex = ref(
|
||||
@@ -44,23 +48,10 @@ const activeImageIndex = ref(
|
||||
attachment => attachment.message_id === props.attachment.message_id
|
||||
) || 0
|
||||
);
|
||||
|
||||
const imageRef = useTemplateRef('imageRef');
|
||||
|
||||
const {
|
||||
imageWrapperStyle,
|
||||
imageStyle,
|
||||
onRotate,
|
||||
activeImageRotation,
|
||||
onZoom,
|
||||
onDoubleClickZoomImage,
|
||||
onWheelImageZoom,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
resetZoomAndRotation,
|
||||
} = useImageZoom(imageRef);
|
||||
const activeImageRotation = ref(0);
|
||||
|
||||
const currentUser = computed(() => getters.getCurrentUser.value);
|
||||
|
||||
const hasMoreThanOneAttachment = computed(
|
||||
() => props.allAttachments.length > 1
|
||||
);
|
||||
@@ -74,10 +65,10 @@ const readableTime = computed(() => {
|
||||
const isImage = computed(
|
||||
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
|
||||
);
|
||||
const isVideo = computed(() =>
|
||||
[ALLOWED_FILE_TYPES.VIDEO, ALLOWED_FILE_TYPES.IG_REEL].includes(
|
||||
activeFileType.value
|
||||
)
|
||||
const isVideo = computed(
|
||||
() =>
|
||||
activeFileType.value === ALLOWED_FILE_TYPES.VIDEO ||
|
||||
activeFileType.value === ALLOWED_FILE_TYPES.IG_REEL
|
||||
);
|
||||
const isAudio = computed(
|
||||
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
|
||||
@@ -91,9 +82,9 @@ const senderDetails = computed(() => {
|
||||
thumbnail,
|
||||
id,
|
||||
} = activeAttachment.value?.sender || props.attachment?.sender || {};
|
||||
|
||||
const currentUserID = currentUser.value?.id;
|
||||
return {
|
||||
name: currentUser.value?.id === id ? 'You' : name || availableName || '',
|
||||
name: currentUserID === id ? 'You' : name || availableName || '',
|
||||
avatar: thumbnail || avatar_url || '',
|
||||
};
|
||||
});
|
||||
@@ -101,32 +92,43 @@ const senderDetails = computed(() => {
|
||||
const fileNameFromDataUrl = computed(() => {
|
||||
const { data_url: dataUrl } = activeAttachment.value;
|
||||
if (!dataUrl) return '';
|
||||
|
||||
const fileName = dataUrl.split('/').pop();
|
||||
return fileName ? decodeURIComponent(fileName) : '';
|
||||
const fileName = dataUrl?.split('/').pop();
|
||||
return decodeURIComponent(fileName || '');
|
||||
});
|
||||
|
||||
const onClose = () => emit('close');
|
||||
const imageRotationStyle = computed(() => ({
|
||||
transform: `rotate(${activeImageRotation.value}deg) scale(${zoomScale.value})`,
|
||||
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
|
||||
}));
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const setImageAndVideoSrc = attachment => {
|
||||
const { file_type: type } = attachment;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
|
||||
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
activeAttachment.value = attachment;
|
||||
activeFileType.value = type;
|
||||
};
|
||||
|
||||
const onClickChangeAttachment = (attachment, index) => {
|
||||
if (!attachment) return;
|
||||
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
activeImageIndex.value = index;
|
||||
setImageAndVideoSrc(attachment);
|
||||
resetZoomAndRotation();
|
||||
activeImageRotation.value = 0;
|
||||
zoomScale.value = 1;
|
||||
};
|
||||
|
||||
const onClickDownload = async () => {
|
||||
const { file_type: type, data_url: url, extension } = activeAttachment.value;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isDownloading.value = true;
|
||||
@@ -138,8 +140,66 @@ const onClickDownload = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onRotate = type => {
|
||||
if (!isImage.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rotation = type === 'clockwise' ? 90 : -90;
|
||||
|
||||
// Reset rotation if it is 360
|
||||
if (Math.abs(activeImageRotation.value) === 360) {
|
||||
activeImageRotation.value = rotation;
|
||||
} else {
|
||||
activeImageRotation.value += rotation;
|
||||
}
|
||||
};
|
||||
|
||||
const onZoom = scale => {
|
||||
if (!isImage.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newZoomScale = zoomScale.value + scale;
|
||||
// Check if the new zoom scale is within the allowed range
|
||||
if (newZoomScale > MAX_ZOOM_LEVEL) {
|
||||
// Set zoom to max but do not reset to default
|
||||
zoomScale.value = MAX_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
if (newZoomScale < MIN_ZOOM_LEVEL) {
|
||||
// Set zoom to min but do not reset to default
|
||||
zoomScale.value = MIN_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
// If within bounds, update the zoom scale
|
||||
zoomScale.value = newZoomScale;
|
||||
};
|
||||
|
||||
const onClickZoomImage = () => {
|
||||
// If already at max zoom, clicking should zoom out to minimum
|
||||
if (zoomScale.value >= MAX_ZOOM_LEVEL) {
|
||||
zoomScale.value = MIN_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
// Otherwise zoom in
|
||||
onZoom(0.1);
|
||||
};
|
||||
|
||||
const onWheelImageZoom = e => {
|
||||
if (!isImage.value) {
|
||||
return;
|
||||
}
|
||||
const scale = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
onZoom(scale);
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: { action: onClose },
|
||||
Escape: {
|
||||
action: () => {
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
ArrowLeft: {
|
||||
action: () => {
|
||||
onClickChangeAttachment(
|
||||
@@ -157,7 +217,6 @@ const keyboardEvents = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -173,46 +232,50 @@ onMounted(() => {
|
||||
:on-close="onClose"
|
||||
>
|
||||
<div
|
||||
class="bg-n-background flex flex-col h-[inherit] w-[inherit] overflow-hidden select-none"
|
||||
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
|
||||
@click="onClose"
|
||||
>
|
||||
<header
|
||||
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-n-background border-b border-n-weak"
|
||||
<div
|
||||
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-white dark:bg-slate-900"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-if="senderDetails"
|
||||
class="flex items-center min-w-[15rem] shrink-0"
|
||||
class="items-center flex justify-start min-w-[15rem]"
|
||||
>
|
||||
<Thumbnail
|
||||
v-if="senderDetails.avatar"
|
||||
:username="senderDetails.name"
|
||||
:src="senderDetails.avatar"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
|
||||
<h3 class="text-base leading-5 m-0 font-medium">
|
||||
<div
|
||||
class="flex flex-col items-start justify-center ml-2 rtl:ml-0 rtl:mr-2"
|
||||
>
|
||||
<h3 class="text-base inline-block leading-[1.4] m-0 p-0 capitalize">
|
||||
<span
|
||||
class="overflow-hidden text-n-slate-12 whitespace-nowrap text-ellipsis"
|
||||
class="overflow-hidden text-slate-800 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ senderDetails.name }}
|
||||
</span>
|
||||
</h3>
|
||||
<span
|
||||
class="text-xs text-n-slate-11 whitespace-nowrap text-ellipsis"
|
||||
class="p-0 m-0 overflow-hidden text-xs text-slate-400 dark:text-slate-200 whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ readableTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12"
|
||||
class="flex items-center justify-start w-auto min-w-0 p-1 text-sm font-semibold text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
|
||||
<span
|
||||
v-dompurify-html="fileNameFromDataUrl"
|
||||
class="overflow-hidden text-slate-700 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 ml-2 shrink-0">
|
||||
<div
|
||||
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
|
||||
>
|
||||
<NextButton
|
||||
v-if="isImage"
|
||||
icon="i-lucide-zoom-in"
|
||||
@@ -251,14 +314,13 @@ onMounted(() => {
|
||||
/>
|
||||
<NextButton icon="i-lucide-x" slate ghost @click="onClose" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex items-stretch flex-1 h-full overflow-hidden">
|
||||
<div class="flex items-center justify-center w-16 shrink-0">
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-full h-full">
|
||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
||||
<NextButton
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
icon="i-lucide-chevron-left"
|
||||
class="z-10"
|
||||
class="z-10 disabled:pointer-events-auto"
|
||||
blue
|
||||
faded
|
||||
lg
|
||||
@@ -271,60 +333,42 @@ onMounted(() => {
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center overflow-hidden">
|
||||
<div
|
||||
v-if="isImage"
|
||||
:style="imageWrapperStyle"
|
||||
class="flex items-center justify-center origin-center"
|
||||
:class="{
|
||||
// Adjust dimensions when rotated 90/270 degrees to maintain visibility
|
||||
// and prevent image from overflowing container in different aspect ratios
|
||||
'w-[calc(100dvh-8rem)] h-[calc(100dvw-7rem)]':
|
||||
activeImageRotation % 180 !== 0,
|
||||
'size-full': activeImageRotation % 180 === 0,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center w-full h-full">
|
||||
<div>
|
||||
<img
|
||||
ref="imageRef"
|
||||
v-if="isImage"
|
||||
:key="activeAttachment.message_id"
|
||||
:src="activeAttachment.data_url"
|
||||
:style="imageStyle"
|
||||
class="max-h-full max-w-full object-contain duration-100 ease-in-out transform select-none"
|
||||
@click.stop
|
||||
@dblclick.stop="onDoubleClickZoomImage"
|
||||
@wheel.prevent.stop="onWheelImageZoom"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseleave="onMouseLeave"
|
||||
class="mx-auto my-0 duration-150 ease-in-out transform modal-image skip-context-menu"
|
||||
:style="imageRotationStyle"
|
||||
@click.stop="onClickZoomImage"
|
||||
@wheel.stop="onWheelImageZoom"
|
||||
/>
|
||||
<video
|
||||
v-if="isVideo"
|
||||
:key="activeAttachment.message_id"
|
||||
:src="activeAttachment.data_url"
|
||||
controls
|
||||
playsInline
|
||||
class="mx-auto my-0 modal-video skip-context-menu"
|
||||
@click.stop
|
||||
/>
|
||||
<audio
|
||||
v-if="isAudio"
|
||||
:key="activeAttachment.message_id"
|
||||
controls
|
||||
class="skip-context-menu"
|
||||
@click.stop
|
||||
>
|
||||
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<video
|
||||
v-if="isVideo"
|
||||
:key="activeAttachment.message_id"
|
||||
:src="activeAttachment.data_url"
|
||||
controls
|
||||
playsInline
|
||||
class="max-h-full max-w-full object-contain"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<audio
|
||||
v-if="isAudio"
|
||||
:key="activeAttachment.message_id"
|
||||
controls
|
||||
class="w-full max-w-md"
|
||||
@click.stop
|
||||
>
|
||||
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center w-16 shrink-0">
|
||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
||||
<NextButton
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
icon="i-lucide-chevron-right"
|
||||
class="z-10"
|
||||
class="z-10 disabled:pointer-events-auto"
|
||||
blue
|
||||
faded
|
||||
lg
|
||||
@@ -337,17 +381,16 @@ onMounted(() => {
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer
|
||||
class="z-10 flex items-center justify-center h-12 border-t border-n-weak"
|
||||
>
|
||||
</div>
|
||||
<div class="z-10 flex items-center justify-center w-full h-16 px-6 py-2">
|
||||
<div
|
||||
class="rounded-md flex items-center justify-center px-3 py-1 bg-n-slate-3 text-n-slate-12 text-sm font-medium"
|
||||
class="items-center rounded-sm flex font-semibold justify-center min-w-[5rem] p-1 bg-slate-25 dark:bg-slate-800 text-slate-600 dark:text-slate-200 text-sm"
|
||||
>
|
||||
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
|
||||
<span class="count">
|
||||
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
|
||||
@@ -63,6 +63,7 @@ export default {
|
||||
<label class="input-container">
|
||||
<span v-if="label">{{ label }}</span>
|
||||
<input
|
||||
class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-600"
|
||||
:value="modelValue"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
@@ -81,7 +82,7 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.help-text {
|
||||
@apply mt-0.5 text-xs not-italic text-n-slate-11;
|
||||
@apply mt-0.5 text-xs not-italic text-slate-600 dark:text-slate-400;
|
||||
}
|
||||
|
||||
.message {
|
||||
|
||||
@@ -175,15 +175,15 @@ export default {
|
||||
<template>
|
||||
<div class="relative phone-input--wrap">
|
||||
<div
|
||||
class="flex items-center justify-start border-none outline outline-1 rounded-lg bg-n-alpha-black2"
|
||||
class="flex items-center justify-start border border-solid rounded-md dark:bg-slate-900"
|
||||
:class="
|
||||
error
|
||||
? 'outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 mb-1'
|
||||
: 'mb-4 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6'
|
||||
? 'border border-solid border-red-400 dark:border-red-400 mb-1'
|
||||
: 'mb-4 border-slate-200 dark:border-slate-600'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-lg rounded-bl-lg flex items-center justify-center gap-1.5 bg-n-solid-3 h-10 w-14"
|
||||
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
|
||||
@click.prevent="toggleCountryDropdown"
|
||||
>
|
||||
<h5 v-if="activeCountry" class="mb-0">
|
||||
@@ -194,7 +194,7 @@ export default {
|
||||
</div>
|
||||
<span
|
||||
v-if="activeDialCode"
|
||||
class="flex py-2 pl-2 pr-0 text-base font-normal leading-normal text-n-slate-12"
|
||||
class="flex py-2 pl-2 pr-0 text-base font-normal leading-normal bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100"
|
||||
>
|
||||
{{ activeDialCode }}
|
||||
</span>
|
||||
@@ -202,7 +202,7 @@ export default {
|
||||
ref="phoneNumberInput"
|
||||
:value="phoneNumber"
|
||||
type="tel"
|
||||
class="no-margin !rounded-tl-none !rounded-bl-none !outline-none !border-0 font-normal !w-full !bg-transparent text-base !px-1.5 placeholder:font-normal"
|
||||
class="!mb-0 !rounded-tl-none !rounded-bl-none !border-0 font-normal !w-full dark:!bg-slate-900 text-base !px-1.5 placeholder:font-normal"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:style="styles"
|
||||
@@ -215,22 +215,20 @@ export default {
|
||||
ref="dropdown"
|
||||
v-on-clickaway="onOutsideClick"
|
||||
tabindex="0"
|
||||
class="z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded-lg px-0 pt-0 pb-1 bg-n-alpha-3 backdrop-blur-[100px]"
|
||||
class="z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900"
|
||||
@keydown.prevent.up="moveUp"
|
||||
@keydown.prevent.down="moveDown"
|
||||
@keydown.prevent.enter="
|
||||
onSelectCountry(filteredCountriesBySearch[selectedIndex])
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 p-1 bg-white dark:bg-transparent backdrop-blur-[100px]"
|
||||
>
|
||||
<div class="sticky top-0 p-1 bg-white dark:bg-slate-900">
|
||||
<input
|
||||
ref="searchbar"
|
||||
v-model="searchCountry"
|
||||
type="text"
|
||||
:placeholder="$t('GENERAL.PHONE_INPUT.PLACEHOLDER')"
|
||||
class="!h-8 !mb-0 !text-sm !outline-n-brand dark:!outline-n-brand"
|
||||
class="!h-8 !mb-0 !text-sm !border !border-solid !border-slate-2000 dark:!border-slate-6000"
|
||||
@input="onSearchCountry"
|
||||
/>
|
||||
</div>
|
||||
@@ -238,10 +236,10 @@ export default {
|
||||
v-for="(country, index) in filteredCountriesBySearch"
|
||||
ref="dropdownItem"
|
||||
:key="index"
|
||||
class="flex items-center px-1 py-0 cursor-pointer h-7 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2"
|
||||
class="flex items-center px-1 py-0 cursor-pointer h-7 hover:bg-slate-50 dark:hover:bg-slate-700"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-alpha-2': country.id === activeCountryCode,
|
||||
'bg-n-alpha-1 dark:bg-n-alpha-2': index === selectedIndex,
|
||||
'bg-slate-50 dark:bg-slate-700': country.id === activeCountryCode,
|
||||
'bg-slate-25 dark:bg-slate-800': index === selectedIndex,
|
||||
}"
|
||||
@click="onSelectCountry(country)"
|
||||
>
|
||||
@@ -252,13 +250,13 @@ export default {
|
||||
>
|
||||
{{ country.name }}
|
||||
</span>
|
||||
<span class="ml-1 text-xs text-n-slate-11">
|
||||
{{ country.dial_code }}
|
||||
</span>
|
||||
<span class="ml-1 text-xs text-slate-300 dark:text-slate-300">{{
|
||||
country.dial_code
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="filteredCountriesBySearch.length === 0">
|
||||
<span
|
||||
class="flex items-center justify-center mt-4 text-sm text-n-slate-10"
|
||||
class="flex items-center justify-center mt-4 text-sm text-slate-500 dark:text-slate-300"
|
||||
>
|
||||
{{ $t('GENERAL.PHONE_INPUT.EMPTY_STATE') }}
|
||||
</span>
|
||||
|
||||
@@ -5,26 +5,9 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||
|
||||
// Mock vue-i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: key => (key === 'AGENT_MGMT.MULTI_SELECTOR.LIST.NONE' ? 'None' : key),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/helper/agentHelper');
|
||||
|
||||
// Create a mock None agent
|
||||
const mockNoneAgent = {
|
||||
confirmed: true,
|
||||
name: 'None',
|
||||
id: 0,
|
||||
role: 'agent',
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
};
|
||||
|
||||
const mockUseMapGetter = (overrides = {}) => {
|
||||
const defaultGetters = {
|
||||
getCurrentUser: ref(allAgentsData[0]),
|
||||
@@ -45,6 +28,14 @@ describe('useAgentsList', () => {
|
||||
agentHelper.getSortedAgentsByAvailability.mockReturnValue(
|
||||
formattedAgentsData.slice(1)
|
||||
);
|
||||
agentHelper.getCombinedAgents.mockImplementation(
|
||||
(agents, includeNone, isAgentSelected) => {
|
||||
if (includeNone && isAgentSelected) {
|
||||
return [agentHelper.createNoneAgent, ...agents];
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
);
|
||||
|
||||
mockUseMapGetter();
|
||||
});
|
||||
@@ -53,26 +44,24 @@ describe('useAgentsList', () => {
|
||||
const { agentsList, assignableAgents } = useAgentsList();
|
||||
|
||||
expect(assignableAgents.value).toEqual(allAgentsData);
|
||||
expect(agentsList.value[0]).toEqual(mockNoneAgent);
|
||||
expect(agentsList.value.length).toBe(
|
||||
formattedAgentsData.slice(1).length + 1
|
||||
);
|
||||
expect(agentsList.value).toEqual([
|
||||
agentHelper.createNoneAgent,
|
||||
...formattedAgentsData.slice(1),
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes None agent when includeNoneAgent is true', () => {
|
||||
const { agentsList } = useAgentsList(true);
|
||||
|
||||
expect(agentsList.value[0]).toEqual(mockNoneAgent);
|
||||
expect(agentsList.value.length).toBe(
|
||||
formattedAgentsData.slice(1).length + 1
|
||||
);
|
||||
expect(agentsList.value[0]).toEqual(agentHelper.createNoneAgent);
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.length);
|
||||
});
|
||||
|
||||
it('excludes None agent when includeNoneAgent is false', () => {
|
||||
const { agentsList } = useAgentsList(false);
|
||||
|
||||
expect(agentsList.value[0].id).not.toBe(0);
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
expect(agentsList.value[0]).not.toEqual(agentHelper.createNoneAgent);
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.length - 1);
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
@@ -84,7 +73,7 @@ describe('useAgentsList', () => {
|
||||
const { agentsList, assignableAgents } = useAgentsList();
|
||||
|
||||
expect(assignableAgents.value).toEqual([]);
|
||||
expect(agentsList.value).toEqual([mockNoneAgent]);
|
||||
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
|
||||
});
|
||||
|
||||
it('handles missing inbox_id', () => {
|
||||
@@ -97,6 +86,6 @@ describe('useAgentsList', () => {
|
||||
const { agentsList, assignableAgents } = useAgentsList();
|
||||
|
||||
expect(assignableAgents.value).toEqual([]);
|
||||
expect(agentsList.value).toEqual([mockNoneAgent]);
|
||||
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { ref } from 'vue';
|
||||
import { useImageZoom } from 'dashboard/composables/useImageZoom';
|
||||
|
||||
describe('useImageZoom', () => {
|
||||
let imageRef;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock imageRef element with getBoundingClientRect method
|
||||
imageRef = ref({
|
||||
getBoundingClientRect: () => ({
|
||||
left: 100,
|
||||
top: 100,
|
||||
width: 200,
|
||||
height: 200,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default values', () => {
|
||||
const { zoomScale, imgTransformOriginPoint, activeImageRotation } =
|
||||
useImageZoom(imageRef);
|
||||
|
||||
expect(zoomScale.value).toBe(1);
|
||||
expect(imgTransformOriginPoint.value).toBe('center center');
|
||||
expect(activeImageRotation.value).toBe(0);
|
||||
});
|
||||
|
||||
it('should update zoom scale when onZoom is called', () => {
|
||||
const { zoomScale, onZoom } = useImageZoom(imageRef);
|
||||
|
||||
onZoom(0.5);
|
||||
expect(zoomScale.value).toBe(1.5);
|
||||
|
||||
// Should respect max zoom level
|
||||
onZoom(10);
|
||||
expect(zoomScale.value).toBe(3);
|
||||
|
||||
// Should respect min zoom level
|
||||
onZoom(-10);
|
||||
expect(zoomScale.value).toBe(1);
|
||||
});
|
||||
|
||||
it('should update rotation when onRotate is called', () => {
|
||||
const { activeImageRotation, onRotate } = useImageZoom(imageRef);
|
||||
|
||||
onRotate('clockwise');
|
||||
expect(activeImageRotation.value).toBe(90);
|
||||
|
||||
onRotate('counter-clockwise');
|
||||
expect(activeImageRotation.value).toBe(0);
|
||||
|
||||
// Test full rotation reset
|
||||
onRotate('clockwise');
|
||||
onRotate('clockwise');
|
||||
onRotate('clockwise');
|
||||
onRotate('clockwise');
|
||||
onRotate('clockwise');
|
||||
// After 360 degrees, it should reset and add the new rotation
|
||||
expect(activeImageRotation.value).toBe(90);
|
||||
});
|
||||
|
||||
it('should reset zoom and rotation', () => {
|
||||
const {
|
||||
zoomScale,
|
||||
activeImageRotation,
|
||||
onZoom,
|
||||
onRotate,
|
||||
resetZoomAndRotation,
|
||||
} = useImageZoom(imageRef);
|
||||
|
||||
onZoom(0.5);
|
||||
onRotate('clockwise');
|
||||
expect(zoomScale.value).toBe(1); // Rotation resets zoom
|
||||
expect(activeImageRotation.value).toBe(90);
|
||||
|
||||
onZoom(0.5);
|
||||
expect(zoomScale.value).toBe(1.5);
|
||||
|
||||
resetZoomAndRotation();
|
||||
expect(zoomScale.value).toBe(1);
|
||||
expect(activeImageRotation.value).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle double click zoom', () => {
|
||||
const { zoomScale, onDoubleClickZoomImage } = useImageZoom(imageRef);
|
||||
|
||||
// Mock event
|
||||
const event = {
|
||||
clientX: 150,
|
||||
clientY: 150,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
|
||||
onDoubleClickZoomImage(event);
|
||||
expect(zoomScale.value).toBe(3); // Max zoom
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
|
||||
onDoubleClickZoomImage(event);
|
||||
expect(zoomScale.value).toBe(1); // Min zoom
|
||||
});
|
||||
|
||||
it('should handle wheel zoom', () => {
|
||||
const { zoomScale, onWheelImageZoom } = useImageZoom(imageRef);
|
||||
|
||||
// Mock event
|
||||
const event = {
|
||||
clientX: 150,
|
||||
clientY: 150,
|
||||
deltaY: -10, // Zoom in
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
|
||||
onWheelImageZoom(event);
|
||||
expect(zoomScale.value).toBe(1.2);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
|
||||
// Zoom out
|
||||
event.deltaY = 10;
|
||||
onWheelImageZoom(event);
|
||||
expect(zoomScale.value).toBe(1);
|
||||
});
|
||||
|
||||
it('should correctly compute zoom origin', () => {
|
||||
const { getZoomOrigin } = useImageZoom(imageRef);
|
||||
|
||||
// Test center point
|
||||
const centerOrigin = getZoomOrigin(200, 200);
|
||||
expect(centerOrigin.x).toBeCloseTo(50);
|
||||
expect(centerOrigin.y).toBeCloseTo(50);
|
||||
|
||||
// Test top-left corner
|
||||
const topLeftOrigin = getZoomOrigin(100, 100);
|
||||
expect(topLeftOrigin.x).toBeCloseTo(0);
|
||||
expect(topLeftOrigin.y).toBeCloseTo(0);
|
||||
|
||||
// Test bottom-right corner
|
||||
const bottomRightOrigin = getZoomOrigin(300, 300);
|
||||
expect(bottomRightOrigin.x).toBeCloseTo(100);
|
||||
expect(bottomRightOrigin.y).toBeCloseTo(100);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getAgentsByUpdatedPresence,
|
||||
getSortedAgentsByAvailability,
|
||||
getCombinedAgents,
|
||||
} from 'dashboard/helper/agentHelper';
|
||||
|
||||
/**
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
* @returns {Object} An object containing the agents list and assignable agents.
|
||||
*/
|
||||
export function useAgentsList(includeNoneAgent = true) {
|
||||
const { t } = useI18n();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
@@ -22,19 +21,6 @@ export function useAgentsList(includeNoneAgent = true) {
|
||||
const inboxId = computed(() => currentChat.value?.inbox_id);
|
||||
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
|
||||
|
||||
/**
|
||||
* Creates a 'None' agent object
|
||||
* @returns {Object} None agent object
|
||||
*/
|
||||
const createNoneAgent = () => ({
|
||||
confirmed: true,
|
||||
name: t('AGENT_MGMT.MULTI_SELECTOR.LIST.NONE') || 'None',
|
||||
id: 0,
|
||||
role: 'agent',
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
});
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
@@ -57,10 +43,11 @@ export function useAgentsList(includeNoneAgent = true) {
|
||||
agentsByUpdatedPresence
|
||||
);
|
||||
|
||||
return [
|
||||
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
|
||||
...filteredAgentsByAvailability,
|
||||
];
|
||||
return getCombinedAgents(
|
||||
filteredAgentsByAvailability,
|
||||
includeNoneAgent,
|
||||
isAgentSelected.value
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
export default function useAutomationValues() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
@@ -60,19 +61,6 @@ export default function useAutomationValues() {
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds a translated "None" option to the beginning of a list
|
||||
* @param {Array} list - The list to add "None" to
|
||||
* @returns {Array} A new array with "None" option at the beginning
|
||||
*/
|
||||
const addNoneToList = list => [
|
||||
{
|
||||
id: 'nil',
|
||||
name: t('AUTOMATION.NONE_OPTION') || 'None',
|
||||
},
|
||||
...(list || []),
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the condition dropdown values for a given type.
|
||||
* @param {string} type - The type of condition.
|
||||
@@ -107,7 +95,6 @@ export default function useAutomationValues() {
|
||||
slaPolicies: slaPolicies.value,
|
||||
languages,
|
||||
type,
|
||||
addNoneToListFn: addNoneToList,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
debounce,
|
||||
calculateCenterOffset,
|
||||
applyRotationTransform,
|
||||
normalizeToPercentage,
|
||||
} from '@chatwoot/utils';
|
||||
|
||||
// Composable for images in gallery view
|
||||
export const useImageZoom = imageRef => {
|
||||
const MAX_ZOOM_LEVEL = 3;
|
||||
const MIN_ZOOM_LEVEL = 1;
|
||||
const ZOOM_INCREMENT = 0.2;
|
||||
const MOUSE_MOVE_DEBOUNCE_MS = 100;
|
||||
const MOUSE_LEAVE_DEBOUNCE_MS = 110;
|
||||
const DEFAULT_IMG_TRANSFORM_ORIGIN = 'center center';
|
||||
|
||||
const zoomScale = ref(1);
|
||||
const imgTransformOriginPoint = ref(DEFAULT_IMG_TRANSFORM_ORIGIN);
|
||||
const activeImageRotation = ref(0);
|
||||
|
||||
const imageWrapperStyle = computed(() => ({
|
||||
transform: `rotate(${activeImageRotation.value}deg)`,
|
||||
}));
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
transform: `scale(${zoomScale.value})`,
|
||||
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
|
||||
transformOrigin: `${imgTransformOriginPoint.value}`,
|
||||
}));
|
||||
|
||||
// Resets the transform origin to center
|
||||
const resetTransformOrigin = () => {
|
||||
if (imageRef.value) {
|
||||
imgTransformOriginPoint.value = DEFAULT_IMG_TRANSFORM_ORIGIN;
|
||||
}
|
||||
};
|
||||
|
||||
// Rotates the current image
|
||||
const onRotate = type => {
|
||||
if (!imageRef.value) return;
|
||||
resetTransformOrigin();
|
||||
|
||||
const rotation = type === 'clockwise' ? 90 : -90;
|
||||
|
||||
// ensure that the value of the rotation is within the range of -360 to 360 degrees
|
||||
activeImageRotation.value = (activeImageRotation.value + rotation) % 360;
|
||||
|
||||
// Reset zoom when rotating
|
||||
zoomScale.value = 1;
|
||||
resetTransformOrigin();
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the appropriate transform origin point based on mouse position and image rotation
|
||||
* Used to create a natural zoom behavior where the image zooms toward/from the cursor position
|
||||
*
|
||||
* @param {number} x - The client X coordinate of the mouse pointer
|
||||
* @param {number} y - The client Y coordinate of the mouse pointer
|
||||
* @returns {{x: number, y: number}} Object containing the transform origin coordinates as percentages
|
||||
*/
|
||||
const getZoomOrigin = (x, y) => {
|
||||
// Default to center
|
||||
if (!imageRef.value) return { x: 50, y: 50 };
|
||||
|
||||
const rect = imageRef.value.getBoundingClientRect();
|
||||
|
||||
// Step 1: Calculate offset from center
|
||||
const { relativeX, relativeY } = calculateCenterOffset(x, y, rect);
|
||||
|
||||
// Step 2: Apply rotation transformation
|
||||
const { rotatedX, rotatedY } = applyRotationTransform(
|
||||
relativeX,
|
||||
relativeY,
|
||||
activeImageRotation.value
|
||||
);
|
||||
|
||||
// Step 3: Convert to percentage coordinates
|
||||
return normalizeToPercentage(rotatedX, rotatedY, rect.width, rect.height);
|
||||
};
|
||||
|
||||
// Handles zooming the image
|
||||
const onZoom = (scale, x, y) => {
|
||||
if (!imageRef.value) return;
|
||||
|
||||
// Calculate new scale within bounds
|
||||
const newScale = Math.max(
|
||||
MIN_ZOOM_LEVEL,
|
||||
Math.min(MAX_ZOOM_LEVEL, zoomScale.value + scale)
|
||||
);
|
||||
|
||||
// Skip if no change
|
||||
if (newScale === zoomScale.value) return;
|
||||
|
||||
// Update transform origin based on mouse position and zoom scale is minimum
|
||||
if (x != null && y != null && zoomScale.value === MIN_ZOOM_LEVEL) {
|
||||
const { x: originX, y: originY } = getZoomOrigin(x, y);
|
||||
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
|
||||
}
|
||||
|
||||
// Apply the new scale
|
||||
zoomScale.value = newScale;
|
||||
};
|
||||
|
||||
// Handles double-click zoom toggling
|
||||
const onDoubleClickZoomImage = e => {
|
||||
if (!imageRef.value) return;
|
||||
e.preventDefault();
|
||||
|
||||
// Toggle between max zoom and min zoom
|
||||
const newScale =
|
||||
zoomScale.value >= MAX_ZOOM_LEVEL ? MIN_ZOOM_LEVEL : MAX_ZOOM_LEVEL;
|
||||
|
||||
// Update transform origin based on mouse position
|
||||
const { x: originX, y: originY } = getZoomOrigin(e.clientX, e.clientY);
|
||||
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
|
||||
|
||||
// Apply the new scale
|
||||
zoomScale.value = newScale;
|
||||
};
|
||||
|
||||
// Handles mouse wheel zooming for images
|
||||
const onWheelImageZoom = e => {
|
||||
if (!imageRef.value) return;
|
||||
e.preventDefault();
|
||||
|
||||
const scale = e.deltaY > 0 ? -ZOOM_INCREMENT : ZOOM_INCREMENT;
|
||||
onZoom(scale, e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets transform origin to mouse position during hover.
|
||||
* Enables precise scroll/double-click zoom targeting by updating the
|
||||
* transform origin to cursor position. Only active at minimum zoom level.
|
||||
* Debounced (100ms) to improve performance during rapid mouse movement.
|
||||
*/
|
||||
const onMouseMove = debounce(
|
||||
e => {
|
||||
if (!imageRef.value) return;
|
||||
if (zoomScale.value !== MIN_ZOOM_LEVEL) return;
|
||||
|
||||
const { x: originX, y: originY } = getZoomOrigin(e.clientX, e.clientY);
|
||||
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
|
||||
},
|
||||
MOUSE_MOVE_DEBOUNCE_MS,
|
||||
false
|
||||
);
|
||||
|
||||
/**
|
||||
* Resets transform origin to center when mouse leaves image.
|
||||
* Ensures button-based zooming works predictably after hover ends.
|
||||
* Uses slightly longer debounce (110ms) to avoid conflicts with onMouseMove.
|
||||
*/
|
||||
const onMouseLeave = debounce(
|
||||
() => {
|
||||
if (!imageRef.value) return;
|
||||
if (zoomScale.value !== MIN_ZOOM_LEVEL) return;
|
||||
imgTransformOriginPoint.value = DEFAULT_IMG_TRANSFORM_ORIGIN;
|
||||
},
|
||||
MOUSE_LEAVE_DEBOUNCE_MS,
|
||||
false
|
||||
);
|
||||
|
||||
const resetZoomAndRotation = () => {
|
||||
activeImageRotation.value = 0;
|
||||
zoomScale.value = 1;
|
||||
resetTransformOrigin();
|
||||
};
|
||||
|
||||
return {
|
||||
zoomScale,
|
||||
imgTransformOriginPoint,
|
||||
activeImageRotation,
|
||||
imageWrapperStyle,
|
||||
imageStyle,
|
||||
getZoomOrigin,
|
||||
resetTransformOrigin,
|
||||
onRotate,
|
||||
onZoom,
|
||||
onDoubleClickZoomImage,
|
||||
onWheelImageZoom,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
resetZoomAndRotation,
|
||||
};
|
||||
};
|
||||
@@ -8,7 +8,6 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'contact_attributes' },
|
||||
{ name: 'previous_conversation' },
|
||||
{ name: 'conversation_participants' },
|
||||
{ name: 'shopify_orders' },
|
||||
]);
|
||||
|
||||
export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user