Compare commits
143
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d914065e0 | ||
|
|
5d9c979ace | ||
|
|
a985010a40 | ||
|
|
ddc863c6f7 | ||
|
|
d765e7c106 | ||
|
|
0bc890302b | ||
|
|
68973c3b7b | ||
|
|
0875ae719e | ||
|
|
e7d9165945 | ||
|
|
2bb358a787 | ||
|
|
872f09f720 | ||
|
|
9e56a024c5 | ||
|
|
1032097978 | ||
|
|
66b0f51279 | ||
|
|
7a7de71c39 | ||
|
|
62e763ecc2 | ||
|
|
8866071029 | ||
|
|
006d7afa44 | ||
|
|
461292fb2b | ||
|
|
3f65838155 | ||
|
|
12f29d0916 | ||
|
|
3c33d912df | ||
|
|
9d889f691b | ||
|
|
801e1e8ec5 | ||
|
|
7795bc9f1d | ||
|
|
f6a88d7f12 | ||
|
|
6b45b97d9c | ||
|
|
25609d88b2 | ||
|
|
5471e52335 | ||
|
|
f0119b3f2b | ||
|
|
b975333fec | ||
|
|
1610b9a4ba | ||
|
|
2e6b415826 | ||
|
|
cd9daacfcc | ||
|
|
ab8fefd3c9 | ||
|
|
dfdadc470d | ||
|
|
c5236ec67e | ||
|
|
ad48473ced | ||
|
|
02ac8f008f | ||
|
|
29fd83c1ea | ||
|
|
96b11e31a1 | ||
|
|
74f9cd764b | ||
|
|
553da80f80 | ||
|
|
83b59ec969 | ||
|
|
e468e45f4d | ||
|
|
7f59c8b771 | ||
|
|
cb180dfc5f | ||
|
|
20af850432 | ||
|
|
4db856e7da | ||
|
|
6d168f3c96 | ||
|
|
b95b5f62a0 | ||
|
|
639d087e53 | ||
|
|
40d3ec99dc | ||
|
|
3210860841 | ||
|
|
20076678d6 | ||
|
|
5e4fc5264d | ||
|
|
4093a8a48d | ||
|
|
2866b29f3a | ||
|
|
0b3284ac62 | ||
|
|
0b1caa7286 | ||
|
|
8df58d0a93 | ||
|
|
23ccec3ebc | ||
|
|
9e012e923b | ||
|
|
c0f1e9237f | ||
|
|
9aa338da0a | ||
|
|
567631b866 | ||
|
|
f712796df3 | ||
|
|
c1e3e61671 | ||
|
|
17ff977b07 | ||
|
|
cb420d83f6 | ||
|
|
97e7c106e3 | ||
|
|
61a8511f7a | ||
|
|
14a9b48d49 | ||
|
|
e6a35193f3 | ||
|
|
4a79abb367 | ||
|
|
5dda138ee7 | ||
|
|
040c11f0dc | ||
|
|
5086edefb3 | ||
|
|
66d9ddb9c4 | ||
|
|
41a7c88aef | ||
|
|
19b5496c56 | ||
|
|
d693d468d8 | ||
|
|
0b1ad1a854 | ||
|
|
6179adac22 | ||
|
|
3c959f0604 | ||
|
|
772734840d | ||
|
|
77c57acffa | ||
|
|
499ac053ee | ||
|
|
5aa1df81a8 | ||
|
|
81464a9087 | ||
|
|
6ed9500792 | ||
|
|
6835f9c85b | ||
|
|
442631102c | ||
|
|
78c615fe42 | ||
|
|
907011e280 | ||
|
|
a766ebf642 | ||
|
|
0e0e0868d7 | ||
|
|
d9077c64d3 | ||
|
|
1abb51992b | ||
|
|
0282156821 | ||
|
|
257a9129d7 | ||
|
|
1ce316efc7 | ||
|
|
ba94d8c2b0 | ||
|
|
6e11bebeae | ||
|
|
069f25f88a | ||
|
|
63c193358c | ||
|
|
7663e2ac30 | ||
|
|
0567655840 | ||
|
|
c25ed094fa | ||
|
|
112a8f9e9e | ||
|
|
723d416ce3 | ||
|
|
f3df486ce6 | ||
|
|
c7b6a87e77 | ||
|
|
19d91e47f1 | ||
|
|
1c945899c7 | ||
|
|
2c025755e2 | ||
|
|
0618e6e229 | ||
|
|
cbef2d4e66 | ||
|
|
cb93aba55c | ||
|
|
beef43f2bf | ||
|
|
cbd3f759b5 | ||
|
|
a7fdc45977 | ||
|
|
41e3ea0d87 | ||
|
|
fd22017292 | ||
|
|
555894793e | ||
|
|
7c87d3d150 | ||
|
|
c37d1b1d6b | ||
|
|
e5a03d08ea | ||
|
|
bc80985f55 | ||
|
|
83cd568ca2 | ||
|
|
f32a7b3f77 | ||
|
|
ba0fcbfc7f | ||
|
|
546193de2a | ||
|
|
67b522c433 | ||
|
|
f5eca9d1d0 | ||
|
|
5c4db3a6bc | ||
|
|
792dc359d4 | ||
|
|
2b0aaa237f | ||
|
|
48022833ea | ||
|
|
e2b3965a08 | ||
|
|
17e25fa3c4 | ||
|
|
37963cc654 | ||
|
|
c7f5277277 |
+4
-18
@@ -2,22 +2,8 @@
|
||||
ignore:
|
||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
||||
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
||||
# Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor.
|
||||
# Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable.
|
||||
- GHSA-jp94-3292-c3xv
|
||||
# Rails 7.1 has no patched release for the Active Storage proxy range
|
||||
# advisories. Chatwoot limits proxy range requests locally.
|
||||
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
|
||||
# storage setup uses local/cloud storage with optional direct uploads to the
|
||||
# storage provider rather than Rails proxy mode. Revisit if we enable
|
||||
# rails_storage_proxy or other app-served Active Storage proxy routes.
|
||||
- CVE-2026-33658
|
||||
# Rails 7.1 has no patched release for this Active Storage direct-upload
|
||||
# advisory. Chatwoot filters internal metadata keys locally.
|
||||
- CVE-2026-33173
|
||||
- CVE-2026-33174
|
||||
# Rails 7.1 has no patched release for these Rails advisories. These are not
|
||||
# reachable through Chatwoot's current usage patterns and should be removed
|
||||
# once we upgrade to Rails 7.2.3.1+.
|
||||
- CVE-2026-33168
|
||||
- CVE-2026-33169
|
||||
- CVE-2026-33170
|
||||
- CVE-2026-33176
|
||||
- CVE-2026-33195
|
||||
- CVE-2026-33202
|
||||
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 20
|
||||
parallelism: 18
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
|
||||
@@ -98,8 +98,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
|
||||
# Mail Incoming
|
||||
# This is the domain set for the reply emails when conversation continuity is enabled
|
||||
MAILER_INBOUND_EMAIL_DOMAIN=
|
||||
# Maximum time in seconds to process a single IMAP email
|
||||
# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
|
||||
# Set this to the appropriate ingress channel with regards to incoming emails
|
||||
# Possible values are :
|
||||
# relay for Exim, Postfix, Qmail
|
||||
@@ -234,10 +232,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
|
||||
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
|
||||
|
||||
## SafeFetch private network access
|
||||
## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
|
||||
# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
## setting this value to true will disable the frontend dashboard endpoints
|
||||
# CW_API_ONLY_SERVER=false
|
||||
|
||||
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
gem 'jwt', '~> 2.10', '>= 2.10.3'
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
|
||||
# super admin
|
||||
@@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
|
||||
gem 'sidekiq', '>= 7.3.1'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 2.4.0'
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
# for sidekiq healthcheck
|
||||
gem 'sidekiq_alive'
|
||||
|
||||
@@ -209,8 +209,6 @@ gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
+14
-23
@@ -196,9 +196,6 @@ GEM
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
cronex (0.15.0)
|
||||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
csv (3.3.0)
|
||||
csv-safe (3.3.1)
|
||||
csv (~> 3.0)
|
||||
@@ -215,7 +212,7 @@ GEM
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
date (3.5.1)
|
||||
date (3.4.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -301,7 +298,7 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.2)
|
||||
faraday (2.14.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -339,7 +336,6 @@ GEM
|
||||
ffi-compiler (1.0.1)
|
||||
ffi (>= 1.0.0)
|
||||
rake
|
||||
firecrawl-sdk (1.4.1)
|
||||
flag_shih_tzu (0.3.23)
|
||||
foreman (0.87.2)
|
||||
fugit (1.11.1)
|
||||
@@ -491,7 +487,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.10.3)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -578,7 +574,7 @@ GEM
|
||||
uri (>= 0.11.1)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.24)
|
||||
net-imap (0.4.20)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -906,11 +902,10 @@ GEM
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.22.2)
|
||||
sidekiq-cron (2.4.0)
|
||||
cronex (>= 0.13.0)
|
||||
fugit (~> 1.8, >= 1.11.1)
|
||||
sidekiq-cron (1.12.0)
|
||||
fugit (~> 1.8)
|
||||
globalid (>= 1.0.1)
|
||||
sidekiq (>= 6.5.0)
|
||||
sidekiq (>= 6)
|
||||
sidekiq_alive (2.5.0)
|
||||
gserver (~> 0.0.1)
|
||||
sidekiq (>= 5, < 9)
|
||||
@@ -966,7 +961,7 @@ GEM
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
timeout (0.6.1)
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (7.6.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
@@ -984,7 +979,6 @@ GEM
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8.2)
|
||||
unicode (0.4.4.5)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
@@ -996,13 +990,11 @@ GEM
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
vite_rails (3.10.0)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_rails (3.0.17)
|
||||
railties (>= 5.1, < 8)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
vite_ruby (3.10.2)
|
||||
vite_ruby (3.8.0)
|
||||
dry-cli (>= 0.7, < 2)
|
||||
logger (~> 1.6)
|
||||
mutex_m
|
||||
rack-proxy (~> 0.6, >= 0.6.1)
|
||||
zeitwerk (~> 2.2)
|
||||
warden (1.2.9)
|
||||
@@ -1082,7 +1074,6 @@ DEPENDENCIES
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
firecrawl-sdk (~> 1.0)
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
@@ -1104,7 +1095,7 @@ DEPENDENCIES
|
||||
json_schemer
|
||||
judoscale-rails
|
||||
judoscale-sidekiq
|
||||
jwt (~> 2.10, >= 2.10.3)
|
||||
jwt
|
||||
kaminari
|
||||
koala
|
||||
letter_opener
|
||||
@@ -1164,8 +1155,8 @@ DEPENDENCIES
|
||||
sentry-sidekiq (>= 5.19.0)
|
||||
shopify_api
|
||||
shoulda-matchers
|
||||
sidekiq (~> 7.3, >= 7.3.1)
|
||||
sidekiq-cron (>= 2.4.0)
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.14.1
|
||||
4.13.0
|
||||
|
||||
@@ -92,18 +92,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
def fallback_params(attachment)
|
||||
{
|
||||
fallback_title: attachment['title'],
|
||||
external_url: attachment['url'] || attachment.dig('payload', 'url')
|
||||
external_url: attachment['url']
|
||||
}
|
||||
end
|
||||
|
||||
# Facebook shared posts point to page URLs, not downloadable media URLs.
|
||||
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
|
||||
def normalize_file_type(type)
|
||||
return :fallback if type.to_sym == :share
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
|
||||
@@ -13,7 +13,6 @@ class Messages::MessageBuilder
|
||||
@account = conversation.account
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
|
||||
@@ -57,25 +56,16 @@ class Messages::MessageBuilder
|
||||
file: uploaded_attachment
|
||||
)
|
||||
|
||||
attachment.file_type = attachment_file_type(uploaded_attachment)
|
||||
tag_voice_message(attachment)
|
||||
attachment.file_type = if uploaded_attachment.is_a?(String)
|
||||
file_type_by_signed_id(
|
||||
uploaded_attachment
|
||||
)
|
||||
else
|
||||
file_type(uploaded_attachment&.content_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def attachment_file_type(uploaded_attachment)
|
||||
if uploaded_attachment.is_a?(String)
|
||||
file_type_by_signed_id(uploaded_attachment)
|
||||
else
|
||||
file_type(uploaded_attachment&.content_type)
|
||||
end
|
||||
end
|
||||
|
||||
def tag_voice_message(attachment)
|
||||
return unless @is_voice_message && attachment.file_type == 'audio'
|
||||
|
||||
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
|
||||
end
|
||||
|
||||
def process_emails
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
|
||||
@@ -31,24 +31,13 @@ class V2::Reports::BotMetricsBuilder
|
||||
end
|
||||
|
||||
def bot_resolutions_count
|
||||
# Exclude conversations that also had a handoff in the same range — handoff wins
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id)
|
||||
.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
|
||||
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||
.distinct.count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range).distinct.count
|
||||
end
|
||||
|
||||
def bot_handoffs_count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id)
|
||||
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
|
||||
.distinct.count
|
||||
end
|
||||
|
||||
def bot_handoff_conversation_ids_subquery
|
||||
account.reporting_events
|
||||
.where(name: :conversation_bot_handoff, created_at: range)
|
||||
.where.not(conversation_id: nil)
|
||||
.select(:conversation_id)
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
|
||||
created_at: range).distinct.count
|
||||
end
|
||||
|
||||
def bot_resolution_rate
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
|
||||
before_action :set_articles, only: [:update_status, :delete_articles]
|
||||
|
||||
def translate
|
||||
head :not_implemented
|
||||
@@ -19,18 +19,6 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def update_category
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@articles.find_each { |article| article.update!(category_id: params[:category_id]) }
|
||||
end
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def delete_articles
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
|
||||
@@ -51,9 +39,5 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
|
||||
def set_articles
|
||||
@articles = @portal.articles.where(id: params[:ids])
|
||||
end
|
||||
|
||||
def category_valid?
|
||||
@portal.categories.exists?(id: params[:category_id])
|
||||
end
|
||||
end
|
||||
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController
|
||||
RESULTS_PER_PAGE = 100
|
||||
|
||||
def index
|
||||
conversations = Conversations::PermissionFilterService.new(
|
||||
Current.account.conversations.where(contact_id: @contact.id),
|
||||
Current.user,
|
||||
Current.account
|
||||
).perform
|
||||
|
||||
@attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id))
|
||||
.includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }])
|
||||
.order(created_at: :desc)
|
||||
.page(params[:page])
|
||||
.per(RESULTS_PER_PAGE)
|
||||
@attachments_count = @attachments.total_count
|
||||
end
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
|
||||
def index
|
||||
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
||||
render json: { payload: counts }
|
||||
end
|
||||
end
|
||||
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
def attachments
|
||||
@attachments_count = @conversation.attachments.count
|
||||
@attachments = @conversation.attachments
|
||||
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
|
||||
.includes(:message)
|
||||
.order(created_at: :desc)
|
||||
.page(attachment_params[:page])
|
||||
.per(ATTACHMENT_RESULTS_PER_PAGE)
|
||||
@@ -162,8 +162,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_columns(updates)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
|
||||
end
|
||||
|
||||
def should_update_last_seen?
|
||||
|
||||
@@ -9,9 +9,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes)
|
||||
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
|
||||
.order_by_name
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -87,7 +85,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_agent_bot
|
||||
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot]
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def create_channel
|
||||
|
||||
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
|
||||
enable_fb_login: '0',
|
||||
force_authentication: '1',
|
||||
response_type: 'code',
|
||||
state: generate_instagram_token(Current.account.id, params[:return_to])
|
||||
state: generate_instagram_token(Current.account.id)
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def destroy_all
|
||||
if params[:type] == 'read'
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
|
||||
else
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
|
||||
end
|
||||
head :ok
|
||||
end
|
||||
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def fetch_notification
|
||||
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id])
|
||||
@notification = current_user.notifications.find(params[:id])
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
|
||||
@@ -8,15 +8,7 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def state
|
||||
# The sgid purpose doubles as a return hint: onboarding tags it so the callback
|
||||
# can route the user back to inbox setup. The purpose is part of the signed
|
||||
# payload (tamper-proof), and a non-onboarding request keeps the default
|
||||
# purpose, leaving callers like Notion byte-identical.
|
||||
Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s
|
||||
end
|
||||
|
||||
def state_purpose
|
||||
params[:return_to] == 'onboarding' ? 'onboarding' : 'default'
|
||||
Current.account.to_sgid(expires_in: 15.minutes).to_s
|
||||
end
|
||||
|
||||
def base_url
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
|
||||
def update
|
||||
@account = Current.account
|
||||
finalize = finalizing_account_details?
|
||||
|
||||
@account.assign_attributes(account_params)
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if finalize
|
||||
@account.save!
|
||||
|
||||
# TODO: re-enable when the help center generation UI is ready to surface progress
|
||||
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
|
||||
|
||||
render 'api/v1/accounts/update', format: :json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finalizing_account_details?
|
||||
@account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
end
|
||||
|
||||
def website
|
||||
custom_attributes_params[:website]
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:name, :locale)
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
end
|
||||
end
|
||||
@@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
|
||||
@portal.custom_domain = parsed_custom_domain
|
||||
@portal.save!
|
||||
process_attached_logo if params[:blob_id].present?
|
||||
process_attached_logo
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -78,9 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -89,7 +87,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
|
||||
|
||||
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||
inbox = Inbox.find(permitted_params[:inbox_id])
|
||||
return {} unless inbox.web_widget?
|
||||
|
||||
{ channel_web_widget_id: inbox.channel.id }
|
||||
@@ -100,8 +98,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def parsed_custom_domain
|
||||
return @portal.custom_domain if @portal.custom_domain.blank?
|
||||
|
||||
domain = URI.parse(@portal.custom_domain)
|
||||
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
|
||||
|
||||
def create
|
||||
redirect_url = Tiktok::AuthClient.authorize_url(
|
||||
state: generate_tiktok_token(Current.account.id, params[:return_to])
|
||||
state: generate_tiktok_token(Current.account.id)
|
||||
)
|
||||
|
||||
if redirect_url
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_webhook, only: [:update]
|
||||
|
||||
def update
|
||||
@working_hour.update!(working_hour_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def working_hour_params
|
||||
params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day)
|
||||
end
|
||||
|
||||
def fetch_working_hour
|
||||
@working_hour = Current.account.working_hours.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.settings.merge!(settings_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||
@account.save!
|
||||
end
|
||||
@@ -110,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
|
||||
end
|
||||
|
||||
def settings_params
|
||||
|
||||
@@ -8,8 +8,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
notification_subscription = current_user.notification_subscriptions
|
||||
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription.destroy! if notification_subscription.present?
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -83,10 +83,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def set_message
|
||||
# `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`,
|
||||
# which means a visitor with more than one open thread could not edit a message in any
|
||||
# but their most recent one. Scoping across all of the visitor's conversations keeps the
|
||||
# happy path correct for that future multi-conversation widget flow.
|
||||
@message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id])
|
||||
@message = @web_widget.inbox.messages.find(permitted_params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,8 +28,6 @@ class Instagram::CallbacksController < ApplicationController
|
||||
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
|
||||
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
|
||||
|
||||
if already_exists
|
||||
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -151,10 +149,6 @@ class Instagram::CallbacksController < ApplicationController
|
||||
verify_instagram_token(params[:state])
|
||||
end
|
||||
|
||||
def return_to
|
||||
instagram_token_return_to(params[:state])
|
||||
end
|
||||
|
||||
def oauth_code
|
||||
params[:code]
|
||||
end
|
||||
|
||||
@@ -14,11 +14,4 @@ class Microsoft::CallbacksController < OauthCallbackController
|
||||
def imap_address
|
||||
'outlook.office365.com'
|
||||
end
|
||||
|
||||
# Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
|
||||
# it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
|
||||
# `upn` is the v1.0 fallback.
|
||||
def imap_login_identity
|
||||
users_data['preferred_username'] || users_data['upn'] || super
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,8 +16,6 @@ class OauthCallbackController < ApplicationController
|
||||
def handle_response
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
|
||||
return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding'
|
||||
|
||||
if already_exists
|
||||
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -46,7 +44,7 @@ class OauthCallbackController < ApplicationController
|
||||
|
||||
def update_channel(channel_email)
|
||||
channel_email.update!({
|
||||
imap_login: imap_login_identity, imap_address: imap_address,
|
||||
imap_login: users_data['email'], imap_address: imap_address,
|
||||
imap_port: '993', imap_enabled: true,
|
||||
provider: provider_name,
|
||||
provider_config: {
|
||||
@@ -57,13 +55,6 @@ class OauthCallbackController < ApplicationController
|
||||
})
|
||||
end
|
||||
|
||||
# Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
|
||||
# id_token's email claim; providers override when their server requires a different
|
||||
# claim (e.g. Microsoft SMTP requires UPN).
|
||||
def imap_login_identity
|
||||
users_data['email']
|
||||
end
|
||||
|
||||
def provider_name
|
||||
raise NotImplementedError
|
||||
end
|
||||
@@ -90,19 +81,10 @@ class OauthCallbackController < ApplicationController
|
||||
decoded_token[0]
|
||||
end
|
||||
|
||||
# The sgid purpose carries the onboarding return hint (see
|
||||
# OauthAuthorizationController#state). Try the onboarding purpose first — a match
|
||||
# both resolves the account and records the return target — then fall back to the
|
||||
# default purpose used by every other caller.
|
||||
def account_from_signed_id
|
||||
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
||||
|
||||
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding'))
|
||||
@return_to = 'onboarding'
|
||||
else
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
end
|
||||
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
raise 'Invalid or expired state' if account.nil?
|
||||
|
||||
account
|
||||
@@ -112,11 +94,6 @@ class OauthCallbackController < ApplicationController
|
||||
@account ||= account_from_signed_id
|
||||
end
|
||||
|
||||
def return_to
|
||||
account # resolving the sgid records which purpose matched
|
||||
@return_to
|
||||
end
|
||||
|
||||
# Fallback name, for when name field is missing from users_data
|
||||
def fallback_name
|
||||
users_data['email'].split('@').first.parameterize.titleize
|
||||
|
||||
@@ -24,10 +24,6 @@ class Public::Api::V1::InboxesController < PublicController
|
||||
def set_conversation
|
||||
return if params[:conversation_id].blank?
|
||||
|
||||
@conversation = if @contact_inbox.hmac_verified?
|
||||
@contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
|
||||
else
|
||||
@contact_inbox.conversations.find_by!(display_id: params[:conversation_id])
|
||||
end
|
||||
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown]
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||
before_action :set_article, only: [:show, :show_markdown]
|
||||
before_action :set_article, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@@ -23,13 +21,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
def show
|
||||
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
|
||||
@parsed_content = render_article_content(@article.content.to_s)
|
||||
end
|
||||
|
||||
def show_markdown
|
||||
return head :not_found unless @article&.published?
|
||||
|
||||
render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8'
|
||||
end
|
||||
|
||||
def tracking_pixel
|
||||
@@ -71,6 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
def set_article
|
||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||
@parsed_content = render_article_content(@article.content.to_s)
|
||||
end
|
||||
|
||||
def set_category
|
||||
|
||||
@@ -7,8 +7,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
around_action :set_locale
|
||||
after_action :allow_iframe_requests
|
||||
|
||||
PORTAL_LAYOUTS = %w[classic documentation].freeze
|
||||
|
||||
private
|
||||
|
||||
def show_plain_layout
|
||||
@@ -19,14 +17,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
|
||||
end
|
||||
|
||||
def set_portal_layout
|
||||
@portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic'
|
||||
end
|
||||
|
||||
def set_view_variant
|
||||
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
|
||||
end
|
||||
|
||||
def portal
|
||||
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
|
||||
end
|
||||
@@ -52,7 +42,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
article_locale = if article.category.present?
|
||||
article.category.locale
|
||||
else
|
||||
article.locale
|
||||
article.portal.default_locale
|
||||
end
|
||||
@locale = validate_and_get_locale(article_locale)
|
||||
I18n.with_locale(@locale, &)
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :set_category, only: [:show]
|
||||
before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' }
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
respond_to do |format|
|
||||
format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently }
|
||||
format.json { @categories = @portal.categories.order(position: :asc) }
|
||||
end
|
||||
@categories = @portal.categories.order(position: :asc)
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -27,9 +21,4 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
|
||||
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
|
||||
render_404 && return if @category.blank?
|
||||
end
|
||||
|
||||
def load_category_articles
|
||||
@articles = @category.articles.published.order(:position).includes(:author)
|
||||
@category_authors = @articles.filter_map(&:author).uniq
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:index]
|
||||
before_action :portal
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@query = params[:query].to_s.strip
|
||||
@articles = @portal.articles.published.includes(:category).where(locale: params[:locale])
|
||||
|
||||
search_articles
|
||||
|
||||
@articles = @articles.page(params[:page]).per(10)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_articles
|
||||
@articles = @query.present? ? @articles.search(search_params) : @articles.none
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.permit(:query, :locale, :sort, :status, :page).tap do |permitted|
|
||||
permitted[:query] = @query
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController')
|
||||
@@ -2,10 +2,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
before_action :ensure_custom_domain_request, only: [:show]
|
||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||
before_action :portal
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
@@ -31,28 +28,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
portal
|
||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||
end
|
||||
|
||||
def load_home_data
|
||||
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||
@visible_categories = @portal.categories
|
||||
.where(locale: @locale)
|
||||
.joins(:articles).where(articles: { status: :published })
|
||||
.order(position: :asc)
|
||||
.group('categories.id')
|
||||
@popular_topics = @visible_categories.first(3)
|
||||
@featured = base_articles.order_by_views.limit(6)
|
||||
@category_contributors = build_category_contributors(@visible_categories)
|
||||
end
|
||||
|
||||
def build_category_contributors(categories)
|
||||
category_ids = categories.map(&:id)
|
||||
return {} if category_ids.empty?
|
||||
|
||||
@portal.articles
|
||||
.published
|
||||
.where(locale: @locale, category_id: category_ids)
|
||||
.includes(:author)
|
||||
.group_by(&:category_id)
|
||||
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
if errors.any?
|
||||
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
|
||||
else
|
||||
redirect_to super_admin_settings_path, flash: success_flash
|
||||
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,21 +58,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
|
||||
)
|
||||
end
|
||||
|
||||
def success_notice
|
||||
message = "#{@config.titleize} settings updated successfully"
|
||||
return message unless restart_required_config_saved?
|
||||
|
||||
"#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere."
|
||||
end
|
||||
|
||||
def success_flash
|
||||
restart_required_config_saved? ? { success: success_notice } : { notice: success_notice }
|
||||
end
|
||||
|
||||
def restart_required_config_saved?
|
||||
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
|
||||
end
|
||||
end
|
||||
|
||||
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
|
||||
|
||||
@@ -25,29 +25,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
|
||||
resource_class.editable
|
||||
end
|
||||
|
||||
def create
|
||||
resource = new_resource(resource_params)
|
||||
authorize_resource(resource)
|
||||
|
||||
if resource.save
|
||||
redirect_to after_resource_created_path(resource), flash: success_flash(resource)
|
||||
else
|
||||
render :new, locals: {
|
||||
page: Administrate::Page::Form.new(dashboard, resource)
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if requested_resource.update(resource_params)
|
||||
redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource)
|
||||
else
|
||||
render :edit, locals: {
|
||||
page: Administrate::Page::Form.new(dashboard, requested_resource)
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Override `resource_params` if you want to transform the submitted
|
||||
# data before it's persisted. For example, the following would turn all
|
||||
# empty values into nil values. It uses other APIs such as `resource_class`
|
||||
@@ -65,20 +42,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
|
||||
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def success_flash(resource)
|
||||
message = translate_with_resource('update.success')
|
||||
message = translate_with_resource('create.success') if action_name == 'create'
|
||||
return { notice: message } unless restart_required_config?(resource)
|
||||
|
||||
{ success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." }
|
||||
end
|
||||
|
||||
def restart_required_config?(resource)
|
||||
resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
|
||||
end
|
||||
|
||||
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
|
||||
# for more information
|
||||
end
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
class SwaggerController < ApplicationController
|
||||
def respond
|
||||
if Rails.env.development? || Rails.env.test?
|
||||
swagger_root = Rails.root.join('swagger')
|
||||
file_path = swagger_root.join(derived_path).cleanpath
|
||||
|
||||
return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file?
|
||||
|
||||
render inline: file_path.read
|
||||
render inline: Rails.root.join('swagger', derived_path).read
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
@@ -16,8 +11,8 @@ class SwaggerController < ApplicationController
|
||||
|
||||
def derived_path
|
||||
params[:path] ||= 'index.html'
|
||||
path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/')
|
||||
path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s)
|
||||
path = Rack::Utils.clean_path_info(params[:path])
|
||||
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
|
||||
path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,8 +20,6 @@ class Tiktok::CallbacksController < ApplicationController
|
||||
def process_successful_authorization
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
|
||||
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
|
||||
|
||||
if already_exists
|
||||
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -129,10 +127,6 @@ class Tiktok::CallbacksController < ApplicationController
|
||||
@account_id ||= verify_tiktok_token(params[:state])
|
||||
end
|
||||
|
||||
def return_to
|
||||
tiktok_token_return_to(params[:state])
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(account_id)
|
||||
end
|
||||
|
||||
@@ -31,11 +31,7 @@ class Twilio::CallbackController < ApplicationController
|
||||
:Latitude,
|
||||
:Longitude,
|
||||
:MessageType,
|
||||
:ProfileName,
|
||||
:ExternalUserId,
|
||||
:ParentExternalUserId,
|
||||
:ProfileUsername,
|
||||
:Username
|
||||
:ProfileName
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,7 +17,6 @@ class AsyncDispatcher < BaseDispatcher
|
||||
InstallationWebhookListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
Conversations::UnreadCounts::Listener.instance,
|
||||
ReportingEventListener.instance,
|
||||
WebhookListener.instance
|
||||
]
|
||||
|
||||
@@ -17,7 +17,6 @@ module FileTypeHelper
|
||||
def image_file?(content_type)
|
||||
[
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
|
||||
@@ -4,21 +4,21 @@ module Instagram::IntegrationHelper
|
||||
# Generates a signed JWT token for Instagram integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @param return_to [String, nil] Optional onboarding return hint
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_instagram_token(account_id, return_to = nil)
|
||||
def generate_instagram_token(account_id)
|
||||
return if client_secret.blank?
|
||||
|
||||
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
|
||||
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def token_payload(account_id, return_to = nil)
|
||||
payload = { sub: account_id, iat: Time.current.to_i }
|
||||
payload[:return_to] = return_to if return_to.present?
|
||||
payload
|
||||
def token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
# Verifies and decodes a Instagram JWT token
|
||||
@@ -28,14 +28,7 @@ module Instagram::IntegrationHelper
|
||||
def verify_instagram_token(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)&.dig('sub')
|
||||
end
|
||||
|
||||
# Reads the onboarding return hint from a Instagram JWT token, if present.
|
||||
def instagram_token_return_to(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)&.dig('return_to')
|
||||
decode_token(token, client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -48,7 +41,7 @@ module Instagram::IntegrationHelper
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
|
||||
nil
|
||||
|
||||
@@ -45,16 +45,9 @@ module PortalHelper
|
||||
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
|
||||
end
|
||||
|
||||
def portal_query_string(theme, is_plain_layout_enabled)
|
||||
query_params = {}
|
||||
query_params[:theme] = theme if theme.present? && theme != 'system'
|
||||
query_params[:show_plain_layout] = true if is_plain_layout_enabled
|
||||
query_params.present? ? "?#{query_params.to_query}" : ''
|
||||
end
|
||||
|
||||
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}"
|
||||
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
|
||||
else
|
||||
"/hc/#{portal_slug}/#{portal_locale}"
|
||||
end
|
||||
@@ -68,7 +61,7 @@ module PortalHelper
|
||||
is_plain_layout_enabled = params[:is_plain_layout_enabled]
|
||||
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
|
||||
else
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
|
||||
end
|
||||
@@ -76,7 +69,7 @@ module PortalHelper
|
||||
|
||||
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
|
||||
else
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}"
|
||||
end
|
||||
@@ -103,15 +96,4 @@ module PortalHelper
|
||||
|
||||
colors[username.length % colors.size]
|
||||
end
|
||||
|
||||
def format_authors_label(authors)
|
||||
return if authors.blank?
|
||||
|
||||
names = authors.map(&:available_name)
|
||||
return names.to_sentence if names.size <= 3
|
||||
|
||||
I18n.t('public_portal.sidebar.authors_others',
|
||||
names: names.first(2).join(', '),
|
||||
count: authors.size - 2)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,12 +53,13 @@ module ReportHelper
|
||||
end
|
||||
|
||||
def resolutions
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range)
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
|
||||
created_at: range)
|
||||
end
|
||||
|
||||
def bot_resolutions
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
|
||||
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range)
|
||||
end
|
||||
|
||||
def bot_handoffs
|
||||
@@ -66,10 +67,6 @@ module ReportHelper
|
||||
created_at: range).distinct
|
||||
end
|
||||
|
||||
def bot_handoff_conversation_ids_subquery
|
||||
bot_handoffs
|
||||
end
|
||||
|
||||
def avg_first_response_time
|
||||
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
|
||||
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
|
||||
|
||||
@@ -2,12 +2,11 @@ module Tiktok::IntegrationHelper
|
||||
# Generates a signed JWT token for Tiktok integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @param return_to [String, nil] Optional onboarding return hint
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_tiktok_token(account_id, return_to = nil)
|
||||
def generate_tiktok_token(account_id)
|
||||
return if client_secret.blank?
|
||||
|
||||
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
|
||||
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
|
||||
nil
|
||||
@@ -20,14 +19,7 @@ module Tiktok::IntegrationHelper
|
||||
def verify_tiktok_token(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)&.dig('sub')
|
||||
end
|
||||
|
||||
# Reads the onboarding return hint from a Tiktok JWT token, if present.
|
||||
def tiktok_token_return_to(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)&.dig('return_to')
|
||||
decode_token(token, client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -36,17 +28,18 @@ module Tiktok::IntegrationHelper
|
||||
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||
end
|
||||
|
||||
def token_payload(account_id, return_to = nil)
|
||||
payload = { sub: account_id, iat: Time.current.to_i }
|
||||
payload[:return_to] = return_to if return_to.present?
|
||||
payload
|
||||
def token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def decode_token(token, secret)
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
|
||||
nil
|
||||
|
||||
@@ -13,10 +13,6 @@ class ConversationApi extends ApiClient {
|
||||
updateLabels(conversationID, labels) {
|
||||
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
|
||||
}
|
||||
|
||||
getUnreadCounts() {
|
||||
return axios.get(`${this.url}/unread_counts`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
||||
@@ -87,13 +87,6 @@ class ArticlesAPI extends PortalsAPI {
|
||||
);
|
||||
}
|
||||
|
||||
bulkUpdateCategory({ portalSlug, articleIds, categoryId }) {
|
||||
return axios.patch(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/update_category`,
|
||||
{ ids: articleIds, category_id: categoryId }
|
||||
);
|
||||
}
|
||||
|
||||
bulkDelete({ portalSlug, articleIds }) {
|
||||
return axios.delete(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
|
||||
|
||||
@@ -12,7 +12,6 @@ export const buildCreatePayload = ({
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
templateParams,
|
||||
isVoiceMessage = false,
|
||||
}) => {
|
||||
let payload;
|
||||
if (files && files.length !== 0) {
|
||||
@@ -34,9 +33,6 @@ export const buildCreatePayload = ({
|
||||
if (contentAttributes) {
|
||||
payload.append('content_attributes', JSON.stringify(contentAttributes));
|
||||
}
|
||||
if (isVoiceMessage) {
|
||||
payload.append('is_voice_message', true);
|
||||
}
|
||||
} else {
|
||||
payload = {
|
||||
content: message,
|
||||
@@ -68,7 +64,6 @@ class MessageApi extends ApiClient {
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
templateParams,
|
||||
isVoiceMessage = false,
|
||||
}) {
|
||||
return axios({
|
||||
method: 'post',
|
||||
@@ -83,7 +78,6 @@ class MessageApi extends ApiClient {
|
||||
bccEmails,
|
||||
toEmails,
|
||||
templateParams,
|
||||
isVoiceMessage,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,14 +52,6 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
resetSecret(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/reset_secret`);
|
||||
}
|
||||
|
||||
enableWhatsappCalling(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`);
|
||||
}
|
||||
|
||||
disableWhatsappCalling(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class OnboardingAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('onboarding', { accountScoped: true });
|
||||
}
|
||||
|
||||
update(data) {
|
||||
return axios.patch(this.url, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OnboardingAPI();
|
||||
@@ -153,33 +153,4 @@ describe('#PortalAPI', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: vi.fn(() => Promise.resolve()),
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
patch: vi.fn(() => Promise.resolve()),
|
||||
delete: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#bulkUpdateCategory', () => {
|
||||
articlesAPI.bulkUpdateCategory({
|
||||
portalSlug: 'room-rental',
|
||||
articleIds: [1, 2, 3],
|
||||
categoryId: 7,
|
||||
});
|
||||
expect(axiosMock.patch).toHaveBeenCalledWith(
|
||||
'/api/v1/portals/room-rental/articles/bulk_actions/update_category',
|
||||
{ ids: [1, 2, 3], category_id: 7 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,8 +41,7 @@ describe('#ContactsAPI', () => {
|
||||
it('#getConversations', () => {
|
||||
contactAPI.getConversations(1);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/1/conversations',
|
||||
{ params: {} }
|
||||
'/api/v1/contacts/1/conversations'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ describe('#ConversationApi', () => {
|
||||
expect(conversationsAPI).toHaveProperty('delete');
|
||||
expect(conversationsAPI).toHaveProperty('getLabels');
|
||||
expect(conversationsAPI).toHaveProperty('updateLabels');
|
||||
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -48,12 +47,5 @@ describe('#ConversationApi', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getUnreadCounts', () => {
|
||||
conversationsAPI.getUnreadCounts();
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/unread_counts'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,29 +83,5 @@ describe('#ConversationAPI', () => {
|
||||
template_params: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('appends is_voice_message when isVoiceMessage is true', () => {
|
||||
const formPayload = buildCreatePayload({
|
||||
message: 'voice message',
|
||||
echoId: 42,
|
||||
isPrivate: false,
|
||||
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
|
||||
isVoiceMessage: true,
|
||||
});
|
||||
expect(formPayload).toBeInstanceOf(FormData);
|
||||
expect(formPayload.get('is_voice_message')).toEqual('true');
|
||||
});
|
||||
|
||||
it('does not append is_voice_message when isVoiceMessage is false', () => {
|
||||
const formPayload = buildCreatePayload({
|
||||
message: 'regular audio',
|
||||
echoId: 43,
|
||||
isPrivate: false,
|
||||
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
|
||||
isVoiceMessage: false,
|
||||
});
|
||||
expect(formPayload).toBeInstanceOf(FormData);
|
||||
expect(formPayload.get('is_voice_message')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,6 @@ const emit = defineEmits(['edit', 'delete']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
const STATUS_PROCESSING = 'processing';
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
@@ -69,15 +68,9 @@ const campaignStatus = computed(() => {
|
||||
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
|
||||
}
|
||||
|
||||
if (props.status === STATUS_COMPLETED) {
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED');
|
||||
}
|
||||
|
||||
if (props.status === STATUS_PROCESSING) {
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
|
||||
}
|
||||
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
return props.status === STATUS_COMPLETED
|
||||
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
|
||||
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
});
|
||||
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
@@ -32,7 +32,7 @@ const emit = defineEmits([
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-20 px-6">
|
||||
<header class="sticky top-0 z-10 px-6">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||
>
|
||||
|
||||
+15
-29
@@ -1,48 +1,34 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
const emit = defineEmits(['add', 'import', 'export']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const contactMenuItems = computed(() => [
|
||||
const contactMenuItems = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
|
||||
action: 'add',
|
||||
value: 'add',
|
||||
icon: 'i-lucide-plus',
|
||||
},
|
||||
...(checkPermissions(['administrator', 'contact_manage'])
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'
|
||||
),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(checkPermissions(['administrator', 'contact_manage'])
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
|
||||
),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
];
|
||||
const showActionsDropdown = ref(false);
|
||||
|
||||
const handleContactAction = ({ action }) => {
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 z-50"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
getVoiceCallProvider,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import {
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_OUTBOUND_INIT_STATUS,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
@@ -43,7 +39,6 @@ const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
@@ -58,16 +53,6 @@ const isInitiatingCall = computed(() => {
|
||||
return contactsUiFlags.value?.isInitiatingCall || false;
|
||||
});
|
||||
|
||||
// Mirror the conversation-header button: block a new call whenever any provider
|
||||
// call is already active or ringing, otherwise starting a WhatsApp call here
|
||||
// would leave a still-live Twilio (or other) session with no visible control.
|
||||
const isCallButtonDisabled = computed(
|
||||
() =>
|
||||
callsStore.hasActiveCall ||
|
||||
callsStore.hasIncomingCall ||
|
||||
isInitiatingCall.value
|
||||
);
|
||||
|
||||
const navigateToConversation = conversationId => {
|
||||
const accountId = route.params.accountId;
|
||||
if (conversationId && accountId) {
|
||||
@@ -115,21 +100,15 @@ const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||
// The composable returns { status: 'locked' } when an init is already in
|
||||
// flight or a call is already active; treat that as a soft no-op rather than
|
||||
// claiming success.
|
||||
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
||||
if (response?.status === 'locked') return;
|
||||
if (!response?.id) {
|
||||
// Permission template path returns no call id. Mirror the header button and
|
||||
// surface whether the request was just sent or is already pending instead of
|
||||
// claiming the call started. The permission message lands in the
|
||||
// conversation, so still navigate there.
|
||||
const messageKey =
|
||||
response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
|
||||
? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING'
|
||||
: 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED';
|
||||
useAlert(t(messageKey));
|
||||
// Permission flow returns no id — banner already handled server-side; surface to user.
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
// Stay non-active until the connect cable event arrives — flipping to active
|
||||
// here would start the duration timer before the contact picks up.
|
||||
callsStore.addCall({
|
||||
@@ -137,8 +116,8 @@ const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||
callId: response.id,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
@@ -146,7 +125,7 @@ const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||
};
|
||||
|
||||
const startCall = async (inboxId, conversationIdHint = null) => {
|
||||
if (isCallButtonDisabled.value) return;
|
||||
if (isInitiatingCall.value) return;
|
||||
|
||||
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
|
||||
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
|
||||
@@ -166,11 +145,12 @@ const startCall = async (inboxId, conversationIdHint = null) => {
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
callDirection: 'outbound',
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
@@ -218,7 +198,7 @@ const onPickInbox = async inbox => {
|
||||
v-if="shouldRender"
|
||||
v-tooltip.top-end="tooltipLabel || null"
|
||||
v-bind="attrs"
|
||||
:disabled="isCallButtonDisabled"
|
||||
:disabled="isInitiatingCall"
|
||||
:is-loading="isInitiatingCall"
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
|
||||
@@ -142,26 +142,28 @@ watch(
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-wrapper {
|
||||
:deep(.ProseMirror-menubar-wrapper) {
|
||||
.ProseMirror.ProseMirror-woot-style {
|
||||
p {
|
||||
@apply first:mt-0 !important;
|
||||
}
|
||||
::v-deep {
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror.ProseMirror-woot-style {
|
||||
p {
|
||||
@apply first:mt-0 !important;
|
||||
}
|
||||
|
||||
.empty-node {
|
||||
@apply m-0 !important;
|
||||
.empty-node {
|
||||
@apply m-0 !important;
|
||||
|
||||
&::before {
|
||||
@apply text-n-slate-11 dark:text-n-slate-11;
|
||||
&::before {
|
||||
@apply text-n-slate-11 dark:text-n-slate-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
width: fit-content !important;
|
||||
position: relative !important;
|
||||
top: unset !important;
|
||||
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
||||
.ProseMirror-menubar {
|
||||
width: fit-content !important;
|
||||
position: relative !important;
|
||||
top: unset !important;
|
||||
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-29
@@ -145,43 +145,45 @@ const handleCreateArticle = event => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.ProseMirror .empty-node::before) {
|
||||
@apply text-n-slate-10 text-base;
|
||||
}
|
||||
|
||||
:deep(.ProseMirror-menubar-wrapper) {
|
||||
.ProseMirror-woot-style {
|
||||
@apply min-h-[15rem] max-h-full;
|
||||
::v-deep {
|
||||
.ProseMirror .empty-node::before {
|
||||
@apply text-n-slate-10 text-base;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ProseMirror-menubar) {
|
||||
display: none; // Hide by default
|
||||
}
|
||||
|
||||
:deep(.editor-root .has-selection) {
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
display: none !important;
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror-woot-style {
|
||||
@apply min-h-[15rem] max-h-full;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
display: none; // Hide by default
|
||||
}
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
|
||||
}
|
||||
.editor-root .has-selection {
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-n-slate-3;
|
||||
.ProseMirror-menubar {
|
||||
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-n-slate-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-7
@@ -84,20 +84,28 @@ const findCategoryFromSlug = slug => {
|
||||
return categories.value?.find(category => category.slug === slug);
|
||||
};
|
||||
|
||||
const assignCategoryFromSlug = slug => {
|
||||
const categoryFromSlug = findCategoryFromSlug(slug);
|
||||
if (categoryFromSlug) {
|
||||
selectedCategoryId.value = categoryFromSlug.id;
|
||||
return categoryFromSlug;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectedCategory = computed(() => {
|
||||
if (isNewArticle.value) {
|
||||
if (selectedCategoryId.value) {
|
||||
return (
|
||||
categories.value?.find(c => c.id === selectedCategoryId.value) || null
|
||||
);
|
||||
}
|
||||
if (categorySlugFromRoute.value) {
|
||||
const categoryFromSlug = findCategoryFromSlug(
|
||||
const categoryFromSlug = assignCategoryFromSlug(
|
||||
categorySlugFromRoute.value
|
||||
);
|
||||
if (categoryFromSlug) return categoryFromSlug;
|
||||
}
|
||||
return categories.value?.[0] || null;
|
||||
return selectedCategoryId.value
|
||||
? categories.value.find(
|
||||
category => category.id === selectedCategoryId.value
|
||||
)
|
||||
: categories.value[0] || null;
|
||||
}
|
||||
return categories.value.find(
|
||||
category => category.id === props.article?.category?.id
|
||||
|
||||
+1
-59
@@ -2,7 +2,6 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
@@ -19,7 +18,6 @@ import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/A
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -64,7 +62,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
@@ -171,9 +168,7 @@ const handlePageChange = page => emit('pageChange', page);
|
||||
const navigateToNewArticlePage = () => {
|
||||
const { categorySlug, locale } = route.params;
|
||||
router.push({
|
||||
name: props.isCategoryArticles
|
||||
? 'portals_categories_articles_new'
|
||||
: 'portals_articles_new',
|
||||
name: 'portals_articles_new',
|
||||
params: { categorySlug, locale },
|
||||
});
|
||||
};
|
||||
@@ -224,34 +219,6 @@ const bulkUpdateStatus = async status => {
|
||||
}
|
||||
};
|
||||
|
||||
const categoryMenuItems = computed(() =>
|
||||
props.categories.map(category => ({
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
action: 'move',
|
||||
emoji: category.icon,
|
||||
}))
|
||||
);
|
||||
|
||||
const handleBulkUpdateCategory = async ({ value }) => {
|
||||
isCategoryMenuOpen.value = false;
|
||||
try {
|
||||
await articlesAPI.bulkUpdateCategory({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
categoryId: value,
|
||||
});
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
deleteConfirmDialogRef.value?.open();
|
||||
};
|
||||
@@ -307,7 +274,6 @@ watch(
|
||||
:categories="categories"
|
||||
:allowed-locales="allowedLocales"
|
||||
:has-selected-category="isCategoryArticles"
|
||||
@new-article="navigateToNewArticlePage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -371,30 +337,6 @@ watch(
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('archived')"
|
||||
/>
|
||||
<div v-if="categoryMenuItems.length" class="relative group">
|
||||
<OnClickOutside @trigger="isCategoryMenuOpen = false">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-folder-input"
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.MOVE_TO_CATEGORY'
|
||||
)
|
||||
"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="isCategoryMenuOpen"
|
||||
:menu-items="categoryMenuItems"
|
||||
show-search
|
||||
class="right-0 w-48 mt-2 top-full max-h-60"
|
||||
@action="handleBulkUpdateCategory"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isTranslationAvailable"
|
||||
sm
|
||||
|
||||
+1
-1
@@ -202,7 +202,7 @@ defineExpose({ state, isSubmitDisabled });
|
||||
size="sm"
|
||||
type="button"
|
||||
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
||||
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
||||
/>
|
||||
<EmojiInput
|
||||
|
||||
+2
-8
@@ -25,7 +25,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['localeChange', 'newArticle']);
|
||||
const emit = defineEmits(['localeChange']);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -179,7 +179,7 @@ const handleBreadcrumbClick = () => {
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div v-else class="relative flex items-center gap-2">
|
||||
<div v-else class="relative">
|
||||
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
|
||||
@@ -196,12 +196,6 @@ const handleBreadcrumbClick = () => {
|
||||
@close="isEditCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<Button
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="emit('newArticle')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
-290
@@ -1,290 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import classicLayoutPreview from './classic-layout-preview.svg?raw';
|
||||
import documentationLayoutPreview from './documentation-layout-preview.svg?raw';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activePortal: { type: Object, required: true },
|
||||
isFetching: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updatePortalConfiguration']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const PORTAL_LAYOUTS = {
|
||||
CLASSIC: 'classic',
|
||||
DOCUMENTATION: 'documentation',
|
||||
};
|
||||
|
||||
// `prefix` is the link the help center auto-fills; the DB only stores the handle.
|
||||
const SOCIAL_PLATFORMS = [
|
||||
{
|
||||
key: 'facebook',
|
||||
label: 'Facebook',
|
||||
icon: 'i-ri-facebook-circle-fill',
|
||||
prefix: 'facebook.com/',
|
||||
},
|
||||
{ key: 'x', label: 'X', icon: 'i-ri-twitter-x-fill', prefix: 'x.com/' },
|
||||
{
|
||||
key: 'instagram',
|
||||
label: 'Instagram',
|
||||
icon: 'i-ri-instagram-fill',
|
||||
prefix: 'instagram.com/',
|
||||
},
|
||||
{
|
||||
key: 'linkedin',
|
||||
label: 'LinkedIn',
|
||||
icon: 'i-ri-linkedin-box-fill',
|
||||
prefix: 'linkedin.com/',
|
||||
},
|
||||
{
|
||||
key: 'youtube',
|
||||
label: 'YouTube',
|
||||
icon: 'i-ri-youtube-fill',
|
||||
prefix: 'youtube.com/',
|
||||
},
|
||||
{
|
||||
key: 'tiktok',
|
||||
label: 'TikTok',
|
||||
icon: 'i-ri-tiktok-fill',
|
||||
prefix: 'tiktok.com/',
|
||||
},
|
||||
{
|
||||
key: 'github',
|
||||
label: 'GitHub',
|
||||
icon: 'i-ri-github-fill',
|
||||
prefix: 'github.com/',
|
||||
},
|
||||
{
|
||||
key: 'whatsapp',
|
||||
label: 'WhatsApp',
|
||||
icon: 'i-ri-whatsapp-fill',
|
||||
prefix: 'wa.me/',
|
||||
},
|
||||
];
|
||||
|
||||
const portalConfig = computed(() => props.activePortal?.config || {});
|
||||
|
||||
const state = reactive({
|
||||
layout: PORTAL_LAYOUTS.CLASSIC,
|
||||
socialProfiles: {},
|
||||
});
|
||||
const visiblePlatforms = ref([]);
|
||||
const showAddMenu = ref(false);
|
||||
|
||||
let originalSnapshot = '';
|
||||
|
||||
const platformByKey = key => SOCIAL_PLATFORMS.find(p => p.key === key);
|
||||
|
||||
const trimmedHandle = key => (state.socialProfiles[key] || '').trim();
|
||||
|
||||
const buildSocialProfiles = () =>
|
||||
visiblePlatforms.value.reduce((acc, key) => {
|
||||
const handle = trimmedHandle(key);
|
||||
if (handle) acc[key] = handle;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const snapshot = () =>
|
||||
JSON.stringify({ layout: state.layout, social: buildSocialProfiles() });
|
||||
|
||||
const resetFromPortal = () => {
|
||||
const savedProfiles = portalConfig.value.social_profiles || {};
|
||||
state.layout = portalConfig.value.layout || PORTAL_LAYOUTS.CLASSIC;
|
||||
state.socialProfiles = SOCIAL_PLATFORMS.reduce((acc, { key }) => {
|
||||
acc[key] = savedProfiles[key] || '';
|
||||
return acc;
|
||||
}, {});
|
||||
visiblePlatforms.value = SOCIAL_PLATFORMS.map(p => p.key).filter(key =>
|
||||
(savedProfiles[key] || '').trim()
|
||||
);
|
||||
originalSnapshot = snapshot();
|
||||
};
|
||||
|
||||
watch(() => props.activePortal, resetFromPortal, {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
});
|
||||
|
||||
const hasChanges = computed(() => snapshot() !== originalSnapshot);
|
||||
|
||||
const visiblePlatformDetails = computed(() =>
|
||||
visiblePlatforms.value.map(platformByKey)
|
||||
);
|
||||
|
||||
const addablePlatforms = computed(() =>
|
||||
SOCIAL_PLATFORMS.filter(p => !visiblePlatforms.value.includes(p.key)).map(
|
||||
p => ({ label: p.label, value: p.key, action: p.key, icon: p.icon })
|
||||
)
|
||||
);
|
||||
|
||||
const addPlatform = ({ value }) => {
|
||||
if (!visiblePlatforms.value.includes(value)) {
|
||||
visiblePlatforms.value.push(value);
|
||||
}
|
||||
showAddMenu.value = false;
|
||||
};
|
||||
|
||||
const removePlatform = key => {
|
||||
visiblePlatforms.value = visiblePlatforms.value.filter(k => k !== key);
|
||||
state.socialProfiles[key] = '';
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
emit('updatePortalConfiguration', {
|
||||
id: props.activePortal.id,
|
||||
slug: props.activePortal.slug,
|
||||
config: {
|
||||
layout: state.layout,
|
||||
social_profiles: buildSocialProfiles(),
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.HEADER') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-n-slate-11">
|
||||
<RadioCard
|
||||
:id="PORTAL_LAYOUTS.CLASSIC"
|
||||
:is-active="state.layout === PORTAL_LAYOUTS.CLASSIC"
|
||||
:label="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.TITLE')
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.DESCRIPTION'
|
||||
)
|
||||
"
|
||||
@select="value => (state.layout = value)"
|
||||
>
|
||||
<div
|
||||
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
|
||||
>
|
||||
<span v-dompurify-html="classicLayoutPreview" />
|
||||
</div>
|
||||
</RadioCard>
|
||||
|
||||
<RadioCard
|
||||
:id="PORTAL_LAYOUTS.DOCUMENTATION"
|
||||
beta
|
||||
:is-active="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
|
||||
:label="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.TITLE')
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.DESCRIPTION'
|
||||
)
|
||||
"
|
||||
@select="value => (state.layout = value)"
|
||||
>
|
||||
<div
|
||||
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
|
||||
>
|
||||
<span v-dompurify-html="documentationLayoutPreview" />
|
||||
</div>
|
||||
</RadioCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h6 class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.HEADER')
|
||||
}}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="platform in visiblePlatformDetails"
|
||||
:key="platform.key"
|
||||
class="flex items-center h-10 gap-1.5 px-3 rounded-lg outline outline-1 outline-n-weak focus-within:outline-n-brand"
|
||||
>
|
||||
<Icon :icon="platform.icon" class="size-4 shrink-0 text-n-slate-11" />
|
||||
<span class="text-sm shrink-0 text-n-slate-10">{{
|
||||
platform.prefix
|
||||
}}</span>
|
||||
<input
|
||||
v-model="state.socialProfiles[platform.key]"
|
||||
type="text"
|
||||
class="flex-1 min-w-0 text-sm bg-transparent outline-none reset-base text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-x"
|
||||
color="slate"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:aria-label="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.REMOVE')
|
||||
"
|
||||
@click="removePlatform(platform.key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="addablePlatforms.length"
|
||||
v-on-clickaway="() => (showAddMenu = false)"
|
||||
class="relative"
|
||||
>
|
||||
<Button
|
||||
:label="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.ADD')
|
||||
"
|
||||
icon="i-lucide-plus"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
@click="showAddMenu = !showAddMenu"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showAddMenu"
|
||||
:menu-items="addablePlatforms"
|
||||
class="mt-1 w-52 top-full ltr:left-0 rtl:right-0"
|
||||
@action="addPlatform"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SAVE')"
|
||||
:disabled="!hasChanges || isFetching"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-7
@@ -7,7 +7,6 @@ import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
|
||||
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
||||
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
|
||||
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -103,12 +102,6 @@ const handleDeletePortal = () => {
|
||||
@send-cname-instructions="handleSendCnameInstructions"
|
||||
/>
|
||||
<div class="w-full h-px bg-n-weak" />
|
||||
<PortalLayoutContentSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
/>
|
||||
<div class="w-full h-px bg-n-weak" />
|
||||
<div class="flex items-end justify-between w-full gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
|
||||
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
|
||||
<rect x="14" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="14" y="22" width="64" height="5" rx="1" fill="currentColor" opacity="0.32"/>
|
||||
<rect x="14" y="32" width="128" height="9" rx="2" fill="currentColor" opacity="0.18"/>
|
||||
<rect x="14" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
|
||||
<rect x="104" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
|
||||
<rect x="14" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
|
||||
<rect x="104" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 818 B |
-18
@@ -1,18 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
|
||||
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
|
||||
<rect x="6" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="0" y="14" width="50" height="106" fill="currentColor" opacity="0.07"/>
|
||||
<rect x="6" y="22" width="38" height="4" rx="1" fill="currentColor" opacity="0.25"/>
|
||||
<rect x="6" y="32" width="30" height="3" rx="1" fill="currentColor" opacity="0.18"/>
|
||||
<rect x="6" y="40" width="35" height="3" rx="1" fill="currentColor" opacity="0.18"/>
|
||||
<rect x="6" y="48" width="28" height="3" rx="1" fill="currentColor" opacity="0.18"/>
|
||||
<rect x="6" y="56" width="32" height="3" rx="1" fill="currentColor" opacity="0.18"/>
|
||||
<rect x="60" y="25" width="80" height="6" rx="1" fill="currentColor" opacity="0.35"/>
|
||||
<rect x="60" y="38" width="120" height="8" rx="2" fill="currentColor" opacity="0.18"/>
|
||||
<rect x="60" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
|
||||
<rect x="101" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
|
||||
<rect x="142" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
|
||||
<rect x="60" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
|
||||
<rect x="101" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
|
||||
<rect x="142" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -19,7 +19,6 @@ const DEFAULT_ROUTE = 'portals_articles_index';
|
||||
const CATEGORY_ROUTE = 'portals_categories_index';
|
||||
const CATEGORY_SUB_ROUTES = [
|
||||
'portals_categories_articles_index',
|
||||
'portals_categories_articles_new',
|
||||
'portals_categories_articles_edit',
|
||||
];
|
||||
|
||||
|
||||
@@ -32,11 +32,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ header }}
|
||||
</span>
|
||||
<template v-if="hideToggle">
|
||||
<slot name="hiddenToggle">
|
||||
<div class="size-2" />
|
||||
</slot>
|
||||
</template>
|
||||
<div v-if="hideToggle" class="size-2" />
|
||||
<ToggleSwitch v-else v-model="modelValue" />
|
||||
</div>
|
||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
<script setup>
|
||||
import CallCard from './CallCard.vue';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
|
||||
const whatsappCall = {
|
||||
callSid: 'wamid.HBgM',
|
||||
conversationId: 1234,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
};
|
||||
|
||||
const twilioCall = {
|
||||
callSid: 'CA1234567890',
|
||||
conversationId: 1234,
|
||||
};
|
||||
|
||||
const richCallInfo = {
|
||||
contactName: 'John Does',
|
||||
phoneNumber: '+1 412 245 0242',
|
||||
inboxName: 'Customer support',
|
||||
location: 'San Francisco, United States',
|
||||
countryFlag: '\u{1F1FA}\u{1F1F8}',
|
||||
hasLocation: true,
|
||||
avatar:
|
||||
'https://app.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd3FodGc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--d218a325af0ef45061eefd352f8efb9ac84275e8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--533c3ad7218e24c4b0e8f8959dc1953ce1d279b9/1707423736896.jpeg',
|
||||
};
|
||||
|
||||
// Falls back to inbox name when neither city nor country is on the contact.
|
||||
const noLocationCallInfo = {
|
||||
contactName: 'Anonymous Lead',
|
||||
phoneNumber: '+44 20 7916 0428',
|
||||
inboxName: 'Sales',
|
||||
location: 'Sales',
|
||||
countryFlag: '',
|
||||
hasLocation: false,
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
// Truncation regression check — both contact name and location should ellipsize.
|
||||
const longCallInfo = {
|
||||
contactName: 'Aleksandra Konstantinopolskaya',
|
||||
phoneNumber: '+49 30 911890',
|
||||
inboxName: 'Customer support',
|
||||
location:
|
||||
'Friedrichshain-Kreuzberg, Federal Republic of Germany (Deutschland)',
|
||||
countryFlag: '\u{1F1E9}\u{1F1EA}',
|
||||
hasLocation: true,
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
const unknownCallerInfo = {
|
||||
contactName: 'Unknown caller',
|
||||
phoneNumber: '',
|
||||
inboxName: 'Customer support',
|
||||
location: 'Customer support',
|
||||
countryFlag: '',
|
||||
hasLocation: false,
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
const log =
|
||||
(label, ...args) =>
|
||||
() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[CallCard.story] ${label}`, ...args);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Call/CallCard"
|
||||
:layout="{ type: 'grid', width: '400px' }"
|
||||
>
|
||||
<Variant title="Incoming · WhatsApp">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Incoming · Twilio (no flag)">
|
||||
<CallCard
|
||||
:call="twilioCall"
|
||||
:call-info="noLocationCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Outgoing · WhatsApp ringing">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.OUTGOING"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · WhatsApp (mute visible, unmuted)">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="01:23"
|
||||
:is-muted="false"
|
||||
show-mute
|
||||
@end="log('end')"
|
||||
@toggle-mute="log('toggleMute')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · WhatsApp muted">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="03:47"
|
||||
is-muted
|
||||
show-mute
|
||||
@end="log('end')"
|
||||
@toggle-mute="log('toggleMute')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · Twilio (no mute control)">
|
||||
<CallCard
|
||||
:call="twilioCall"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="00:42"
|
||||
@end="log('end')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Incoming · Unknown caller">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="unknownCallerInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Truncation · long name and location">
|
||||
<CallCard
|
||||
:call="whatsappCall"
|
||||
:call-info="longCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.INCOMING"
|
||||
@accept="log('accept')"
|
||||
@reject="log('reject')"
|
||||
@go-to-conversation="log('goToConversation')"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Ongoing · no conversation link in footer">
|
||||
<CallCard
|
||||
:call="{ ...whatsappCall, conversationId: null }"
|
||||
:call-info="richCallInfo"
|
||||
:state="VOICE_CALL_DIRECTION.ONGOING"
|
||||
duration="02:10"
|
||||
show-mute
|
||||
@end="log('end')"
|
||||
@toggle-mute="log('toggleMute')"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@ const store = useStore();
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const isSyncableDocument = doc =>
|
||||
!doc.pdf_document && doc.status === 'available';
|
||||
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
|
||||
|
||||
const syncableSelectedIds = computed(() => {
|
||||
if (!props.selectedIds.size) return [];
|
||||
|
||||
@@ -127,6 +127,7 @@ const menuItems = computed(() => {
|
||||
value: 'sync',
|
||||
action: 'sync',
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
disabled: props.syncInProgress,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
import { defineModel, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, defineModel, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, defineProps, defineEmits } from 'vue';
|
||||
import { Chrome } from '@lk77/vue3-color';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, h, watch, ref } from 'vue';
|
||||
import { computed, defineModel, h, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { defineModel, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineModel, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { h } from 'vue';
|
||||
import { h, defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
country: { type: String, required: true },
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<script setup>
|
||||
import ChannelIcon from './ChannelIcon.vue';
|
||||
import { useChannelBrandIcon } from './provider';
|
||||
|
||||
const inboxes = [
|
||||
{ name: 'API', channel_type: 'Channel::Api' },
|
||||
{ name: 'Email', channel_type: 'Channel::Email' },
|
||||
{ name: 'Gmail', channel_type: 'Channel::Email', provider: 'google' },
|
||||
{ name: 'Outlook', channel_type: 'Channel::Email', provider: 'microsoft' },
|
||||
{ name: 'Messenger', channel_type: 'Channel::FacebookPage' },
|
||||
{ name: 'Instagram', channel_type: 'Channel::Instagram' },
|
||||
{ name: 'Line', channel_type: 'Channel::Line' },
|
||||
{ name: 'Telegram', channel_type: 'Channel::Telegram' },
|
||||
{ name: 'WhatsApp', channel_type: 'Channel::Whatsapp' },
|
||||
{ name: 'TikTok', channel_type: 'Channel::Tiktok' },
|
||||
{ name: 'SMS', channel_type: 'Channel::Sms' },
|
||||
{ name: 'Twilio SMS', channel_type: 'Channel::TwilioSms' },
|
||||
{
|
||||
name: 'Twilio WhatsApp',
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'whatsapp',
|
||||
},
|
||||
{
|
||||
name: 'Voice',
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
voice_enabled: true,
|
||||
},
|
||||
{ name: 'Website', channel_type: 'Channel::WebWidget' },
|
||||
];
|
||||
|
||||
const brandInboxes = inboxes.filter(inbox => useChannelBrandIcon(inbox).value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Icons/ChannelIcon">
|
||||
<Variant title="Glyph (default)">
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
<div
|
||||
v-for="inbox in inboxes"
|
||||
:key="inbox.name"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<ChannelIcon :inbox="inbox" class="size-6 text-n-slate-11" />
|
||||
<span>{{ inbox.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Brand icon">
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
<div
|
||||
v-for="inbox in brandInboxes"
|
||||
:key="inbox.name"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<ChannelIcon
|
||||
:inbox="inbox"
|
||||
use-brand-icon
|
||||
class="size-6 text-n-slate-11"
|
||||
/>
|
||||
<span>{{ inbox.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { useChannelIcon, useChannelBrandIcon } from './provider';
|
||||
import { useChannelIcon } from './provider';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -9,35 +9,22 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// When true, render the full-color brand icon (when one exists for the
|
||||
// channel type) and fall back to the monochrome glyph otherwise.
|
||||
useBrandIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const inboxRef = toRef(props, 'inbox');
|
||||
|
||||
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
|
||||
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
|
||||
const channelIcon = useChannelIcon(inboxRef);
|
||||
const brandIcon = useChannelBrandIcon(inboxRef);
|
||||
|
||||
const icon = computed(() =>
|
||||
props.useBrandIcon && brandIcon.value ? brandIcon.value : channelIcon.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="relative inline-flex" v-bind="$attrs">
|
||||
<Icon :icon="icon" class="size-full" />
|
||||
<Icon :icon="channelIcon" class="size-full" />
|
||||
<span
|
||||
v-if="hasVoiceBadge"
|
||||
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
|
||||
class="absolute -top-1 -right-1 inline-flex items-center justify-center size-2.5 rounded-full bg-n-alpha-2 ring-1 ring-n-solid-1"
|
||||
>
|
||||
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
|
||||
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-10" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -18,7 +18,6 @@ const fileTypeIcon = computed(() => {
|
||||
json: 'i-woot-file-txt',
|
||||
odt: 'i-woot-file-doc',
|
||||
pdf: 'i-woot-file-pdf',
|
||||
pfx: 'i-woot-file-pfx',
|
||||
ppt: 'i-woot-file-ppt',
|
||||
pptx: 'i-woot-file-ppt',
|
||||
rar: 'i-woot-file-zip',
|
||||
@@ -27,7 +26,6 @@ const fileTypeIcon = computed(() => {
|
||||
txt: 'i-woot-file-txt',
|
||||
xls: 'i-woot-file-xls',
|
||||
xlsx: 'i-woot-file-xls',
|
||||
xml: 'i-woot-file-txt',
|
||||
zip: 'i-woot-file-zip',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,63 +1,39 @@
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const channelTypeIconMap = {
|
||||
'Channel::Api': 'i-woot-api',
|
||||
'Channel::Email': 'i-woot-mail',
|
||||
'Channel::FacebookPage': 'i-woot-messenger',
|
||||
'Channel::Line': 'i-woot-line',
|
||||
'Channel::Sms': 'i-woot-sms',
|
||||
'Channel::Telegram': 'i-woot-telegram',
|
||||
'Channel::TwilioSms': 'i-woot-sms',
|
||||
'Channel::TwitterProfile': 'i-woot-x',
|
||||
'Channel::WebWidget': 'i-woot-website',
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
microsoft: 'i-woot-outlook',
|
||||
google: 'i-woot-gmail',
|
||||
};
|
||||
|
||||
// Full-color brand icons. Most come from the `logos` Iconify set; Instagram,
|
||||
// Outlook and Line use custom `woot` glyphs since the `logos` versions are
|
||||
// monochrome or missing. Channels not listed here have no brand variant and
|
||||
// callers should fall back to the monochrome glyph via useChannelIcon.
|
||||
const channelTypeBrandIconMap = {
|
||||
'Channel::FacebookPage': 'i-logos-messenger',
|
||||
'Channel::Line': 'i-woot-line-color',
|
||||
'Channel::Telegram': 'i-logos-telegram',
|
||||
'Channel::Whatsapp': 'i-logos-whatsapp-icon',
|
||||
'Channel::Instagram': 'i-woot-instagram-color',
|
||||
'Channel::Tiktok': 'i-logos-tiktok-icon',
|
||||
};
|
||||
|
||||
const providerBrandIconMap = {
|
||||
microsoft: 'i-woot-outlook-color',
|
||||
google: 'i-logos-google-gmail',
|
||||
};
|
||||
|
||||
const resolveInbox = inbox => inbox?.value ?? inbox;
|
||||
|
||||
export function useChannelIcon(inbox) {
|
||||
const channelTypeIconMap = {
|
||||
'Channel::Api': 'i-woot-api',
|
||||
'Channel::Email': 'i-woot-mail',
|
||||
'Channel::FacebookPage': 'i-woot-messenger',
|
||||
'Channel::Line': 'i-woot-line',
|
||||
'Channel::Sms': 'i-woot-sms',
|
||||
'Channel::Telegram': 'i-woot-telegram',
|
||||
'Channel::TwilioSms': 'i-woot-sms',
|
||||
'Channel::TwitterProfile': 'i-woot-x',
|
||||
'Channel::WebWidget': 'i-woot-website',
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
microsoft: 'i-woot-outlook',
|
||||
google: 'i-woot-gmail',
|
||||
};
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
const inboxDetails = resolveInbox(inbox);
|
||||
const inboxDetails = inbox.value || inbox;
|
||||
const type = inboxDetails.channel_type;
|
||||
let icon = channelTypeIconMap[type];
|
||||
|
||||
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
|
||||
if (type === 'Channel::Email' && inboxDetails.provider) {
|
||||
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
|
||||
icon = providerIconMap[inboxDetails.provider];
|
||||
}
|
||||
}
|
||||
|
||||
// Special case for Twilio whatsapp
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
) {
|
||||
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
@@ -66,9 +42,9 @@ export function useChannelIcon(inbox) {
|
||||
const voiceEnabled =
|
||||
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
type === 'Channel::TwilioSms' &&
|
||||
voiceEnabled &&
|
||||
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
inboxDetails.medium !== 'whatsapp'
|
||||
) {
|
||||
icon = 'i-woot-voice';
|
||||
}
|
||||
@@ -78,26 +54,3 @@ export function useChannelIcon(inbox) {
|
||||
|
||||
return channelIcon;
|
||||
}
|
||||
|
||||
export function useChannelBrandIcon(inbox) {
|
||||
return computed(() => {
|
||||
const inboxDetails = resolveInbox(inbox);
|
||||
const type = inboxDetails.channel_type;
|
||||
let icon = channelTypeBrandIconMap[type];
|
||||
|
||||
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
|
||||
if (Object.keys(providerBrandIconMap).includes(inboxDetails.provider)) {
|
||||
icon = providerBrandIconMap[inboxDetails.provider];
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
) {
|
||||
icon = channelTypeBrandIconMap['Channel::Whatsapp'];
|
||||
}
|
||||
|
||||
return icon ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import FileBubble from './bubbles/File.vue';
|
||||
import AudioBubble from './bubbles/Audio.vue';
|
||||
import VideoBubble from './bubbles/Video.vue';
|
||||
import EmbedBubble from './bubbles/Embed.vue';
|
||||
import FallbackBubble from './bubbles/Fallback.vue';
|
||||
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
||||
import EmailBubble from './bubbles/Email/Index.vue';
|
||||
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
||||
@@ -329,8 +328,6 @@ const componentToRender = computed(() => {
|
||||
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||
const fileType = props.attachments[0].fileType;
|
||||
|
||||
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
|
||||
|
||||
if (!props.content) {
|
||||
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
|
||||
@@ -557,10 +554,11 @@ provideMessageContext({
|
||||
<Avatar v-bind="avatarInfo" :size="24" />
|
||||
</div>
|
||||
<div
|
||||
class="[grid-area:bubble] flex min-w-0"
|
||||
class="[grid-area:bubble] flex"
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue';
|
||||
import { defineProps, computed, reactive } from 'vue';
|
||||
import Message from './Message.vue';
|
||||
import { MESSAGE_TYPES } from './constants.js';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
defineProps({
|
||||
showingOriginal: Boolean,
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="text-sm min-w-0"
|
||||
class="text-sm"
|
||||
:class="[
|
||||
messageClass,
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import FormattedContent from './Text/FormattedContent.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
@@ -42,8 +41,7 @@ const starRatingValue = computed(() => {
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
|
||||
<FormattedContent v-if="content" :content="content" />
|
||||
<h4 v-else>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
|
||||
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
|
||||
<dl v-if="isRatingSubmitted" class="mt-4">
|
||||
<dt class="text-n-slate-11 italic">
|
||||
{{ t('CONVERSATION.RATING_TITLE') }}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import FormattedContent from './Text/FormattedContent.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { attachments, content } = useMessageContext();
|
||||
|
||||
const attachment = computed(() => attachments.value?.[0] || {});
|
||||
const url = computed(
|
||||
() => attachment.value.dataUrl || attachment.value.data_url
|
||||
);
|
||||
const title = computed(
|
||||
() =>
|
||||
attachment.value.fallbackTitle ||
|
||||
attachment.value.fallback_title ||
|
||||
url.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-3" data-bubble-name="fallback">
|
||||
<FormattedContent v-if="content" :content="content" class="mb-2" />
|
||||
<a
|
||||
v-if="url"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block max-w-[320px] truncate text-sm text-n-brand underline"
|
||||
>
|
||||
{{ title }}
|
||||
</a>
|
||||
<span v-else class="text-sm text-n-slate-11">
|
||||
{{ title }}
|
||||
</span>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -6,12 +6,9 @@ import BaseBubble from './Base.vue';
|
||||
|
||||
const { inboxId } = useMessageContext();
|
||||
|
||||
const {
|
||||
isAFacebookInbox,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
isAWhatsAppChannel,
|
||||
} = useInbox(inboxId.value);
|
||||
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
|
||||
inboxId.value
|
||||
);
|
||||
|
||||
const unsupportedMessageKey = computed(() => {
|
||||
if (isAFacebookInbox.value)
|
||||
@@ -19,8 +16,6 @@ const unsupportedMessageKey = computed(() => {
|
||||
if (isAnInstagramChannel.value)
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
|
||||
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
|
||||
if (isAWhatsAppChannel.value)
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP';
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,27 +2,17 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_OUTBOUND_INIT_STATUS,
|
||||
VOICE_CALL_END_REASON,
|
||||
MESSAGE_TYPES,
|
||||
ATTACHMENT_TYPES,
|
||||
} from '../constants';
|
||||
import { VOICE_CALL_STATUS, MESSAGE_TYPES } from '../constants';
|
||||
import { useCallActions } from 'dashboard/composables/useCallSession';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
@@ -51,11 +41,6 @@ const {
|
||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||
useCallActions();
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
const callsStore = useCallsStore();
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isInitiatingCall = computed(
|
||||
() => contactsUiFlags.value?.isInitiatingCall || false
|
||||
);
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
// Server-side call records use `outgoing`/`incoming`, while the Pinia store
|
||||
@@ -63,34 +48,22 @@ const status = computed(() => call.value?.status);
|
||||
// bubble label matches the message orientation no matter the source.
|
||||
const isOutbound = computed(() => {
|
||||
const dir = call.value?.direction;
|
||||
if (
|
||||
dir === VOICE_CALL_DIRECTION.OUTGOING ||
|
||||
dir === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
dir === VOICE_CALL_DIRECTION.INCOMING ||
|
||||
dir === VOICE_CALL_DIRECTION.INBOUND
|
||||
)
|
||||
return false;
|
||||
if (dir === 'outgoing' || dir === 'outbound') return true;
|
||||
if (dir === 'incoming' || dir === 'inbound') return false;
|
||||
// Fall back to the message orientation: agent-authored messages sit on the
|
||||
// right (outbound) and contact-authored ones on the left.
|
||||
return messageType.value === MESSAGE_TYPES.OUTGOING;
|
||||
});
|
||||
const isWhatsapp = computed(
|
||||
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
);
|
||||
const isWhatsapp = computed(() => call.value?.provider === 'whatsapp');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
||||
const endReason = computed(() => call.value?.endReason);
|
||||
const wasDeclinedByAgent = computed(
|
||||
() =>
|
||||
isMissedInbound.value &&
|
||||
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
@@ -108,7 +81,7 @@ const displayAgentName = computed(() => {
|
||||
});
|
||||
|
||||
const audioAttachment = computed(() =>
|
||||
(attachments?.value || []).find(a => a.fileType === ATTACHMENT_TYPES.AUDIO)
|
||||
(attachments?.value || []).find(a => a.fileType === 'audio')
|
||||
);
|
||||
|
||||
const durationSeconds = computed(() => {
|
||||
@@ -120,61 +93,46 @@ const durationSeconds = computed(() => {
|
||||
|
||||
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
|
||||
|
||||
// Agent who handled the call (initiator on outbound, answerer on inbound), taken
|
||||
// strictly from the persisted accept fields — never the conversation's current
|
||||
// assignee, which would mis-attribute a historical call after a reassignment.
|
||||
const handlerName = computed(() => {
|
||||
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
|
||||
if (!acceptedByAgentId.value) return null;
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
return agent?.available_name || agent?.name || null;
|
||||
});
|
||||
|
||||
const handledBy = computed(() =>
|
||||
handlerName.value
|
||||
? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value })
|
||||
: null
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
if (isFailed.value) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
|
||||
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||
}
|
||||
// RINGING or an as-yet-unknown/initial status: orient purely by direction so an
|
||||
// outbound call never falls through to the "Incoming call" label.
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtext = computed(() => {
|
||||
// Completed: "Handled by {agent} · 0:42" (drops either part when absent).
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? t('CONVERSATION.VOICE_CALL.CALLING')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return [handledBy.value, formattedDuration.value]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return formattedDuration.value;
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return handledBy.value;
|
||||
}
|
||||
if (isFailed.value) {
|
||||
// Missed/failed calls have no handler, so keep the reason rather than "Handled by".
|
||||
if (isOutbound.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
|
||||
if (isOutbound.value) return null;
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
if (wasDeclinedByAgent.value && displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', {
|
||||
if (displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
|
||||
return null;
|
||||
}
|
||||
// RINGING or an as-yet-unknown/initial status.
|
||||
if (isOutbound.value) {
|
||||
return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING');
|
||||
if (isFailed.value) {
|
||||
return isOutbound.value
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT')
|
||||
: t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
@@ -221,7 +179,7 @@ const recordingAttachment = computed(() => {
|
||||
if (!url) return null;
|
||||
return {
|
||||
dataUrl: url,
|
||||
fileType: ATTACHMENT_TYPES.AUDIO,
|
||||
fileType: 'audio',
|
||||
extension: 'wav',
|
||||
transcribedText: call.value?.transcript || '',
|
||||
};
|
||||
@@ -246,53 +204,35 @@ const handleJoinCall = async () => {
|
||||
};
|
||||
|
||||
const canCallBack = computed(
|
||||
() =>
|
||||
isMissedInbound.value &&
|
||||
!!inboxId.value &&
|
||||
!!conversationId.value &&
|
||||
!hasActiveCall.value &&
|
||||
!callsStore.hasIncomingCall
|
||||
() => isMissedInbound.value && !!inboxId.value && !!conversationId.value
|
||||
);
|
||||
|
||||
const handleCallBack = async () => {
|
||||
if (!canCallBack.value || isInitiatingCall.value) return;
|
||||
if (!canCallBack.value) return;
|
||||
try {
|
||||
if (isWhatsapp.value) {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
conversationId.value
|
||||
);
|
||||
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
||||
// Permission template path returns no call id — show banner, no widget yet.
|
||||
if (!response?.id) {
|
||||
useAlert(
|
||||
response?.status ===
|
||||
VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
|
||||
? t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING')
|
||||
: t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED')
|
||||
);
|
||||
return;
|
||||
if (response?.status === 'locked') return;
|
||||
if (response?.call_id) {
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
}
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const response = await store.dispatch('contacts/initiateCall', {
|
||||
await store.dispatch('contacts/initiateCall', {
|
||||
contactId: sender.value?.id,
|
||||
inboxId: inboxId.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
callsStore.addCall({
|
||||
callSid: response?.call_sid,
|
||||
conversationId: response?.conversation_id ?? conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
}
|
||||
@@ -333,28 +273,31 @@ const handleCallBack = async () => {
|
||||
/>
|
||||
|
||||
<!-- Call back button (missed inbound) -->
|
||||
<NextButton
|
||||
<button
|
||||
v-if="canCallBack"
|
||||
type="button"
|
||||
:label="$t('CONVERSATION.VOICE_CALL.CALL_BACK')"
|
||||
icon="i-ph-phone-bold"
|
||||
teal
|
||||
class="!rounded-full"
|
||||
:disabled="isInitiatingCall"
|
||||
class="flex justify-center items-center gap-2 px-3 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleCallBack"
|
||||
/>
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
<span class="text-sm font-medium text-white tracking-tight">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.CALL_BACK') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Join call button (ringing inbound) -->
|
||||
<NextButton
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
:label="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')"
|
||||
icon="i-ph-phone-bold"
|
||||
teal
|
||||
class="!rounded-full"
|
||||
class="flex justify-center items-center gap-2 px-3 h-10 bg-n-teal-9 hover:bg-n-teal-10 disabled:opacity-50 rounded-full transition-colors"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
/>
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
<span class="text-sm font-medium text-white tracking-tight">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, useAttrs } from 'vue';
|
||||
import { computed, defineOptions, useAttrs } from 'vue';
|
||||
|
||||
import ImageChip from 'next/message/chips/Image.vue';
|
||||
import VideoChip from 'next/message/chips/Video.vue';
|
||||
|
||||
@@ -31,17 +31,6 @@ const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
});
|
||||
|
||||
const TRANSCRIPT_PREVIEW_LENGTH = 200;
|
||||
const isTranscriptExpanded = ref(false);
|
||||
const isTranscriptLong = computed(
|
||||
() => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH
|
||||
);
|
||||
const displayedTranscript = computed(() => {
|
||||
const text = attachment.transcribedText || '';
|
||||
if (!isTranscriptLong.value || isTranscriptExpanded.value) return text;
|
||||
return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}…`;
|
||||
});
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
|
||||
const isPlaying = ref(false);
|
||||
@@ -226,18 +215,7 @@ const downloadAudio = async () => {
|
||||
v-if="attachment.transcribedText && showTranscribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ displayedTranscript }}
|
||||
<button
|
||||
v-if="isTranscriptLong"
|
||||
class="block mt-1 p-0 border-0 bg-transparent text-n-slate-11 hover:text-n-slate-12 font-medium"
|
||||
@click="isTranscriptExpanded = !isTranscriptExpanded"
|
||||
>
|
||||
{{
|
||||
isTranscriptExpanded
|
||||
? $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_LESS')
|
||||
: $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_MORE')
|
||||
}}
|
||||
</button>
|
||||
{{ attachment.transcribedText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -88,18 +88,5 @@ export const VOICE_CALL_STATUS = {
|
||||
|
||||
export const VOICE_CALL_DIRECTION = {
|
||||
INBOUND: 'inbound',
|
||||
INCOMING: 'incoming',
|
||||
OUTGOING: 'outgoing',
|
||||
ONGOING: 'ongoing',
|
||||
OUTBOUND: 'outbound',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_OUTBOUND_INIT_STATUS = {
|
||||
LOCKED: 'locked',
|
||||
PERMISSION_REQUESTED: 'permission_requested',
|
||||
PERMISSION_PENDING: 'permission_pending',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_END_REASON = {
|
||||
AGENT_REJECTED: 'agent_rejected',
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -31,16 +30,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
beta: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleChange = () => {
|
||||
if (!props.isActive && !props.disabled) {
|
||||
emit('select', props.id);
|
||||
@@ -49,14 +42,14 @@ const handleChange = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
:for="id"
|
||||
class="rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6 focus-within:has-[:focus-visible]:ring-2 focus-within:has-[:focus-visible]:ring-n-strong"
|
||||
<div
|
||||
class="cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
:class="[
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
||||
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
||||
]"
|
||||
@click="handleChange"
|
||||
>
|
||||
<div class="flex flex-col gap-2 items-start">
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
@@ -65,7 +58,6 @@ const handleChange = () => {
|
||||
{{ label }}
|
||||
</h3>
|
||||
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
|
||||
<Label v-if="beta" :label="t('GENERAL.BETA')" color="blue" compact />
|
||||
</div>
|
||||
<input
|
||||
:id="`${id}`"
|
||||
@@ -74,7 +66,7 @@ const handleChange = () => {
|
||||
:name="id"
|
||||
:disabled="disabled"
|
||||
type="radio"
|
||||
class="shadow cursor-pointer grid place-items-center border-2 border-n-strong appearance-none rounded-full w-5 h-5 checked:bg-n-brand before:content-[''] before:bg-n-brand before:border-4 before:rounded-full before:border-n-strong checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-n-brand"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -83,5 +75,5 @@ const handleChange = () => {
|
||||
</p>
|
||||
<slot />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@@ -18,10 +17,6 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
badgeCount: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const reauthorizationRequired = computed(() => {
|
||||
@@ -34,7 +29,6 @@ const reauthorizationRequired = computed(() => {
|
||||
<ChannelIcon :inbox="inbox" class="size-4" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
<div
|
||||
v-if="reauthorizationRequired"
|
||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { h, ref, computed, onMounted, watch } from 'vue';
|
||||
import { h, ref, computed, onMounted } from 'vue';
|
||||
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
@@ -61,12 +61,6 @@ const hasAdvancedAssignment = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const fetchConversationUnreadCounts = currentAccountId => {
|
||||
if (!currentAccountId) return;
|
||||
|
||||
store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -163,15 +157,6 @@ useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const getInboxUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getInboxUnreadCount'
|
||||
);
|
||||
const getLabelUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getLabelUnreadCount'
|
||||
);
|
||||
const getTeamUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getTeamUnreadCount'
|
||||
);
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
@@ -188,48 +173,8 @@ onMounted(() => {
|
||||
store.dispatch('customViews/get', 'contact');
|
||||
});
|
||||
|
||||
watch(accountId, fetchConversationUnreadCounts, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const normalizeUnreadCount = count => {
|
||||
const unreadCount = Number(count);
|
||||
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
|
||||
};
|
||||
|
||||
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
|
||||
items.slice().sort((a, b) => {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(b)) -
|
||||
normalizeUnreadCount(unreadCountKey(a));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
|
||||
return labelKey(a).localeCompare(labelKey(b));
|
||||
});
|
||||
|
||||
const sortedTeams = computed(() =>
|
||||
sortByUnreadCount(
|
||||
teams.value,
|
||||
team => team.name,
|
||||
team => getTeamUnreadCount.value(team.id)
|
||||
)
|
||||
);
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
sortByUnreadCount(
|
||||
inboxes.value,
|
||||
inbox => inbox.name,
|
||||
inbox => getInboxUnreadCount.value(inbox.id)
|
||||
)
|
||||
);
|
||||
|
||||
const sortedLabels = computed(() =>
|
||||
sortByUnreadCount(
|
||||
labels.value,
|
||||
label => label.title,
|
||||
label => getLabelUnreadCount.value(label.id)
|
||||
)
|
||||
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
@@ -322,10 +267,9 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: ['conversations_through_team'],
|
||||
children: sortedTeams.value.map(team => ({
|
||||
children: teams.value.map(team => ({
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
badgeCount: getTeamUnreadCount.value(team.id),
|
||||
to: accountScopedRoute('team_conversations', { teamId: team.id }),
|
||||
})),
|
||||
},
|
||||
@@ -337,7 +281,6 @@ const menuItems = computed(() => {
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
badgeCount: getInboxUnreadCount.value(inbox.id),
|
||||
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
component: leafProps =>
|
||||
@@ -345,7 +288,6 @@ const menuItems = computed(() => {
|
||||
label: leafProps.label,
|
||||
active: leafProps.active,
|
||||
inbox,
|
||||
badgeCount: leafProps.badgeCount,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
@@ -354,10 +296,9 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tag',
|
||||
activeOn: ['conversations_through_label'],
|
||||
children: sortedLabels.value.map(label => ({
|
||||
children: labels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
badgeCount: getLabelUnreadCount.value(label.id),
|
||||
icon: h('span', {
|
||||
class: `size-[8px] rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user