Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8b65f86c9 | ||
|
|
4339e0ee0a | ||
|
|
e76650d6d0 | ||
|
|
3b7b94f8d1 | ||
|
|
6c8741b314 | ||
|
|
68e358d732 | ||
|
|
d20950c5b4 | ||
|
|
9c17a6f302 | ||
|
|
2628d7a7fd | ||
|
|
94daf26ead | ||
|
|
7422b656cd | ||
|
|
39a93db007 | ||
|
|
b495aad1e6 | ||
|
|
b33eecaff7 | ||
|
|
7c16071fc7 | ||
|
|
b981ba766f | ||
|
|
37c8e7e699 | ||
|
|
75c2f91019 | ||
|
|
56e30102eb | ||
|
|
6fbff026eb | ||
|
|
52da165cb7 | ||
|
|
03fb6591e0 | ||
|
|
c4a6a19e9b | ||
|
|
b9757447a8 | ||
|
|
e5d66020fd | ||
|
|
6b1d8203c6 | ||
|
|
4550c4130b | ||
|
|
e09496078b | ||
|
|
4cdfe4168c | ||
|
|
0722750a55 | ||
|
|
bef25781de | ||
|
|
1d7a9093d2 | ||
|
|
3c67c41544 | ||
|
|
d0ecdc14d8 | ||
|
|
b1db6c3e9b | ||
|
|
3d20a7b049 | ||
|
|
3cd8cf43ce | ||
|
|
f33e469e9a | ||
|
|
27f2c2b392 | ||
|
|
40deaef458 | ||
|
|
3fae800936 | ||
|
|
1913ccadfa | ||
|
|
bca95efb82 | ||
|
|
6560dbb68d | ||
|
|
497d34c097 | ||
|
|
4371793741 | ||
|
|
64585faff0 | ||
|
|
e05008e0a4 | ||
|
|
92f8c13ce5 | ||
|
|
c4089f2226 | ||
|
|
bcb66cdcc0 | ||
|
|
7f0d5caca4 | ||
|
|
1d2f3e86dd | ||
|
|
3253e863ed | ||
|
|
059d840272 | ||
|
|
b8deb89613 | ||
|
|
ef27e571f7 | ||
|
|
1528fcde0c | ||
|
|
5f6bd951b9 | ||
|
|
cfc7699b7e | ||
|
|
dc332dd93e | ||
|
|
13f66e3a88 | ||
|
|
fbcb89e955 | ||
|
|
8712879681 | ||
|
|
ffbf40c720 | ||
|
|
dd7f5c27e5 | ||
|
|
c2523c5d8b | ||
|
|
05bda5f742 | ||
|
|
379e28df1f | ||
|
|
c6dceb0e07 | ||
|
|
cd33cea69f | ||
|
|
71cc5168be | ||
|
|
58fdd20625 | ||
|
|
6c67eb9ba0 | ||
|
|
3df827c931 | ||
|
|
de696a55cb | ||
|
|
79a7423f9f | ||
|
|
85ddc68834 | ||
|
|
086aa36ffe | ||
|
|
f6be0d80ef | ||
|
|
3489298726 | ||
|
|
2e13f69fdf | ||
|
|
bc768bf04f | ||
|
|
cc612e755b | ||
|
|
aa10d42237 |
+18
-4
@@ -2,8 +2,22 @@
|
|||||||
ignore:
|
ignore:
|
||||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
- 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+)
|
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
||||||
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
|
# Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor.
|
||||||
# storage setup uses local/cloud storage with optional direct uploads to the
|
# Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable.
|
||||||
# storage provider rather than Rails proxy mode. Revisit if we enable
|
- GHSA-jp94-3292-c3xv
|
||||||
# rails_storage_proxy or other app-served Active Storage proxy routes.
|
# Rails 7.1 has no patched release for the Active Storage proxy range
|
||||||
|
# advisories. Chatwoot limits proxy range requests locally.
|
||||||
- CVE-2026-33658
|
- 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 with parallelization
|
||||||
backend-tests:
|
backend-tests:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
parallelism: 18
|
parallelism: 20
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- node/install:
|
- node/install:
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer
|
|||||||
# Mail Incoming
|
# Mail Incoming
|
||||||
# This is the domain set for the reply emails when conversation continuity is enabled
|
# This is the domain set for the reply emails when conversation continuity is enabled
|
||||||
MAILER_INBOUND_EMAIL_DOMAIN=
|
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
|
# Set this to the appropriate ingress channel with regards to incoming emails
|
||||||
# Possible values are :
|
# Possible values are :
|
||||||
# relay for Exim, Postfix, Qmail
|
# relay for Exim, Postfix, Qmail
|
||||||
@@ -232,6 +234,10 @@ 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
|
# 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
|
# 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
|
## Running chatwoot as an API only server
|
||||||
## setting this value to true will disable the frontend dashboard endpoints
|
## setting this value to true will disable the frontend dashboard endpoints
|
||||||
# CW_API_ONLY_SERVER=false
|
# CW_API_ONLY_SERVER=false
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
|
|||||||
# two-factor authentication
|
# two-factor authentication
|
||||||
gem 'devise-two-factor', '>= 5.0.0'
|
gem 'devise-two-factor', '>= 5.0.0'
|
||||||
# authorization
|
# authorization
|
||||||
gem 'jwt'
|
gem 'jwt', '~> 2.10', '>= 2.10.3'
|
||||||
gem 'pundit'
|
gem 'pundit'
|
||||||
|
|
||||||
# super admin
|
# super admin
|
||||||
@@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false
|
|||||||
gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
||||||
|
|
||||||
##-- background job processing --##
|
##-- background job processing --##
|
||||||
gem 'sidekiq', '>= 7.3.1'
|
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
|
||||||
# We want cron jobs
|
# We want cron jobs
|
||||||
gem 'sidekiq-cron', '>= 1.12.0'
|
gem 'sidekiq-cron', '>= 2.4.0'
|
||||||
# for sidekiq healthcheck
|
# for sidekiq healthcheck
|
||||||
gem 'sidekiq_alive'
|
gem 'sidekiq_alive'
|
||||||
|
|
||||||
@@ -195,10 +195,10 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.9.1'
|
gem 'ai-agents', '>= 0.10.0'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
gem 'ruby_llm', '>= 1.8.2'
|
gem 'ruby_llm', '>= 1.14.1'
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
gem 'cld3', '~> 3.7'
|
gem 'cld3', '~> 3.7'
|
||||||
@@ -209,6 +209,8 @@ gem 'opentelemetry-exporter-otlp'
|
|||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|
||||||
|
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
|
||||||
|
|
||||||
### Gems required only in specific deployment environments ###
|
### Gems required only in specific deployment environments ###
|
||||||
##############################################################
|
##############################################################
|
||||||
|
|
||||||
|
|||||||
+34
-27
@@ -126,8 +126,8 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
ai-agents (0.9.1)
|
ai-agents (0.10.0)
|
||||||
ruby_llm (~> 1.9.1)
|
ruby_llm (~> 1.14)
|
||||||
annotaterb (4.20.0)
|
annotaterb (4.20.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
@@ -196,6 +196,9 @@ GEM
|
|||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
|
cronex (0.15.0)
|
||||||
|
tzinfo
|
||||||
|
unicode (>= 0.4.4.5)
|
||||||
csv (3.3.0)
|
csv (3.3.0)
|
||||||
csv-safe (3.3.1)
|
csv-safe (3.3.1)
|
||||||
csv (~> 3.0)
|
csv (~> 3.0)
|
||||||
@@ -212,7 +215,7 @@ GEM
|
|||||||
logger
|
logger
|
||||||
msgpack
|
msgpack
|
||||||
datadog-ruby_core_source (3.4.1)
|
datadog-ruby_core_source (3.4.1)
|
||||||
date (3.4.1)
|
date (3.5.1)
|
||||||
debug (1.8.0)
|
debug (1.8.0)
|
||||||
irb (>= 1.5.0)
|
irb (>= 1.5.0)
|
||||||
reline (>= 0.3.1)
|
reline (>= 0.3.1)
|
||||||
@@ -298,7 +301,7 @@ GEM
|
|||||||
railties (>= 5.0.0)
|
railties (>= 5.0.0)
|
||||||
faker (3.2.0)
|
faker (3.2.0)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.14.1)
|
faraday (2.14.2)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
@@ -307,8 +310,8 @@ GEM
|
|||||||
faraday-mashify (1.0.0)
|
faraday-mashify (1.0.0)
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
hashie
|
hashie
|
||||||
faraday-multipart (1.0.4)
|
faraday-multipart (1.2.0)
|
||||||
multipart-post (~> 2)
|
multipart-post (~> 2.0)
|
||||||
faraday-net_http (3.4.2)
|
faraday-net_http (3.4.2)
|
||||||
net-http (~> 0.5)
|
net-http (~> 0.5)
|
||||||
faraday-net_http_persistent (2.1.0)
|
faraday-net_http_persistent (2.1.0)
|
||||||
@@ -336,6 +339,7 @@ GEM
|
|||||||
ffi-compiler (1.0.1)
|
ffi-compiler (1.0.1)
|
||||||
ffi (>= 1.0.0)
|
ffi (>= 1.0.0)
|
||||||
rake
|
rake
|
||||||
|
firecrawl-sdk (1.4.1)
|
||||||
flag_shih_tzu (0.3.23)
|
flag_shih_tzu (0.3.23)
|
||||||
foreman (0.87.2)
|
foreman (0.87.2)
|
||||||
fugit (1.11.1)
|
fugit (1.11.1)
|
||||||
@@ -466,7 +470,7 @@ GEM
|
|||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.19.2)
|
json (2.19.5)
|
||||||
json_refs (0.1.8)
|
json_refs (0.1.8)
|
||||||
hana
|
hana
|
||||||
json_schemer (0.2.24)
|
json_schemer (0.2.24)
|
||||||
@@ -487,7 +491,7 @@ GEM
|
|||||||
judoscale-sidekiq (1.8.2)
|
judoscale-sidekiq (1.8.2)
|
||||||
judoscale-ruby (= 1.8.2)
|
judoscale-ruby (= 1.8.2)
|
||||||
sidekiq (>= 5.0)
|
sidekiq (>= 5.0)
|
||||||
jwt (2.10.1)
|
jwt (2.10.3)
|
||||||
base64
|
base64
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
@@ -574,7 +578,7 @@ GEM
|
|||||||
uri (>= 0.11.1)
|
uri (>= 0.11.1)
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.20)
|
net-imap (0.4.24)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -590,14 +594,14 @@ GEM
|
|||||||
newrelic_rpm (9.6.0)
|
newrelic_rpm (9.6.0)
|
||||||
base64
|
base64
|
||||||
nio4r (2.7.3)
|
nio4r (2.7.3)
|
||||||
nokogiri (1.19.1)
|
nokogiri (1.19.3)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-arm64-darwin)
|
nokogiri (1.19.3-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-x86_64-darwin)
|
nokogiri (1.19.3-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oauth (1.1.0)
|
oauth (1.1.0)
|
||||||
oauth-tty (~> 1.0, >= 1.0.1)
|
oauth-tty (~> 1.0, >= 1.0.1)
|
||||||
@@ -835,17 +839,17 @@ GEM
|
|||||||
ruby2ruby (2.5.0)
|
ruby2ruby (2.5.0)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
ruby_llm (1.9.2)
|
ruby_llm (1.15.0)
|
||||||
base64
|
base64
|
||||||
event_stream_parser (~> 1)
|
event_stream_parser (~> 1)
|
||||||
faraday (>= 1.10.0)
|
faraday (>= 1.10.0)
|
||||||
faraday-multipart (>= 1)
|
faraday-multipart (>= 1)
|
||||||
faraday-net_http (>= 1)
|
faraday-net_http (>= 1)
|
||||||
faraday-retry (>= 1)
|
faraday-retry (>= 1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1)
|
||||||
ruby_llm-schema (~> 0.2.1)
|
ruby_llm-schema (~> 0)
|
||||||
zeitwerk (~> 2)
|
zeitwerk (~> 2)
|
||||||
ruby_llm-schema (0.2.5)
|
ruby_llm-schema (0.3.0)
|
||||||
ruby_parser (3.20.0)
|
ruby_parser (3.20.0)
|
||||||
sexp_processor (~> 4.16)
|
sexp_processor (~> 4.16)
|
||||||
sass (3.7.4)
|
sass (3.7.4)
|
||||||
@@ -902,10 +906,11 @@ GEM
|
|||||||
logger
|
logger
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
redis-client (>= 0.22.2)
|
redis-client (>= 0.22.2)
|
||||||
sidekiq-cron (1.12.0)
|
sidekiq-cron (2.4.0)
|
||||||
fugit (~> 1.8)
|
cronex (>= 0.13.0)
|
||||||
|
fugit (~> 1.8, >= 1.11.1)
|
||||||
globalid (>= 1.0.1)
|
globalid (>= 1.0.1)
|
||||||
sidekiq (>= 6)
|
sidekiq (>= 6.5.0)
|
||||||
sidekiq_alive (2.5.0)
|
sidekiq_alive (2.5.0)
|
||||||
gserver (~> 0.0.1)
|
gserver (~> 0.0.1)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
@@ -961,7 +966,7 @@ GEM
|
|||||||
time_diff (0.3.0)
|
time_diff (0.3.0)
|
||||||
activesupport
|
activesupport
|
||||||
i18n
|
i18n
|
||||||
timeout (0.4.3)
|
timeout (0.6.1)
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
twilio-ruby (7.6.0)
|
twilio-ruby (7.6.0)
|
||||||
faraday (>= 0.9, < 3.0)
|
faraday (>= 0.9, < 3.0)
|
||||||
@@ -979,6 +984,7 @@ GEM
|
|||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.8.2)
|
unf_ext (0.0.8.2)
|
||||||
|
unicode (0.4.4.5)
|
||||||
unicode-display_width (3.1.4)
|
unicode-display_width (3.1.4)
|
||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
@@ -1019,7 +1025,7 @@ GEM
|
|||||||
working_hours (1.4.1)
|
working_hours (1.4.1)
|
||||||
activesupport (>= 3.2)
|
activesupport (>= 3.2)
|
||||||
tzinfo
|
tzinfo
|
||||||
zeitwerk (2.7.4)
|
zeitwerk (2.7.5)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
arm64-darwin-20
|
arm64-darwin-20
|
||||||
@@ -1039,7 +1045,7 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
ai-agents (>= 0.9.1)
|
ai-agents (>= 0.10.0)
|
||||||
annotaterb
|
annotaterb
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
@@ -1074,6 +1080,7 @@ DEPENDENCIES
|
|||||||
faker
|
faker
|
||||||
faraday_middleware-aws-sigv4
|
faraday_middleware-aws-sigv4
|
||||||
fcm
|
fcm
|
||||||
|
firecrawl-sdk (~> 1.0)
|
||||||
flag_shih_tzu
|
flag_shih_tzu
|
||||||
foreman
|
foreman
|
||||||
gemoji
|
gemoji
|
||||||
@@ -1095,7 +1102,7 @@ DEPENDENCIES
|
|||||||
json_schemer
|
json_schemer
|
||||||
judoscale-rails
|
judoscale-rails
|
||||||
judoscale-sidekiq
|
judoscale-sidekiq
|
||||||
jwt
|
jwt (~> 2.10, >= 2.10.3)
|
||||||
kaminari
|
kaminari
|
||||||
koala
|
koala
|
||||||
letter_opener
|
letter_opener
|
||||||
@@ -1144,7 +1151,7 @@ DEPENDENCIES
|
|||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
ruby-openai
|
ruby-openai
|
||||||
ruby_llm (>= 1.8.2)
|
ruby_llm (>= 1.14.1)
|
||||||
ruby_llm-schema
|
ruby_llm-schema
|
||||||
scout_apm
|
scout_apm
|
||||||
scss_lint
|
scss_lint
|
||||||
@@ -1155,8 +1162,8 @@ DEPENDENCIES
|
|||||||
sentry-sidekiq (>= 5.19.0)
|
sentry-sidekiq (>= 5.19.0)
|
||||||
shopify_api
|
shopify_api
|
||||||
shoulda-matchers
|
shoulda-matchers
|
||||||
sidekiq (>= 7.3.1)
|
sidekiq (~> 7.3, >= 7.3.1)
|
||||||
sidekiq-cron (>= 1.12.0)
|
sidekiq-cron (>= 2.4.0)
|
||||||
sidekiq_alive
|
sidekiq_alive
|
||||||
simplecov (>= 0.21)
|
simplecov (>= 0.21)
|
||||||
simplecov_json_formatter
|
simplecov_json_formatter
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.13.0
|
4.14.1
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class Messages::Messenger::MessageBuilder
|
|||||||
filename: attachment_file.original_filename,
|
filename: attachment_file.original_filename,
|
||||||
content_type: attachment_file.content_type
|
content_type: attachment_file.content_type
|
||||||
)
|
)
|
||||||
|
# The Attachment row is saved before the blob is attached, so the
|
||||||
|
# after_create_commit broadcast bails on `file.attached?`. Re-fire here
|
||||||
|
# for audio so the bubble updates without waiting on transcription.
|
||||||
|
attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio
|
||||||
end
|
end
|
||||||
|
|
||||||
def attachment_params(attachment)
|
def attachment_params(attachment)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class NotificationBuilder
|
|||||||
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
|
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
|
||||||
# skip notifications for blocked conversations except for user mentions
|
# skip notifications for blocked conversations except for user mentions
|
||||||
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
|
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
|
||||||
|
# respect conversation access (inbox/team membership and custom-role permissions)
|
||||||
|
return unless user_can_access_conversation?
|
||||||
|
|
||||||
user.notifications.create!(
|
user.notifications.create!(
|
||||||
notification_type: notification_type,
|
notification_type: notification_type,
|
||||||
@@ -36,4 +38,17 @@ class NotificationBuilder
|
|||||||
secondary_actor: secondary_actor || current_user
|
secondary_actor: secondary_actor || current_user
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def user_can_access_conversation?
|
||||||
|
conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation)
|
||||||
|
return true if conversation.blank?
|
||||||
|
|
||||||
|
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
|
||||||
|
return false if account_user.blank?
|
||||||
|
|
||||||
|
ConversationPolicy.new(
|
||||||
|
{ user: user, account: account, account_user: account_user },
|
||||||
|
conversation
|
||||||
|
).show?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,13 +31,24 @@ class V2::Reports::BotMetricsBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def bot_resolutions_count
|
def bot_resolutions_count
|
||||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
# Exclude conversations that also had a handoff in the same range — handoff wins
|
||||||
created_at: range).distinct.count
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
def bot_handoffs_count
|
def bot_handoffs_count
|
||||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
|
account.reporting_events.joins(:conversation).select(:conversation_id)
|
||||||
created_at: range).distinct.count
|
.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)
|
||||||
end
|
end
|
||||||
|
|
||||||
def bot_resolution_rate
|
def bot_resolution_rate
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :set_articles, only: [:update_status, :delete_articles]
|
before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
|
||||||
|
|
||||||
def translate
|
def translate
|
||||||
head :not_implemented
|
head :not_implemented
|
||||||
@@ -19,6 +19,18 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
|
|||||||
render_could_not_create_error(e.message)
|
render_could_not_create_error(e.message)
|
||||||
end
|
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
|
def delete_articles
|
||||||
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.no_articles_found')) if @articles.none?
|
||||||
|
|
||||||
@@ -39,5 +51,9 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
|
|||||||
def set_articles
|
def set_articles
|
||||||
@articles = @portal.articles.where(id: params[:ids])
|
@articles = @portal.articles.where(id: params[:ids])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def category_valid?
|
||||||
|
@portal.categories.exists?(id: params[:category_id])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
|
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :ensure_unread_counts_enabled
|
||||||
|
|
||||||
|
def index
|
||||||
|
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
||||||
|
render json: { payload: counts }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_unread_counts_enabled
|
||||||
|
return if Current.account.feature_enabled?('conversation_unread_counts')
|
||||||
|
|
||||||
|
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
def attachments
|
def attachments
|
||||||
@attachments_count = @conversation.attachments.count
|
@attachments_count = @conversation.attachments.count
|
||||||
@attachments = @conversation.attachments
|
@attachments = @conversation.attachments
|
||||||
.includes(:message)
|
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
|
||||||
.order(created_at: :desc)
|
.order(created_at: :desc)
|
||||||
.page(attachment_params[:page])
|
.page(attachment_params[:page])
|
||||||
.per(ATTACHMENT_RESULTS_PER_PAGE)
|
.per(ATTACHMENT_RESULTS_PER_PAGE)
|
||||||
@@ -162,6 +162,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
# rubocop:disable Rails/SkipsModelValidations
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
@conversation.update_columns(updates)
|
@conversation.update_columns(updates)
|
||||||
# rubocop:enable Rails/SkipsModelValidations
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
|
||||||
|
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_update_last_seen?
|
def should_update_last_seen?
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
@inboxes = policy_scope(Current.account.inboxes)
|
||||||
|
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
|
||||||
|
.order_by_name
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
@@ -85,7 +87,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def fetch_agent_bot
|
def fetch_agent_bot
|
||||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot]
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_channel
|
def create_channel
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
|||||||
{
|
{
|
||||||
redirect_uri: "#{base_url}/microsoft/callback",
|
redirect_uri: "#{base_url}/microsoft/callback",
|
||||||
scope: scope,
|
scope: scope,
|
||||||
state: state,
|
state: state
|
||||||
prompt: 'consent'
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if redirect_url
|
if redirect_url
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
|||||||
|
|
||||||
def destroy_all
|
def destroy_all
|
||||||
if params[:type] == 'read'
|
if params[:type] == 'read'
|
||||||
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
|
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
|
||||||
else
|
else
|
||||||
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
|
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
|
||||||
end
|
end
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def fetch_notification
|
def fetch_notification
|
||||||
@notification = current_user.notifications.find(params[:id])
|
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_current_page
|
def set_current_page
|
||||||
|
|||||||
@@ -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 = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
|
||||||
@portal.custom_domain = parsed_custom_domain
|
@portal.custom_domain = parsed_custom_domain
|
||||||
@portal.save!
|
@portal.save!
|
||||||
process_attached_logo
|
process_attached_logo if params[:blob_id].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -78,7 +78,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
def portal_params
|
def portal_params
|
||||||
params.require(:portal).permit(
|
params.require(:portal).permit(
|
||||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
|
:name, :page_title, :slug, :archived,
|
||||||
|
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||||
|
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
return {} unless permitted_params.key?(:inbox_id)
|
return {} unless permitted_params.key?(:inbox_id)
|
||||||
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
|
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
|
||||||
|
|
||||||
inbox = Inbox.find(permitted_params[:inbox_id])
|
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||||
return {} unless inbox.web_widget?
|
return {} unless inbox.web_widget?
|
||||||
|
|
||||||
{ channel_web_widget_id: inbox.channel.id }
|
{ channel_web_widget_id: inbox.channel.id }
|
||||||
@@ -98,6 +100,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def parsed_custom_domain
|
def parsed_custom_domain
|
||||||
|
return @portal.custom_domain if @portal.custom_domain.blank?
|
||||||
|
|
||||||
domain = URI.parse(@portal.custom_domain)
|
domain = URI.parse(@portal.custom_domain)
|
||||||
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
|
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -111,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def custom_attributes_params
|
def custom_attributes_params
|
||||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
|
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
notification_subscription = current_user.notification_subscriptions
|
||||||
|
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||||
notification_subscription.destroy! if notification_subscription.present?
|
notification_subscription.destroy! if notification_subscription.present?
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_message
|
def set_message
|
||||||
@message = @web_widget.inbox.messages.find(permitted_params[:id])
|
# `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])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class Public::Api::V1::InboxesController < PublicController
|
|||||||
def set_conversation
|
def set_conversation
|
||||||
return if params[:conversation_id].blank?
|
return if params[:conversation_id].blank?
|
||||||
|
|
||||||
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
|
@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
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
|
before_action :set_portal_layout
|
||||||
|
before_action :set_view_variant
|
||||||
before_action :ensure_portal_feature_enabled
|
before_action :ensure_portal_feature_enabled
|
||||||
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||||
before_action :set_article, only: [:show]
|
before_action :set_article, only: [:show, :show_markdown]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@@ -21,6 +23,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
|
@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
|
end
|
||||||
|
|
||||||
def tracking_pixel
|
def tracking_pixel
|
||||||
@@ -62,7 +71,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
|
|
||||||
def set_article
|
def set_article
|
||||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||||
@parsed_content = render_article_content(@article.content.to_s)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_category
|
def set_category
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
around_action :set_locale
|
around_action :set_locale
|
||||||
after_action :allow_iframe_requests
|
after_action :allow_iframe_requests
|
||||||
|
|
||||||
|
PORTAL_LAYOUTS = %w[classic documentation].freeze
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def show_plain_layout
|
def show_plain_layout
|
||||||
@@ -17,6 +19,14 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
|
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
|
||||||
end
|
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
|
def portal
|
||||||
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
|
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
|
||||||
end
|
end
|
||||||
@@ -42,7 +52,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
article_locale = if article.category.present?
|
article_locale = if article.category.present?
|
||||||
article.category.locale
|
article.category.locale
|
||||||
else
|
else
|
||||||
article.portal.default_locale
|
article.locale
|
||||||
end
|
end
|
||||||
@locale = validate_and_get_locale(article_locale)
|
@locale = validate_and_get_locale(article_locale)
|
||||||
I18n.with_locale(@locale, &)
|
I18n.with_locale(@locale, &)
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
|
before_action :set_portal_layout
|
||||||
|
before_action :set_view_variant
|
||||||
before_action :ensure_portal_feature_enabled
|
before_action :ensure_portal_feature_enabled
|
||||||
before_action :set_category, only: [:show]
|
before_action :set_category, only: [:show]
|
||||||
|
before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' }
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@categories = @portal.categories.order(position: :asc)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@@ -21,4 +27,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
|
|||||||
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
|
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
|
||||||
render_404 && return if @category.blank?
|
render_404 && return if @category.blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def load_category_articles
|
||||||
|
@articles = @category.articles.published.order(:position).includes(:author)
|
||||||
|
@category_authors = @articles.filter_map(&:author).uniq
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
|||||||
before_action :ensure_custom_domain_request, only: [:show]
|
before_action :ensure_custom_domain_request, only: [:show]
|
||||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
|
before_action :set_portal_layout
|
||||||
|
before_action :set_view_variant
|
||||||
before_action :ensure_portal_feature_enabled
|
before_action :ensure_portal_feature_enabled
|
||||||
|
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@@ -28,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
|||||||
portal
|
portal
|
||||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
if errors.any?
|
if errors.any?
|
||||||
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
|
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
|
||||||
else
|
else
|
||||||
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
redirect_to super_admin_settings_path, flash: success_flash
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -58,6 +58,21 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
|
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
|
||||||
)
|
)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
|
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
|
||||||
|
|||||||
@@ -25,6 +25,29 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
|
|||||||
resource_class.editable
|
resource_class.editable
|
||||||
end
|
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
|
# Override `resource_params` if you want to transform the submitted
|
||||||
# data before it's persisted. For example, the following would turn all
|
# 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`
|
# empty values into nil values. It uses other APIs such as `resource_class`
|
||||||
@@ -42,6 +65,20 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
|
|||||||
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
|
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
|
||||||
end
|
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
|
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
|
||||||
# for more information
|
# for more information
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
class SwaggerController < ApplicationController
|
class SwaggerController < ApplicationController
|
||||||
def respond
|
def respond
|
||||||
if Rails.env.development? || Rails.env.test?
|
if Rails.env.development? || Rails.env.test?
|
||||||
render inline: Rails.root.join('swagger', derived_path).read
|
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
|
||||||
else
|
else
|
||||||
head :not_found
|
head :not_found
|
||||||
end
|
end
|
||||||
@@ -11,8 +16,8 @@ class SwaggerController < ApplicationController
|
|||||||
|
|
||||||
def derived_path
|
def derived_path
|
||||||
params[:path] ||= 'index.html'
|
params[:path] ||= 'index.html'
|
||||||
path = Rack::Utils.clean_path_info(params[:path])
|
path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/')
|
||||||
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
|
path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s)
|
||||||
path
|
path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ class Twilio::CallbackController < ApplicationController
|
|||||||
:Latitude,
|
:Latitude,
|
||||||
:Longitude,
|
:Longitude,
|
||||||
:MessageType,
|
:MessageType,
|
||||||
:ProfileName
|
:ProfileName,
|
||||||
|
:ExternalUserId,
|
||||||
|
:ParentExternalUserId,
|
||||||
|
:ProfileUsername,
|
||||||
|
:Username
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class AsyncDispatcher < BaseDispatcher
|
|||||||
InstallationWebhookListener.instance,
|
InstallationWebhookListener.instance,
|
||||||
NotificationListener.instance,
|
NotificationListener.instance,
|
||||||
ParticipationListener.instance,
|
ParticipationListener.instance,
|
||||||
|
Conversations::UnreadCounts::Listener.instance,
|
||||||
ReportingEventListener.instance,
|
ReportingEventListener.instance,
|
||||||
WebhookListener.instance
|
WebhookListener.instance
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ module FileTypeHelper
|
|||||||
def image_file?(content_type)
|
def image_file?(content_type)
|
||||||
[
|
[
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
'image/png',
|
'image/png',
|
||||||
'image/gif',
|
'image/gif',
|
||||||
'image/bmp',
|
'image/bmp',
|
||||||
|
|||||||
@@ -96,4 +96,15 @@ module PortalHelper
|
|||||||
|
|
||||||
colors[username.length % colors.size]
|
colors[username.length % colors.size]
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -53,13 +53,12 @@ module ReportHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def resolutions
|
def resolutions
|
||||||
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
|
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range)
|
||||||
created_at: range)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def bot_resolutions
|
def bot_resolutions
|
||||||
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
|
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
|
||||||
created_at: range)
|
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||||
end
|
end
|
||||||
|
|
||||||
def bot_handoffs
|
def bot_handoffs
|
||||||
@@ -67,6 +66,10 @@ module ReportHelper
|
|||||||
created_at: range).distinct
|
created_at: range).distinct
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bot_handoff_conversation_ids_subquery
|
||||||
|
bot_handoffs
|
||||||
|
end
|
||||||
|
|
||||||
def avg_first_response_time
|
def avg_first_response_time
|
||||||
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
|
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]
|
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
|
||||||
|
|||||||
@@ -6,15 +6,22 @@ class CaptainDocument extends ApiClient {
|
|||||||
super('captain/documents', { accountScoped: true });
|
super('captain/documents', { accountScoped: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
get({ page = 1, searchKey, assistantId } = {}) {
|
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
|
||||||
return axios.get(this.url, {
|
return axios.get(this.url, {
|
||||||
params: {
|
params: {
|
||||||
page,
|
page,
|
||||||
searchKey,
|
search_key: searchKey,
|
||||||
assistant_id: assistantId,
|
assistant_id: assistantId,
|
||||||
|
filter,
|
||||||
|
source,
|
||||||
|
sort,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sync(id) {
|
||||||
|
return axios.post(`${this.url}/${id}/sync`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CaptainDocument();
|
export default new CaptainDocument();
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../../ApiClient';
|
||||||
|
|
||||||
|
class WhatsappCallsAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('whatsapp_calls', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
show(callId) {
|
||||||
|
return axios.get(`${this.url}/${callId}`).then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
initiate(conversationId, sdpOffer) {
|
||||||
|
return axios
|
||||||
|
.post(`${this.url}/initiate`, {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
sdp_offer: sdpOffer,
|
||||||
|
})
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
accept(callId, sdpAnswer) {
|
||||||
|
return axios
|
||||||
|
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(callId) {
|
||||||
|
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate(callId) {
|
||||||
|
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadRecording(callId, blob, filename = 'call-recording.webm') {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('recording', blob, filename);
|
||||||
|
return axios
|
||||||
|
.post(`${this.url}/${callId}/upload_recording`, formData)
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new WhatsappCallsAPI();
|
||||||
@@ -28,6 +28,14 @@ class CompanyAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
|
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listNotes(id) {
|
||||||
|
return axios.get(`${this.url}/${id}/notes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
listConversations(id) {
|
||||||
|
return axios.get(`${this.url}/${id}/conversations`);
|
||||||
|
}
|
||||||
|
|
||||||
searchContacts(id, query = '', page = 1) {
|
searchContacts(id, query = '', page = 1) {
|
||||||
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
|
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
|
||||||
return axios.get(requestURL);
|
return axios.get(requestURL);
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
|
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
getConversations(contactId) {
|
getConversations(contactId, { inboxId } = {}) {
|
||||||
return axios.get(`${this.url}/${contactId}/conversations`);
|
const params = inboxId ? { inbox_id: inboxId } : {};
|
||||||
|
return axios.get(`${this.url}/${contactId}/conversations`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getContactableInboxes(contactId) {
|
getContactableInboxes(contactId) {
|
||||||
@@ -47,9 +48,10 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${contactId}/labels`);
|
return axios.get(`${this.url}/${contactId}/labels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
initiateCall(contactId, inboxId) {
|
initiateCall(contactId, inboxId, conversationId = null) {
|
||||||
return axios.post(`${this.url}/${contactId}/call`, {
|
return axios.post(`${this.url}/${contactId}/call`, {
|
||||||
inbox_id: inboxId,
|
inbox_id: inboxId,
|
||||||
|
conversation_id: conversationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ class ConversationApi extends ApiClient {
|
|||||||
updateLabels(conversationID, labels) {
|
updateLabels(conversationID, labels) {
|
||||||
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
|
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUnreadCounts() {
|
||||||
|
return axios.get(`${this.url}/unread_counts`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ConversationApi();
|
export default new ConversationApi();
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ 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 }) {
|
bulkDelete({ portalSlug, articleIds }) {
|
||||||
return axios.delete(
|
return axios.delete(
|
||||||
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
|
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
resetSecret(inboxId) {
|
resetSecret(inboxId) {
|
||||||
return axios.post(`${this.url}/${inboxId}/reset_secret`);
|
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();
|
export default new Inboxes();
|
||||||
|
|||||||
@@ -153,4 +153,33 @@ 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,7 +41,8 @@ describe('#ContactsAPI', () => {
|
|||||||
it('#getConversations', () => {
|
it('#getConversations', () => {
|
||||||
contactAPI.getConversations(1);
|
contactAPI.getConversations(1);
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
'/api/v1/contacts/1/conversations'
|
'/api/v1/contacts/1/conversations',
|
||||||
|
{ params: {} }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe('#ConversationApi', () => {
|
|||||||
expect(conversationsAPI).toHaveProperty('delete');
|
expect(conversationsAPI).toHaveProperty('delete');
|
||||||
expect(conversationsAPI).toHaveProperty('getLabels');
|
expect(conversationsAPI).toHaveProperty('getLabels');
|
||||||
expect(conversationsAPI).toHaveProperty('updateLabels');
|
expect(conversationsAPI).toHaveProperty('updateLabels');
|
||||||
|
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('API calls', () => {
|
||||||
@@ -47,5 +48,12 @@ describe('#ConversationApi', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#getUnreadCounts', () => {
|
||||||
|
conversationsAPI.getUnreadCounts();
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/conversations/unread_counts'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,10 +56,15 @@ const closeMobileSidebar = () => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="slots.sidebar"
|
v-if="slots.sidebar"
|
||||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
|
||||||
>
|
>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<slot name="sidebarHeader" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||||
<slot name="sidebar" />
|
<slot name="sidebar" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="slots.sidebar"
|
v-if="slots.sidebar"
|
||||||
@@ -105,10 +110,15 @@ const closeMobileSidebar = () => {
|
|||||||
<div
|
<div
|
||||||
v-if="isSidebarOpen"
|
v-if="isSidebarOpen"
|
||||||
id="details-sidebar-content"
|
id="details-sidebar-content"
|
||||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
|
||||||
>
|
>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<slot name="sidebarHeader" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||||
<slot name="sidebar" />
|
<slot name="sidebar" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+3
-3
@@ -169,7 +169,7 @@ const handleContactSelect = contactId => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6 px-6 pb-8 pt-1">
|
<div class="flex flex-col gap-6 px-6 pb-8">
|
||||||
<div v-if="!selectedContact" class="flex flex-col gap-4">
|
<div v-if="!selectedContact" class="flex flex-col gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-base text-n-slate-12">
|
<label class="text-base text-n-slate-12">
|
||||||
@@ -288,7 +288,7 @@ const handleContactSelect = contactId => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="!hasContacts"
|
v-else-if="!hasContacts"
|
||||||
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
|
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||||
>
|
>
|
||||||
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
|
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -346,7 +346,7 @@ const handleContactSelect = contactId => {
|
|||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:total-items="totalContacts"
|
:total-items="totalContacts"
|
||||||
:items-per-page="15"
|
:items-per-page="15"
|
||||||
class="!px-0 before:hidden"
|
class="!px-0 before:hidden bg-transparent"
|
||||||
@update:current-page="emit('update:currentPage', $event)"
|
@update:current-page="emit('update:currentPage', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
conversations: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const contactsById = useMapGetter('contacts/getContactById');
|
||||||
|
const stateInbox = useMapGetter('inboxes/getInboxById');
|
||||||
|
const accountLabels = useMapGetter('labels/getLabels');
|
||||||
|
|
||||||
|
const accountLabelsValue = computed(() => accountLabels.value);
|
||||||
|
const conversationContact = conversation => {
|
||||||
|
const sender = conversation.meta?.sender || {};
|
||||||
|
const contact = contactsById.value(sender.id);
|
||||||
|
return contact.id ? contact : sender;
|
||||||
|
};
|
||||||
|
const conversationInbox = conversation =>
|
||||||
|
stateInbox.value(conversation.inboxId) || {
|
||||||
|
name: '',
|
||||||
|
channelType: conversation.meta?.channel,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||||
|
>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="conversations.length > 0"
|
||||||
|
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||||
|
>
|
||||||
|
<ConversationCard
|
||||||
|
v-for="conversation in conversations"
|
||||||
|
:key="conversation.id"
|
||||||
|
:conversation="conversation"
|
||||||
|
:contact="conversationContact(conversation)"
|
||||||
|
:state-inbox="conversationInbox(conversation)"
|
||||||
|
:account-labels="accountLabelsValue"
|
||||||
|
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||||
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
|
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
notes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { formatMessage } = useMessageFormatter();
|
||||||
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
|
|
||||||
|
const hasNotes = computed(() => props.notes.length > 0);
|
||||||
|
|
||||||
|
const contactName = contact =>
|
||||||
|
contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
|
||||||
|
|
||||||
|
const getWrittenBy = note => {
|
||||||
|
const isCurrentUser = note?.user?.id === currentUser.value.id;
|
||||||
|
return isCurrentUser
|
||||||
|
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
|
||||||
|
: note?.user?.name || 'Bot';
|
||||||
|
};
|
||||||
|
|
||||||
|
const openContact = contactId => {
|
||||||
|
router.push({
|
||||||
|
name: 'contacts_edit',
|
||||||
|
params: {
|
||||||
|
accountId: route.params.accountId,
|
||||||
|
contactId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="hasNotes" class="flex flex-col px-6">
|
||||||
|
<div class="flex flex-col divide-y divide-n-strong">
|
||||||
|
<div
|
||||||
|
v-for="note in notes"
|
||||||
|
:key="note.id"
|
||||||
|
class="flex flex-col gap-2 py-4 group/note"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<Avatar
|
||||||
|
:name="contactName(note.contact)"
|
||||||
|
:src="note.contact?.thumbnail"
|
||||||
|
:size="16"
|
||||||
|
rounded-full
|
||||||
|
hide-offline-status
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between min-w-0 gap-1 w-full text-sm text-n-slate-11"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="min-w-0 font-medium truncate text-start text-n-slate-12 hover:text-n-blue-11 p-0"
|
||||||
|
@click="openContact(note.contact.id)"
|
||||||
|
>
|
||||||
|
{{ contactName(note.contact) }}
|
||||||
|
</button>
|
||||||
|
<div class="min-w-0 truncate">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 text-sm text-n-slate-10"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-n-slate-11">
|
||||||
|
{{ getWrittenBy(note) }}
|
||||||
|
</span>
|
||||||
|
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||||
|
<span class="font-medium text-n-slate-11">
|
||||||
|
{{ dynamicTime(note.createdAt) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-dompurify-html="formatMessage(note.content || '')"
|
||||||
|
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="isLoading"
|
||||||
|
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||||
|
>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="py-8 mx-6 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{ t('COMPANIES.DETAIL.NOTES.EMPTY') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -32,7 +32,7 @@ const emit = defineEmits([
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="sticky top-0 z-10 px-6">
|
<header class="sticky top-0 z-20 px-6">
|
||||||
<div
|
<div
|
||||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||||
>
|
>
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
|
|||||||
<div
|
<div
|
||||||
v-if="isMenuOpen"
|
v-if="isMenuOpen"
|
||||||
v-on-clickaway="() => (isMenuOpen = false)"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="text-sm text-n-slate-12">
|
<span class="text-sm text-n-slate-12">
|
||||||
|
|||||||
@@ -3,10 +3,20 @@ import { computed, ref, useAttrs } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
import {
|
||||||
|
isVoiceCallEnabled,
|
||||||
|
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 { useAlert } from 'dashboard/composables';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import { useCallsStore } from 'dashboard/stores/calls';
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||||
|
import ContactAPI from 'dashboard/api/contacts';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
@@ -14,6 +24,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
phone: { type: String, default: '' },
|
phone: { type: String, default: '' },
|
||||||
contactId: { type: [String, Number], required: true },
|
contactId: { type: [String, Number], required: true },
|
||||||
|
// When set, the WhatsApp call continues in this conversation (matching the
|
||||||
|
// header button) instead of looking up the contact's most recent one.
|
||||||
|
conversationId: { type: [String, Number], default: null },
|
||||||
label: { type: String, default: '' },
|
label: { type: String, default: '' },
|
||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
size: { type: String, default: 'sm' },
|
size: { type: String, default: 'sm' },
|
||||||
@@ -30,6 +43,7 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const dialogRef = ref(null);
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
|
const callsStore = useCallsStore();
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
|
||||||
@@ -38,13 +52,22 @@ const voiceInboxes = computed(() =>
|
|||||||
);
|
);
|
||||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||||
|
|
||||||
// Unified behavior: hide when no phone
|
|
||||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||||
|
|
||||||
const isInitiatingCall = computed(() => {
|
const isInitiatingCall = computed(() => {
|
||||||
return contactsUiFlags.value?.isInitiatingCall || false;
|
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 navigateToConversation = conversationId => {
|
||||||
const accountId = route.params.accountId;
|
const accountId = route.params.accountId;
|
||||||
if (conversationId && accountId) {
|
if (conversationId && accountId) {
|
||||||
@@ -58,23 +81,96 @@ const navigateToConversation = conversationId => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startCall = async inboxId => {
|
const whatsappCallSession = useWhatsappCallSession();
|
||||||
if (isInitiatingCall.value) return;
|
|
||||||
|
// Find the most recent open conversation for this contact in the picked inbox.
|
||||||
|
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
|
||||||
|
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
|
||||||
|
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
|
||||||
|
// across all inboxes would be treated as having no conversation.
|
||||||
|
const findWhatsappConversationId = async inboxId => {
|
||||||
|
const { data } = await ContactAPI.getConversations(props.contactId, {
|
||||||
|
inboxId,
|
||||||
|
});
|
||||||
|
const conversations = data?.payload || [];
|
||||||
|
const match = [...conversations].sort(
|
||||||
|
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
|
||||||
|
)[0];
|
||||||
|
return match?.id || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||||
|
// WhatsApp /initiate is conversation-scoped, so we must hand it a
|
||||||
|
// conversation. Use the caller's hint when given (in-conversation flow);
|
||||||
|
// otherwise pick the most recent one in the inbox.
|
||||||
|
const conversationId =
|
||||||
|
conversationIdHint || (await findWhatsappConversationId(inboxId));
|
||||||
|
if (!conversationId) {
|
||||||
|
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response =
|
||||||
|
await whatsappCallSession.initiateOutboundCall(conversationId);
|
||||||
|
// 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?.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));
|
||||||
|
navigateToConversation(conversationId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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({
|
||||||
|
callSid: response.call_id,
|
||||||
|
callId: response.id,
|
||||||
|
conversationId,
|
||||||
|
inboxId,
|
||||||
|
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||||
|
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
|
||||||
|
});
|
||||||
|
|
||||||
|
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||||
|
navigateToConversation(conversationId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCall = async (inboxId, conversationIdHint = null) => {
|
||||||
|
if (isCallButtonDisabled.value) return;
|
||||||
|
|
||||||
|
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
|
||||||
|
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
|
||||||
|
try {
|
||||||
|
await startWhatsappCall(inboxId, conversationIdHint);
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await store.dispatch('contacts/initiateCall', {
|
const response = await store.dispatch('contacts/initiateCall', {
|
||||||
contactId: props.contactId,
|
contactId: props.contactId,
|
||||||
inboxId,
|
inboxId,
|
||||||
|
conversationId: conversationIdHint,
|
||||||
});
|
});
|
||||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||||
|
|
||||||
// Add call to store immediately so widget shows
|
|
||||||
const callsStore = useCallsStore();
|
|
||||||
callsStore.addCall({
|
callsStore.addCall({
|
||||||
callSid,
|
callSid,
|
||||||
conversationId,
|
conversationId,
|
||||||
inboxId,
|
inboxId,
|
||||||
callDirection: 'outbound',
|
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||||
});
|
});
|
||||||
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||||
@@ -86,6 +182,22 @@ const startCall = async inboxId => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onClick = async () => {
|
const onClick = async () => {
|
||||||
|
// In conversation context, only stay in this conversation if its inbox is
|
||||||
|
// itself voice-capable (works the same for Twilio and WhatsApp). For
|
||||||
|
// non-voice channels (email, web, …) fall back to the picker so the call
|
||||||
|
// goes out via a voice inbox.
|
||||||
|
if (props.conversationId) {
|
||||||
|
const conversation = store.getters.getConversationById(
|
||||||
|
props.conversationId
|
||||||
|
);
|
||||||
|
const conversationInbox = (inboxesList.value || []).find(
|
||||||
|
i => i.id === conversation?.inbox_id
|
||||||
|
);
|
||||||
|
if (conversationInbox && isVoiceCallEnabled(conversationInbox)) {
|
||||||
|
await startCall(conversationInbox.id, props.conversationId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (voiceInboxes.value.length > 1) {
|
if (voiceInboxes.value.length > 1) {
|
||||||
dialogRef.value?.open();
|
dialogRef.value?.open();
|
||||||
return;
|
return;
|
||||||
@@ -106,7 +218,7 @@ const onPickInbox = async inbox => {
|
|||||||
v-if="shouldRender"
|
v-if="shouldRender"
|
||||||
v-tooltip.top-end="tooltipLabel || null"
|
v-tooltip.top-end="tooltipLabel || null"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
:disabled="isInitiatingCall"
|
:disabled="isCallButtonDisabled"
|
||||||
:is-loading="isInitiatingCall"
|
:is-loading="isInitiatingCall"
|
||||||
:label="label"
|
:label="label"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
|
|||||||
@@ -142,8 +142,7 @@ watch(
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
::v-deep {
|
:deep(.ProseMirror-menubar-wrapper) {
|
||||||
.ProseMirror-menubar-wrapper {
|
|
||||||
.ProseMirror.ProseMirror-woot-style {
|
.ProseMirror.ProseMirror-woot-style {
|
||||||
p {
|
p {
|
||||||
@apply first:mt-0 !important;
|
@apply first:mt-0 !important;
|
||||||
@@ -165,6 +164,5 @@ watch(
|
|||||||
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+7
-9
@@ -145,22 +145,21 @@ const handleCreateArticle = event => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
::v-deep {
|
:deep(.ProseMirror .empty-node::before) {
|
||||||
.ProseMirror .empty-node::before {
|
|
||||||
@apply text-n-slate-10 text-base;
|
@apply text-n-slate-10 text-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror-menubar-wrapper {
|
:deep(.ProseMirror-menubar-wrapper) {
|
||||||
.ProseMirror-woot-style {
|
.ProseMirror-woot-style {
|
||||||
@apply min-h-[15rem] max-h-full;
|
@apply min-h-[15rem] max-h-full;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror-menubar {
|
:deep(.ProseMirror-menubar) {
|
||||||
display: none; // Hide by default
|
display: none; // Hide by default
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-root .has-selection {
|
:deep(.editor-root .has-selection) {
|
||||||
.ProseMirror-menubar:not(:has(*)) {
|
.ProseMirror-menubar:not(:has(*)) {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -185,6 +184,5 @@ const handleCreateArticle = event => {
|
|||||||
@apply bg-n-slate-3;
|
@apply bg-n-slate-3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+7
-15
@@ -84,28 +84,20 @@ const findCategoryFromSlug = slug => {
|
|||||||
return categories.value?.find(category => category.slug === 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(() => {
|
const selectedCategory = computed(() => {
|
||||||
if (isNewArticle.value) {
|
if (isNewArticle.value) {
|
||||||
|
if (selectedCategoryId.value) {
|
||||||
|
return (
|
||||||
|
categories.value?.find(c => c.id === selectedCategoryId.value) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
if (categorySlugFromRoute.value) {
|
if (categorySlugFromRoute.value) {
|
||||||
const categoryFromSlug = assignCategoryFromSlug(
|
const categoryFromSlug = findCategoryFromSlug(
|
||||||
categorySlugFromRoute.value
|
categorySlugFromRoute.value
|
||||||
);
|
);
|
||||||
if (categoryFromSlug) return categoryFromSlug;
|
if (categoryFromSlug) return categoryFromSlug;
|
||||||
}
|
}
|
||||||
return selectedCategoryId.value
|
return categories.value?.[0] || null;
|
||||||
? categories.value.find(
|
|
||||||
category => category.id === selectedCategoryId.value
|
|
||||||
)
|
|
||||||
: categories.value[0] || null;
|
|
||||||
}
|
}
|
||||||
return categories.value.find(
|
return categories.value.find(
|
||||||
category => category.id === props.article?.category?.id
|
category => category.id === props.article?.category?.id
|
||||||
|
|||||||
+59
-1
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { OnClickOutside } from '@vueuse/components';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||||
@@ -18,6 +19,7 @@ import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/A
|
|||||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.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';
|
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -62,6 +64,7 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
|
|
||||||
const selectedArticleIds = ref(new Set());
|
const selectedArticleIds = ref(new Set());
|
||||||
const deleteConfirmDialogRef = ref(null);
|
const deleteConfirmDialogRef = ref(null);
|
||||||
|
const isCategoryMenuOpen = ref(false);
|
||||||
|
|
||||||
const { isEnterprise } = useConfig();
|
const { isEnterprise } = useConfig();
|
||||||
|
|
||||||
@@ -168,7 +171,9 @@ const handlePageChange = page => emit('pageChange', page);
|
|||||||
const navigateToNewArticlePage = () => {
|
const navigateToNewArticlePage = () => {
|
||||||
const { categorySlug, locale } = route.params;
|
const { categorySlug, locale } = route.params;
|
||||||
router.push({
|
router.push({
|
||||||
name: 'portals_articles_new',
|
name: props.isCategoryArticles
|
||||||
|
? 'portals_categories_articles_new'
|
||||||
|
: 'portals_articles_new',
|
||||||
params: { categorySlug, locale },
|
params: { categorySlug, locale },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -219,6 +224,34 @@ 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 = () => {
|
const confirmBulkDelete = () => {
|
||||||
deleteConfirmDialogRef.value?.open();
|
deleteConfirmDialogRef.value?.open();
|
||||||
};
|
};
|
||||||
@@ -274,6 +307,7 @@ watch(
|
|||||||
:categories="categories"
|
:categories="categories"
|
||||||
:allowed-locales="allowedLocales"
|
:allowed-locales="allowedLocales"
|
||||||
:has-selected-category="isCategoryArticles"
|
:has-selected-category="isCategoryArticles"
|
||||||
|
@new-article="navigateToNewArticlePage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -337,6 +371,30 @@ watch(
|
|||||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||||
@click="bulkUpdateStatus('archived')"
|
@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
|
<Button
|
||||||
v-if="isTranslationAvailable"
|
v-if="isTranslationAvailable"
|
||||||
sm
|
sm
|
||||||
|
|||||||
+1
-1
@@ -202,7 +202,7 @@ defineExpose({ state, isSubmitDisabled });
|
|||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
||||||
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"
|
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"
|
||||||
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
||||||
/>
|
/>
|
||||||
<EmojiInput
|
<EmojiInput
|
||||||
|
|||||||
+8
-2
@@ -25,7 +25,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['localeChange']);
|
const emit = defineEmits(['localeChange', 'newArticle']);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -179,7 +179,7 @@ const handleBreadcrumbClick = () => {
|
|||||||
/>
|
/>
|
||||||
</OnClickOutside>
|
</OnClickOutside>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative">
|
<div v-else class="relative flex items-center gap-2">
|
||||||
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
||||||
<Button
|
<Button
|
||||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
|
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
|
||||||
@@ -196,6 +196,12 @@ const handleBreadcrumbClick = () => {
|
|||||||
@close="isEditCategoryDialogOpen = false"
|
@close="isEditCategoryDialogOpen = false"
|
||||||
/>
|
/>
|
||||||
</OnClickOutside>
|
</OnClickOutside>
|
||||||
|
<Button
|
||||||
|
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
size="sm"
|
||||||
|
@click="emit('newArticle')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
<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,6 +7,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
|
|||||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||||
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
|
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
|
||||||
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
||||||
|
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
|
||||||
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
|
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
@@ -102,6 +103,12 @@ const handleDeletePortal = () => {
|
|||||||
@send-cname-instructions="handleSendCnameInstructions"
|
@send-cname-instructions="handleSendCnameInstructions"
|
||||||
/>
|
/>
|
||||||
<div class="w-full h-px bg-n-weak" />
|
<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 items-end justify-between w-full gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h6 class="text-base font-medium text-n-slate-12">
|
<h6 class="text-base font-medium text-n-slate-12">
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 818 B |
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -19,6 +19,7 @@ const DEFAULT_ROUTE = 'portals_articles_index';
|
|||||||
const CATEGORY_ROUTE = 'portals_categories_index';
|
const CATEGORY_ROUTE = 'portals_categories_index';
|
||||||
const CATEGORY_SUB_ROUTES = [
|
const CATEGORY_SUB_ROUTES = [
|
||||||
'portals_categories_articles_index',
|
'portals_categories_articles_index',
|
||||||
|
'portals_categories_articles_new',
|
||||||
'portals_categories_articles_edit',
|
'portals_categories_articles_edit',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ const modelValue = defineModel({ type: Boolean, default: false });
|
|||||||
<span class="text-heading-3 text-n-slate-12">
|
<span class="text-heading-3 text-n-slate-12">
|
||||||
{{ header }}
|
{{ header }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="hideToggle" class="size-2" />
|
<template v-if="hideToggle">
|
||||||
|
<slot name="hiddenToggle">
|
||||||
|
<div class="size-2" />
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
<ToggleSwitch v-else v-model="modelValue" />
|
<ToggleSwitch v-else v-model="modelValue" />
|
||||||
</div>
|
</div>
|
||||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||||
|
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
call: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
callInfo: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
// 'incoming' | 'outgoing' | 'ongoing'
|
||||||
|
state: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
isMuted: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showMute: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits([
|
||||||
|
'accept',
|
||||||
|
'reject',
|
||||||
|
'end',
|
||||||
|
'toggleMute',
|
||||||
|
'goToConversation',
|
||||||
|
'dismiss',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isOngoing = computed(() => props.state === VOICE_CALL_DIRECTION.ONGOING);
|
||||||
|
const isIncoming = computed(
|
||||||
|
() => props.state === VOICE_CALL_DIRECTION.INCOMING
|
||||||
|
);
|
||||||
|
const isOutgoing = computed(
|
||||||
|
() => props.state === VOICE_CALL_DIRECTION.OUTGOING
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusIcon = computed(() => {
|
||||||
|
if (isOngoing.value) return 'i-ph-phone-call-bold';
|
||||||
|
if (isOutgoing.value) return 'i-ph-phone-outgoing-bold';
|
||||||
|
return 'i-ph-phone-incoming-bold';
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
if (isOngoing.value) return t('CONVERSATION.VOICE_WIDGET.CALL_IN_PROGRESS');
|
||||||
|
if (isOutgoing.value) return t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL');
|
||||||
|
return t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL');
|
||||||
|
});
|
||||||
|
|
||||||
|
const channelIcon = computed(() => {
|
||||||
|
if (props.call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP)
|
||||||
|
return 'i-ri-whatsapp-fill';
|
||||||
|
return 'i-ph-phone-bold';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 pt-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
|
||||||
|
:class="call?.conversationId ? 'pb-2' : 'pb-4'"
|
||||||
|
>
|
||||||
|
<!-- Top section: status badge + location/inbox + duration -->
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-2 px-4">
|
||||||
|
<!-- Ongoing: status badge on left -->
|
||||||
|
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
|
||||||
|
<Icon :icon="statusIcon" class="size-3.5 text-n-teal-9 shrink-0" />
|
||||||
|
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
|
||||||
|
{{ statusLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Caller location (city, country) or fallback to channel + inbox name -->
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
v-if="callInfo.hasLocation && callInfo.countryFlag"
|
||||||
|
class="text-sm leading-none shrink-0"
|
||||||
|
>
|
||||||
|
{{ callInfo.countryFlag }}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
v-else-if="!isOngoing"
|
||||||
|
:icon="channelIcon"
|
||||||
|
class="size-3.5 text-n-slate-10 shrink-0"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
|
||||||
|
>
|
||||||
|
{{ callInfo.location }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ongoing: duration on right -->
|
||||||
|
<p
|
||||||
|
v-if="isOngoing"
|
||||||
|
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
|
||||||
|
>
|
||||||
|
{{ duration }}
|
||||||
|
</p>
|
||||||
|
<!-- Incoming/Outgoing: status badge on right -->
|
||||||
|
<div v-else class="flex items-center gap-1.5 shrink-0">
|
||||||
|
<Icon :icon="statusIcon" class="size-3.5 text-n-teal-9 shrink-0" />
|
||||||
|
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
|
||||||
|
{{ statusLabel }}
|
||||||
|
</span>
|
||||||
|
<!-- Dismiss: removes the notification from the UI without declining.
|
||||||
|
Incoming only — outgoing/ongoing calls are ended via the call
|
||||||
|
controls, not silently dismissed. -->
|
||||||
|
<NextButton
|
||||||
|
v-if="isIncoming"
|
||||||
|
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.DISMISS_CALL')"
|
||||||
|
icon="i-ph-x-bold"
|
||||||
|
slate
|
||||||
|
ghost
|
||||||
|
xs
|
||||||
|
class="!rounded-full -my-1"
|
||||||
|
@click="$emit('dismiss')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main row: avatar + name/phone + actions -->
|
||||||
|
<div class="flex items-center gap-3 px-4">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<Avatar
|
||||||
|
:src="callInfo.avatar"
|
||||||
|
:name="callInfo.contactName"
|
||||||
|
:size="40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
|
||||||
|
>
|
||||||
|
{{ callInfo.contactName }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="callInfo.phoneNumber"
|
||||||
|
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
|
||||||
|
>
|
||||||
|
{{ callInfo.phoneNumber }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<!-- Mute toggle (WhatsApp ongoing only) -->
|
||||||
|
<NextButton
|
||||||
|
v-if="isOngoing && showMute"
|
||||||
|
v-tooltip.top="
|
||||||
|
isMuted
|
||||||
|
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
|
||||||
|
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
|
||||||
|
"
|
||||||
|
:icon="
|
||||||
|
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
|
||||||
|
"
|
||||||
|
:variant="isMuted ? 'solid' : 'faded'"
|
||||||
|
:color="isMuted ? 'amber' : 'teal'"
|
||||||
|
class="!rounded-full"
|
||||||
|
@click="$emit('toggleMute')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Accept call (incoming only) -->
|
||||||
|
<NextButton
|
||||||
|
v-if="isIncoming"
|
||||||
|
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.JOIN_CALL')"
|
||||||
|
icon="i-ph-phone-bold"
|
||||||
|
teal
|
||||||
|
class="!rounded-full"
|
||||||
|
@click="$emit('accept')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Reject / end call (all states) -->
|
||||||
|
<NextButton
|
||||||
|
v-tooltip.top="
|
||||||
|
isOngoing
|
||||||
|
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
|
||||||
|
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
|
||||||
|
"
|
||||||
|
icon="i-ph-phone-x-bold"
|
||||||
|
ruby
|
||||||
|
class="!rounded-full rotate-[134deg]"
|
||||||
|
@click="isOngoing ? $emit('end') : $emit('reject')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer: go to conversation thread -->
|
||||||
|
<NextButton
|
||||||
|
v-if="call?.conversationId"
|
||||||
|
slate
|
||||||
|
ghost
|
||||||
|
trailing-icon
|
||||||
|
class="!justify-between !px-2 !mx-2"
|
||||||
|
@click="$emit('goToConversation')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="i-ph-chat-circle-text-bold"
|
||||||
|
class="size-3.5 text-n-slate-11 shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="text-sm tracking-tight tabular-nums">
|
||||||
|
#{{ call.conversationId }}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
icon="i-ph-caret-right-bold"
|
||||||
|
class="size-3 text-n-slate-11 shrink-0"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
|
||||||
|
>
|
||||||
|
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
|
||||||
|
</span>
|
||||||
|
</NextButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||||
|
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||||
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
|
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||||
|
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||||
|
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||||
|
import CallCard from 'dashboard/components-next/call/CallCard.vue';
|
||||||
|
import countriesList from 'shared/constants/countries.js';
|
||||||
|
|
||||||
|
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeCall,
|
||||||
|
incomingCalls,
|
||||||
|
hasActiveCall,
|
||||||
|
isJoining,
|
||||||
|
joinCall,
|
||||||
|
endCall: endCallSession,
|
||||||
|
rejectIncomingCall,
|
||||||
|
dismissCall,
|
||||||
|
formattedCallDuration,
|
||||||
|
} = useCallSession();
|
||||||
|
|
||||||
|
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
|
||||||
|
// don't expose a mic track on the browser side.
|
||||||
|
const isMuted = ref(false);
|
||||||
|
const isWhatsappActive = computed(
|
||||||
|
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryIncomingCall = computed(() =>
|
||||||
|
hasActiveCall.value ? null : incomingCalls.value[0] || null
|
||||||
|
);
|
||||||
|
|
||||||
|
const stackedIncomingCalls = computed(() =>
|
||||||
|
hasActiveCall.value ? incomingCalls.value : incomingCalls.value.slice(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainCardState = computed(() => {
|
||||||
|
if (hasActiveCall.value) return VOICE_CALL_DIRECTION.ONGOING;
|
||||||
|
const direction = primaryIncomingCall.value?.callDirection;
|
||||||
|
return direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||||
|
? VOICE_CALL_DIRECTION.OUTGOING
|
||||||
|
: VOICE_CALL_DIRECTION.INCOMING;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stacked cards are always non-active (ringing) calls, so reflect each call's
|
||||||
|
// real direction. An outbound call must render as OUTGOING — otherwise it shows
|
||||||
|
// the incoming-only dismiss (✕) control and the agent could drop it locally
|
||||||
|
// without terminating, leaving the customer ringing with no widget to end it.
|
||||||
|
const stackedCardState = call =>
|
||||||
|
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND
|
||||||
|
? VOICE_CALL_DIRECTION.OUTGOING
|
||||||
|
: VOICE_CALL_DIRECTION.INCOMING;
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
isMuted.value = !isMuted.value;
|
||||||
|
setWhatsappCallMuted(isMuted.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(hasActiveCall, active => {
|
||||||
|
if (!active) isMuted.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert ISO 3166-1 alpha-2 country code (e.g. "US") to its regional indicator
|
||||||
|
// flag emoji. Returns empty string if the code is missing or malformed.
|
||||||
|
const countryCodeToFlag = code => {
|
||||||
|
if (!code || code.length !== 2) return '';
|
||||||
|
const base = 0x1f1e6;
|
||||||
|
const offset = 'A'.charCodeAt(0);
|
||||||
|
return String.fromCodePoint(
|
||||||
|
...code
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map(c => base + (c.charCodeAt(0) - offset))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCallInfo = call => {
|
||||||
|
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||||
|
// Look up inbox from the call's own inboxId — the conversation can drop out
|
||||||
|
// of the Vuex store when the user navigates between inbox views, so going
|
||||||
|
// through `conversation.inbox_id` would lose the inbox name (and fall back
|
||||||
|
// to the literal "Customer support" string).
|
||||||
|
const inbox = store.getters['inboxes/getInbox'](call?.inboxId);
|
||||||
|
const sender = conversation?.meta?.sender;
|
||||||
|
// `caller` is the snapshot captured when the call first landed (from the
|
||||||
|
// message sender or the WhatsApp cable payload). It outlives the
|
||||||
|
// conversation being in the store, so prefer it for display.
|
||||||
|
const caller = call?.caller;
|
||||||
|
const additional =
|
||||||
|
sender?.additional_attributes || caller?.additionalAttributes || {};
|
||||||
|
const city = additional.city || '';
|
||||||
|
const countryCode = additional.country_code || '';
|
||||||
|
const country =
|
||||||
|
additional.country ||
|
||||||
|
countriesList.find(c => c.id === countryCode.toUpperCase())?.name ||
|
||||||
|
'';
|
||||||
|
// Prefer the richest available location string ("City, Country"); fall back to
|
||||||
|
// whichever single field is present; finally fall back to the inbox name so
|
||||||
|
// there's always something to show.
|
||||||
|
const locationParts = [city, country].filter(Boolean);
|
||||||
|
const location =
|
||||||
|
locationParts.join(', ') || inbox?.name || 'Customer support';
|
||||||
|
return {
|
||||||
|
conversation,
|
||||||
|
inbox,
|
||||||
|
contactName:
|
||||||
|
caller?.name ||
|
||||||
|
sender?.name ||
|
||||||
|
caller?.phone ||
|
||||||
|
sender?.phone_number ||
|
||||||
|
'Unknown caller',
|
||||||
|
phoneNumber: caller?.phone || sender?.phone_number || '',
|
||||||
|
inboxName: inbox?.name || 'Customer support',
|
||||||
|
location,
|
||||||
|
countryFlag: countryCodeToFlag(countryCode),
|
||||||
|
hasLocation: locationParts.length > 0,
|
||||||
|
avatar: caller?.avatar || sender?.avatar || sender?.thumbnail,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToConversation = call => {
|
||||||
|
const conversationId = call?.conversationId;
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
if (!conversationId || !accountId) return;
|
||||||
|
router.push({
|
||||||
|
path: frontendURL(conversationUrl({ accountId, id: conversationId })),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndCall = async () => {
|
||||||
|
const call = activeCall.value;
|
||||||
|
if (!call) return;
|
||||||
|
|
||||||
|
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
|
||||||
|
if (!inboxId) return;
|
||||||
|
|
||||||
|
await endCallSession({
|
||||||
|
conversationId: call.conversationId,
|
||||||
|
inboxId,
|
||||||
|
callSid: call.callSid,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinCall = async call => {
|
||||||
|
if (!call || isJoining.value) return;
|
||||||
|
const { conversation } = getCallInfo(call);
|
||||||
|
|
||||||
|
if (hasActiveCall.value) {
|
||||||
|
await handleEndCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The conversation may not be hydrated yet (post-refresh seeding path);
|
||||||
|
// call.inboxId already carries what joinCall needs.
|
||||||
|
const result = await joinCall({
|
||||||
|
conversationId: call.conversationId,
|
||||||
|
inboxId: call.inboxId || conversation?.inbox_id,
|
||||||
|
callSid: call.callSid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result && conversation) {
|
||||||
|
router.push({
|
||||||
|
name: 'inbox_conversation',
|
||||||
|
params: { conversation_id: call.conversationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-join outbound calls when window is visible. WhatsApp outbound has no
|
||||||
|
// separate join step (the offer was sent at initiate time and the answer is
|
||||||
|
// applied directly by the cable handler), so this only covers Twilio.
|
||||||
|
watch(
|
||||||
|
() => incomingCalls.value[0],
|
||||||
|
call => {
|
||||||
|
if (
|
||||||
|
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND &&
|
||||||
|
call?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP &&
|
||||||
|
!hasActiveCall.value &&
|
||||||
|
WindowVisibilityHelper.isWindowVisible()
|
||||||
|
) {
|
||||||
|
handleJoinCall(call);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loop the ringtone while an inbound call is unanswered. Stop the moment any
|
||||||
|
// call is active (we joined), every inbound call cleared, or the widget tears
|
||||||
|
// down. The watcher only fires on the boolean transitioning, so additional
|
||||||
|
// ringing calls arriving while one is already ringing don't restart the audio
|
||||||
|
// — they silently stack into the UI without producing a fresh ring.
|
||||||
|
// Browser autoplay may reject the first play() if the tab has no prior
|
||||||
|
// user gesture; that's fine — the visual widget still surfaces the call.
|
||||||
|
const ringtone = new Audio(RINGTONE_URL);
|
||||||
|
ringtone.loop = true;
|
||||||
|
ringtone.volume = 1;
|
||||||
|
|
||||||
|
const stopRingtone = () => {
|
||||||
|
ringtone.pause();
|
||||||
|
ringtone.currentTime = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ringingInbound = computed(() =>
|
||||||
|
incomingCalls.value.some(
|
||||||
|
call => call.callDirection !== VOICE_CALL_DIRECTION.OUTBOUND
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => ringingInbound.value && !hasActiveCall.value,
|
||||||
|
shouldRing => {
|
||||||
|
if (shouldRing) {
|
||||||
|
ringtone.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
stopRingtone();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(stopRingtone);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="incomingCalls.length || hasActiveCall"
|
||||||
|
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-3 w-[400px]"
|
||||||
|
>
|
||||||
|
<!-- Stacked incoming calls (shown above the primary card) -->
|
||||||
|
<CallCard
|
||||||
|
v-for="call in stackedIncomingCalls"
|
||||||
|
:key="call.callSid"
|
||||||
|
:call="call"
|
||||||
|
:state="stackedCardState(call)"
|
||||||
|
:call-info="getCallInfo(call)"
|
||||||
|
@accept="handleJoinCall(call)"
|
||||||
|
@reject="rejectIncomingCall(call.callSid)"
|
||||||
|
@dismiss="dismissCall(call.callSid)"
|
||||||
|
@go-to-conversation="goToConversation(call)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main Call Widget -->
|
||||||
|
<CallCard
|
||||||
|
v-if="hasActiveCall || primaryIncomingCall"
|
||||||
|
:call="activeCall || primaryIncomingCall"
|
||||||
|
:state="mainCardState"
|
||||||
|
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
|
||||||
|
:duration="hasActiveCall ? formattedCallDuration : ''"
|
||||||
|
:is-muted="isMuted"
|
||||||
|
:show-mute="hasActiveCall && isWhatsappActive"
|
||||||
|
@accept="handleJoinCall(primaryIncomingCall)"
|
||||||
|
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
|
||||||
|
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
|
||||||
|
@end="handleEndCall"
|
||||||
|
@toggle-mute="toggleMute"
|
||||||
|
@go-to-conversation="goToConversation(activeCall || primaryIncomingCall)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
|
||||||
|
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||||
|
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
selectedIds: { type: Set, default: () => new Set() },
|
||||||
|
documents: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:selectedIds',
|
||||||
|
'bulkSyncQueued',
|
||||||
|
'bulkDeleteSucceeded',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const bulkDeleteDialog = ref(null);
|
||||||
|
|
||||||
|
const isSyncableDocument = doc =>
|
||||||
|
!doc.pdf_document && doc.status === 'available';
|
||||||
|
|
||||||
|
const syncableSelectedIds = computed(() => {
|
||||||
|
if (!props.selectedIds.size) return [];
|
||||||
|
return props.documents
|
||||||
|
.filter(doc => props.selectedIds.has(doc.id) && isSyncableDocument(doc))
|
||||||
|
.map(doc => doc.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasSyncableSelection = computed(
|
||||||
|
() => syncableSelectedIds.value.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectAllLabel = computed(() => {
|
||||||
|
const count = props.documents.length;
|
||||||
|
const isAllSelected = props.selectedIds.size === count && count > 0;
|
||||||
|
return isAllSelected
|
||||||
|
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
|
||||||
|
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCountLabel = computed(() =>
|
||||||
|
t('CAPTAIN.DOCUMENTS.SELECTED', { count: props.selectedIds.size })
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBulkSync = async () => {
|
||||||
|
const ids = syncableSelectedIds.value;
|
||||||
|
if (!ids.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await store.dispatch('captainBulkActions/handleBulkSync', {
|
||||||
|
ids,
|
||||||
|
});
|
||||||
|
const queuedCount = response?.count ?? response?.ids?.length ?? 0;
|
||||||
|
let message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.ZERO_MESSAGE');
|
||||||
|
|
||||||
|
if (queuedCount === 1) {
|
||||||
|
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE_ONE');
|
||||||
|
} else if (queuedCount > 1) {
|
||||||
|
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE', {
|
||||||
|
count: queuedCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useAlert(message);
|
||||||
|
emit('update:selectedIds', new Set());
|
||||||
|
if (queuedCount > 0) emit('bulkSyncQueued');
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('CAPTAIN.DOCUMENTS.BULK_SYNC.ERROR_MESSAGE'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<BulkSelectBar
|
||||||
|
:model-value="selectedIds"
|
||||||
|
:all-items="documents"
|
||||||
|
:select-all-label="selectAllLabel"
|
||||||
|
:selected-count-label="selectedCountLabel"
|
||||||
|
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
|
||||||
|
class="w-fit"
|
||||||
|
:class="{ 'mb-2': selectedIds.size > 0 }"
|
||||||
|
@update:model-value="emit('update:selectedIds', $event)"
|
||||||
|
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
||||||
|
>
|
||||||
|
<template v-if="hasSyncableSelection" #secondaryActions>
|
||||||
|
<Button
|
||||||
|
:label="$t('CAPTAIN.DOCUMENTS.BULK_SYNC_BUTTON')"
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
ghost
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
class="!px-1.5"
|
||||||
|
@click="handleBulkSync"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</BulkSelectBar>
|
||||||
|
<BulkDeleteDialog
|
||||||
|
ref="bulkDeleteDialog"
|
||||||
|
:bulk-ids="selectedIds"
|
||||||
|
type="AssistantDocument"
|
||||||
|
@delete-success="emit('bulkDeleteSucceeded')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -5,14 +5,17 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||||
import {
|
import {
|
||||||
isPdfDocument,
|
isSafeHttpLink,
|
||||||
formatDocumentLink,
|
formatDocumentLink,
|
||||||
|
getDocumentDisplayPath,
|
||||||
} from 'shared/helpers/documentHelper';
|
} from 'shared/helpers/documentHelper';
|
||||||
|
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
|
import DocumentSyncStatus from 'dashboard/components-next/captain/assistant/DocumentSyncStatus.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -31,10 +34,38 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
pdfDocument: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
syncStatus: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
lastSyncedAt: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
lastSyncErrorCode: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
syncInProgress: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
syncStaleAfterHours: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
isSelected: {
|
isSelected: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -64,6 +95,20 @@ const modelValue = computed({
|
|||||||
set: () => emit('select', props.id),
|
set: () => emit('select', props.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isPdf = computed(() => props.pdfDocument);
|
||||||
|
const hasSafeLink = computed(() => isSafeHttpLink(props.externalLink));
|
||||||
|
const canManage = computed(() => checkPermissions(['administrator']));
|
||||||
|
const isAvailable = computed(() => props.status === 'available');
|
||||||
|
const canSync = computed(
|
||||||
|
() => canManage.value && !isPdf.value && isAvailable.value
|
||||||
|
);
|
||||||
|
const isSyncing = computed(() => props.syncStatus === 'syncing');
|
||||||
|
const isFailed = computed(() => props.syncStatus === 'failed');
|
||||||
|
const isRetryableSync = computed(
|
||||||
|
() => isFailed.value || (isSyncing.value && !props.syncInProgress)
|
||||||
|
);
|
||||||
|
const showSyncStatus = computed(() => !isPdf.value);
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
const allOptions = [
|
const allOptions = [
|
||||||
{
|
{
|
||||||
@@ -74,7 +119,18 @@ const menuItems = computed(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (checkPermissions(['administrator'])) {
|
if (canSync.value) {
|
||||||
|
allOptions.push({
|
||||||
|
label: isRetryableSync.value
|
||||||
|
? t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')
|
||||||
|
: t('CAPTAIN.DOCUMENTS.OPTIONS.SYNC_NOW'),
|
||||||
|
value: 'sync',
|
||||||
|
action: 'sync',
|
||||||
|
icon: 'i-lucide-refresh-cw',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canManage.value) {
|
||||||
allOptions.push({
|
allOptions.push({
|
||||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
|
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
@@ -86,17 +142,25 @@ const menuItems = computed(() => {
|
|||||||
return allOptions;
|
return allOptions;
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdAt = computed(() => dynamicTime(props.createdAt));
|
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
|
||||||
|
|
||||||
const displayLink = computed(() => formatDocumentLink(props.externalLink));
|
const displayLink = computed(() =>
|
||||||
|
isPdf.value
|
||||||
|
? formatDocumentLink(props.externalLink)
|
||||||
|
: getDocumentDisplayPath(props.externalLink)
|
||||||
|
);
|
||||||
const linkIcon = computed(() =>
|
const linkIcon = computed(() =>
|
||||||
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
|
isPdf.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAction = ({ action, value }) => {
|
const handleAction = ({ action, value }) => {
|
||||||
toggleDropdown(false);
|
toggleDropdown(false);
|
||||||
emit('action', { action, value, id: props.id });
|
emit('action', { action, value, id: props.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
emit('action', { action: 'sync', id: props.id });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -141,17 +205,41 @@ const handleAction = ({ action, value }) => {
|
|||||||
<span
|
<span
|
||||||
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
|
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
|
||||||
>
|
>
|
||||||
<i class="i-woot-captain" />
|
<Icon icon="i-woot-captain" />
|
||||||
{{ assistant?.name || '' }}
|
{{ assistant?.name || '' }}
|
||||||
</span>
|
</span>
|
||||||
|
<a
|
||||||
|
v-if="!isPdf && hasSafeLink"
|
||||||
|
:href="externalLink"
|
||||||
|
:title="externalLink"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11 hover:text-n-slate-12 hover:underline"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<Icon :icon="linkIcon" class="shrink-0" />
|
||||||
|
<span class="truncate">{{ displayLink }}</span>
|
||||||
|
<Icon icon="i-lucide-external-link size-3 shrink-0 opacity-70" />
|
||||||
|
</a>
|
||||||
<span
|
<span
|
||||||
|
v-else
|
||||||
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
|
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
|
||||||
>
|
>
|
||||||
<i :class="linkIcon" class="shrink-0" />
|
<Icon :icon="linkIcon" class="shrink-0" />
|
||||||
<span class="truncate">{{ displayLink }}</span>
|
<span class="truncate">{{ displayLink }}</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
|
<DocumentSyncStatus
|
||||||
{{ createdAt }}
|
v-if="showSyncStatus"
|
||||||
|
:status="syncStatus"
|
||||||
|
:last-synced-at="lastSyncedAt"
|
||||||
|
:error-code="lastSyncErrorCode"
|
||||||
|
:sync-in-progress="syncInProgress"
|
||||||
|
:stale-after-hours="syncStaleAfterHours"
|
||||||
|
:show-retry="canSync && isRetryableSync"
|
||||||
|
@retry="handleRetry"
|
||||||
|
/>
|
||||||
|
<div v-else class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
|
||||||
|
{{ createdAtLabel }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardLayout>
|
</CardLayout>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import DocumentFiltersBar from 'dashboard/components-next/captain/assistant/DocumentFiltersBar.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
|
const source = ref('all');
|
||||||
|
const status = ref(null);
|
||||||
|
const sort = ref('recently_updated');
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(
|
||||||
|
() => source.value !== 'all' || Boolean(status.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildParams = (page = 1) => {
|
||||||
|
const params = { page };
|
||||||
|
if (source.value !== 'all') params.source = source.value;
|
||||||
|
if (status.value) params.filter = status.value;
|
||||||
|
if (sort.value) params.sort = sort.value;
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitChange = () => emit('change');
|
||||||
|
|
||||||
|
const handleSourceSelect = sourceKey => {
|
||||||
|
source.value = sourceKey;
|
||||||
|
if (sourceKey !== 'web') status.value = null;
|
||||||
|
emitChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusSelect = statusKey => {
|
||||||
|
status.value = statusKey;
|
||||||
|
if (statusKey) source.value = 'web';
|
||||||
|
emitChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortSelect = sortKey => {
|
||||||
|
sort.value = sortKey;
|
||||||
|
emitChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
source.value = 'all';
|
||||||
|
status.value = null;
|
||||||
|
sort.value = 'recently_updated';
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ buildParams, reset, hasActiveFilters });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DocumentFiltersBar
|
||||||
|
:active-source-filter="source"
|
||||||
|
:active-status-filter="status"
|
||||||
|
:active-sort="sort"
|
||||||
|
@select-source="handleSourceSelect"
|
||||||
|
@select-status="handleStatusSelect"
|
||||||
|
@select-sort="handleSortSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeSourceFilter: { type: String, default: 'all' },
|
||||||
|
activeStatusFilter: { type: String, default: null },
|
||||||
|
activeSort: { type: String, default: 'recently_updated' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['selectSource', 'selectStatus', 'selectSort']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const openMenu = ref(null);
|
||||||
|
|
||||||
|
const MENU_CONFIG = [
|
||||||
|
{
|
||||||
|
key: 'source',
|
||||||
|
activeKey: 'activeSourceFilter',
|
||||||
|
dropdownClass: 'min-w-48',
|
||||||
|
options: [
|
||||||
|
{ labelKey: 'SOURCE.ALL', value: 'all', icon: 'i-lucide-files' },
|
||||||
|
{ labelKey: 'SOURCE.WEB', value: 'web', icon: 'i-lucide-link' },
|
||||||
|
{ labelKey: 'SOURCE.PDF', value: 'pdf', icon: 'i-lucide-file-text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
activeKey: 'activeStatusFilter',
|
||||||
|
dropdownClass: 'min-w-52',
|
||||||
|
options: [
|
||||||
|
{ labelKey: 'STATUS.ANY', value: null, icon: 'i-lucide-circle-dashed' },
|
||||||
|
{
|
||||||
|
labelKey: 'STATUS.UPDATED',
|
||||||
|
value: 'synced',
|
||||||
|
icon: 'i-lucide-check-circle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: 'STATUS.NEEDS_UPDATE',
|
||||||
|
value: 'stale',
|
||||||
|
icon: 'i-lucide-clock',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: 'STATUS.UPDATING',
|
||||||
|
value: 'syncing',
|
||||||
|
icon: 'i-lucide-refresh-cw',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: 'STATUS.FAILED',
|
||||||
|
value: 'failed',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sort',
|
||||||
|
activeKey: 'activeSort',
|
||||||
|
dropdownClass: 'min-w-56',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
labelKey: 'SORT.RECENTLY_UPDATED',
|
||||||
|
value: 'recently_updated',
|
||||||
|
icon: 'i-lucide-arrow-down-up',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: 'SORT.RECENTLY_CREATED',
|
||||||
|
value: 'recently_created',
|
||||||
|
icon: 'i-lucide-clock',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterMenus = computed(() =>
|
||||||
|
MENU_CONFIG.filter(
|
||||||
|
menu => !(menu.key === 'status' && props.activeSourceFilter === 'pdf')
|
||||||
|
).map(menu => {
|
||||||
|
const active = props[menu.activeKey];
|
||||||
|
const items = menu.options.map(opt => ({
|
||||||
|
label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`),
|
||||||
|
value: opt.value,
|
||||||
|
icon: opt.icon,
|
||||||
|
action: menu.key,
|
||||||
|
isSelected: opt.value === active,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
...menu,
|
||||||
|
items,
|
||||||
|
selected: items.find(item => item.isSelected) || items[0],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
openMenu.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMenu = menu => {
|
||||||
|
openMenu.value = openMenu.value === menu ? null : menu;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuAction = ({ action, value }) => {
|
||||||
|
closeMenu();
|
||||||
|
if (action === 'source') emit('selectSource', value);
|
||||||
|
else if (action === 'status') emit('selectStatus', value);
|
||||||
|
else if (action === 'sort') emit('selectSort', value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-on-click-outside="closeMenu"
|
||||||
|
class="inline-flex flex-wrap items-center gap-2 pt-2 w-fit"
|
||||||
|
>
|
||||||
|
<div v-for="menu in filterMenus" :key="menu.key" class="relative">
|
||||||
|
<Button
|
||||||
|
:icon="menu.selected.icon"
|
||||||
|
slate
|
||||||
|
size="sm"
|
||||||
|
:class="{ 'bg-n-slate-9/10': openMenu === menu.key }"
|
||||||
|
@click="toggleMenu(menu.key)"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{{ menu.selected.label }}</span>
|
||||||
|
<Icon icon="i-lucide-chevron-down" class="shrink-0 size-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="openMenu === menu.key"
|
||||||
|
:menu-items="menu.items"
|
||||||
|
:class="menu.dropdownClass"
|
||||||
|
class="top-full mt-2 ltr:left-0 rtl:right-0"
|
||||||
|
@action="handleMenuAction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
lastSyncedAt: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
errorCode: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
syncInProgress: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
staleAfterHours: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
showRetry: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['retry']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const SECONDS_PER_HOUR = 3600;
|
||||||
|
|
||||||
|
const SYNCING = 'syncing';
|
||||||
|
const FAILED = 'failed';
|
||||||
|
|
||||||
|
const ERROR_CODE_LABELS = {
|
||||||
|
not_found: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.NOT_FOUND',
|
||||||
|
access_denied: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.ACCESS_DENIED',
|
||||||
|
timeout: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.TIMEOUT',
|
||||||
|
content_empty: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.CONTENT_EMPTY',
|
||||||
|
fetch_failed: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.FETCH_FAILED',
|
||||||
|
sync_error: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.SYNC_ERROR',
|
||||||
|
};
|
||||||
|
const DEFAULT_ERROR_LABEL = 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.DEFAULT';
|
||||||
|
|
||||||
|
const hasSyncingStatus = computed(() => props.status === SYNCING);
|
||||||
|
const isSyncing = computed(
|
||||||
|
() => hasSyncingStatus.value && props.syncInProgress
|
||||||
|
);
|
||||||
|
const isStaleSync = computed(
|
||||||
|
() => hasSyncingStatus.value && !props.syncInProgress
|
||||||
|
);
|
||||||
|
const isFailed = computed(() => props.status === FAILED);
|
||||||
|
const canRetry = computed(() => isFailed.value || isStaleSync.value);
|
||||||
|
const hasBeenSynced = computed(() => Boolean(props.lastSyncedAt));
|
||||||
|
|
||||||
|
const ageInHours = computed(() => {
|
||||||
|
if (!props.lastSyncedAt) return null;
|
||||||
|
const nowSeconds = Date.now() / 1000;
|
||||||
|
return (nowSeconds - props.lastSyncedAt) / SECONDS_PER_HOUR;
|
||||||
|
});
|
||||||
|
|
||||||
|
const staleAfterHours = computed(() => Number(props.staleAfterHours));
|
||||||
|
const hasStaleThreshold = computed(
|
||||||
|
() => Number.isFinite(staleAfterHours.value) && staleAfterHours.value > 0
|
||||||
|
);
|
||||||
|
const isStale = computed(
|
||||||
|
() =>
|
||||||
|
hasStaleThreshold.value &&
|
||||||
|
ageInHours.value !== null &&
|
||||||
|
ageInHours.value >= staleAfterHours.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorLabel = computed(() =>
|
||||||
|
t(ERROR_CODE_LABELS[props.errorCode] || DEFAULT_ERROR_LABEL)
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
|
||||||
|
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
|
||||||
|
if (isFailed.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED');
|
||||||
|
if (hasBeenSynced.value)
|
||||||
|
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
|
||||||
|
time: dynamicTime(props.lastSyncedAt),
|
||||||
|
});
|
||||||
|
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullLabel = computed(() => {
|
||||||
|
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
|
||||||
|
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
|
||||||
|
if (isFailed.value)
|
||||||
|
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED', {
|
||||||
|
error: errorLabel.value,
|
||||||
|
});
|
||||||
|
if (hasBeenSynced.value)
|
||||||
|
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
|
||||||
|
time: dynamicTime(props.lastSyncedAt),
|
||||||
|
});
|
||||||
|
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
|
||||||
|
});
|
||||||
|
|
||||||
|
const tone = computed(() => {
|
||||||
|
if (isSyncing.value) return 'amber';
|
||||||
|
if (isStaleSync.value) return 'amber';
|
||||||
|
if (isFailed.value) return 'ruby';
|
||||||
|
if (isStale.value) return 'amber';
|
||||||
|
return 'slate';
|
||||||
|
});
|
||||||
|
|
||||||
|
const textClass = computed(() => {
|
||||||
|
if (tone.value === 'amber') return 'text-n-amber-11';
|
||||||
|
if (tone.value === 'ruby') return 'text-n-ruby-11';
|
||||||
|
return 'text-n-slate-11';
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusIcon = computed(() => {
|
||||||
|
if (isFailed.value || isStale.value || isStaleSync.value) {
|
||||||
|
return 'i-lucide-circle-alert';
|
||||||
|
}
|
||||||
|
return 'i-lucide-refresh-cw';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="flex gap-1.5 items-center text-sm truncate shrink-0 tabular-nums"
|
||||||
|
:class="textClass"
|
||||||
|
:title="fullLabel"
|
||||||
|
>
|
||||||
|
<Spinner v-if="isSyncing" class="text-n-amber-11 size-3" />
|
||||||
|
<Icon v-else :icon="statusIcon" class="shrink-0 size-3.5" />
|
||||||
|
<span class="truncate">{{ label }}</span>
|
||||||
|
<Button
|
||||||
|
v-if="showRetry && canRetry"
|
||||||
|
:label="t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')"
|
||||||
|
xs
|
||||||
|
link
|
||||||
|
ruby
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
class="hover:!no-underline !gap-1 ms-1"
|
||||||
|
@click.stop="emit('retry')"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineModel, watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, defineModel, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,7 @@ defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close', 'createSuccess']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
|
|||||||
const handleSubmit = async newDocument => {
|
const handleSubmit = async newDocument => {
|
||||||
try {
|
try {
|
||||||
await store.dispatch('captainDocuments/create', newDocument);
|
await store.dispatch('captainDocuments/create', newDocument);
|
||||||
|
emit('createSuccess');
|
||||||
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
||||||
dialogRef.value.close();
|
dialogRef.value.close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, defineProps, defineEmits } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { Chrome } from '@lk77/vue3-color';
|
import { Chrome } from '@lk77/vue3-color';
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
import { OnClickOutside } from '@vueuse/components';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, defineModel, h, watch, ref } from 'vue';
|
import { computed, h, watch, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineModel, computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineModel, computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { picoSearch } from '@scmmishra/pico-search';
|
import { picoSearch } from '@scmmishra/pico-search';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { h, defineProps } from 'vue';
|
import { h } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
country: { type: String, required: true },
|
country: { type: String, required: true },
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<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,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { toRef } from 'vue';
|
import { computed, toRef } from 'vue';
|
||||||
import { useChannelIcon } from './provider';
|
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||||
|
import { useChannelIcon, useChannelBrandIcon } from './provider';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -8,11 +9,35 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
|
const inboxRef = 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Icon :icon="channelIcon" />
|
<span class="relative inline-flex" v-bind="$attrs">
|
||||||
|
<Icon :icon="icon" 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"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const fileTypeIcon = computed(() => {
|
|||||||
json: 'i-woot-file-txt',
|
json: 'i-woot-file-txt',
|
||||||
odt: 'i-woot-file-doc',
|
odt: 'i-woot-file-doc',
|
||||||
pdf: 'i-woot-file-pdf',
|
pdf: 'i-woot-file-pdf',
|
||||||
|
pfx: 'i-woot-file-pfx',
|
||||||
ppt: 'i-woot-file-ppt',
|
ppt: 'i-woot-file-ppt',
|
||||||
pptx: 'i-woot-file-ppt',
|
pptx: 'i-woot-file-ppt',
|
||||||
rar: 'i-woot-file-zip',
|
rar: 'i-woot-file-zip',
|
||||||
@@ -26,6 +27,7 @@ const fileTypeIcon = computed(() => {
|
|||||||
txt: 'i-woot-file-txt',
|
txt: 'i-woot-file-txt',
|
||||||
xls: 'i-woot-file-xls',
|
xls: 'i-woot-file-xls',
|
||||||
xlsx: 'i-woot-file-xls',
|
xlsx: 'i-woot-file-xls',
|
||||||
|
xml: 'i-woot-file-txt',
|
||||||
zip: 'i-woot-file-zip',
|
zip: 'i-woot-file-zip',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
|
||||||
|
|
||||||
export function useChannelIcon(inbox) {
|
const channelTypeIconMap = {
|
||||||
const channelTypeIconMap = {
|
|
||||||
'Channel::Api': 'i-woot-api',
|
'Channel::Api': 'i-woot-api',
|
||||||
'Channel::Email': 'i-woot-mail',
|
'Channel::Email': 'i-woot-mail',
|
||||||
'Channel::FacebookPage': 'i-woot-messenger',
|
'Channel::FacebookPage': 'i-woot-messenger',
|
||||||
@@ -15,31 +14,62 @@ export function useChannelIcon(inbox) {
|
|||||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||||
'Channel::Instagram': 'i-woot-instagram',
|
'Channel::Instagram': 'i-woot-instagram',
|
||||||
'Channel::Tiktok': 'i-woot-tiktok',
|
'Channel::Tiktok': 'i-woot-tiktok',
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerIconMap = {
|
const providerIconMap = {
|
||||||
microsoft: 'i-woot-outlook',
|
microsoft: 'i-woot-outlook',
|
||||||
google: 'i-woot-gmail',
|
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 channelIcon = computed(() => {
|
const channelIcon = computed(() => {
|
||||||
const inboxDetails = inbox.value || inbox;
|
const inboxDetails = resolveInbox(inbox);
|
||||||
const type = inboxDetails.channel_type;
|
const type = inboxDetails.channel_type;
|
||||||
let icon = channelTypeIconMap[type];
|
let icon = channelTypeIconMap[type];
|
||||||
|
|
||||||
if (type === 'Channel::Email' && inboxDetails.provider) {
|
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
|
||||||
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
|
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
|
||||||
icon = providerIconMap[inboxDetails.provider];
|
icon = providerIconMap[inboxDetails.provider];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case for Twilio whatsapp
|
// Special case for Twilio whatsapp
|
||||||
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
|
if (
|
||||||
|
type === INBOX_TYPES.TWILIO &&
|
||||||
|
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||||
|
) {
|
||||||
icon = 'i-woot-whatsapp';
|
icon = 'i-woot-whatsapp';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
|
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
|
||||||
if (isVoiceCallEnabled(inboxDetails)) {
|
// is presented as a Voice channel, so show the phone icon.
|
||||||
|
const voiceEnabled =
|
||||||
|
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
|
||||||
|
if (
|
||||||
|
type === INBOX_TYPES.TWILIO &&
|
||||||
|
voiceEnabled &&
|
||||||
|
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||||
|
) {
|
||||||
icon = 'i-woot-voice';
|
icon = 'i-woot-voice';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,3 +78,26 @@ export function useChannelIcon(inbox) {
|
|||||||
|
|
||||||
return channelIcon;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -554,11 +554,10 @@ provideMessageContext({
|
|||||||
<Avatar v-bind="avatarInfo" :size="24" />
|
<Avatar v-bind="avatarInfo" :size="24" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="[grid-area:bubble] flex"
|
class="[grid-area:bubble] flex min-w-0"
|
||||||
:class="{
|
:class="{
|
||||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
|
||||||
}"
|
}"
|
||||||
@contextmenu="openContextMenu($event)"
|
@contextmenu="openContextMenu($event)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, computed, reactive } from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
import Message from './Message.vue';
|
import Message from './Message.vue';
|
||||||
import { MESSAGE_TYPES } from './constants.js';
|
import { MESSAGE_TYPES } from './constants.js';
|
||||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits } from 'vue';
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
showingOriginal: Boolean,
|
showingOriginal: Boolean,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="text-sm"
|
class="text-sm min-w-0"
|
||||||
:class="[
|
:class="[
|
||||||
messageClass,
|
messageClass,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import BaseBubble from './Base.vue';
|
import BaseBubble from './Base.vue';
|
||||||
|
import FormattedContent from './Text/FormattedContent.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
@@ -41,7 +42,8 @@ const starRatingValue = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
|
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
|
||||||
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
|
<FormattedContent v-if="content" :content="content" />
|
||||||
|
<h4 v-else>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
|
||||||
<dl v-if="isRatingSubmitted" class="mt-4">
|
<dl v-if="isRatingSubmitted" class="mt-4">
|
||||||
<dt class="text-n-slate-11 italic">
|
<dt class="text-n-slate-11 italic">
|
||||||
{{ t('CONVERSATION.RATING_TITLE') }}
|
{{ t('CONVERSATION.RATING_TITLE') }}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import BaseBubble from './Base.vue';
|
|||||||
|
|
||||||
const { inboxId } = useMessageContext();
|
const { inboxId } = useMessageContext();
|
||||||
|
|
||||||
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
|
const {
|
||||||
inboxId.value
|
isAFacebookInbox,
|
||||||
);
|
isAnInstagramChannel,
|
||||||
|
isATiktokChannel,
|
||||||
|
isAWhatsAppChannel,
|
||||||
|
} = useInbox(inboxId.value);
|
||||||
|
|
||||||
const unsupportedMessageKey = computed(() => {
|
const unsupportedMessageKey = computed(() => {
|
||||||
if (isAFacebookInbox.value)
|
if (isAFacebookInbox.value)
|
||||||
@@ -16,6 +19,8 @@ const unsupportedMessageKey = computed(() => {
|
|||||||
if (isAnInstagramChannel.value)
|
if (isAnInstagramChannel.value)
|
||||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
|
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
|
||||||
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
|
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
|
||||||
|
if (isAWhatsAppChannel.value)
|
||||||
|
return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP';
|
||||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
|
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,14 +2,27 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
import { VOICE_CALL_STATUS } from '../constants';
|
import {
|
||||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
VOICE_CALL_STATUS,
|
||||||
|
VOICE_CALL_DIRECTION,
|
||||||
|
VOICE_CALL_OUTBOUND_INIT_STATUS,
|
||||||
|
VOICE_CALL_END_REASON,
|
||||||
|
MESSAGE_TYPES,
|
||||||
|
ATTACHMENT_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 { formatDuration } from 'shared/helpers/timeHelper';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||||
import AudioChip from 'next/message/chips/Audio.vue';
|
import AudioChip from 'next/message/chips/Audio.vue';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const LABEL_MAP = {
|
const LABEL_MAP = {
|
||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||||
@@ -17,39 +30,67 @@ const LABEL_MAP = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ICON_MAP = {
|
const ICON_MAP = {
|
||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call-bold',
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
|
||||||
};
|
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
|
||||||
|
|
||||||
const BG_COLOR_MAP = {
|
|
||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
|
|
||||||
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
|
|
||||||
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
|
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
|
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
|
const {
|
||||||
|
call,
|
||||||
|
attachments,
|
||||||
|
contentAttributes,
|
||||||
|
conversationId,
|
||||||
|
currentUserId,
|
||||||
|
inboxId,
|
||||||
|
sender,
|
||||||
|
messageType,
|
||||||
|
} = useMessageContext();
|
||||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||||
useCallSession();
|
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);
|
const status = computed(() => call.value?.status);
|
||||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
// Server-side call records use `outgoing`/`incoming`, while the Pinia store
|
||||||
|
// and a few API hops normalise to `outbound`/`inbound`. Accept either so the
|
||||||
|
// 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;
|
||||||
|
// 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 isFailed = computed(() =>
|
const isFailed = computed(() =>
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||||
);
|
);
|
||||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
||||||
const didCurrentUserAnswer = computed(
|
const endReason = computed(() => call.value?.endReason);
|
||||||
|
const wasDeclinedByAgent = computed(
|
||||||
() =>
|
() =>
|
||||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
isMissedInbound.value &&
|
||||||
|
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
|
||||||
);
|
);
|
||||||
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
|
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||||
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
|
|
||||||
// Twilio's call-status webhook flipped the call to in-progress before the
|
|
||||||
// participant-join webhook claimed it).
|
|
||||||
const conversationAssignee = computed(() => {
|
const conversationAssignee = computed(() => {
|
||||||
const conversation = store.getters.getConversationById?.(
|
const conversation = store.getters.getConversationById?.(
|
||||||
conversationId?.value
|
conversationId?.value
|
||||||
@@ -66,67 +107,107 @@ const displayAgentName = computed(() => {
|
|||||||
return conversationAssignee.value?.name || null;
|
return conversationAssignee.value?.name || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const audioAttachment = computed(() =>
|
||||||
|
(attachments?.value || []).find(a => a.fileType === ATTACHMENT_TYPES.AUDIO)
|
||||||
|
);
|
||||||
|
|
||||||
|
const durationSeconds = computed(() => {
|
||||||
|
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
|
||||||
|
if (fromCall != null) return fromCall;
|
||||||
|
const data = contentAttributes?.value?.data;
|
||||||
|
return data?.durationSeconds || data?.duration_seconds;
|
||||||
|
});
|
||||||
|
|
||||||
|
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(() => {
|
const labelKey = computed(() => {
|
||||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
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
|
return isOutbound.value
|
||||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
}
|
|
||||||
return isFailed.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const formattedDuration = computed(() =>
|
|
||||||
formatDuration(call.value?.durationSeconds)
|
|
||||||
);
|
|
||||||
|
|
||||||
const subtext = computed(() => {
|
const subtext = computed(() => {
|
||||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
// Completed: "Handled by {agent} · 0:42" (drops either part when absent).
|
||||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
|
||||||
}
|
|
||||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||||
return formattedDuration.value;
|
return [handledBy.value, formattedDuration.value]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
}
|
}
|
||||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||||
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
return handledBy.value;
|
||||||
if (didCurrentUserAnswer.value) {
|
|
||||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
|
||||||
}
|
}
|
||||||
if (displayAgentName.value) {
|
if (isFailed.value) {
|
||||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
// 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 (wasDeclinedByAgent.value && displayAgentName.value) {
|
||||||
|
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', {
|
||||||
agentName: displayAgentName.value,
|
agentName: displayAgentName.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
|
||||||
}
|
}
|
||||||
return isFailed.value
|
// RINGING or an as-yet-unknown/initial status.
|
||||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
if (isOutbound.value) {
|
||||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING');
|
||||||
|
}
|
||||||
|
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconName = computed(() => {
|
const iconName = computed(() => {
|
||||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
return isOutbound.value
|
||||||
|
? 'i-ph-phone-outgoing-bold'
|
||||||
|
: 'i-ph-phone-incoming-bold';
|
||||||
});
|
});
|
||||||
|
|
||||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
// Subtle icon container — matches the design's tonal swatch over the bubble bg.
|
||||||
|
// Status drives the accent: teal for live, ruby for missed, neutral otherwise.
|
||||||
|
const iconContainerClass = computed(() => {
|
||||||
|
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||||
|
return 'bg-n-teal-3 text-n-teal-11';
|
||||||
|
}
|
||||||
|
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||||
|
return 'bg-n-teal-3 text-n-teal-11';
|
||||||
|
}
|
||||||
|
if (isMissedInbound.value) {
|
||||||
|
return 'bg-n-alpha-2 text-n-ruby-9';
|
||||||
|
}
|
||||||
|
return 'bg-n-alpha-2 text-n-slate-12';
|
||||||
|
});
|
||||||
|
|
||||||
const callSid = computed(() => call.value?.providerCallId);
|
const callSid = computed(() => call.value?.providerCallId);
|
||||||
|
|
||||||
// Show "Join call" when the call is still ringing, no agent has claimed it,
|
|
||||||
// and the conversation is unassigned or assigned to the current user. Mirrors
|
|
||||||
// the eligibility used by FloatingCallWidget so the bubble can act as a
|
|
||||||
// recovery affordance after a refresh or missed widget.
|
|
||||||
const canJoinCall = computed(() => {
|
const canJoinCall = computed(() => {
|
||||||
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
|
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
|
||||||
if (isOutbound.value) return false;
|
if (isOutbound.value) return false;
|
||||||
if (acceptedByAgentId.value) return false;
|
if (acceptedByAgentId.value) return false;
|
||||||
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
|
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
|
||||||
// Suppress the button once this call is the local active session — the
|
|
||||||
// message status webhook may lag behind, so we can't rely on `status` alone
|
|
||||||
// to hide it after a successful join from this client.
|
|
||||||
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
|
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
|
||||||
return false;
|
return false;
|
||||||
const assignee = conversationAssignee.value;
|
const assignee = conversationAssignee.value;
|
||||||
@@ -135,11 +216,12 @@ const canJoinCall = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const recordingAttachment = computed(() => {
|
const recordingAttachment = computed(() => {
|
||||||
|
if (audioAttachment.value) return audioAttachment.value;
|
||||||
const url = call.value?.recordingUrl;
|
const url = call.value?.recordingUrl;
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return {
|
return {
|
||||||
dataUrl: url,
|
dataUrl: url,
|
||||||
fileType: 'audio',
|
fileType: ATTACHMENT_TYPES.AUDIO,
|
||||||
extension: 'wav',
|
extension: 'wav',
|
||||||
transcribedText: call.value?.transcript || '',
|
transcribedText: call.value?.transcript || '',
|
||||||
};
|
};
|
||||||
@@ -162,48 +244,117 @@ const handleJoinCall = async () => {
|
|||||||
callSid: callSid.value,
|
callSid: callSid.value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canCallBack = computed(
|
||||||
|
() =>
|
||||||
|
isMissedInbound.value &&
|
||||||
|
!!inboxId.value &&
|
||||||
|
!!conversationId.value &&
|
||||||
|
!hasActiveCall.value &&
|
||||||
|
!callsStore.hasIncomingCall
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCallBack = async () => {
|
||||||
|
if (!canCallBack.value || isInitiatingCall.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;
|
||||||
|
}
|
||||||
|
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', {
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseBubble class="p-0 border-none" hide-meta>
|
<BaseBubble class="!p-3 !max-w-md min-w-[240px]" hide-meta>
|
||||||
<div class="flex overflow-hidden flex-col w-full max-w-sm">
|
<div class="flex flex-col gap-3 w-full">
|
||||||
<div class="flex gap-3 items-center p-3 w-full">
|
<!-- Header row: icon + title + duration/subtext -->
|
||||||
|
<div class="flex gap-2.5 items-start">
|
||||||
<div
|
<div
|
||||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
class="flex justify-center items-center rounded-xl size-11 shrink-0"
|
||||||
:class="bgColor"
|
:class="iconContainerClass"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon class="size-4" :icon="iconName" />
|
||||||
class="size-5"
|
|
||||||
:icon="iconName"
|
|
||||||
:class="{
|
|
||||||
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
|
||||||
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col flex-1 min-w-0 self-center">
|
||||||
<div class="flex overflow-hidden flex-col flex-grow">
|
<span
|
||||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
class="font-display text-sm font-medium leading-tight truncate tracking-tight"
|
||||||
|
>
|
||||||
{{ $t(labelKey) }}
|
{{ $t(labelKey) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="subtext" class="text-xs text-n-slate-11">
|
<span
|
||||||
|
v-if="subtext"
|
||||||
|
class="text-sm leading-tight truncate tracking-tight opacity-75"
|
||||||
|
>
|
||||||
{{ subtext }}
|
{{ subtext }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
v-if="canJoinCall"
|
|
||||||
type="button"
|
|
||||||
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
|
|
||||||
:disabled="isJoining"
|
|
||||||
@click="handleJoinCall"
|
|
||||||
>
|
|
||||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="recordingAttachment" class="px-3 pb-3">
|
<!-- Audio player (when there's a recording) -->
|
||||||
<AudioChip :attachment="recordingAttachment" />
|
<AudioChip
|
||||||
</div>
|
v-if="recordingAttachment"
|
||||||
|
:attachment="recordingAttachment"
|
||||||
|
show-transcribed-text
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Call back button (missed inbound) -->
|
||||||
|
<NextButton
|
||||||
|
v-if="canCallBack"
|
||||||
|
type="button"
|
||||||
|
:label="$t('CONVERSATION.VOICE_CALL.CALL_BACK')"
|
||||||
|
icon="i-ph-phone-bold"
|
||||||
|
teal
|
||||||
|
class="!rounded-full"
|
||||||
|
:disabled="isInitiatingCall"
|
||||||
|
@click="handleCallBack"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Join call button (ringing inbound) -->
|
||||||
|
<NextButton
|
||||||
|
v-if="canJoinCall"
|
||||||
|
type="button"
|
||||||
|
:label="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')"
|
||||||
|
icon="i-ph-phone-bold"
|
||||||
|
teal
|
||||||
|
class="!rounded-full"
|
||||||
|
:disabled="isJoining"
|
||||||
|
@click="handleJoinCall"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BaseBubble>
|
</BaseBubble>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, defineOptions, useAttrs } from 'vue';
|
import { computed, useAttrs } from 'vue';
|
||||||
|
|
||||||
import ImageChip from 'next/message/chips/Image.vue';
|
import ImageChip from 'next/message/chips/Image.vue';
|
||||||
import VideoChip from 'next/message/chips/Video.vue';
|
import VideoChip from 'next/message/chips/Video.vue';
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ const timeStampURL = computed(() => {
|
|||||||
return timeStampAppendedURL(attachment.dataUrl);
|
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 audioPlayer = useTemplateRef('audioPlayer');
|
||||||
|
|
||||||
const isPlaying = ref(false);
|
const isPlaying = ref(false);
|
||||||
@@ -41,8 +52,33 @@ const playbackSpeed = ref(1);
|
|||||||
|
|
||||||
const { uid } = getCurrentInstance();
|
const { uid } = getCurrentInstance();
|
||||||
|
|
||||||
|
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
|
||||||
|
// resolves to Infinity until we seek past the end, which forces the engine to
|
||||||
|
// scan the file and compute the real length. Safe no-op for files with a real
|
||||||
|
// duration already (mp3/m4a/etc).
|
||||||
|
const resolveStreamingDuration = () => {
|
||||||
|
const el = audioPlayer.value;
|
||||||
|
if (!el) return;
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
|
el.currentTime = 0;
|
||||||
|
duration.value = el.duration;
|
||||||
|
};
|
||||||
|
el.addEventListener('timeupdate', onTimeUpdate);
|
||||||
|
try {
|
||||||
|
el.currentTime = Number.MAX_SAFE_INTEGER;
|
||||||
|
} catch {
|
||||||
|
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onLoadedMetadata = () => {
|
const onLoadedMetadata = () => {
|
||||||
duration.value = audioPlayer.value?.duration;
|
const d = audioPlayer.value?.duration;
|
||||||
|
if (!Number.isFinite(d)) {
|
||||||
|
resolveStreamingDuration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
duration.value = d;
|
||||||
};
|
};
|
||||||
|
|
||||||
const playbackSpeedLabel = computed(() => {
|
const playbackSpeedLabel = computed(() => {
|
||||||
@@ -53,7 +89,8 @@ const playbackSpeedLabel = computed(() => {
|
|||||||
// When the onLoadMetadata is called, so we need to set the duration
|
// When the onLoadMetadata is called, so we need to set the duration
|
||||||
// value when the component is mounted
|
// value when the component is mounted
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
duration.value = audioPlayer.value?.duration;
|
const d = audioPlayer.value?.duration;
|
||||||
|
if (Number.isFinite(d)) duration.value = d;
|
||||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,7 +226,18 @@ const downloadAudio = async () => {
|
|||||||
v-if="attachment.transcribedText && showTranscribedText"
|
v-if="attachment.transcribedText && showTranscribedText"
|
||||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||||
>
|
>
|
||||||
{{ attachment.transcribedText }}
|
{{ 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -88,5 +88,18 @@ export const VOICE_CALL_STATUS = {
|
|||||||
|
|
||||||
export const VOICE_CALL_DIRECTION = {
|
export const VOICE_CALL_DIRECTION = {
|
||||||
INBOUND: 'inbound',
|
INBOUND: 'inbound',
|
||||||
|
INCOMING: 'incoming',
|
||||||
|
OUTGOING: 'outgoing',
|
||||||
|
ONGOING: 'ongoing',
|
||||||
OUTBOUND: 'outbound',
|
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,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import Label from 'dashboard/components-next/label/Label.vue';
|
import Label from 'dashboard/components-next/label/Label.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -30,10 +31,16 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
beta: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['select']);
|
const emit = defineEmits(['select']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
if (!props.isActive && !props.disabled) {
|
if (!props.isActive && !props.disabled) {
|
||||||
emit('select', props.id);
|
emit('select', props.id);
|
||||||
@@ -42,14 +49,14 @@ const handleChange = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<label
|
||||||
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"
|
: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"
|
||||||
:class="[
|
:class="[
|
||||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
||||||
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
||||||
]"
|
]"
|
||||||
@click="handleChange"
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2 items-start">
|
<div class="flex flex-col gap-2 items-start">
|
||||||
<div class="flex items-center justify-between w-full gap-3">
|
<div class="flex items-center justify-between w-full gap-3">
|
||||||
@@ -58,6 +65,7 @@ const handleChange = () => {
|
|||||||
{{ label }}
|
{{ label }}
|
||||||
</h3>
|
</h3>
|
||||||
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
|
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
|
||||||
|
<Label v-if="beta" :label="t('GENERAL.BETA')" color="blue" compact />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
:id="`${id}`"
|
:id="`${id}`"
|
||||||
@@ -66,7 +74,7 @@ const handleChange = () => {
|
|||||||
:name="id"
|
:name="id"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
type="radio"
|
type="radio"
|
||||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
|
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"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,5 +83,5 @@ const handleChange = () => {
|
|||||||
</p>
|
</p>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||||
|
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
@@ -17,6 +18,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
badgeCount: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const reauthorizationRequired = computed(() => {
|
const reauthorizationRequired = computed(() => {
|
||||||
@@ -29,6 +34,7 @@ const reauthorizationRequired = computed(() => {
|
|||||||
<ChannelIcon :inbox="inbox" class="size-4" />
|
<ChannelIcon :inbox="inbox" class="size-4" />
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||||
|
<SidebarUnreadBadge :count="badgeCount" />
|
||||||
<div
|
<div
|
||||||
v-if="reauthorizationRequired"
|
v-if="reauthorizationRequired"
|
||||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { h, ref, computed, onMounted } from 'vue';
|
import { h, ref, computed, onMounted, watch } from 'vue';
|
||||||
import { provideSidebarContext, useSidebarResize } from './provider';
|
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||||
@@ -61,6 +61,24 @@ const hasAdvancedAssignment = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasConversationUnreadCounts = computed(() => {
|
||||||
|
return isFeatureEnabledonAccount.value(
|
||||||
|
accountId.value,
|
||||||
|
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||||
|
if (!currentAccountId) return;
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
store.dispatch('conversationUnreadCounts/clear');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.dispatch('conversationUnreadCounts/get');
|
||||||
|
};
|
||||||
|
|
||||||
const toggleShortcutModalFn = show => {
|
const toggleShortcutModalFn = show => {
|
||||||
if (show) {
|
if (show) {
|
||||||
emit('openKeyShortcutModal');
|
emit('openKeyShortcutModal');
|
||||||
@@ -157,6 +175,15 @@ useEventListener(document, 'touchend', onResizeEnd);
|
|||||||
|
|
||||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
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 teams = useMapGetter('teams/getMyTeams');
|
||||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||||
const conversationCustomViews = useMapGetter(
|
const conversationCustomViews = useMapGetter(
|
||||||
@@ -173,8 +200,48 @@ onMounted(() => {
|
|||||||
store.dispatch('customViews/get', 'contact');
|
store.dispatch('customViews/get', 'contact');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch([accountId, hasConversationUnreadCounts], 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(() =>
|
const sortedInboxes = computed(() =>
|
||||||
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
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)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeMobileSidebar = () => {
|
const closeMobileSidebar = () => {
|
||||||
@@ -267,9 +334,10 @@ const menuItems = computed(() => {
|
|||||||
label: t('SIDEBAR.TEAMS'),
|
label: t('SIDEBAR.TEAMS'),
|
||||||
icon: 'i-lucide-users',
|
icon: 'i-lucide-users',
|
||||||
activeOn: ['conversations_through_team'],
|
activeOn: ['conversations_through_team'],
|
||||||
children: teams.value.map(team => ({
|
children: sortedTeams.value.map(team => ({
|
||||||
name: `${team.name}-${team.id}`,
|
name: `${team.name}-${team.id}`,
|
||||||
label: team.name,
|
label: team.name,
|
||||||
|
badgeCount: getTeamUnreadCount.value(team.id),
|
||||||
to: accountScopedRoute('team_conversations', { teamId: team.id }),
|
to: accountScopedRoute('team_conversations', { teamId: team.id }),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -281,6 +349,7 @@ const menuItems = computed(() => {
|
|||||||
children: sortedInboxes.value.map(inbox => ({
|
children: sortedInboxes.value.map(inbox => ({
|
||||||
name: `${inbox.name}-${inbox.id}`,
|
name: `${inbox.name}-${inbox.id}`,
|
||||||
label: inbox.name,
|
label: inbox.name,
|
||||||
|
badgeCount: getInboxUnreadCount.value(inbox.id),
|
||||||
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
||||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||||
component: leafProps =>
|
component: leafProps =>
|
||||||
@@ -288,6 +357,7 @@ const menuItems = computed(() => {
|
|||||||
label: leafProps.label,
|
label: leafProps.label,
|
||||||
active: leafProps.active,
|
active: leafProps.active,
|
||||||
inbox,
|
inbox,
|
||||||
|
badgeCount: leafProps.badgeCount,
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -296,9 +366,10 @@ const menuItems = computed(() => {
|
|||||||
label: t('SIDEBAR.LABELS'),
|
label: t('SIDEBAR.LABELS'),
|
||||||
icon: 'i-lucide-tag',
|
icon: 'i-lucide-tag',
|
||||||
activeOn: ['conversations_through_label'],
|
activeOn: ['conversations_through_label'],
|
||||||
children: labels.value.map(label => ({
|
children: sortedLabels.value.map(label => ({
|
||||||
name: `${label.title}-${label.id}`,
|
name: `${label.title}-${label.id}`,
|
||||||
label: label.title,
|
label: label.title,
|
||||||
|
badgeCount: getLabelUnreadCount.value(label.id),
|
||||||
icon: h('span', {
|
icon: h('span', {
|
||||||
class: `size-[8px] rounded-sm`,
|
class: `size-[8px] rounded-sm`,
|
||||||
style: { backgroundColor: label.color },
|
style: { backgroundColor: label.color },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSidebarContext } from './provider';
|
|||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||||
|
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: { type: String, required: true },
|
label: { type: String, required: true },
|
||||||
@@ -166,6 +167,7 @@ onMounted(async () => {
|
|||||||
class="size-4 flex-shrink-0"
|
class="size-4 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
||||||
|
<SidebarUnreadBadge :count="subChild.badgeCount" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -188,6 +190,7 @@ onMounted(async () => {
|
|||||||
class="size-4 flex-shrink-0"
|
class="size-4 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span class="flex-1 truncate">{{ child.label }}</span>
|
<span class="flex-1 truncate">{{ child.label }}</span>
|
||||||
|
<SidebarUnreadBadge :count="child.badgeCount" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { isVNode, computed } from 'vue';
|
|||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
import { useSidebarContext } from './provider';
|
import { useSidebarContext } from './provider';
|
||||||
|
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: { type: String, required: true },
|
label: { type: String, required: true },
|
||||||
@@ -10,6 +11,7 @@ const props = defineProps({
|
|||||||
icon: { type: [String, Object], default: null },
|
icon: { type: [String, Object], default: null },
|
||||||
active: { type: Boolean, default: false },
|
active: { type: Boolean, default: false },
|
||||||
component: { type: Function, default: null },
|
component: { type: Function, default: null },
|
||||||
|
badgeCount: { type: [Number, String], default: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||||
@@ -39,15 +41,14 @@ const shouldRenderComponent = computed(() => {
|
|||||||
<component
|
<component
|
||||||
:is="component"
|
:is="component"
|
||||||
v-if="shouldRenderComponent"
|
v-if="shouldRenderComponent"
|
||||||
:label
|
v-bind="{ label, icon, active, badgeCount }"
|
||||||
:icon
|
|
||||||
:active
|
|
||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
|
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
|
||||||
<Icon :icon="icon" class="size-4 inline-block" />
|
<Icon :icon="icon" class="size-4 inline-block" />
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
|
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
|
||||||
|
<SidebarUnreadBadge :count="badgeCount" />
|
||||||
</template>
|
</template>
|
||||||
</component>
|
</component>
|
||||||
</Policy>
|
</Policy>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user