Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fbb93698 | ||
|
|
38d174bda6 | ||
|
|
7ca5d365ae | ||
|
|
6be85a40f7 | ||
|
|
922c989574 | ||
|
|
59928bd85c | ||
|
|
46c55a53bd | ||
|
|
176e09cde7 | ||
|
|
b09c6b4aac | ||
|
|
96e18d74c5 | ||
|
|
311093f0fb | ||
|
|
54514d9d21 | ||
|
|
4633d2a179 | ||
|
|
73c2690526 | ||
|
|
25b545200e | ||
|
|
23c35c8886 | ||
|
|
3f801fda2c | ||
|
|
ea7c5798ff | ||
|
|
1271459874 | ||
|
|
81098b02a8 | ||
|
|
18d6a51d6b | ||
|
|
9c9c1c301d | ||
|
|
5fb40e0b2e | ||
|
|
9ba51a3fd4 | ||
|
|
05d83ce704 | ||
|
|
36b7a86bf7 | ||
|
|
e6d336f727 | ||
|
|
feff61755f | ||
|
|
0c4e21bc6a | ||
|
|
a582b8284d | ||
|
|
bbcbc6d52b | ||
|
|
28e837e8f7 | ||
|
|
f4ea90c2dc | ||
|
|
1d9a28ad64 | ||
|
|
f36a3a2e19 | ||
|
|
a14df55210 | ||
|
|
b3633d5c08 | ||
|
|
b52caf0341 | ||
|
|
e37d61f836 | ||
|
|
10673a8d68 | ||
|
|
d488b8ced6 | ||
|
|
0381bdac96 | ||
|
|
87fee33039 | ||
|
|
fe68542547 | ||
|
|
2e1529c755 | ||
|
|
eb20e39b11 | ||
|
|
b771652656 | ||
|
|
e6d720082c | ||
|
|
d9a0d4eee0 | ||
|
|
2a0f2a8b1e | ||
|
|
aedb59ea67 | ||
|
|
4e2abbaa3d | ||
|
|
9db096f046 | ||
|
|
59b2679641 | ||
|
|
b1d26887f0 | ||
|
|
89a748eef6 | ||
|
|
c00230c8af | ||
|
|
2f6d581384 | ||
|
|
468ef34f85 | ||
|
|
7f4005cdcc | ||
|
|
57fdfe26f0 | ||
|
|
88ba4e4562 | ||
|
|
394ba4ff73 | ||
|
|
c0d1efa0ad | ||
|
|
dfc80e6f20 | ||
|
|
187b435eb9 | ||
|
|
1fce8d1183 | ||
|
|
c51d2d3c93 | ||
|
|
4450bebf35 | ||
|
|
b3ce7bd278 | ||
|
|
345a212447 | ||
|
|
83450b7c24 | ||
|
|
b58ec24d81 | ||
|
|
5e799e7e6b | ||
|
|
01ad380822 | ||
|
|
84d2c0fd25 | ||
|
|
87212d3b6e | ||
|
|
4ca6ec1812 | ||
|
|
4452d84097 | ||
|
|
9a81927154 | ||
|
|
daed6eb1e5 | ||
|
|
673669cd25 | ||
|
|
bdd5a77f45 | ||
|
|
92150cb7c8 | ||
|
|
e52becbc40 | ||
|
|
7f3d4aaaa7 | ||
|
|
7748ef2787 | ||
|
|
f2d9895597 | ||
|
|
db4e8588cd | ||
|
|
0c05599282 | ||
|
|
00e1f50458 | ||
|
|
ee89943aa2 | ||
|
|
9dc6b94537 | ||
|
|
e9816f8d3d | ||
|
|
51d1c501cf | ||
|
|
1539a8bc36 | ||
|
|
ffa8c0ce8a | ||
|
|
4762edcba3 | ||
|
|
1bd2da4832 | ||
|
|
b923bfecca | ||
|
|
46e758cc84 | ||
|
|
9165cab1ec | ||
|
|
bda09cc064 | ||
|
|
408cc4dd3f | ||
|
|
c6b636baee | ||
|
|
bc24d1f02c | ||
|
|
62a7cfa188 | ||
|
|
c4fa5cd4d9 | ||
|
|
d40fa27289 | ||
|
|
c789ef555c | ||
|
|
0674edf81b | ||
|
|
9362d126aa | ||
|
|
35797bd393 | ||
|
|
68ff52c432 | ||
|
|
5d2afc065f | ||
|
|
cd8f245483 | ||
|
|
735979c834 | ||
|
|
5cd6996d5e | ||
|
|
ff49996950 | ||
|
|
14417ef14f | ||
|
|
1393ad5143 | ||
|
|
4f262fc041 | ||
|
|
aa639dd78e | ||
|
|
aa9c0b9f6b | ||
|
|
526d6e2a0a | ||
|
|
96746cc592 |
@@ -56,20 +56,3 @@
|
|||||||
## Ruby Best Practices
|
## Ruby Best Practices
|
||||||
|
|
||||||
- Use compact `module/class` definitions; avoid nested styles
|
- Use compact `module/class` definitions; avoid nested styles
|
||||||
|
|
||||||
## Enterprise Edition Notes
|
|
||||||
|
|
||||||
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.
|
|
||||||
- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible.
|
|
||||||
- Follow the Enterprise development practices documented here:
|
|
||||||
- https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38
|
|
||||||
|
|
||||||
Practical checklist for any change impacting core logic or public APIs
|
|
||||||
- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`).
|
|
||||||
- If adding new endpoints, services, or models, consider whether Enterprise needs:
|
|
||||||
- An override (e.g., `enterprise/app/...`), or
|
|
||||||
- An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks.
|
|
||||||
- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise.
|
|
||||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
|
||||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
|
||||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
|
|||||||
##-- apm and error monitoring ---#
|
##-- apm and error monitoring ---#
|
||||||
# loaded only when environment variables are set.
|
# loaded only when environment variables are set.
|
||||||
# ref application.rb
|
# ref application.rb
|
||||||
gem 'datadog', '~> 2.0', require: false
|
gem 'ddtrace', require: false
|
||||||
gem 'elastic-apm', require: false
|
gem 'elastic-apm', require: false
|
||||||
gem 'newrelic_rpm', require: false
|
gem 'newrelic_rpm', require: false
|
||||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||||
@@ -180,8 +180,6 @@ gem 'reverse_markdown'
|
|||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.4.3'
|
gem 'ai-agents', '>= 0.4.3'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|||||||
+60
-74
@@ -25,35 +25,35 @@ GIT
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (7.1.5.2)
|
actioncable (7.1.5.1)
|
||||||
actionpack (= 7.1.5.2)
|
actionpack (= 7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (7.1.5.2)
|
actionmailbox (7.1.5.1)
|
||||||
actionpack (= 7.1.5.2)
|
actionpack (= 7.1.5.1)
|
||||||
activejob (= 7.1.5.2)
|
activejob (= 7.1.5.1)
|
||||||
activerecord (= 7.1.5.2)
|
activerecord (= 7.1.5.1)
|
||||||
activestorage (= 7.1.5.2)
|
activestorage (= 7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
actionmailer (7.1.5.2)
|
actionmailer (7.1.5.1)
|
||||||
actionpack (= 7.1.5.2)
|
actionpack (= 7.1.5.1)
|
||||||
actionview (= 7.1.5.2)
|
actionview (= 7.1.5.1)
|
||||||
activejob (= 7.1.5.2)
|
activejob (= 7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (7.1.5.2)
|
actionpack (7.1.5.1)
|
||||||
actionview (= 7.1.5.2)
|
actionview (= 7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
racc
|
racc
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
@@ -61,38 +61,38 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
actiontext (7.1.5.2)
|
actiontext (7.1.5.1)
|
||||||
actionpack (= 7.1.5.2)
|
actionpack (= 7.1.5.1)
|
||||||
activerecord (= 7.1.5.2)
|
activerecord (= 7.1.5.1)
|
||||||
activestorage (= 7.1.5.2)
|
activestorage (= 7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (7.1.5.2)
|
actionview (7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
active_record_query_trace (1.8)
|
active_record_query_trace (1.8)
|
||||||
activejob (7.1.5.2)
|
activejob (7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (7.1.5.2)
|
activemodel (7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
activerecord (7.1.5.2)
|
activerecord (7.1.5.1)
|
||||||
activemodel (= 7.1.5.2)
|
activemodel (= 7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activerecord-import (2.1.0)
|
activerecord-import (2.1.0)
|
||||||
activerecord (>= 4.2)
|
activerecord (>= 4.2)
|
||||||
activestorage (7.1.5.2)
|
activestorage (7.1.5.1)
|
||||||
actionpack (= 7.1.5.2)
|
actionpack (= 7.1.5.1)
|
||||||
activejob (= 7.1.5.2)
|
activejob (= 7.1.5.1)
|
||||||
activerecord (= 7.1.5.2)
|
activerecord (= 7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (7.1.5.2)
|
activesupport (7.1.5.1)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -155,10 +155,10 @@ GEM
|
|||||||
barnes (0.0.9)
|
barnes (0.0.9)
|
||||||
multi_json (~> 1)
|
multi_json (~> 1)
|
||||||
statsd-ruby (~> 1.1)
|
statsd-ruby (~> 1.1)
|
||||||
base64 (0.3.0)
|
base64 (0.2.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
benchmark (0.4.1)
|
benchmark (0.4.0)
|
||||||
bigdecimal (3.2.2)
|
bigdecimal (3.1.9)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.16.0)
|
bootsnap (1.16.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
@@ -194,14 +194,10 @@ GEM
|
|||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
datadog (2.19.0)
|
|
||||||
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
|
|
||||||
libdatadog (~> 18.1.0.1.0)
|
|
||||||
libddwaf (~> 1.24.1.0.3)
|
|
||||||
logger
|
|
||||||
msgpack
|
|
||||||
datadog-ruby_core_source (3.4.1)
|
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
|
ddtrace (0.48.0)
|
||||||
|
ffi (~> 1.0)
|
||||||
|
msgpack
|
||||||
debug (1.8.0)
|
debug (1.8.0)
|
||||||
irb (>= 1.5.0)
|
irb (>= 1.5.0)
|
||||||
reline (>= 0.3.1)
|
reline (>= 0.3.1)
|
||||||
@@ -448,16 +444,6 @@ GEM
|
|||||||
logger (~> 1.6)
|
logger (~> 1.6)
|
||||||
letter_opener (1.10.0)
|
letter_opener (1.10.0)
|
||||||
launchy (>= 2.2, < 4)
|
launchy (>= 2.2, < 4)
|
||||||
libdatadog (18.1.0.1.0)
|
|
||||||
libdatadog (18.1.0.1.0-x86_64-linux)
|
|
||||||
libddwaf (1.24.1.0.3)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
libddwaf (1.24.1.0.3-arm64-darwin)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
libddwaf (1.24.1.0.3-x86_64-darwin)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
libddwaf (1.24.1.0.3-x86_64-linux)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
line-bot-api (1.28.0)
|
line-bot-api (1.28.0)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
liquid (5.4.0)
|
liquid (5.4.0)
|
||||||
@@ -613,20 +599,20 @@ GEM
|
|||||||
rackup (1.0.1)
|
rackup (1.0.1)
|
||||||
rack (< 3)
|
rack (< 3)
|
||||||
webrick
|
webrick
|
||||||
rails (7.1.5.2)
|
rails (7.1.5.1)
|
||||||
actioncable (= 7.1.5.2)
|
actioncable (= 7.1.5.1)
|
||||||
actionmailbox (= 7.1.5.2)
|
actionmailbox (= 7.1.5.1)
|
||||||
actionmailer (= 7.1.5.2)
|
actionmailer (= 7.1.5.1)
|
||||||
actionpack (= 7.1.5.2)
|
actionpack (= 7.1.5.1)
|
||||||
actiontext (= 7.1.5.2)
|
actiontext (= 7.1.5.1)
|
||||||
actionview (= 7.1.5.2)
|
actionview (= 7.1.5.1)
|
||||||
activejob (= 7.1.5.2)
|
activejob (= 7.1.5.1)
|
||||||
activemodel (= 7.1.5.2)
|
activemodel (= 7.1.5.1)
|
||||||
activerecord (= 7.1.5.2)
|
activerecord (= 7.1.5.1)
|
||||||
activestorage (= 7.1.5.2)
|
activestorage (= 7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 7.1.5.2)
|
railties (= 7.1.5.1)
|
||||||
rails-dom-testing (2.2.0)
|
rails-dom-testing (2.2.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -634,9 +620,9 @@ GEM
|
|||||||
rails-html-sanitizer (1.6.1)
|
rails-html-sanitizer (1.6.1)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.21)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
railties (7.1.5.2)
|
railties (7.1.5.1)
|
||||||
actionpack (= 7.1.5.2)
|
actionpack (= 7.1.5.1)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.1)
|
||||||
irb
|
irb
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@@ -942,7 +928,7 @@ DEPENDENCIES
|
|||||||
commonmarker
|
commonmarker
|
||||||
csv-safe
|
csv-safe
|
||||||
database_cleaner
|
database_cleaner
|
||||||
datadog (~> 2.0)
|
ddtrace
|
||||||
debug (~> 1.8)
|
debug (~> 1.8)
|
||||||
devise (>= 4.9.4)
|
devise (>= 4.9.4)
|
||||||
devise-secure_password!
|
devise-secure_password!
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_assignment_policy
|
|
||||||
before_action -> { check_authorization(AssignmentPolicy) }
|
|
||||||
|
|
||||||
def index
|
|
||||||
@inboxes = @assignment_policy.inboxes
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def fetch_assignment_policy
|
|
||||||
@assignment_policy = Current.account.assignment_policies.find(
|
|
||||||
params[:assignment_policy_id]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_params
|
|
||||||
params.permit(:assignment_policy_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
|
|
||||||
before_action :check_authorization
|
|
||||||
|
|
||||||
def index
|
|
||||||
@assignment_policies = Current.account.assignment_policies
|
|
||||||
end
|
|
||||||
|
|
||||||
def show; end
|
|
||||||
|
|
||||||
def create
|
|
||||||
@assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
@assignment_policy.update!(assignment_policy_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy
|
|
||||||
@assignment_policy.destroy!
|
|
||||||
head :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def fetch_assignment_policy
|
|
||||||
@assignment_policy = Current.account.assignment_policies.find(params[:id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def assignment_policy_params
|
|
||||||
params.require(:assignment_policy).permit(
|
|
||||||
:name, :description, :assignment_order, :conversation_priority,
|
|
||||||
:fair_distribution_limit, :fair_distribution_window, :enabled
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_inbox
|
|
||||||
before_action :fetch_assignment_policy, only: [:create]
|
|
||||||
before_action -> { check_authorization(AssignmentPolicy) }
|
|
||||||
before_action :validate_assignment_policy, only: [:show, :destroy]
|
|
||||||
|
|
||||||
def show
|
|
||||||
@assignment_policy = @inbox.assignment_policy
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
# There should be only one assignment policy for an inbox.
|
|
||||||
# If there is a new request to add an assignment policy, we will
|
|
||||||
# delete the old one and attach the new policy
|
|
||||||
remove_inbox_assignment_policy
|
|
||||||
@inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy)
|
|
||||||
@assignment_policy = @inbox.assignment_policy
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy
|
|
||||||
remove_inbox_assignment_policy
|
|
||||||
head :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def remove_inbox_assignment_policy
|
|
||||||
@inbox.inbox_assignment_policy&.destroy
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_inbox
|
|
||||||
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_assignment_policy
|
|
||||||
@assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_params
|
|
||||||
params.permit(:assignment_policy_id, :inbox_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_assignment_policy
|
|
||||||
return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -26,8 +26,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
||||||
# @portal.custom_domain = parsed_custom_domain
|
# @portal.custom_domain = parsed_custom_domain
|
||||||
process_attached_logo if params[:blob_id].present?
|
process_attached_logo if params[:blob_id].present?
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue StandardError => e
|
||||||
render_record_invalid(e)
|
Rails.logger.error e
|
||||||
|
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||||
before_action :validate_feature_enabled!
|
before_action :validate_feature_enabled!
|
||||||
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
|
||||||
|
|
||||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||||
# Handles both initial authorization and reauthorization
|
# Handles the embedded signup callback data from the Facebook SDK
|
||||||
# If inbox_id is present in params, it performs reauthorization
|
|
||||||
def create
|
def create
|
||||||
validate_embedded_signup_params!
|
validate_embedded_signup_params!
|
||||||
channel = process_embedded_signup
|
channel = process_embedded_signup
|
||||||
@@ -18,42 +16,21 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
|||||||
def process_embedded_signup
|
def process_embedded_signup
|
||||||
service = Whatsapp::EmbeddedSignupService.new(
|
service = Whatsapp::EmbeddedSignupService.new(
|
||||||
account: Current.account,
|
account: Current.account,
|
||||||
params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys,
|
code: params[:code],
|
||||||
inbox_id: params[:inbox_id]
|
business_id: params[:business_id],
|
||||||
|
waba_id: params[:waba_id],
|
||||||
|
phone_number_id: params[:phone_number_id]
|
||||||
)
|
)
|
||||||
service.perform
|
service.perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_and_validate_inbox
|
|
||||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
|
||||||
validate_reauthorization_required
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_reauthorization_required
|
|
||||||
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
|
|
||||||
|
|
||||||
render json: {
|
|
||||||
success: false,
|
|
||||||
message: I18n.t('inbox.reauthorization.not_required')
|
|
||||||
}, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
def can_upgrade_to_embedded_signup?
|
|
||||||
channel = @inbox.channel
|
|
||||||
return false unless channel.provider == 'whatsapp_cloud'
|
|
||||||
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_success_response(inbox)
|
def render_success_response(inbox)
|
||||||
response = {
|
render json: {
|
||||||
success: true,
|
success: true,
|
||||||
id: inbox.id,
|
id: inbox.id,
|
||||||
name: inbox.name,
|
name: inbox.name,
|
||||||
channel_type: 'whatsapp'
|
channel_type: 'whatsapp'
|
||||||
}
|
}
|
||||||
response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present?
|
|
||||||
render json: response
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_error_response(error)
|
def render_error_response(error)
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
class Platform::Api::V1::AccountsController < PlatformController
|
class Platform::Api::V1::AccountsController < PlatformController
|
||||||
def index
|
|
||||||
@resources = @platform_app.platform_app_permissibles
|
|
||||||
.where(permissible_type: 'Account')
|
|
||||||
.includes(:permissible)
|
|
||||||
.map(&:permissible)
|
|
||||||
end
|
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
|||||||
@@ -17,12 +17,7 @@ class SlackUploadsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def blob_url
|
def blob_url
|
||||||
# Only generate representations for images
|
url_for(@blob.representation(resize_to_fill: [250, nil]))
|
||||||
if @blob.content_type.start_with?('image/')
|
|
||||||
url_for(@blob.representation(resize_to_fill: [250, nil]))
|
|
||||||
else
|
|
||||||
url_for(@blob)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def avatar_url
|
def avatar_url
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ class Twilio::CallbackController < ApplicationController
|
|||||||
:NumMedia,
|
:NumMedia,
|
||||||
:Latitude,
|
:Latitude,
|
||||||
:Longitude,
|
:Longitude,
|
||||||
:MessageType,
|
:MessageType
|
||||||
:ProfileName
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,13 +15,7 @@ class NotificationFinder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def unread_count
|
def unread_count
|
||||||
if type_included?('read')
|
@notifications.where(read_at: nil).count
|
||||||
# If we're including read notifications, filter to unread
|
|
||||||
@notifications.where(read_at: nil).count
|
|
||||||
else
|
|
||||||
# Already filtered to unread notifications, just count
|
|
||||||
@notifications.count
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def count
|
def count
|
||||||
@@ -33,7 +27,7 @@ class NotificationFinder
|
|||||||
def set_up
|
def set_up
|
||||||
find_all_notifications
|
find_all_notifications
|
||||||
filter_snoozed_notifications
|
filter_snoozed_notifications
|
||||||
filter_read_notifications
|
fitler_read_notifications
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_all_notifications
|
def find_all_notifications
|
||||||
@@ -44,7 +38,7 @@ class NotificationFinder
|
|||||||
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
|
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_read_notifications
|
def fitler_read_notifications
|
||||||
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
|
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -18,25 +18,12 @@ module ReportingEventHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def last_non_human_activity(conversation)
|
def last_non_human_activity(conversation)
|
||||||
# Try to get either a handoff or reopened event first
|
# check if a handoff event already exists
|
||||||
# These will always take precedence over any other activity
|
handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
|
||||||
# Also, any of these events can happen at any time in the course of a conversation lifecycle.
|
|
||||||
# So we pick the latest event
|
|
||||||
event = ReportingEvent.where(
|
|
||||||
conversation_id: conversation.id,
|
|
||||||
name: %w[conversation_bot_handoff conversation_opened]
|
|
||||||
).order(event_end_time: :desc).first
|
|
||||||
|
|
||||||
return event.event_end_time if event&.event_end_time
|
# if a handoff exists, last non human activity is when the handoff ended,
|
||||||
|
# otherwise it's when the conversation was created
|
||||||
# Fallback to bot resolved event
|
handoff_event&.event_end_time || conversation.created_at
|
||||||
# Because this will be closest to the most accurate activity instead of conversation.created_at
|
|
||||||
bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last
|
|
||||||
|
|
||||||
return bot_event.event_end_time if bot_event&.event_end_time
|
|
||||||
|
|
||||||
# If no events found, return conversation creation time
|
|
||||||
conversation.created_at
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export default {
|
|||||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||||
id="app"
|
id="app"
|
||||||
class="flex flex-col w-full h-screen min-h-0"
|
class="flex flex-col w-full h-screen min-h-0"
|
||||||
|
:class="{ 'app-rtl--wrapper': isRTL }"
|
||||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||||
>
|
>
|
||||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ class WhatsappChannel extends ApiClient {
|
|||||||
createEmbeddedSignup(params) {
|
createEmbeddedSignup(params) {
|
||||||
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
|
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
reauthorizeWhatsApp({ inboxId, ...params }) {
|
|
||||||
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, {
|
|
||||||
...params,
|
|
||||||
inbox_id: inboxId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new WhatsappChannel();
|
export default new WhatsappChannel();
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ const campaignStatus = computed(() => {
|
|||||||
const inboxName = computed(() => props.inbox?.name || '');
|
const inboxName = computed(() => props.inbox?.name || '');
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { medium, channel_type: type } = props.inbox;
|
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
|
||||||
return getInboxIconByType(type, medium);
|
return getInboxIconByType(type, phoneNumber);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+5
-7
@@ -38,13 +38,11 @@ const handleClose = () => emit('close');
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto"
|
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
|
||||||
>
|
>
|
||||||
<div class="p-6 flex flex-col gap-6">
|
<h3 class="text-base font-medium text-n-slate-12">
|
||||||
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
|
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
||||||
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
</h3>
|
||||||
</h3>
|
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||||
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+106
-15
@@ -9,7 +9,6 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
||||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel']);
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
|
|
||||||
@@ -19,9 +18,7 @@ const formState = {
|
|||||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||||
labels: useMapGetter('labels/getLabels'),
|
labels: useMapGetter('labels/getLabels'),
|
||||||
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
||||||
getFilteredWhatsAppTemplates: useMapGetter(
|
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
|
||||||
'inboxes/getFilteredWhatsAppTemplates'
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
@@ -33,7 +30,7 @@ const initialState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
const templateParserRef = ref(null);
|
const processedParams = ref({});
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
title: { required, minLength: minLength(1) },
|
title: { required, minLength: minLength(1) },
|
||||||
@@ -70,7 +67,7 @@ const inboxOptions = computed(() =>
|
|||||||
|
|
||||||
const templateOptions = computed(() => {
|
const templateOptions = computed(() => {
|
||||||
if (!state.inboxId) return [];
|
if (!state.inboxId) return [];
|
||||||
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
|
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
|
||||||
return templates.map(template => {
|
return templates.map(template => {
|
||||||
// Create a more user-friendly label from template name
|
// Create a more user-friendly label from template name
|
||||||
const friendlyName = template.name
|
const friendlyName = template.name
|
||||||
@@ -91,6 +88,26 @@ const selectedTemplate = computed(() => {
|
|||||||
?.template;
|
?.template;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const templateString = computed(() => {
|
||||||
|
if (!selectedTemplate.value) return '';
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
selectedTemplate.value.components?.find(
|
||||||
|
component => component.type === 'BODY'
|
||||||
|
)?.text || ''
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const processedString = computed(() => {
|
||||||
|
if (!templateString.value) return '';
|
||||||
|
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||||
|
return processedParams.value[variable] || `{{${variable}}}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const getErrorMessage = (field, errorKey) => {
|
const getErrorMessage = (field, errorKey) => {
|
||||||
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
|
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
|
||||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
||||||
@@ -105,7 +122,8 @@ const formErrors = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const hasRequiredTemplateParams = computed(() => {
|
const hasRequiredTemplateParams = computed(() => {
|
||||||
return templateParserRef.value?.v$?.$invalid === false || true;
|
const params = Object.values(processedParams.value);
|
||||||
|
return params.length === 0 || params.every(param => param.trim() !== '');
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSubmitDisabled = computed(
|
const isSubmitDisabled = computed(
|
||||||
@@ -117,18 +135,32 @@ const formatToUTCString = localDateTime =>
|
|||||||
|
|
||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
Object.assign(state, initialState);
|
Object.assign(state, initialState);
|
||||||
|
processedParams.value = {};
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => emit('cancel');
|
const handleCancel = () => emit('cancel');
|
||||||
|
|
||||||
|
const generateVariables = () => {
|
||||||
|
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
||||||
|
if (!matchedVariables) {
|
||||||
|
processedParams.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
|
||||||
|
processedParams.value = finalVars.reduce((acc, variable) => {
|
||||||
|
acc[variable] = processedParams.value[variable] || '';
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
const prepareCampaignDetails = () => {
|
const prepareCampaignDetails = () => {
|
||||||
// Find the selected template to get its content
|
// Find the selected template to get its content
|
||||||
const currentTemplate = selectedTemplate.value;
|
const currentTemplate = selectedTemplate.value;
|
||||||
const parserData = templateParserRef.value;
|
|
||||||
|
|
||||||
// Extract template content - this should be the template message body
|
// Extract template content - this should be the template message body
|
||||||
const templateContent = parserData?.renderedTemplate || '';
|
const templateContent = templateString.value;
|
||||||
|
|
||||||
// Prepare template_params object with the same structure as used in contacts
|
// Prepare template_params object with the same structure as used in contacts
|
||||||
const templateParams = {
|
const templateParams = {
|
||||||
@@ -136,7 +168,7 @@ const prepareCampaignDetails = () => {
|
|||||||
namespace: currentTemplate?.namespace || '',
|
namespace: currentTemplate?.namespace || '',
|
||||||
category: currentTemplate?.category || 'UTILITY',
|
category: currentTemplate?.category || 'UTILITY',
|
||||||
language: currentTemplate?.language || 'en_US',
|
language: currentTemplate?.language || 'en_US',
|
||||||
processed_params: parserData?.processedParams || {},
|
processed_params: processedParams.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -166,6 +198,15 @@ watch(
|
|||||||
() => state.inboxId,
|
() => state.inboxId,
|
||||||
() => {
|
() => {
|
||||||
state.templateId = null;
|
state.templateId = null;
|
||||||
|
processedParams.value = {};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate variables when template changes
|
||||||
|
watch(
|
||||||
|
() => state.templateId,
|
||||||
|
() => {
|
||||||
|
generateVariables();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -213,12 +254,62 @@ watch(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Template Parser -->
|
<!-- Template Preview -->
|
||||||
<WhatsAppTemplateParser
|
<div
|
||||||
v-if="selectedTemplate"
|
v-if="selectedTemplate"
|
||||||
ref="templateParserRef"
|
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
|
||||||
:template="selectedTemplate"
|
>
|
||||||
/>
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ selectedTemplate.name }}
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-n-slate-11">
|
||||||
|
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
|
||||||
|
{{ selectedTemplate.language || 'en' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="rounded-md bg-n-alpha-black3">
|
||||||
|
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
||||||
|
{{ processedString }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-n-slate-11">
|
||||||
|
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
|
||||||
|
{{ selectedTemplate.category || 'UTILITY' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Template Variables -->
|
||||||
|
<div
|
||||||
|
v-if="Object.keys(processedParams).length > 0"
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(value, key) in processedParams"
|
||||||
|
:key="key"
|
||||||
|
class="flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="processedParams[key]"
|
||||||
|
type="text"
|
||||||
|
class="flex-1"
|
||||||
|
:placeholder="
|
||||||
|
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
|
||||||
|
variable: key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
|||||||
:src="thumbnail"
|
:src="thumbnail"
|
||||||
:size="48"
|
:size="48"
|
||||||
:status="availabilityStatus"
|
:status="availabilityStatus"
|
||||||
hide-offline-status
|
|
||||||
rounded-full
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-0.5 flex-1">
|
<div class="flex flex-col gap-0.5 flex-1">
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, useSlots, ref } from 'vue';
|
import { computed, useSlots } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||||
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedContact: {
|
selectedContact: {
|
||||||
@@ -26,8 +24,6 @@ const { t } = useI18n();
|
|||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const isContactSidebarOpen = ref(false);
|
|
||||||
|
|
||||||
const contactId = computed(() => route.params.contactId);
|
const contactId = computed(() => route.params.contactId);
|
||||||
|
|
||||||
const selectedContactName = computed(() => {
|
const selectedContactName = computed(() => {
|
||||||
@@ -60,15 +56,6 @@ const handleBreadcrumbClick = () => {
|
|||||||
const toggleBlock = () => {
|
const toggleBlock = () => {
|
||||||
emit('toggleBlock', isContactBlocked.value);
|
emit('toggleBlock', isContactBlocked.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConversationSidebarToggle = () => {
|
|
||||||
isContactSidebarOpen.value = !isContactSidebarOpen.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeMobileSidebar = () => {
|
|
||||||
if (!isContactSidebarOpen.value) return;
|
|
||||||
isContactSidebarOpen.value = false;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -80,9 +67,7 @@ const closeMobileSidebar = () => {
|
|||||||
>
|
>
|
||||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||||
<div
|
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
|
||||||
>
|
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
:items="breadcrumbItems"
|
:items="breadcrumbItems"
|
||||||
@click="handleBreadcrumbClick"
|
@click="handleBreadcrumbClick"
|
||||||
@@ -100,11 +85,6 @@ const closeMobileSidebar = () => {
|
|||||||
:disabled="isUpdating"
|
:disabled="isUpdating"
|
||||||
@click="toggleBlock"
|
@click="toggleBlock"
|
||||||
/>
|
/>
|
||||||
<VoiceCallButton
|
|
||||||
:phone="selectedContact?.phoneNumber"
|
|
||||||
:label="$t('CONTACT_PANEL.CALL')"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<ComposeConversation :contact-id="contactId">
|
<ComposeConversation :contact-id="contactId">
|
||||||
<template #trigger="{ toggle }">
|
<template #trigger="{ toggle }">
|
||||||
<Button
|
<Button
|
||||||
@@ -125,65 +105,11 @@ const closeMobileSidebar = () => {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop sidebar -->
|
|
||||||
<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="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||||
>
|
>
|
||||||
<slot name="sidebar" />
|
<slot name="sidebar" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile sidebar container -->
|
|
||||||
<div
|
|
||||||
v-if="slots.sidebar"
|
|
||||||
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
|
|
||||||
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
|
|
||||||
>
|
|
||||||
<!-- Toggle button -->
|
|
||||||
<div
|
|
||||||
v-on-click-outside="[
|
|
||||||
closeMobileSidebar,
|
|
||||||
{ ignore: ['#contact-sidebar-content'] },
|
|
||||||
]"
|
|
||||||
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
|
|
||||||
:class="[
|
|
||||||
isContactSidebarOpen
|
|
||||||
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
|
|
||||||
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
ghost
|
|
||||||
slate
|
|
||||||
sm
|
|
||||||
class="!rounded-full rtl:rotate-180"
|
|
||||||
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
|
|
||||||
:icon="
|
|
||||||
isContactSidebarOpen
|
|
||||||
? 'i-lucide-panel-right-close'
|
|
||||||
: 'i-lucide-panel-right-open'
|
|
||||||
"
|
|
||||||
data-contact-sidebar-toggle
|
|
||||||
@click="handleConversationSidebarToggle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
|
||||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
|
||||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
|
||||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
|
||||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
|
||||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="isContactSidebarOpen"
|
|
||||||
id="contact-sidebar-content"
|
|
||||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
|
||||||
>
|
|
||||||
<slot name="sidebar" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+54
-57
@@ -34,13 +34,13 @@ const emit = defineEmits([
|
|||||||
<template>
|
<template>
|
||||||
<header class="sticky top-0 z-10">
|
<header class="sticky top-0 z-10">
|
||||||
<div
|
<div
|
||||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||||
>
|
>
|
||||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
|
<div class="flex items-center flex-shrink-0 gap-4">
|
||||||
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
<div v-if="showSearch" class="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
:model-value="searchValue"
|
:model-value="searchValue"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -48,7 +48,6 @@ const emit = defineEmits([
|
|||||||
:custom-input-class="[
|
:custom-input-class="[
|
||||||
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||||
]"
|
]"
|
||||||
class="w-full"
|
|
||||||
@input="emit('search', $event.target.value)"
|
@input="emit('search', $event.target.value)"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -59,66 +58,64 @@ const emit = defineEmits([
|
|||||||
</template>
|
</template>
|
||||||
</Input>
|
</Input>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center flex-shrink-0 gap-4">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div v-if="!isLabelView && !isActiveView" class="relative">
|
||||||
<div v-if="!isLabelView && !isActiveView" class="relative">
|
|
||||||
<Button
|
|
||||||
id="toggleContactsFilterButton"
|
|
||||||
:icon="
|
|
||||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
|
||||||
"
|
|
||||||
color="slate"
|
|
||||||
size="sm"
|
|
||||||
class="relative w-8"
|
|
||||||
variant="ghost"
|
|
||||||
@click="emit('filter')"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="hasActiveFilters && !isSegmentsView"
|
|
||||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<slot name="filter" />
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
v-if="
|
id="toggleContactsFilterButton"
|
||||||
hasActiveFilters &&
|
:icon="
|
||||||
!isSegmentsView &&
|
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||||
!isLabelView &&
|
|
||||||
!isActiveView
|
|
||||||
"
|
"
|
||||||
icon="i-lucide-save"
|
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
class="relative w-8"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@click="emit('createSegment')"
|
@click="emit('filter')"
|
||||||
/>
|
>
|
||||||
<Button
|
<div
|
||||||
v-if="isSegmentsView && !isLabelView && !isActiveView"
|
v-if="hasActiveFilters && !isSegmentsView"
|
||||||
icon="i-lucide-trash"
|
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||||
color="slate"
|
/>
|
||||||
size="sm"
|
</Button>
|
||||||
variant="ghost"
|
<slot name="filter" />
|
||||||
@click="emit('deleteSegment')"
|
|
||||||
/>
|
|
||||||
<ContactSortMenu
|
|
||||||
:active-sort="activeSort"
|
|
||||||
:active-ordering="activeOrdering"
|
|
||||||
@update:sort="emit('update:sort', $event)"
|
|
||||||
/>
|
|
||||||
<ContactMoreActions
|
|
||||||
@add="emit('add')"
|
|
||||||
@import="emit('import')"
|
|
||||||
@export="emit('export')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-px h-4 bg-n-strong" />
|
<Button
|
||||||
<ComposeConversation>
|
v-if="
|
||||||
<template #trigger="{ toggle }">
|
hasActiveFilters &&
|
||||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
!isSegmentsView &&
|
||||||
</template>
|
!isLabelView &&
|
||||||
</ComposeConversation>
|
!isActiveView
|
||||||
|
"
|
||||||
|
icon="i-lucide-save"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
@click="emit('createSegment')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="isSegmentsView && !isLabelView && !isActiveView"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
@click="emit('deleteSegment')"
|
||||||
|
/>
|
||||||
|
<ContactSortMenu
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
@update:sort="emit('update:sort', $event)"
|
||||||
|
/>
|
||||||
|
<ContactMoreActions
|
||||||
|
@add="emit('add')"
|
||||||
|
@import="emit('import')"
|
||||||
|
@export="emit('export')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-px h-4 bg-n-strong" />
|
||||||
|
<ComposeConversation>
|
||||||
|
<template #trigger="{ toggle }">
|
||||||
|
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||||
|
</template>
|
||||||
|
</ComposeConversation>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+11
-14
@@ -291,20 +291,17 @@ defineExpose({
|
|||||||
@delete-segment="openDeleteSegmentDialog"
|
@delete-segment="openDeleteSegmentDialog"
|
||||||
>
|
>
|
||||||
<template #filter>
|
<template #filter>
|
||||||
<div
|
<ContactsFilter
|
||||||
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
|
v-if="showFiltersModal"
|
||||||
>
|
v-model="appliedFilter"
|
||||||
<ContactsFilter
|
:segment-name="activeSegmentName"
|
||||||
v-if="showFiltersModal"
|
:is-segment-view="hasActiveSegments"
|
||||||
v-model="appliedFilter"
|
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||||
:segment-name="activeSegmentName"
|
@apply-filter="onApplyFilter"
|
||||||
:is-segment-view="hasActiveSegments"
|
@update-segment="onUpdateSegment"
|
||||||
@apply-filter="onApplyFilter"
|
@close="closeAdvanceFiltersModal"
|
||||||
@update-segment="onUpdateSegment"
|
@clear-filters="clearFilters"
|
||||||
@close="closeAdvanceFiltersModal"
|
/>
|
||||||
@clear-filters="clearFilters"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</ContactsHeader>
|
</ContactsHeader>
|
||||||
|
|
||||||
|
|||||||
+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-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<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">
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ const openFilter = () => {
|
|||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
<footer
|
||||||
|
v-if="showPaginationFooter"
|
||||||
|
class="sticky bottom-0 z-10 px-4 pb-4"
|
||||||
|
>
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
|
|||||||
+3
-3
@@ -4,8 +4,8 @@ export default [
|
|||||||
city: 'Los Angeles',
|
city: 'Los Angeles',
|
||||||
country: 'United States',
|
country: 'United States',
|
||||||
description:
|
description:
|
||||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Lumora.",
|
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
|
||||||
companyName: 'Lumora',
|
companyName: 'Chatwoot',
|
||||||
countryCode: 'US',
|
countryCode: 'US',
|
||||||
socialProfiles: {
|
socialProfiles: {
|
||||||
github: 'candice-dev',
|
github: 'candice-dev',
|
||||||
@@ -16,7 +16,7 @@ export default [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
availabilityStatus: 'offline',
|
availabilityStatus: 'offline',
|
||||||
email: 'candice.matherson@lumora.com',
|
email: 'candice.matherson@chatwoot.com',
|
||||||
id: 22,
|
id: 22,
|
||||||
name: 'Candice Matherson',
|
name: 'Candice Matherson',
|
||||||
phoneNumber: '+14155552671',
|
phoneNumber: '+14155552671',
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, ref, useAttrs } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
||||||
import { useAlert } from 'dashboard/composables';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
phone: { type: String, default: '' },
|
|
||||||
label: { type: String, default: '' },
|
|
||||||
icon: { type: [String, Object, Function], default: '' },
|
|
||||||
size: { type: String, default: 'sm' },
|
|
||||||
tooltipLabel: { type: String, default: '' },
|
|
||||||
});
|
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
|
||||||
const attrs = useAttrs();
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
|
||||||
const voiceInboxes = computed(() =>
|
|
||||||
(inboxesList.value || []).filter(
|
|
||||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
|
||||||
|
|
||||||
// Unified behavior: hide when no phone
|
|
||||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
|
||||||
|
|
||||||
const onClick = () => {
|
|
||||||
if (voiceInboxes.value.length > 1) {
|
|
||||||
dialogRef.value?.open();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPickInbox = () => {
|
|
||||||
// Placeholder until actual call wiring happens
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
|
||||||
dialogRef.value?.close();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span class="contents">
|
|
||||||
<Button
|
|
||||||
v-if="shouldRender"
|
|
||||||
v-tooltip.top-end="tooltipLabel || null"
|
|
||||||
v-bind="attrs"
|
|
||||||
:label="label"
|
|
||||||
:icon="icon"
|
|
||||||
:size="size"
|
|
||||||
@click="onClick"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
v-if="shouldRender && voiceInboxes.length > 1"
|
|
||||||
ref="dialogRef"
|
|
||||||
:title="$t('CONTACT_PANEL.VOICE_INBOX_PICKER.TITLE')"
|
|
||||||
show-cancel-button
|
|
||||||
:show-confirm-button="false"
|
|
||||||
width="md"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<button
|
|
||||||
v-for="inbox in voiceInboxes"
|
|
||||||
:key="inbox.id"
|
|
||||||
type="button"
|
|
||||||
class="flex items-center justify-between w-full px-4 py-2 text-left rounded-lg hover:bg-n-alpha-2"
|
|
||||||
@click="onPickInbox(inbox)"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="i-ri-phone-fill text-n-slate-10" />
|
|
||||||
<span class="text-sm text-n-slate-12">{{ inbox.name }}</span>
|
|
||||||
</div>
|
|
||||||
<span v-if="inbox.phone_number" class="text-xs text-n-slate-10">
|
|
||||||
{{ inbox.phone_number }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
-1
@@ -47,7 +47,6 @@ const unreadMessagesCount = computed(() => {
|
|||||||
</p>
|
</p>
|
||||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
v-if="assignee.name"
|
|
||||||
:name="assignee.name"
|
:name="assignee.name"
|
||||||
:src="assignee.thumbnail"
|
:src="assignee.thumbnail"
|
||||||
:size="20"
|
:size="20"
|
||||||
|
|||||||
-1
@@ -96,7 +96,6 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Avatar
|
<Avatar
|
||||||
v-if="assignee.name"
|
|
||||||
:name="assignee.name"
|
:name="assignee.name"
|
||||||
:src="assignee.thumbnail"
|
:src="assignee.thumbnail"
|
||||||
:size="20"
|
:size="20"
|
||||||
|
|||||||
+2
-2
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
|
|||||||
const inboxName = computed(() => inbox.value?.name);
|
const inboxName = computed(() => inbox.value?.name);
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { channelType, medium } = inbox.value;
|
const { phoneNumber, channelType } = inbox.value;
|
||||||
return getInboxIconByType(channelType, medium);
|
return getInboxIconByType(channelType, phoneNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastActivityAt = computed(() => {
|
const lastActivityAt = computed(() => {
|
||||||
|
|||||||
+4
-6
@@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
|||||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, minLength, helpers, url } from '@vuelidate/validators';
|
import { required, minLength, helpers } from '@vuelidate/validators';
|
||||||
import { isValidSlug } from 'shared/helpers/Validators';
|
import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators';
|
||||||
|
|
||||||
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';
|
||||||
@@ -71,7 +71,7 @@ const rules = {
|
|||||||
isValidSlug
|
isValidSlug
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
homePageLink: { url },
|
homePageLink: { shouldBeUrl },
|
||||||
};
|
};
|
||||||
|
|
||||||
const v$ = useVuelidate(rules, state);
|
const v$ = useVuelidate(rules, state);
|
||||||
@@ -315,9 +315,7 @@ const handleAvatarDelete = () => {
|
|||||||
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex items-start justify-between w-full gap-2">
|
||||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
|
||||||
>
|
|
||||||
<label
|
<label
|
||||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
|
|||||||
const inbox = computed(() => props.stateInbox);
|
const inbox = computed(() => props.stateInbox);
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { channelType, medium } = inbox.value;
|
const { phoneNumber, channelType } = inbox.value;
|
||||||
return getInboxIconByType(channelType, medium);
|
return getInboxIconByType(channelType, phoneNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasSlaThreshold = computed(() => {
|
const hasSlaThreshold = computed(() => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useWindowSize } from '@vueuse/core';
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
@@ -16,7 +15,6 @@ import {
|
|||||||
processContactableInboxes,
|
processContactableInboxes,
|
||||||
mergeInboxDetails,
|
mergeInboxDetails,
|
||||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
|
||||||
|
|
||||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||||
|
|
||||||
@@ -39,16 +37,9 @@ const emit = defineEmits(['close']);
|
|||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { width: windowWidth } = useWindowSize();
|
|
||||||
|
|
||||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||||
|
|
||||||
const isSmallScreen = computed(
|
|
||||||
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
|
||||||
);
|
|
||||||
|
|
||||||
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
|
|
||||||
|
|
||||||
const contacts = ref([]);
|
const contacts = ref([]);
|
||||||
const selectedContact = ref(null);
|
const selectedContact = ref(null);
|
||||||
const targetInbox = ref(null);
|
const targetInbox = ref(null);
|
||||||
@@ -76,7 +67,7 @@ const directUploadsEnabled = computed(
|
|||||||
const activeContact = computed(() => contactById.value(props.contactId));
|
const activeContact = computed(() => contactById.value(props.contactId));
|
||||||
|
|
||||||
const composePopoverClass = computed(() => {
|
const composePopoverClass = computed(() => {
|
||||||
if (viewInModal.value) return '';
|
if (props.isModal) return '';
|
||||||
|
|
||||||
return props.alignPosition === 'right'
|
return props.alignPosition === 'right'
|
||||||
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
||||||
@@ -188,18 +179,14 @@ const toggle = () => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
activeContact,
|
activeContact,
|
||||||
(currentContact, previousContact) => {
|
() => {
|
||||||
if (currentContact && props.contactId) {
|
if (activeContact.value && props.contactId) {
|
||||||
// Reset on contact change
|
const contactInboxes = activeContact.value?.contactInboxes || [];
|
||||||
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
|
|
||||||
|
|
||||||
// First process the contactable inboxes to get the right structure
|
// First process the contactable inboxes to get the right structure
|
||||||
const processedInboxes = processContactableInboxes(
|
const processedInboxes = processContactableInboxes(contactInboxes);
|
||||||
currentContact.contactInboxes || []
|
|
||||||
);
|
|
||||||
// Then Merge processedInboxes with the inboxes list
|
// Then Merge processedInboxes with the inboxes list
|
||||||
selectedContact.value = {
|
selectedContact.value = {
|
||||||
...currentContact,
|
...activeContact.value,
|
||||||
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
|
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -215,7 +202,7 @@ const handleClickOutside = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onModalBackdropClick = () => {
|
const onModalBackdropClick = () => {
|
||||||
if (!viewInModal.value) return;
|
if (!props.isModal) return;
|
||||||
handleClickOutside();
|
handleClickOutside();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -244,7 +231,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
]"
|
]"
|
||||||
class="relative"
|
class="relative"
|
||||||
:class="{
|
:class="{
|
||||||
'z-50': showComposeNewConversation && !viewInModal,
|
'z-40': showComposeNewConversation,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<slot
|
<slot
|
||||||
@@ -256,12 +243,12 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
v-if="showComposeNewConversation"
|
v-if="showComposeNewConversation"
|
||||||
:class="{
|
:class="{
|
||||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
||||||
viewInModal,
|
isModal,
|
||||||
}"
|
}"
|
||||||
@click.self="onModalBackdropClick"
|
@click.self="onModalBackdropClick"
|
||||||
>
|
>
|
||||||
<ComposeNewConversationForm
|
<ComposeNewConversationForm
|
||||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
:contact-id="contactId"
|
:contact-id="contactId"
|
||||||
:is-loading="isSearching"
|
:is-loading="isSearching"
|
||||||
|
|||||||
+3
-5
@@ -25,7 +25,6 @@ const props = defineProps({
|
|||||||
hasNoInbox: { type: Boolean, default: false },
|
hasNoInbox: { type: Boolean, default: false },
|
||||||
isDropdownActive: { type: Boolean, default: false },
|
isDropdownActive: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
inboxId: { type: Number, default: null },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -151,10 +150,9 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
<div
|
<div
|
||||||
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
|
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex items-center gap-2">
|
||||||
<WhatsAppOptions
|
<WhatsAppOptions
|
||||||
v-if="isWhatsappInbox"
|
v-if="isWhatsappInbox"
|
||||||
:inbox-id="inboxId"
|
|
||||||
:message-templates="messageTemplates"
|
:message-templates="messageTemplates"
|
||||||
@send-message="emit('sendWhatsappMessage', $event)"
|
@send-message="emit('sendWhatsappMessage', $event)"
|
||||||
/>
|
/>
|
||||||
@@ -172,7 +170,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
/>
|
/>
|
||||||
<EmojiInput
|
<EmojiInput
|
||||||
v-if="isEmojiPickerOpen"
|
v-if="isEmojiPickerOpen"
|
||||||
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
|
class="left-0 top-full mt-1.5"
|
||||||
:on-click="onClickInsertEmoji"
|
:on-click="onClickInsertEmoji"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,7 +206,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
||||||
variant="faded"
|
variant="faded"
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ const removeAttachment = id => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon="i-lucide-trash"
|
icon="i-lucide-trash"
|
||||||
color="slate"
|
color="slate"
|
||||||
class="absolute top-1 ltr:right-1 rtl:left-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
|
class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
|
||||||
@click="removeAttachment(attachment.resource.id)"
|
@click="removeAttachment(attachment.resource.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-2
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
|
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
||||||
>
|
>
|
||||||
<ContactSelector
|
<ContactSelector
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
@@ -336,7 +336,6 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
|||||||
:is-loading="isCreating"
|
:is-loading="isCreating"
|
||||||
:disable-send-button="isCreating"
|
:disable-send-button="isCreating"
|
||||||
:has-selected-inbox="!!targetInbox"
|
:has-selected-inbox="!!targetInbox"
|
||||||
:inbox-id="targetInbox?.id"
|
|
||||||
:has-no-inbox="showNoInboxAlert"
|
:has-no-inbox="showNoInboxAlert"
|
||||||
:is-dropdown-active="isAnyDropdownActive"
|
:is-dropdown-active="isAnyDropdownActive"
|
||||||
:message-signature="messageSignature"
|
:message-signature="messageSignature"
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
|
|||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||||
:menu-items="contactableInboxesList"
|
:menu-items="contactableInboxesList"
|
||||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||||
@action="emit('handleInboxAction', $event)"
|
@action="emit('handleInboxAction', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+26
-19
@@ -1,25 +1,23 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import WhatsappTemplate from './WhatsappTemplate.vue';
|
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
inboxId: {
|
messageTemplates: {
|
||||||
type: Number,
|
type: Array,
|
||||||
required: true,
|
default: () => [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['sendMessage']);
|
const emit = defineEmits(['sendMessage']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const getFilteredWhatsAppTemplates = useMapGetter(
|
|
||||||
'inboxes/getFilteredWhatsAppTemplates'
|
// TODO: Remove this when we support all formats
|
||||||
);
|
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
||||||
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const selectedTemplate = ref(null);
|
const selectedTemplate = ref(null);
|
||||||
@@ -27,7 +25,19 @@ const selectedTemplate = ref(null);
|
|||||||
const showTemplatesMenu = ref(false);
|
const showTemplatesMenu = ref(false);
|
||||||
|
|
||||||
const whatsAppTemplateMessages = computed(() => {
|
const whatsAppTemplateMessages = computed(() => {
|
||||||
return getFilteredWhatsAppTemplates.value(props.inboxId);
|
// Add null check and ensure it's an array
|
||||||
|
const templates = Array.isArray(props.messageTemplates)
|
||||||
|
? props.messageTemplates
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// TODO: Remove the last filter when we support all formats
|
||||||
|
return templates
|
||||||
|
.filter(template => template?.status?.toLowerCase() === 'approved')
|
||||||
|
.filter(template => {
|
||||||
|
return template?.components?.every(component => {
|
||||||
|
return !formatsToRemove.includes(component.format);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTemplates = computed(() => {
|
const filteredTemplates = computed(() => {
|
||||||
@@ -74,13 +84,10 @@ const handleSendMessage = template => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="showTemplatesMenu"
|
v-if="showTemplatesMenu"
|
||||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||||
>
|
>
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<Icon
|
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||||
icon="i-lucide-search"
|
|
||||||
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -89,13 +96,13 @@ const handleSendMessage = template => {
|
|||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="template in filteredTemplates"
|
v-for="template in filteredTemplates"
|
||||||
:key="template.id"
|
:key="template.id"
|
||||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||||
@click="handleTemplateClick(template)"
|
@click="handleTemplateClick(template)"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||||
@@ -104,12 +111,12 @@ const handleSendMessage = template => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="filteredTemplates.length === 0">
|
<template v-if="filteredTemplates.length === 0">
|
||||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
<p class="w-full pt-2 text-sm text-n-slate-11">
|
||||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<WhatsappTemplate
|
<WhatsappTemplateParser
|
||||||
v-if="selectedTemplate"
|
v-if="selectedTemplate"
|
||||||
:template="selectedTemplate"
|
:template="selectedTemplate"
|
||||||
@send-message="handleSendMessage"
|
@send-message="handleSendMessage"
|
||||||
|
|||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
template: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['sendMessage', 'back']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const handleSendMessage = payload => {
|
|
||||||
emit('sendMessage', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
emit('back');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="w-full">
|
|
||||||
<WhatsAppTemplateParser
|
|
||||||
:template="template"
|
|
||||||
@send-message="handleSendMessage"
|
|
||||||
@back="handleBack"
|
|
||||||
>
|
|
||||||
<template #actions="{ sendMessage, goBack, disabled }">
|
|
||||||
<div class="flex gap-3 justify-between items-end w-full h-14">
|
|
||||||
<Button
|
|
||||||
:label="
|
|
||||||
t(
|
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
color="slate"
|
|
||||||
variant="faded"
|
|
||||||
class="w-full font-medium"
|
|
||||||
@click="goBack"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
:label="
|
|
||||||
t(
|
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="w-full font-medium"
|
|
||||||
:disabled="disabled"
|
|
||||||
@click="sendMessage"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</WhatsAppTemplateParser>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
+2
-3
@@ -106,7 +106,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-n-slate-12">
|
<span class="text-sm text-n-slate-12">
|
||||||
{{
|
{{
|
||||||
@@ -138,8 +138,7 @@ onMounted(() => {
|
|||||||
class="flex items-center w-full gap-2"
|
class="flex items-center w-full gap-2"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="block h-8 text-sm min-w-6 text-start truncate text-n-slate-10 leading-8"
|
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
|
||||||
:title="key"
|
|
||||||
>
|
>
|
||||||
{{ key }}
|
{{ key }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+1
-2
@@ -36,11 +36,10 @@ const transformInbox = ({
|
|||||||
email,
|
email,
|
||||||
channelType,
|
channelType,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
medium,
|
|
||||||
...rest
|
...rest
|
||||||
}) => ({
|
}) => ({
|
||||||
id,
|
id,
|
||||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
|
||||||
label: generateLabelForContactableInboxesList({
|
label: generateLabelForContactableInboxesList({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { removeEmoji } from 'shared/helpers/emoji';
|
import { removeEmoji } from 'shared/helpers/emoji';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -34,18 +33,10 @@ const props = defineProps({
|
|||||||
validator: value =>
|
validator: value =>
|
||||||
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
||||||
},
|
},
|
||||||
inbox: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
iconName: {
|
iconName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
hideOfflineStatus: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['upload', 'delete']);
|
const emit = defineEmits(['upload', 'delete']);
|
||||||
@@ -75,11 +66,11 @@ const AVATAR_COLORS = {
|
|||||||
default: { bg: '#E8E8E8', text: '#60646C' },
|
default: { bg: '#E8E8E8', text: '#60646C' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_CLASSES = computed(() => ({
|
const STATUS_CLASSES = {
|
||||||
online: 'bg-n-teal-10',
|
online: 'bg-n-teal-10',
|
||||||
busy: 'bg-n-amber-10',
|
busy: 'bg-n-amber-10',
|
||||||
...(props.hideOfflineStatus ? {} : { offline: 'bg-n-slate-10' }),
|
offline: 'bg-n-slate-10',
|
||||||
}));
|
};
|
||||||
|
|
||||||
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
||||||
|
|
||||||
@@ -183,31 +174,21 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span class="relative inline-flex group/avatar z-0" :style="containerStyles">
|
||||||
class="relative inline-flex group/avatar z-0 flex-shrink-0"
|
|
||||||
:style="containerStyles"
|
|
||||||
>
|
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
<slot name="badge" :size="size">
|
<slot name="badge" :size="size">
|
||||||
<div
|
<div
|
||||||
v-if="status && STATUS_CLASSES[status]"
|
v-if="status"
|
||||||
class="absolute z-20 border rounded-full border-n-slate-3"
|
class="absolute z-20 border rounded-full border-n-slate-3"
|
||||||
:style="badgeStyles"
|
:style="badgeStyles"
|
||||||
:class="STATUS_CLASSES[status]"
|
:class="STATUS_CLASSES[status]"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
v-if="inbox && !(status && STATUS_CLASSES[status])"
|
|
||||||
:style="badgeStyles"
|
|
||||||
class="absolute z-20 flex items-center justify-center rounded-full bg-n-solid-1 border border-transparent flex-shrink-0"
|
|
||||||
>
|
|
||||||
<ChannelIcon :inbox="inbox" class="w-full h-full text-n-slate-11" />
|
|
||||||
</div>
|
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<!-- Delete Avatar Button -->
|
<!-- Delete Avatar Button -->
|
||||||
<div
|
<div
|
||||||
v-if="src && allowUpload"
|
v-if="src && allowUpload"
|
||||||
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 ltr:-right-2 rtl:-left-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 -right-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||||
@click="handleDismiss"
|
@click="handleDismiss"
|
||||||
>
|
>
|
||||||
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
|
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
|
||||||
@@ -251,40 +232,31 @@ watch(
|
|||||||
<!-- Fallback Icon if no name or image -->
|
<!-- Fallback Icon if no name or image -->
|
||||||
<Icon
|
<Icon
|
||||||
v-else
|
v-else
|
||||||
:title="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
||||||
icon="i-lucide-user"
|
icon="i-lucide-user"
|
||||||
:style="iconStyles"
|
:style="iconStyles"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Upload Overlay and Input -->
|
<!-- Upload Overlay and Input -->
|
||||||
<slot
|
<div
|
||||||
v-if="allowUpload || $slots.overlay"
|
v-if="allowUpload"
|
||||||
name="overlay"
|
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||||
:size="size"
|
@click="handleUploadAvatar"
|
||||||
:handle-upload="handleUploadAvatar"
|
|
||||||
:file-input-ref="fileInput"
|
|
||||||
:handle-image-upload="handleImageUpload"
|
|
||||||
>
|
>
|
||||||
<div
|
<Icon
|
||||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
icon="i-lucide-upload"
|
||||||
@click="handleUploadAvatar"
|
class="text-white"
|
||||||
>
|
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
|
||||||
<Icon
|
/>
|
||||||
icon="i-lucide-upload"
|
<input
|
||||||
class="text-white"
|
ref="fileInput"
|
||||||
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
|
type="file"
|
||||||
/>
|
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||||
<input
|
class="hidden"
|
||||||
v-if="allowUpload"
|
@change="handleImageUpload"
|
||||||
ref="fileInput"
|
/>
|
||||||
type="file"
|
</div>
|
||||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
|
||||||
class="hidden"
|
|
||||||
@change="handleImageUpload"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,17 +21,9 @@ const onClick = (item, index) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav
|
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
|
||||||
:aria-label="t('BREADCRUMB.ARIA_LABEL')"
|
<ol class="flex items-center mb-0">
|
||||||
class="flex items-center h-8 min-w-0"
|
<li v-for="(item, index) in items" :key="index" class="flex items-center">
|
||||||
>
|
|
||||||
<ol class="flex items-center mb-0 min-w-0">
|
|
||||||
<li
|
|
||||||
v-for="(item, index) in items"
|
|
||||||
:key="index"
|
|
||||||
class="flex items-center"
|
|
||||||
:class="{ 'min-w-0 flex-1': index === items.length - 1 }"
|
|
||||||
>
|
|
||||||
<Icon
|
<Icon
|
||||||
v-if="index > 0"
|
v-if="index > 0"
|
||||||
icon="i-lucide-chevron-right"
|
icon="i-lucide-chevron-right"
|
||||||
@@ -48,7 +40,7 @@ const onClick = (item, index) => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- The last breadcrumb item is plain text -->
|
<!-- The last breadcrumb item is plain text -->
|
||||||
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
|
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
|
||||||
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ const handleBreadcrumbClick = item => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||||
>
|
>
|
||||||
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
|
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
|
||||||
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
|
<header class="mb-7 sticky top-0 z-10 bg-n-background">
|
||||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||||
</header>
|
</header>
|
||||||
<main class="flex gap-16 w-full flex-1 pb-16">
|
<main class="flex gap-16 w-full flex-1 pb-16">
|
||||||
|
|||||||
@@ -57,10 +57,9 @@ const menuItems = computed(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const icon = computed(() => {
|
const icon = computed(() =>
|
||||||
const { medium, channel_type: type } = props.inbox;
|
getInboxIconByType(props.inbox.channel_type, '', 'outline')
|
||||||
return getInboxIconByType(type, medium, 'outline');
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const handleAction = ({ action, value }) => {
|
const handleAction = ({ action, value }) => {
|
||||||
toggleDropdown(false);
|
toggleDropdown(false);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, h, reactive, ref } from 'vue';
|
import { computed, h, reactive } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useToggle, useElementSize } from '@vueuse/core';
|
import { useToggle } from '@vueuse/core';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, minLength } from '@vuelidate/validators';
|
import { required, minLength } from '@vuelidate/validators';
|
||||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
@@ -11,7 +11,6 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
|||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -32,7 +31,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
required: true,
|
||||||
},
|
},
|
||||||
selectable: {
|
selectable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -61,13 +60,7 @@ const state = reactive({
|
|||||||
instruction: '',
|
instruction: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const instructionContentRef = ref();
|
|
||||||
|
|
||||||
const [isEditing, toggleEditing] = useToggle();
|
const [isEditing, toggleEditing] = useToggle();
|
||||||
const [isInstructionExpanded, toggleInstructionExpanded] = useToggle();
|
|
||||||
|
|
||||||
const { height: contentHeight } = useElementSize(instructionContentRef);
|
|
||||||
const needsOverlay = computed(() => contentHeight.value > 160);
|
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
Object.assign(state, {
|
Object.assign(state, {
|
||||||
@@ -118,7 +111,7 @@ const LINK_INSTRUCTION_CLASS =
|
|||||||
|
|
||||||
const renderInstruction = instruction => () =>
|
const renderInstruction = instruction => () =>
|
||||||
h('p', {
|
h('p', {
|
||||||
class: `text-sm text-n-slate-12 py-4 mb-0 prose prose-sm min-w-0 break-words max-w-none ${LINK_INSTRUCTION_CLASS}`,
|
class: `text-sm text-n-slate-12 py-4 mb-0 [&_ol]:list-decimal ${LINK_INSTRUCTION_CLASS}`,
|
||||||
innerHTML: instruction,
|
innerHTML: instruction,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -164,38 +157,8 @@ const renderInstruction = instruction => () =>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<component :is="renderInstruction(formatMessage(instruction, false))" />
|
||||||
<div
|
<span class="text-sm text-n-slate-11 font-medium mb-1">
|
||||||
class="relative overflow-hidden transition-all duration-300 ease-in-out group/expandable"
|
|
||||||
:class="{ 'cursor-pointer': needsOverlay }"
|
|
||||||
:style="{
|
|
||||||
maxHeight: isInstructionExpanded ? `${contentHeight}px` : '10rem',
|
|
||||||
}"
|
|
||||||
@click="needsOverlay ? toggleInstructionExpanded() : null"
|
|
||||||
>
|
|
||||||
<div ref="instructionContentRef">
|
|
||||||
<component
|
|
||||||
:is="renderInstruction(formatMessage(instruction, false))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute bottom-0 w-full flex items-end justify-center text-xs text-n-slate-11 bg-gradient-to-t h-40 from-n-solid-2 via-n-solid-2 via-10% to-transparent transition-all duration-500 ease-in-out px-2 py-1 rounded pointer-events-none"
|
|
||||||
:class="{
|
|
||||||
'visible opacity-100': !isInstructionExpanded,
|
|
||||||
'invisible opacity-0': isInstructionExpanded || !needsOverlay,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
class="text-n-slate-7 mb-4 size-4 group-hover/expandable:text-n-slate-11 transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
v-if="tools?.length"
|
|
||||||
class="text-sm text-n-slate-11 font-medium mb-1"
|
|
||||||
>
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
||||||
{{ tools?.map(tool => `@${tool}`).join(', ') }}
|
{{ tools?.map(tool => `@${tool}`).join(', ') }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
-10
@@ -35,7 +35,6 @@ const initialState = {
|
|||||||
productName: '',
|
productName: '',
|
||||||
featureFaq: false,
|
featureFaq: false,
|
||||||
featureMemory: false,
|
featureMemory: false,
|
||||||
featureCitation: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
@@ -71,7 +70,6 @@ const prepareAssistantDetails = () => ({
|
|||||||
product_name: state.productName,
|
product_name: state.productName,
|
||||||
feature_faq: state.featureFaq,
|
feature_faq: state.featureFaq,
|
||||||
feature_memory: state.featureMemory,
|
feature_memory: state.featureMemory,
|
||||||
feature_citation: state.featureCitation,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,7 +93,6 @@ const updateStateFromAssistant = assistant => {
|
|||||||
productName: config.product_name,
|
productName: config.product_name,
|
||||||
featureFaq: config.feature_faq || false,
|
featureFaq: config.feature_faq || false,
|
||||||
featureMemory: config.feature_memory || false,
|
featureMemory: config.feature_memory || false,
|
||||||
featureCitation: config.feature_citation || false,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,13 +151,6 @@ watch(
|
|||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input v-model="state.featureCitation" type="checkbox" />
|
|
||||||
<span class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="flex items-center justify-between w-full gap-3">
|
<div class="flex items-center justify-between w-full gap-3">
|
||||||
|
|||||||
+6
-8
@@ -41,7 +41,6 @@ const initialState = {
|
|||||||
features: {
|
features: {
|
||||||
conversationFaqs: false,
|
conversationFaqs: false,
|
||||||
memories: false,
|
memories: false,
|
||||||
citations: false,
|
|
||||||
},
|
},
|
||||||
temperature: 1,
|
temperature: 1,
|
||||||
};
|
};
|
||||||
@@ -88,7 +87,6 @@ const updateStateFromAssistant = assistant => {
|
|||||||
state.features = {
|
state.features = {
|
||||||
conversationFaqs: config.feature_faq || false,
|
conversationFaqs: config.feature_faq || false,
|
||||||
memories: config.feature_memory || false,
|
memories: config.feature_memory || false,
|
||||||
citations: config.feature_citation || false,
|
|
||||||
};
|
};
|
||||||
state.temperature = config.temperature || 1;
|
state.temperature = config.temperature || 1;
|
||||||
};
|
};
|
||||||
@@ -154,7 +152,6 @@ const handleFeaturesUpdate = () => {
|
|||||||
...props.assistant.config,
|
...props.assistant.config,
|
||||||
feature_faq: state.features.conversationFaqs,
|
feature_faq: state.features.conversationFaqs,
|
||||||
feature_memory: state.features.memories,
|
feature_memory: state.features.memories,
|
||||||
feature_citation: state.features.citations,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -303,19 +300,20 @@ watch(
|
|||||||
<input
|
<input
|
||||||
v-model="state.features.conversationFaqs"
|
v-model="state.features.conversationFaqs"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
/>
|
/>
|
||||||
{{
|
{{
|
||||||
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
|
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
|
||||||
}}
|
}}
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input v-model="state.features.memories" type="checkbox" />
|
<input
|
||||||
|
v-model="state.features.memories"
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
/>
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input v-model="state.features.citations" type="checkbox" />
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -26,7 +26,6 @@ const initialState = {
|
|||||||
features: {
|
features: {
|
||||||
conversationFaqs: false,
|
conversationFaqs: false,
|
||||||
memories: false,
|
memories: false,
|
||||||
citations: false,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,7 +57,6 @@ const updateStateFromAssistant = assistant => {
|
|||||||
state.features = {
|
state.features = {
|
||||||
conversationFaqs: config.feature_faq || false,
|
conversationFaqs: config.feature_faq || false,
|
||||||
memories: config.feature_memory || false,
|
memories: config.feature_memory || false,
|
||||||
citations: config.feature_citation || false,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +76,6 @@ const handleBasicInfoUpdate = async () => {
|
|||||||
product_name: state.productName,
|
product_name: state.productName,
|
||||||
feature_faq: state.features.conversationFaqs,
|
feature_faq: state.features.conversationFaqs,
|
||||||
feature_memory: state.features.memories,
|
feature_memory: state.features.memories,
|
||||||
feature_citation: state.features.citations,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,17 +123,21 @@ watch(
|
|||||||
</label>
|
</label>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input v-model="state.features.conversationFaqs" type="checkbox" />
|
<input
|
||||||
|
v-model="state.features.conversationFaqs"
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
/>
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS') }}
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS') }}
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input v-model="state.features.memories" type="checkbox" />
|
<input
|
||||||
|
v-model="state.features.memories"
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
/>
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input v-model="state.features.citations" type="checkbox" />
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ defineExpose({ validate });
|
|||||||
solid
|
solid
|
||||||
slate
|
slate
|
||||||
icon="i-lucide-trash"
|
icon="i-lucide-trash"
|
||||||
class="flex-shrink-0"
|
|
||||||
@click.stop="emit('remove')"
|
@click.stop="emit('remove')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ const outsideClickHandler = [
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-on-click-outside="outsideClickHandler"
|
v-on-click-outside="outsideClickHandler"
|
||||||
class="z-40 max-w-3xl min-w-96 lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
||||||
>
|
>
|
||||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||||
{{ filterModalHeaderTitle }}
|
{{ filterModalHeaderTitle }}
|
||||||
@@ -146,10 +146,10 @@ const outsideClickHandler = [
|
|||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
<Button sm ghost blue class="flex-shrink-0" @click="addFilter">
|
<Button sm ghost blue @click="addFilter">
|
||||||
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.ADD_FILTER') }}
|
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.ADD_FILTER') }}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex gap-2 flex-shrink-0">
|
<div class="flex gap-2">
|
||||||
<Button sm faded slate @click="resetFilter">
|
<Button sm faded slate @click="resetFilter">
|
||||||
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.CLEAR_FILTERS') }}
|
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.CLEAR_FILTERS') }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { toRef } from 'vue';
|
|
||||||
import { useChannelIcon } from './provider';
|
import { useChannelIcon } from './provider';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
|
const channelIcon = useChannelIcon(props.inbox);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -22,21 +22,15 @@ export function useChannelIcon(inbox) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const channelIcon = computed(() => {
|
const channelIcon = computed(() => {
|
||||||
const inboxDetails = inbox.value || inbox;
|
const type = inbox.channel_type;
|
||||||
const type = inboxDetails.channel_type;
|
|
||||||
let icon = channelTypeIconMap[type];
|
let icon = channelTypeIconMap[type];
|
||||||
|
|
||||||
if (type === 'Channel::Email' && inboxDetails.provider) {
|
if (type === 'Channel::Email' && inbox.provider) {
|
||||||
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
|
if (Object.keys(providerIconMap).includes(inbox.provider)) {
|
||||||
icon = providerIconMap[inboxDetails.provider];
|
icon = providerIconMap[inbox.provider];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case for Twilio whatsapp
|
|
||||||
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
|
|
||||||
icon = 'i-ri-whatsapp-fill';
|
|
||||||
}
|
|
||||||
|
|
||||||
return icon ?? 'i-ri-global-fill';
|
return icon ?? 'i-ri-global-fill';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,77 +25,6 @@ describe('useChannelIcon', () => {
|
|||||||
expect(icon).toBe('i-ri-phone-fill');
|
expect(icon).toBe('i-ri-phone-fill');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct icon for Line channel', () => {
|
|
||||||
const inbox = { channel_type: 'Channel::Line' };
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-ri-line-fill');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct icon for SMS channel', () => {
|
|
||||||
const inbox = { channel_type: 'Channel::Sms' };
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-ri-chat-1-fill');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct icon for Telegram channel', () => {
|
|
||||||
const inbox = { channel_type: 'Channel::Telegram' };
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-ri-telegram-fill');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct icon for Twitter channel', () => {
|
|
||||||
const inbox = { channel_type: 'Channel::TwitterProfile' };
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-ri-twitter-x-fill');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct icon for WebWidget channel', () => {
|
|
||||||
const inbox = { channel_type: 'Channel::WebWidget' };
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-ri-global-fill');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct icon for Instagram channel', () => {
|
|
||||||
const inbox = { channel_type: 'Channel::Instagram' };
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-ri-instagram-fill');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TwilioSms channel', () => {
|
|
||||||
it('returns chat icon for regular Twilio SMS channel', () => {
|
|
||||||
const inbox = { channel_type: 'Channel::TwilioSms' };
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-ri-chat-1-fill');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns WhatsApp icon for Twilio SMS with WhatsApp medium', () => {
|
|
||||||
const inbox = {
|
|
||||||
channel_type: 'Channel::TwilioSms',
|
|
||||||
medium: 'whatsapp',
|
|
||||||
};
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-ri-whatsapp-fill');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns chat icon for Twilio SMS with non-WhatsApp medium', () => {
|
|
||||||
const inbox = {
|
|
||||||
channel_type: 'Channel::TwilioSms',
|
|
||||||
medium: 'sms',
|
|
||||||
};
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-ri-chat-1-fill');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns chat icon for Twilio SMS with undefined medium', () => {
|
|
||||||
const inbox = {
|
|
||||||
channel_type: 'Channel::TwilioSms',
|
|
||||||
medium: undefined,
|
|
||||||
};
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-ri-chat-1-fill');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Email channel', () => {
|
describe('Email channel', () => {
|
||||||
it('returns mail icon for generic email channel', () => {
|
it('returns mail icon for generic email channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::Email' };
|
const inbox = { channel_type: 'Channel::Email' };
|
||||||
|
|||||||
@@ -509,8 +509,10 @@ const menuItems = computed(() => {
|
|||||||
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
|
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
|
||||||
:class="[
|
:class="[
|
||||||
{
|
{
|
||||||
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
'ltr:translate-x-0 rtl:-translate-x-0 shadow-lg md:shadow-none':
|
||||||
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
|
isMobileSidebarOpen,
|
||||||
|
'ltr:-translate-x-full rtl:translate-x-full md:translate-x-0':
|
||||||
|
!isMobileSidebarOpen,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -218,8 +218,8 @@ onMounted(async () => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app[dir='rtl'] .sidebar-group-children > .child-item:last-child::after,
|
.app-rtl--wrapper .sidebar-group-children > .child-item:last-child::after,
|
||||||
#app[dir='rtl']
|
.app-rtl--wrapper
|
||||||
.sidebar-group-children
|
.sidebar-group-children
|
||||||
> *:last-child
|
> *:last-child
|
||||||
> *:last-child
|
> *:last-child
|
||||||
|
|||||||
@@ -86,15 +86,6 @@ const menuItems = computed(() => {
|
|||||||
nativeLink: true,
|
nativeLink: true,
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
show: true,
|
|
||||||
showOnCustomBrandedInstance: false,
|
|
||||||
label: t('SIDEBAR_ITEMS.CHANGELOG'),
|
|
||||||
icon: 'i-lucide-scroll-text',
|
|
||||||
link: 'https://www.chatwoot.com/changelog/',
|
|
||||||
nativeLink: true,
|
|
||||||
target: '_blank',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
show: currentUser.value.type === 'SuperAdmin',
|
show: currentUser.value.type === 'SuperAdmin',
|
||||||
showOnCustomBrandedInstance: true,
|
showOnCustomBrandedInstance: true,
|
||||||
@@ -123,7 +114,7 @@ const allowedMenuItems = computed(() => {
|
|||||||
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
|
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
|
||||||
<template #trigger="{ toggle, isOpen }">
|
<template #trigger="{ toggle, isOpen }">
|
||||||
<button
|
<button
|
||||||
class="flex gap-2 items-center p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
|
||||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
@@ -136,16 +127,16 @@ const allowedMenuItems = computed(() => {
|
|||||||
rounded-full
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
|
||||||
{{ currentUser.available_name }}
|
{{ currentUser.available_name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs truncate text-n-slate-11">
|
<div class="text-n-slate-11 text-xs truncate">
|
||||||
{{ currentUser.email }}
|
{{ currentUser.email }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<DropdownBody class="bottom-12 z-50 mb-2 w-80 ltr:left-0 rtl:right-0">
|
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
|
||||||
<SidebarProfileMenuStatus />
|
<SidebarProfileMenuStatus />
|
||||||
<DropdownSeparator />
|
<DropdownSeparator />
|
||||||
<template v-for="item in allowedMenuItems" :key="item.label">
|
<template v-for="item in allowedMenuItems" :key="item.label">
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ const handleBlur = e => emit('blur', e);
|
|||||||
v-if="showDropdownMenu"
|
v-if="showDropdownMenu"
|
||||||
:menu-items="filteredMenuItems"
|
:menu-items="filteredMenuItems"
|
||||||
:is-searching="isLoading"
|
:is-searching="isLoading"
|
||||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
|
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
|
||||||
@action="handleDropdownAction"
|
@action="handleDropdownAction"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
|
||||||
import { requiredIf } from '@vuelidate/validators';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
|
||||||
import {
|
|
||||||
buildTemplateParameters,
|
|
||||||
allKeysRequired,
|
|
||||||
replaceTemplateVariables,
|
|
||||||
DEFAULT_LANGUAGE,
|
|
||||||
DEFAULT_CATEGORY,
|
|
||||||
COMPONENT_TYPES,
|
|
||||||
MEDIA_FORMATS,
|
|
||||||
findComponentByType,
|
|
||||||
} from 'dashboard/helper/templateHelper';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
template: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
validator: value => {
|
|
||||||
if (!value || typeof value !== 'object') return false;
|
|
||||||
if (!value.components || !Array.isArray(value.components)) return false;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const processedParams = ref({});
|
|
||||||
|
|
||||||
const languageLabel = computed(() => {
|
|
||||||
return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || DEFAULT_LANGUAGE}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const categoryLabel = computed(() => {
|
|
||||||
return `${t('WHATSAPP_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || DEFAULT_CATEGORY}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const headerComponent = computed(() => {
|
|
||||||
return findComponentByType(props.template, COMPONENT_TYPES.HEADER);
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodyComponent = computed(() => {
|
|
||||||
return findComponentByType(props.template, COMPONENT_TYPES.BODY);
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodyText = computed(() => {
|
|
||||||
return bodyComponent.value?.text || '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasMediaHeader = computed(() =>
|
|
||||||
MEDIA_FORMATS.includes(headerComponent.value?.format)
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatType = computed(() => {
|
|
||||||
const format = headerComponent.value?.format;
|
|
||||||
return format ? format.charAt(0) + format.slice(1).toLowerCase() : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasVariables = computed(() => {
|
|
||||||
return bodyText.value?.match(/{{([^}]+)}}/g) !== null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderedTemplate = computed(() => {
|
|
||||||
return replaceTemplateVariables(bodyText.value, processedParams.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const isFormInvalid = computed(() => {
|
|
||||||
if (!hasVariables.value && !hasMediaHeader.value) return false;
|
|
||||||
|
|
||||||
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasVariables.value && processedParams.value.body) {
|
|
||||||
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
|
|
||||||
value => !value
|
|
||||||
);
|
|
||||||
if (hasEmptyBodyVariable) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (processedParams.value.buttons) {
|
|
||||||
const hasEmptyButtonParameter = processedParams.value.buttons.some(
|
|
||||||
button => !button.parameter
|
|
||||||
);
|
|
||||||
if (hasEmptyButtonParameter) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const v$ = useVuelidate(
|
|
||||||
{
|
|
||||||
processedParams: {
|
|
||||||
requiredIfKeysPresent: requiredIf(hasVariables),
|
|
||||||
allKeysRequired,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ processedParams }
|
|
||||||
);
|
|
||||||
|
|
||||||
const initializeTemplateParameters = () => {
|
|
||||||
processedParams.value = buildTemplateParameters(
|
|
||||||
props.template,
|
|
||||||
hasMediaHeader.value
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateMediaUrl = value => {
|
|
||||||
processedParams.value.header ??= {};
|
|
||||||
processedParams.value.header.media_url = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendMessage = () => {
|
|
||||||
v$.value.$touch();
|
|
||||||
if (v$.value.$invalid) return;
|
|
||||||
|
|
||||||
const { name, category, language, namespace } = props.template;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
message: renderedTemplate.value,
|
|
||||||
templateParams: {
|
|
||||||
name,
|
|
||||||
category,
|
|
||||||
language,
|
|
||||||
namespace,
|
|
||||||
processed_params: processedParams.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
emit('sendMessage', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetTemplate = () => {
|
|
||||||
emit('resetTemplate');
|
|
||||||
};
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
emit('back');
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(initializeTemplateParameters);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.template,
|
|
||||||
() => {
|
|
||||||
initializeTemplateParameters();
|
|
||||||
v$.value.$reset();
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
processedParams,
|
|
||||||
hasVariables,
|
|
||||||
hasMediaHeader,
|
|
||||||
headerComponent,
|
|
||||||
renderedTemplate,
|
|
||||||
v$,
|
|
||||||
updateMediaUrl,
|
|
||||||
sendMessage,
|
|
||||||
resetTemplate,
|
|
||||||
goBack,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<h3 class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ template.name }}
|
|
||||||
</h3>
|
|
||||||
<span class="text-xs text-n-slate-11">
|
|
||||||
{{ languageLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div class="rounded-md">
|
|
||||||
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
|
||||||
{{ renderedTemplate }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xs text-n-slate-11">
|
|
||||||
{{ categoryLabel }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="hasVariables || hasMediaHeader">
|
|
||||||
<div v-if="hasMediaHeader" class="mb-4">
|
|
||||||
<p class="mb-2.5 text-sm font-semibold">
|
|
||||||
{{
|
|
||||||
$t('WHATSAPP_TEMPLATES.PARSER.MEDIA_HEADER_LABEL', {
|
|
||||||
type: formatType,
|
|
||||||
}) || `${formatType} Header`
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center mb-2.5">
|
|
||||||
<Input
|
|
||||||
:model-value="processedParams.header?.media_url || ''"
|
|
||||||
type="url"
|
|
||||||
class="flex-1"
|
|
||||||
:placeholder="
|
|
||||||
t('WHATSAPP_TEMPLATES.PARSER.MEDIA_URL_LABEL', {
|
|
||||||
type: formatType,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
@update:model-value="updateMediaUrl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body Variables Section -->
|
|
||||||
<div v-if="processedParams.body">
|
|
||||||
<p class="mb-2.5 text-sm font-semibold">
|
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-for="(variable, key) in processedParams.body"
|
|
||||||
:key="`body-${key}`"
|
|
||||||
class="flex items-center mb-2.5"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
v-model="processedParams.body[key]"
|
|
||||||
type="text"
|
|
||||||
class="flex-1"
|
|
||||||
:placeholder="
|
|
||||||
t('WHATSAPP_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
|
|
||||||
variable: key,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Button Variables Section -->
|
|
||||||
<div v-if="processedParams.buttons">
|
|
||||||
<p class="mb-2.5 text-sm font-semibold">
|
|
||||||
{{ t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETERS') }}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-for="(button, index) in processedParams.buttons"
|
|
||||||
:key="`button-${index}`"
|
|
||||||
class="flex items-center mb-2.5"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
v-model="processedParams.buttons[index].parameter"
|
|
||||||
type="text"
|
|
||||||
class="flex-1"
|
|
||||||
:placeholder="t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETER')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="v$.$dirty && v$.$invalid"
|
|
||||||
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
|
|
||||||
>
|
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<slot
|
|
||||||
name="actions"
|
|
||||||
:send-message="sendMessage"
|
|
||||||
:reset-template="resetTemplate"
|
|
||||||
:go-back="goBack"
|
|
||||||
:is-valid="!v$.$invalid"
|
|
||||||
:disabled="isFormInvalid"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -98,7 +98,7 @@ const toggleConversationLayout = () => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
id="saveFilterTeleportTarget"
|
id="saveFilterTeleportTarget"
|
||||||
class="absolute z-50 mt-2"
|
class="absolute z-40 mt-2"
|
||||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +124,7 @@ const toggleConversationLayout = () => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
id="conversationFilterTeleportTarget"
|
id="conversationFilterTeleportTarget"
|
||||||
class="absolute z-50 mt-2"
|
class="absolute z-40 mt-2"
|
||||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +150,7 @@ const toggleConversationLayout = () => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
id="conversationFilterTeleportTarget"
|
id="conversationFilterTeleportTarget"
|
||||||
class="absolute z-50 mt-2"
|
class="absolute z-40 mt-2"
|
||||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file.
|
// [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file.
|
||||||
/* eslint no-plusplus: 0 */
|
/* eslint no-plusplus: 0 */
|
||||||
|
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
|
||||||
import Code from './Code.vue';
|
import Code from './Code.vue';
|
||||||
import ColorPicker from './widgets/ColorPicker.vue';
|
import ColorPicker from './widgets/ColorPicker.vue';
|
||||||
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
|
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
|
||||||
@@ -17,9 +18,11 @@ import Modal from './Modal.vue';
|
|||||||
import Spinner from 'shared/components/Spinner.vue';
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
import Tabs from './ui/Tabs/Tabs.vue';
|
import Tabs from './ui/Tabs/Tabs.vue';
|
||||||
import TabsItem from './ui/Tabs/TabsItem.vue';
|
import TabsItem from './ui/Tabs/TabsItem.vue';
|
||||||
|
import Thumbnail from './widgets/Thumbnail.vue';
|
||||||
import DatePicker from './ui/DatePicker/DatePicker.vue';
|
import DatePicker from './ui/DatePicker/DatePicker.vue';
|
||||||
|
|
||||||
const WootUIKit = {
|
const WootUIKit = {
|
||||||
|
AvatarUploader,
|
||||||
Code,
|
Code,
|
||||||
ColorPicker,
|
ColorPicker,
|
||||||
ConfirmDeleteModal,
|
ConfirmDeleteModal,
|
||||||
@@ -37,6 +40,7 @@ const WootUIKit = {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsItem,
|
TabsItem,
|
||||||
|
Thumbnail,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
install(Vue) {
|
install(Vue) {
|
||||||
const keys = Object.keys(this);
|
const keys = Object.keys(this);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
|
/* eslint no-console: 0 */
|
||||||
|
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [globalConfigMixin],
|
||||||
props: {
|
props: {
|
||||||
items: {
|
items: {
|
||||||
type: Array,
|
type: Array,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'Avatar',
|
||||||
|
props: {
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: 40,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
style() {
|
||||||
|
return {
|
||||||
|
fontSize: `${Math.floor(this.size / 2.5)}px`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
userInitial() {
|
||||||
|
const parts = this.username.split(/[ -]/);
|
||||||
|
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
|
||||||
|
|
||||||
|
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
|
||||||
|
initials = initials.replace(/[a-z]+/g, '');
|
||||||
|
}
|
||||||
|
initials = initials.substring(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return initials;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="avatar-container" :style="style" aria-hidden="true">
|
||||||
|
<slot>{{ userInitial }}</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@tailwind components;
|
||||||
|
@layer components {
|
||||||
|
.avatar-color {
|
||||||
|
background-image: linear-gradient(to top, #c2e1ff 0%, #d6ebff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-avatar-color {
|
||||||
|
background-image: linear-gradient(to top, #135899 0%, #135899 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.avatar-container {
|
||||||
|
@apply flex leading-[100%] font-medium items-center justify-center text-center cursor-default avatar-color dark:dark-avatar-color text-n-blue-text;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,22 +1,32 @@
|
|||||||
<script setup>
|
<script>
|
||||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
import { getInboxClassByType } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
defineProps({
|
export default {
|
||||||
inbox: {
|
props: {
|
||||||
type: Object,
|
inbox: {
|
||||||
default: () => {},
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
computed: {
|
||||||
|
computedInboxClass() {
|
||||||
|
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
|
||||||
|
const classByType = getInboxClassByType(type, phoneNumber);
|
||||||
|
return classByType;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
|
<div
|
||||||
<ChannelIcon
|
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
|
||||||
:inbox="inbox"
|
>
|
||||||
class="size-3 ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
|
<fluent-icon
|
||||||
|
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
|
||||||
|
:icon="computedInboxClass"
|
||||||
|
size="12"
|
||||||
/>
|
/>
|
||||||
<span class="truncate">
|
{{ inbox.name }}
|
||||||
{{ inbox.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import Avatar from './Avatar.vue';
|
||||||
|
import Thumbnail from './Thumbnail.vue';
|
||||||
|
|
||||||
|
describe('Thumbnail.vue', () => {
|
||||||
|
it('should render the agent thumbnail if valid image is passed', () => {
|
||||||
|
const wrapper = mount(Thumbnail, {
|
||||||
|
propsData: {
|
||||||
|
src: 'https://some_valid_url.com',
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hasImageLoaded: true,
|
||||||
|
imgError: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.user-thumbnail').exists()).toBe(true);
|
||||||
|
const avatarComponent = wrapper.findComponent(Avatar);
|
||||||
|
expect(avatarComponent.isVisible()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the avatar component if invalid image is passed', () => {
|
||||||
|
const wrapper = mount(Thumbnail, {
|
||||||
|
propsData: {
|
||||||
|
src: 'https://some_invalid_url.com',
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hasImageLoaded: true,
|
||||||
|
imgError: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.find('#image').exists()).toBe(false);
|
||||||
|
const avatarComponent = wrapper.findComponent(Avatar);
|
||||||
|
expect(avatarComponent.isVisible()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should the initial of the name if no image is passed', () => {
|
||||||
|
const wrapper = mount(Avatar, {
|
||||||
|
propsData: {
|
||||||
|
username: 'Angie Rojas',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.find('div').text()).toBe('AR');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Thumbnail Component
|
||||||
|
* Src - source for round image
|
||||||
|
* Size - Size of the thumbnail
|
||||||
|
* Badge - Chat source indication { fb / telegram }
|
||||||
|
* Username - Username for avatar
|
||||||
|
*/
|
||||||
|
import Avatar from './Avatar.vue';
|
||||||
|
import { removeEmoji } from 'shared/helpers/emoji';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Avatar,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: '40px',
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
hasBorder: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
shouldShowStatusAlways: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'circle',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hasImageLoaded: false,
|
||||||
|
imgError: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userNameWithoutEmoji() {
|
||||||
|
return removeEmoji(this.username);
|
||||||
|
},
|
||||||
|
showStatusIndicator() {
|
||||||
|
if (this.shouldShowStatusAlways) return true;
|
||||||
|
return this.status === 'online' || this.status === 'busy';
|
||||||
|
},
|
||||||
|
avatarSize() {
|
||||||
|
return Number(this.size.replace(/\D+/g, ''));
|
||||||
|
},
|
||||||
|
badgeSrc() {
|
||||||
|
return {
|
||||||
|
instagram_direct_message: 'instagram-dm',
|
||||||
|
facebook: 'messenger',
|
||||||
|
'twitter-tweet': 'twitter-tweet',
|
||||||
|
'twitter-dm': 'twitter-dm',
|
||||||
|
whatsapp: 'whatsapp',
|
||||||
|
sms: 'sms',
|
||||||
|
'Channel::Line': 'line',
|
||||||
|
'Channel::Telegram': 'telegram',
|
||||||
|
'Channel::WebWidget': '',
|
||||||
|
}[this.badge];
|
||||||
|
},
|
||||||
|
badgeStyle() {
|
||||||
|
const size = Math.floor(this.avatarSize / 3);
|
||||||
|
const badgeSize = `${size + 2}px`;
|
||||||
|
const borderRadius = `${size / 2}px`;
|
||||||
|
return { width: badgeSize, height: badgeSize, borderRadius };
|
||||||
|
},
|
||||||
|
statusStyle() {
|
||||||
|
const statusSize = `${this.avatarSize / 4}px`;
|
||||||
|
return { width: statusSize, height: statusSize };
|
||||||
|
},
|
||||||
|
thumbnailClass() {
|
||||||
|
const className = this.hasBorder
|
||||||
|
? 'border border-solid border-white dark:border-n-weak'
|
||||||
|
: '';
|
||||||
|
const variant =
|
||||||
|
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
|
||||||
|
return `user-thumbnail ${className} ${variant}`;
|
||||||
|
},
|
||||||
|
thumbnailBoxClass() {
|
||||||
|
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
|
||||||
|
return `user-thumbnail-box ${boxClass}`;
|
||||||
|
},
|
||||||
|
shouldShowImage() {
|
||||||
|
if (!this.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.hasImageLoaded) {
|
||||||
|
return !this.imgError;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
src(value, oldValue) {
|
||||||
|
if (value !== oldValue && this.imgError) {
|
||||||
|
this.imgError = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onImgError() {
|
||||||
|
this.imgError = true;
|
||||||
|
},
|
||||||
|
onImgLoad() {
|
||||||
|
this.hasImageLoaded = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="thumbnailBoxClass"
|
||||||
|
:style="{ height: size, width: size }"
|
||||||
|
:title="title"
|
||||||
|
>
|
||||||
|
<!-- Using v-show instead of v-if to avoid flickering as v-if removes dom elements. -->
|
||||||
|
<slot>
|
||||||
|
<img
|
||||||
|
v-show="shouldShowImage"
|
||||||
|
:src="src"
|
||||||
|
draggable="false"
|
||||||
|
:class="thumbnailClass"
|
||||||
|
@load="onImgLoad"
|
||||||
|
@error="onImgError"
|
||||||
|
/>
|
||||||
|
<Avatar
|
||||||
|
v-show="!shouldShowImage"
|
||||||
|
:username="userNameWithoutEmoji"
|
||||||
|
:class="thumbnailClass"
|
||||||
|
:size="avatarSize"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
<img
|
||||||
|
v-if="badgeSrc"
|
||||||
|
class="source-badge z-20"
|
||||||
|
:style="badgeStyle"
|
||||||
|
:src="`/integrations/channels/badges/${badgeSrc}.png`"
|
||||||
|
alt="Badge"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="showStatusIndicator"
|
||||||
|
class="z-20"
|
||||||
|
:class="`source-badge user-online-status user-online-status--${status}`"
|
||||||
|
:style="statusStyle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.user-thumbnail-box {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.is-rounded {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-thumbnail {
|
||||||
|
border-radius: 50%;
|
||||||
|
&.thumbnail-square {
|
||||||
|
border-radius: 0.5625rem;
|
||||||
|
}
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
object-fit: cover;
|
||||||
|
vertical-align: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-badge {
|
||||||
|
border-radius: 0.1875rem;
|
||||||
|
bottom: -0.125rem;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
@apply bg-n-background p-0.5 size-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-online-status {
|
||||||
|
@apply bottom-0.5 rounded-full;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-online-status--online {
|
||||||
|
@apply bg-n-teal-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-online-status--busy {
|
||||||
|
@apply bg-n-amber-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-online-status--offline {
|
||||||
|
@apply bg-n-slate-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,69 +1,78 @@
|
|||||||
<script setup>
|
<script>
|
||||||
import { computed } from 'vue';
|
import Thumbnail from './Thumbnail.vue';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
export default {
|
||||||
usersList: {
|
components: {
|
||||||
type: Array,
|
Thumbnail,
|
||||||
default: () => [],
|
|
||||||
},
|
},
|
||||||
size: {
|
props: {
|
||||||
type: Number,
|
usersList: {
|
||||||
default: 24,
|
type: Array,
|
||||||
},
|
default: () => [],
|
||||||
showMoreThumbnailsCount: {
|
},
|
||||||
type: Boolean,
|
size: {
|
||||||
default: false,
|
type: String,
|
||||||
},
|
default: '24px',
|
||||||
moreThumbnailsText: {
|
},
|
||||||
type: String,
|
showMoreThumbnailsCount: {
|
||||||
default: '',
|
type: Boolean,
|
||||||
},
|
default: false,
|
||||||
gap: {
|
},
|
||||||
type: String,
|
moreThumbnailsText: {
|
||||||
default: 'normal',
|
type: String,
|
||||||
validator(value) {
|
default: '',
|
||||||
// The value must match one of these strings
|
},
|
||||||
return ['normal', 'tight'].includes(value);
|
gap: {
|
||||||
|
type: String,
|
||||||
|
default: 'normal',
|
||||||
|
validator(value) {
|
||||||
|
// The value must match one of these strings
|
||||||
|
return ['normal', '', 'tight'].includes(value);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
const gapClass = computed(() => {
|
|
||||||
if (props.gap === 'tight') {
|
|
||||||
return 'ltr:[&:not(:first-child)]:-ml-2 rtl:[&:not(:first-child)]:-mr-2';
|
|
||||||
}
|
|
||||||
return 'ltr:[&:not(:first-child)]:-ml-1 rtl:[&:not(:first-child)]:-mr-1';
|
|
||||||
});
|
|
||||||
|
|
||||||
const moreThumbnailsClass = computed(() => {
|
|
||||||
if (props.gap === 'tight') {
|
|
||||||
return 'ltr:-ml-2 rtl:-mr-2';
|
|
||||||
}
|
|
||||||
return 'ltr:-ml-1 rtl:-mr-1';
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex">
|
<div class="overlapping-thumbnails">
|
||||||
<Avatar
|
<Thumbnail
|
||||||
v-for="user in usersList"
|
v-for="user in usersList"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
v-tooltip="user.name"
|
v-tooltip="user.name"
|
||||||
:title="user.name"
|
:title="user.name"
|
||||||
:src="user.thumbnail"
|
:src="user.thumbnail"
|
||||||
:name="user.name"
|
:username="user.name"
|
||||||
|
has-border
|
||||||
:size="size"
|
:size="size"
|
||||||
class="[&>span]:outline [&>span]:outline-1 [&>span]:outline-n-background [&>span]:shadow"
|
:class="`overlapping-thumbnail gap-${gap}`"
|
||||||
:class="gapClass"
|
|
||||||
rounded-full
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span v-if="showMoreThumbnailsCount" class="thumbnail-more-text">
|
||||||
v-if="showMoreThumbnailsCount"
|
|
||||||
class="text-n-slate-11 bg-n-slate-4 outline outline-1 outline-n-background text-xs font-medium rounded-full px-2 inline-flex items-center shadow relative"
|
|
||||||
:class="moreThumbnailsClass"
|
|
||||||
>
|
|
||||||
{{ moreThumbnailsText }}
|
{{ moreThumbnailsText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.overlapping-thumbnails {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlapping-thumbnail {
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-left: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-tight {
|
||||||
|
margin-left: -0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-more-text {
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
@apply text-n-slate-11 bg-n-slate-4 border border-n-weak text-xs font-medium rounded-full px-2 ltr:-ml-2 rtl:-mr-2 inline-flex items-center relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
user: {
|
user: {
|
||||||
@@ -7,8 +7,8 @@ defineProps({
|
|||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
type: Number,
|
type: String,
|
||||||
default: 20,
|
default: '20px',
|
||||||
},
|
},
|
||||||
textClass: {
|
textClass: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -19,13 +19,11 @@ defineProps({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-1.5 text-left">
|
<div class="flex items-center gap-1.5 text-left">
|
||||||
<Avatar
|
<Thumbnail
|
||||||
:src="user.thumbnail"
|
:src="user.thumbnail"
|
||||||
:size="size"
|
:size="size"
|
||||||
:name="user.name"
|
:username="user.name"
|
||||||
:status="user.availability_status"
|
:status="user.availability_status"
|
||||||
hide-offline-status
|
|
||||||
rounded-full
|
|
||||||
/>
|
/>
|
||||||
<span class="my-0 truncate text-capitalize" :class="textClass">
|
<span class="my-0 truncate text-capitalize" :class="textClass">
|
||||||
{{ user.name }}
|
{{ user.name }}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup>
|
<script>
|
||||||
import { computed, ref } from 'vue';
|
import { mapGetters } from 'vuex';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
|
||||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import Thumbnail from '../Thumbnail.vue';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
|
||||||
import MessagePreview from './MessagePreview.vue';
|
import MessagePreview from './MessagePreview.vue';
|
||||||
|
import router from '../../../routes';
|
||||||
|
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
|
||||||
import InboxName from '../InboxName.vue';
|
import InboxName from '../InboxName.vue';
|
||||||
|
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||||
@@ -14,227 +14,257 @@ import PriorityMark from './PriorityMark.vue';
|
|||||||
import SLACardLabel from './components/SLACardLabel.vue';
|
import SLACardLabel from './components/SLACardLabel.vue';
|
||||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
export default {
|
||||||
activeLabel: { type: String, default: '' },
|
components: {
|
||||||
chat: { type: Object, default: () => ({}) },
|
CardLabels,
|
||||||
hideInboxName: { type: Boolean, default: false },
|
InboxName,
|
||||||
hideThumbnail: { type: Boolean, default: false },
|
Thumbnail,
|
||||||
teamId: { type: [String, Number], default: 0 },
|
ConversationContextMenu,
|
||||||
foldersId: { type: [String, Number], default: 0 },
|
TimeAgo,
|
||||||
showAssignee: { type: Boolean, default: false },
|
MessagePreview,
|
||||||
conversationType: { type: String, default: '' },
|
PriorityMark,
|
||||||
selected: { type: Boolean, default: false },
|
SLACardLabel,
|
||||||
compact: { type: Boolean, default: false },
|
ContextMenu,
|
||||||
enableContextMenu: { type: Boolean, default: false },
|
},
|
||||||
allowedContextMenuOptions: { type: Array, default: () => [] },
|
mixins: [inboxMixin],
|
||||||
});
|
props: {
|
||||||
|
activeLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
hideInboxName: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
hideThumbnail: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
teamId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
foldersId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
showAssignee: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
conversationType: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
enableContextMenu: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
allowedContextMenuOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'contextMenuToggle',
|
||||||
|
'assignAgent',
|
||||||
|
'assignLabel',
|
||||||
|
'assignTeam',
|
||||||
|
'markAsUnread',
|
||||||
|
'markAsRead',
|
||||||
|
'assignPriority',
|
||||||
|
'updateConversationStatus',
|
||||||
|
'deleteConversation',
|
||||||
|
],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hovered: false,
|
||||||
|
showContextMenu: false,
|
||||||
|
contextMenu: {
|
||||||
|
x: null,
|
||||||
|
y: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
currentChat: 'getSelectedChat',
|
||||||
|
inboxesList: 'inboxes/getInboxes',
|
||||||
|
activeInbox: 'getSelectedInbox',
|
||||||
|
accountId: 'getCurrentAccountId',
|
||||||
|
}),
|
||||||
|
chatMetadata() {
|
||||||
|
return this.chat.meta || {};
|
||||||
|
},
|
||||||
|
|
||||||
const emit = defineEmits([
|
assignee() {
|
||||||
'contextMenuToggle',
|
return this.chatMetadata.assignee || {};
|
||||||
'assignAgent',
|
},
|
||||||
'assignLabel',
|
|
||||||
'assignTeam',
|
|
||||||
'markAsUnread',
|
|
||||||
'markAsRead',
|
|
||||||
'assignPriority',
|
|
||||||
'updateConversationStatus',
|
|
||||||
'deleteConversation',
|
|
||||||
'selectConversation',
|
|
||||||
'deSelectConversation',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const router = useRouter();
|
currentContact() {
|
||||||
const store = useStore();
|
return this.$store.getters['contacts/getContact'](
|
||||||
|
this.chatMetadata.sender.id
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
const hovered = ref(false);
|
isActiveChat() {
|
||||||
const showContextMenu = ref(false);
|
return this.currentChat.id === this.chat.id;
|
||||||
const contextMenu = ref({
|
},
|
||||||
x: null,
|
|
||||||
y: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentChat = useMapGetter('getSelectedChat');
|
unreadCount() {
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
return this.chat.unread_count;
|
||||||
const activeInbox = useMapGetter('getSelectedInbox');
|
},
|
||||||
const accountId = useMapGetter('getCurrentAccountId');
|
|
||||||
|
|
||||||
const chatMetadata = computed(() => props.chat.meta || {});
|
hasUnread() {
|
||||||
|
return this.unreadCount > 0;
|
||||||
|
},
|
||||||
|
|
||||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
isInboxNameVisible() {
|
||||||
|
return !this.activeInbox;
|
||||||
|
},
|
||||||
|
|
||||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
lastMessageInChat() {
|
||||||
|
return getLastMessage(this.chat);
|
||||||
|
},
|
||||||
|
|
||||||
const currentContact = computed(() => {
|
inbox() {
|
||||||
return senderId.value
|
const { inbox_id: inboxId } = this.chat;
|
||||||
? store.getters['contacts/getContact'](senderId.value)
|
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
|
||||||
: {};
|
return stateInbox;
|
||||||
});
|
},
|
||||||
|
|
||||||
const isActiveChat = computed(() => {
|
showInboxName() {
|
||||||
return currentChat.value.id === props.chat.id;
|
return (
|
||||||
});
|
!this.hideInboxName &&
|
||||||
|
this.isInboxNameVisible &&
|
||||||
|
this.inboxesList.length > 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
inboxName() {
|
||||||
|
const stateInbox = this.inbox;
|
||||||
|
return stateInbox.name || '';
|
||||||
|
},
|
||||||
|
hasSlaPolicyId() {
|
||||||
|
return this.chat?.sla_policy_id;
|
||||||
|
},
|
||||||
|
conversationPath() {
|
||||||
|
const { activeInbox, chat } = this;
|
||||||
|
return frontendURL(
|
||||||
|
conversationUrl({
|
||||||
|
accountId: this.accountId,
|
||||||
|
activeInbox,
|
||||||
|
id: chat.id,
|
||||||
|
label: this.activeLabel,
|
||||||
|
teamId: this.teamId,
|
||||||
|
foldersId: this.foldersId,
|
||||||
|
conversationType: this.conversationType,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onCardClick(e) {
|
||||||
|
const path = this.conversationPath;
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
const unreadCount = computed(() => props.chat.unread_count);
|
// Handle Ctrl/Cmd + Click for new tab
|
||||||
|
if (e.metaKey || e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.open(
|
||||||
|
`${window.chatwootConfig.hostURL}${path}`,
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hasUnread = computed(() => unreadCount.value > 0);
|
// Skip if already active
|
||||||
|
if (this.isActiveChat) return;
|
||||||
|
|
||||||
const isInboxNameVisible = computed(() => !activeInbox.value);
|
router.push({ path });
|
||||||
|
},
|
||||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
onThumbnailHover() {
|
||||||
|
this.hovered = !this.hideThumbnail;
|
||||||
const inboxId = computed(() => props.chat.inbox_id);
|
},
|
||||||
|
onThumbnailLeave() {
|
||||||
const inbox = computed(() => {
|
this.hovered = false;
|
||||||
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
|
},
|
||||||
});
|
onSelectConversation(checked) {
|
||||||
|
const action = checked ? 'selectConversation' : 'deSelectConversation';
|
||||||
const showInboxName = computed(() => {
|
this.$emit(action, this.chat.id, this.inbox.id);
|
||||||
return (
|
},
|
||||||
!props.hideInboxName &&
|
openContextMenu(e) {
|
||||||
isInboxNameVisible.value &&
|
if (!this.enableContextMenu) return;
|
||||||
inboxesList.value.length > 1
|
e.preventDefault();
|
||||||
);
|
this.$emit('contextMenuToggle', true);
|
||||||
});
|
this.contextMenu.x = e.pageX || e.clientX;
|
||||||
|
this.contextMenu.y = e.pageY || e.clientY;
|
||||||
const showMetaSection = computed(() => {
|
this.showContextMenu = true;
|
||||||
return (
|
},
|
||||||
showInboxName.value ||
|
closeContextMenu() {
|
||||||
(props.showAssignee && assignee.value.name) ||
|
this.$emit('contextMenuToggle', false);
|
||||||
props.chat.priority
|
this.showContextMenu = false;
|
||||||
);
|
this.contextMenu.x = null;
|
||||||
});
|
this.contextMenu.y = null;
|
||||||
|
},
|
||||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
onUpdateConversation(status, snoozedUntil) {
|
||||||
|
this.closeContextMenu();
|
||||||
const showLabelsSection = computed(() => {
|
this.$emit(
|
||||||
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
|
'updateConversationStatus',
|
||||||
});
|
this.chat.id,
|
||||||
|
status,
|
||||||
const messagePreviewClass = computed(() => {
|
snoozedUntil
|
||||||
return [
|
);
|
||||||
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
|
},
|
||||||
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
|
async onAssignAgent(agent) {
|
||||||
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
|
this.$emit('assignAgent', agent, [this.chat.id]);
|
||||||
];
|
this.closeContextMenu();
|
||||||
});
|
},
|
||||||
|
async onAssignLabel(label) {
|
||||||
const conversationPath = computed(() => {
|
this.$emit('assignLabel', [label.title], [this.chat.id]);
|
||||||
return frontendURL(
|
this.closeContextMenu();
|
||||||
conversationUrl({
|
},
|
||||||
accountId: accountId.value,
|
async onAssignTeam(team) {
|
||||||
activeInbox: activeInbox.value,
|
this.$emit('assignTeam', team, this.chat.id);
|
||||||
id: props.chat.id,
|
this.closeContextMenu();
|
||||||
label: props.activeLabel,
|
},
|
||||||
teamId: props.teamId,
|
async markAsUnread() {
|
||||||
conversationType: props.conversationType,
|
this.$emit('markAsUnread', this.chat.id);
|
||||||
foldersId: props.foldersId,
|
this.closeContextMenu();
|
||||||
})
|
},
|
||||||
);
|
async markAsRead() {
|
||||||
});
|
this.$emit('markAsRead', this.chat.id);
|
||||||
|
this.closeContextMenu();
|
||||||
const onCardClick = e => {
|
},
|
||||||
const path = conversationPath.value;
|
async assignPriority(priority) {
|
||||||
if (!path) return;
|
this.$emit('assignPriority', priority, this.chat.id);
|
||||||
|
this.closeContextMenu();
|
||||||
// Handle Ctrl/Cmd + Click for new tab
|
},
|
||||||
if (e.metaKey || e.ctrlKey) {
|
async deleteConversation() {
|
||||||
e.preventDefault();
|
this.$emit('deleteConversation', this.chat.id);
|
||||||
window.open(
|
this.closeContextMenu();
|
||||||
`${window.chatwootConfig.hostURL}${path}`,
|
},
|
||||||
'_blank',
|
},
|
||||||
'noopener,noreferrer'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if already active
|
|
||||||
if (isActiveChat.value) return;
|
|
||||||
|
|
||||||
router.push({ path });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onThumbnailHover = () => {
|
|
||||||
hovered.value = !props.hideThumbnail;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onThumbnailLeave = () => {
|
|
||||||
hovered.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSelectConversation = checked => {
|
|
||||||
if (checked) {
|
|
||||||
emit('selectConversation', props.chat.id, inbox.value.id);
|
|
||||||
} else {
|
|
||||||
emit('deSelectConversation', props.chat.id, inbox.value.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openContextMenu = e => {
|
|
||||||
if (!props.enableContextMenu) return;
|
|
||||||
e.preventDefault();
|
|
||||||
emit('contextMenuToggle', true);
|
|
||||||
contextMenu.value.x = e.pageX || e.clientX;
|
|
||||||
contextMenu.value.y = e.pageY || e.clientY;
|
|
||||||
showContextMenu.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeContextMenu = () => {
|
|
||||||
emit('contextMenuToggle', false);
|
|
||||||
showContextMenu.value = false;
|
|
||||||
contextMenu.value.x = null;
|
|
||||||
contextMenu.value.y = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUpdateConversation = (status, snoozedUntil) => {
|
|
||||||
closeContextMenu();
|
|
||||||
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAssignAgent = agent => {
|
|
||||||
emit('assignAgent', agent, [props.chat.id]);
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAssignLabel = label => {
|
|
||||||
emit('assignLabel', [label.title], [props.chat.id]);
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAssignTeam = team => {
|
|
||||||
emit('assignTeam', team, props.chat.id);
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const markAsUnread = () => {
|
|
||||||
emit('markAsUnread', props.chat.id);
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const markAsRead = () => {
|
|
||||||
emit('markAsRead', props.chat.id);
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const assignPriority = priority => {
|
|
||||||
emit('assignPriority', priority, props.chat.id);
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteConversation = () => {
|
|
||||||
emit('deleteConversation', props.chat.id);
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||||
:class="{
|
:class="{
|
||||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
||||||
isActiveChat,
|
isActiveChat,
|
||||||
'bg-n-slate-2 dark:bg-n-slate-3': selected,
|
'unread-chat': hasUnread,
|
||||||
'px-0': compact,
|
'has-inbox-name': showInboxName,
|
||||||
'px-3': !compact,
|
'conversation-selected': selected,
|
||||||
}"
|
}"
|
||||||
@click="onCardClick"
|
@click="onCardClick"
|
||||||
@contextmenu="openContextMenu($event)"
|
@contextmenu="openContextMenu($event)"
|
||||||
@@ -244,65 +274,46 @@ const deleteConversation = () => {
|
|||||||
@mouseenter="onThumbnailHover"
|
@mouseenter="onThumbnailHover"
|
||||||
@mouseleave="onThumbnailLeave"
|
@mouseleave="onThumbnailLeave"
|
||||||
>
|
>
|
||||||
<Avatar
|
<label
|
||||||
v-if="!hideThumbnail"
|
v-if="hovered || selected"
|
||||||
:name="currentContact.name"
|
class="checkbox-wrapper absolute inset-0 z-20 backdrop-blur-[2px]"
|
||||||
:src="currentContact.thumbnail"
|
@click.stop
|
||||||
:size="32"
|
|
||||||
:status="currentContact.availability_status"
|
|
||||||
:inbox="inbox"
|
|
||||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
|
||||||
hide-offline-status
|
|
||||||
rounded-full
|
|
||||||
>
|
>
|
||||||
<template #overlay="{ size }">
|
<input
|
||||||
<label
|
:value="selected"
|
||||||
v-if="hovered || selected"
|
:checked="selected"
|
||||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
|
class="checkbox"
|
||||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
type="checkbox"
|
||||||
@click.stop
|
@change="onSelectConversation($event.target.checked)"
|
||||||
>
|
/>
|
||||||
<input
|
</label>
|
||||||
:value="selected"
|
<Thumbnail
|
||||||
:checked="selected"
|
v-if="!hideThumbnail"
|
||||||
class="!m-0 cursor-pointer"
|
:src="currentContact.thumbnail"
|
||||||
type="checkbox"
|
:badge="inboxBadge"
|
||||||
@change="onSelectConversation($event.target.checked)"
|
:username="currentContact.name"
|
||||||
/>
|
:status="currentContact.availability_status"
|
||||||
</label>
|
size="32px"
|
||||||
</template>
|
/>
|
||||||
</Avatar>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
|
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
|
||||||
>
|
>
|
||||||
<div
|
<div class="flex justify-between conversation-card--meta">
|
||||||
v-if="showMetaSection"
|
<InboxName v-if="showInboxName" :inbox="inbox" />
|
||||||
class="flex items-center min-w-0 gap-1"
|
<div class="flex gap-2 ml-2 rtl:mr-2 rtl:ml-0">
|
||||||
:class="{
|
|
||||||
'ltr:ml-2 rtl:mr-2': !compact,
|
|
||||||
'mx-2': compact,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 flex-shrink-0"
|
|
||||||
:class="{
|
|
||||||
'flex-1 justify-between': !showInboxName,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
v-if="showAssignee && assignee.name"
|
v-if="showAssignee && assignee.name"
|
||||||
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
|
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex text-ellipsis overflow-hidden whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
||||||
{{ assignee.name }}
|
{{ assignee.name }}
|
||||||
</span>
|
</span>
|
||||||
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
|
<PriorityMark :priority="chat.priority" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h4
|
<h4
|
||||||
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
|
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-70px)] text-n-slate-12"
|
||||||
:class="hasUnread ? 'font-semibold' : 'font-medium'"
|
:class="hasUnread ? 'font-semibold' : 'font-medium'"
|
||||||
>
|
>
|
||||||
{{ currentContact.name }}
|
{{ currentContact.name }}
|
||||||
@@ -310,27 +321,24 @@ const deleteConversation = () => {
|
|||||||
<MessagePreview
|
<MessagePreview
|
||||||
v-if="lastMessageInChat"
|
v-if="lastMessageInChat"
|
||||||
:message="lastMessageInChat"
|
:message="lastMessageInChat"
|
||||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
|
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
|
||||||
:class="messagePreviewClass"
|
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
v-else
|
v-else
|
||||||
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
class="conversation--message text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
:class="messagePreviewClass"
|
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
|
||||||
>
|
>
|
||||||
<fluent-icon
|
<fluent-icon
|
||||||
size="16"
|
size="16"
|
||||||
class="-mt-0.5 align-middle inline-block text-n-slate-10"
|
class="-mt-0.5 align-middle inline-block text-n-slate-10"
|
||||||
icon="info"
|
icon="info"
|
||||||
/>
|
/>
|
||||||
<span class="mx-0.5">
|
<span>
|
||||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div class="absolute flex flex-col mt-4 ltr:right-4 rtl:left-4 top-4">
|
||||||
class="absolute flex flex-col ltr:right-3 rtl:left-3"
|
|
||||||
:class="showMetaSection ? 'top-8' : 'top-4'"
|
|
||||||
>
|
|
||||||
<span class="ml-auto font-normal leading-4 text-xxs">
|
<span class="ml-auto font-normal leading-4 text-xxs">
|
||||||
<TimeAgo
|
<TimeAgo
|
||||||
:last-activity-timestamp="chat.timestamp"
|
:last-activity-timestamp="chat.timestamp"
|
||||||
@@ -338,17 +346,12 @@ const deleteConversation = () => {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
class="unread shadow-lg rounded-full hidden text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||||
:class="hasUnread ? 'block' : 'hidden'"
|
|
||||||
>
|
>
|
||||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CardLabels
|
<CardLabels :conversation-labels="chat.labels" class="mt-0.5 mx-2 mb-0">
|
||||||
v-if="showLabelsSection"
|
|
||||||
:conversation-labels="chat.labels"
|
|
||||||
class="mt-0.5 mx-2 mb-0"
|
|
||||||
>
|
|
||||||
<template v-if="hasSlaPolicyId" #before>
|
<template v-if="hasSlaPolicyId" #before>
|
||||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||||
</template>
|
</template>
|
||||||
@@ -381,3 +384,55 @@ const deleteConversation = () => {
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.conversation {
|
||||||
|
&.unread-chat {
|
||||||
|
.unread {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
@apply pl-0;
|
||||||
|
|
||||||
|
.conversation-card--meta {
|
||||||
|
@apply ltr:pr-4 rtl:pl-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation--details {
|
||||||
|
@apply rounded-sm ml-0 pl-5 pr-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::v-deep .user-thumbnail-box {
|
||||||
|
@apply mt-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.conversation-selected {
|
||||||
|
@apply bg-n-slate-2 dark:bg-n-slate-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-inbox-name {
|
||||||
|
&::v-deep .user-thumbnail-box {
|
||||||
|
@apply mt-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper {
|
||||||
|
@apply mt-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation--meta {
|
||||||
|
@apply mt-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper {
|
||||||
|
@apply flex items-center justify-center rounded-full cursor-pointer mt-4;
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
@apply m-0 cursor-pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useElementSize } from '@vueuse/core';
|
|||||||
import BackButton from '../BackButton.vue';
|
import BackButton from '../BackButton.vue';
|
||||||
import InboxName from '../InboxName.vue';
|
import InboxName from '../InboxName.vue';
|
||||||
import MoreActions from './MoreActions.vue';
|
import MoreActions from './MoreActions.vue';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Thumbnail from '../Thumbnail.vue';
|
||||||
import SLACardLabel from './components/SLACardLabel.vue';
|
import SLACardLabel from './components/SLACardLabel.vue';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||||
@@ -105,13 +105,12 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
|||||||
:back-url="backButtonUrl"
|
:back-url="backButtonUrl"
|
||||||
class="ltr:mr-2 rtl:ml-2"
|
class="ltr:mr-2 rtl:ml-2"
|
||||||
/>
|
/>
|
||||||
<Avatar
|
<Thumbnail
|
||||||
:name="currentContact.name"
|
|
||||||
:src="currentContact.thumbnail"
|
:src="currentContact.thumbnail"
|
||||||
:size="32"
|
:username="currentContact.name"
|
||||||
:status="currentContact.availability_status"
|
:status="currentContact.availability_status"
|
||||||
hide-offline-status
|
size="32px"
|
||||||
rounded-full
|
class="flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
|
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
|
||||||
|
|||||||
+143
-36
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script>
|
||||||
/**
|
/**
|
||||||
* This component handles parsing and sending WhatsApp message templates.
|
* This component handles parsing and sending WhatsApp message templates.
|
||||||
* It works as follows:
|
* It works as follows:
|
||||||
@@ -8,51 +8,158 @@
|
|||||||
* 4. Replaces placeholders with user-provided values.
|
* 4. Replaces placeholders with user-provided values.
|
||||||
* 5. Emits events to send the processed message or reset the template.
|
* 5. Emits events to send the processed message or reset the template.
|
||||||
*/
|
*/
|
||||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { requiredIf } from '@vuelidate/validators';
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
defineProps({
|
export default {
|
||||||
template: {
|
components: {
|
||||||
type: Object,
|
NextButton,
|
||||||
default: () => ({}),
|
|
||||||
},
|
},
|
||||||
});
|
props: {
|
||||||
|
template: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['sendMessage', 'resetTemplate'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const processVariable = str => {
|
||||||
|
return str.replace(/{{|}}/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
const emit = defineEmits(['sendMessage', 'resetTemplate']);
|
const allKeysRequired = value => {
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
return keys.every(key => value[key]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSendMessage = payload => {
|
const processedParams = ref({});
|
||||||
emit('sendMessage', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetTemplate = () => {
|
const templateString = computed(() => {
|
||||||
emit('resetTemplate');
|
return props.template.components.find(
|
||||||
|
component => component.type === 'BODY'
|
||||||
|
).text;
|
||||||
|
});
|
||||||
|
|
||||||
|
const variables = computed(() => {
|
||||||
|
return templateString.value.match(/{{([^}]+)}}/g);
|
||||||
|
});
|
||||||
|
|
||||||
|
const processedString = computed(() => {
|
||||||
|
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||||
|
const variableKey = processVariable(variable);
|
||||||
|
return processedParams.value[variableKey] || `{{${variable}}}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const v$ = useVuelidate(
|
||||||
|
{
|
||||||
|
processedParams: {
|
||||||
|
requiredIfKeysPresent: requiredIf(variables),
|
||||||
|
allKeysRequired,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ processedParams }
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateVariables = () => {
|
||||||
|
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
||||||
|
if (!matchedVariables) return;
|
||||||
|
|
||||||
|
const finalVars = matchedVariables.map(i => processVariable(i));
|
||||||
|
processedParams.value = finalVars.reduce((acc, variable) => {
|
||||||
|
acc[variable] = '';
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTemplate = () => {
|
||||||
|
emit('resetTemplate');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
v$.value.$touch();
|
||||||
|
if (v$.value.$invalid) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
message: processedString.value,
|
||||||
|
templateParams: {
|
||||||
|
name: props.template.name,
|
||||||
|
category: props.template.category,
|
||||||
|
language: props.template.language,
|
||||||
|
namespace: props.template.namespace,
|
||||||
|
processed_params: processedParams.value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
emit('sendMessage', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(generateVariables);
|
||||||
|
|
||||||
|
return {
|
||||||
|
processedParams,
|
||||||
|
variables,
|
||||||
|
templateString,
|
||||||
|
processedString,
|
||||||
|
v$,
|
||||||
|
resetTemplate,
|
||||||
|
sendMessage,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<WhatsAppTemplateParser
|
<textarea
|
||||||
:template="template"
|
v-model="processedString"
|
||||||
@send-message="handleSendMessage"
|
rows="4"
|
||||||
@reset-template="handleResetTemplate"
|
readonly
|
||||||
>
|
class="template-input"
|
||||||
<template #actions="{ sendMessage, resetTemplate, disabled }">
|
/>
|
||||||
<footer class="flex gap-2 justify-end">
|
<div v-if="variables" class="p-2.5">
|
||||||
<NextButton
|
<p class="text-sm font-semibold mb-2.5">
|
||||||
faded
|
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||||
slate
|
</p>
|
||||||
type="reset"
|
<div
|
||||||
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
v-for="(variable, key) in processedParams"
|
||||||
@click="resetTemplate"
|
:key="key"
|
||||||
/>
|
class="items-center flex mb-2.5"
|
||||||
<NextButton
|
>
|
||||||
type="button"
|
<span
|
||||||
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
class="bg-n-alpha-black2 text-n-slate-12 inline-block rounded-md text-xs py-2.5 px-6"
|
||||||
:disabled="disabled"
|
>
|
||||||
@click="sendMessage"
|
{{ key }}
|
||||||
/>
|
</span>
|
||||||
</footer>
|
<woot-input
|
||||||
</template>
|
v-model="processedParams[key]"
|
||||||
</WhatsAppTemplateParser>
|
type="text"
|
||||||
|
class="flex-1 text-sm ml-2.5"
|
||||||
|
:styles="{ marginBottom: 0 }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="v$.$dirty && v$.$invalid"
|
||||||
|
class="bg-n-ruby-9/20 rounded-md text-n-ruby-9 p-2.5 text-center"
|
||||||
|
>
|
||||||
|
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<footer class="flex justify-end gap-2">
|
||||||
|
<NextButton
|
||||||
|
faded
|
||||||
|
slate
|
||||||
|
type="reset"
|
||||||
|
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
||||||
|
@click="resetTemplate"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
type="button"
|
||||||
|
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
||||||
|
@click="sendMessage"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+66
-129
@@ -1,71 +1,60 @@
|
|||||||
<script setup>
|
<script>
|
||||||
import { ref, computed, toRef } from 'vue';
|
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
|
|
||||||
import {
|
|
||||||
COMPONENT_TYPES,
|
|
||||||
MEDIA_FORMATS,
|
|
||||||
findComponentByType,
|
|
||||||
} from 'dashboard/helper/templateHelper';
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
// TODO: Remove this when we support all formats
|
||||||
|
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
||||||
|
|
||||||
const props = defineProps({
|
export default {
|
||||||
inboxId: {
|
components: {
|
||||||
type: Number,
|
Icon,
|
||||||
default: undefined,
|
},
|
||||||
|
props: {
|
||||||
|
inboxId: {
|
||||||
|
type: Number,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['onSelect'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
query: '',
|
||||||
|
isRefreshing: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
whatsAppTemplateMessages() {
|
||||||
|
// TODO: Remove the last filter when we support all formats
|
||||||
|
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
|
||||||
|
.filter(template => template.status.toLowerCase() === 'approved')
|
||||||
|
.filter(template => {
|
||||||
|
return template.components.every(component => {
|
||||||
|
return !formatsToRemove.includes(component.format);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
filteredTemplateMessages() {
|
||||||
|
return this.whatsAppTemplateMessages.filter(template =>
|
||||||
|
template.name.toLowerCase().includes(this.query.toLowerCase())
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getTemplatebody(template) {
|
||||||
|
return template.components.find(component => component.type === 'BODY')
|
||||||
|
.text;
|
||||||
|
},
|
||||||
|
async refreshTemplates() {
|
||||||
|
this.isRefreshing = true;
|
||||||
|
try {
|
||||||
|
await this.$store.dispatch('inboxes/syncTemplates', this.inboxId);
|
||||||
|
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
|
||||||
|
} finally {
|
||||||
|
this.isRefreshing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['onSelect']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const store = useStore();
|
|
||||||
const query = ref('');
|
|
||||||
const isRefreshing = ref(false);
|
|
||||||
|
|
||||||
const whatsAppTemplateMessages = useFunctionGetter(
|
|
||||||
'inboxes/getFilteredWhatsAppTemplates',
|
|
||||||
toRef(props, 'inboxId')
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredTemplateMessages = computed(() =>
|
|
||||||
whatsAppTemplateMessages.value.filter(template =>
|
|
||||||
template.name.toLowerCase().includes(query.value.toLowerCase())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const getTemplateBody = template => {
|
|
||||||
return findComponentByType(template, COMPONENT_TYPES.BODY)?.text || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTemplateHeader = template => {
|
|
||||||
return findComponentByType(template, COMPONENT_TYPES.HEADER);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTemplateFooter = template => {
|
|
||||||
return findComponentByType(template, COMPONENT_TYPES.FOOTER);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTemplateButtons = template => {
|
|
||||||
return findComponentByType(template, COMPONENT_TYPES.BUTTONS);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasMediaContent = template => {
|
|
||||||
const header = getTemplateHeader(template);
|
|
||||||
return header && MEDIA_FORMATS.includes(header.format);
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshTemplates = async () => {
|
|
||||||
isRefreshing.value = true;
|
|
||||||
try {
|
|
||||||
await store.dispatch('inboxes/syncTemplates', props.inboxId);
|
|
||||||
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
|
||||||
} catch (error) {
|
|
||||||
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
|
|
||||||
} finally {
|
|
||||||
isRefreshing.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,14 +68,14 @@ const refreshTemplates = async () => {
|
|||||||
<input
|
<input
|
||||||
v-model="query"
|
v-model="query"
|
||||||
type="search"
|
type="search"
|
||||||
:placeholder="t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||||
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
:disabled="isRefreshing"
|
:disabled="isRefreshing"
|
||||||
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
:title="t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
:title="$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||||
@click="refreshTemplates"
|
@click="refreshTemplates"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -102,7 +91,7 @@ const refreshTemplates = async () => {
|
|||||||
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
|
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
|
||||||
<button
|
<button
|
||||||
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
||||||
@click="emit('onSelect', template)"
|
@click="$emit('onSelect', template)"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between items-center mb-2.5">
|
<div class="flex justify-between items-center mb-2.5">
|
||||||
@@ -112,73 +101,21 @@ const refreshTemplates = async () => {
|
|||||||
<span
|
<span
|
||||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||||
>
|
>
|
||||||
{{ t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }}:
|
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
|
||||||
{{ template.language }}
|
{{ template.language }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Header -->
|
|
||||||
<div v-if="getTemplateHeader(template)" class="mb-3">
|
|
||||||
<p class="text-xs font-medium text-n-slate-11">
|
|
||||||
{{ t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-if="getTemplateHeader(template).format === 'TEXT'"
|
|
||||||
class="text-sm label-body"
|
|
||||||
>
|
|
||||||
{{ getTemplateHeader(template).text }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="hasMediaContent(template)"
|
|
||||||
class="text-sm italic text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
|
|
||||||
format: getTemplateHeader(template).format,
|
|
||||||
}) ||
|
|
||||||
`${getTemplateHeader(template).format} ${t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT_FALLBACK')}`
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-medium text-n-slate-11">
|
<p class="font-medium">
|
||||||
{{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
|
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm label-body">{{ getTemplateBody(template) }}</p>
|
<p class="label-body">{{ getTemplatebody(template) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
<!-- Footer -->
|
<p class="font-medium">
|
||||||
<div v-if="getTemplateFooter(template)" class="mt-3">
|
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
||||||
<p class="text-xs font-medium text-n-slate-11">
|
|
||||||
{{ t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
|
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm label-body">
|
<p>{{ template.category }}</p>
|
||||||
{{ getTemplateFooter(template).text }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Buttons -->
|
|
||||||
<div v-if="getTemplateButtons(template)" class="mt-3">
|
|
||||||
<p class="text-xs font-medium text-n-slate-11">
|
|
||||||
{{ t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
|
||||||
<span
|
|
||||||
v-for="button in getTemplateButtons(template).buttons"
|
|
||||||
:key="button.text"
|
|
||||||
class="px-2 py-1 text-xs rounded bg-n-slate-3 text-n-slate-12"
|
|
||||||
>
|
|
||||||
{{ button.text }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<p class="text-xs font-medium text-n-slate-11">
|
|
||||||
{{ t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm">{{ template.category }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -191,13 +128,13 @@ const refreshTemplates = async () => {
|
|||||||
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
||||||
<div v-if="query && whatsAppTemplateMessages.length">
|
<div v-if="query && whatsAppTemplateMessages.length">
|
||||||
<p>
|
<p>
|
||||||
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||||
<strong>{{ query }}</strong>
|
<strong>{{ query }}</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
|
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
|
||||||
<p class="text-n-slate-11">
|
<p class="text-n-slate-11">
|
||||||
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { messageTimestamp } from 'shared/helpers/timeHelper';
|
|||||||
import { downloadFile } from '@chatwoot/utils';
|
import { downloadFile } from '@chatwoot/utils';
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -186,12 +186,10 @@ onMounted(() => {
|
|||||||
v-if="senderDetails"
|
v-if="senderDetails"
|
||||||
class="flex items-center min-w-[15rem] shrink-0"
|
class="flex items-center min-w-[15rem] shrink-0"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Thumbnail
|
||||||
v-if="senderDetails.avatar"
|
v-if="senderDetails.avatar"
|
||||||
:name="senderDetails.name"
|
:username="senderDetails.name"
|
||||||
:src="senderDetails.avatar"
|
:src="senderDetails.avatar"
|
||||||
:size="40"
|
|
||||||
rounded-full
|
|
||||||
class="flex-shrink-0"
|
class="flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
|
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
|
||||||
|
|||||||
+13
-6
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
// components
|
// components
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from '../../Avatar.vue';
|
||||||
|
|
||||||
// composables
|
// composables
|
||||||
import { useAI } from 'dashboard/composables/useAI';
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
@@ -226,16 +226,18 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sender--info has-tooltip" data-original-title="null">
|
<div class="sender--info has-tooltip" data-original-title="null">
|
||||||
<Avatar
|
<woot-thumbnail
|
||||||
v-tooltip.top="{
|
v-tooltip.top="{
|
||||||
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
|
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
|
||||||
delay: { show: 600, hide: 0 },
|
delay: { show: 600, hide: 0 },
|
||||||
hideOnClick: true,
|
hideOnClick: true,
|
||||||
}"
|
}"
|
||||||
:size="16"
|
size="16px"
|
||||||
name="chatwoot-ai"
|
>
|
||||||
icon-name="i-lucide-sparkles"
|
<Avatar class="user-thumbnail thumbnail-rounded">
|
||||||
/>
|
<fluent-icon class="chatwoot-ai-icon" icon="chatwoot-ai" />
|
||||||
|
</Avatar>
|
||||||
|
</woot-thumbnail>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -266,6 +268,11 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatwoot-ai-icon {
|
||||||
|
height: 0.75rem;
|
||||||
|
width: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.label-suggestion--title {
|
.label-suggestion--title {
|
||||||
@apply text-n-slate-11 mt-0.5 text-xxs;
|
@apply text-n-slate-11 mt-0.5 text-xxs;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-7
@@ -1,12 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||||
import Spinner from 'shared/components/Spinner.vue';
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Avatar,
|
Thumbnail,
|
||||||
Spinner,
|
Spinner,
|
||||||
NextButton,
|
NextButton,
|
||||||
},
|
},
|
||||||
@@ -123,13 +123,11 @@ export default {
|
|||||||
</li>
|
</li>
|
||||||
<li v-for="agent in filteredAgents" :key="agent.id">
|
<li v-for="agent in filteredAgents" :key="agent.id">
|
||||||
<div class="agent-list-item" @click="assignAgent(agent)">
|
<div class="agent-list-item" @click="assignAgent(agent)">
|
||||||
<Avatar
|
<Thumbnail
|
||||||
:name="agent.name"
|
|
||||||
:src="agent.thumbnail"
|
:src="agent.thumbnail"
|
||||||
:status="agent.availability_status"
|
:status="agent.availability_status"
|
||||||
:size="22"
|
:username="agent.name"
|
||||||
hide-offline-status
|
size="22px"
|
||||||
rounded-full
|
|
||||||
/>
|
/>
|
||||||
<span class="my-0 text-n-slate-12">
|
<span class="my-0 text-n-slate-12">
|
||||||
{{ agent.name }}
|
{{ agent.name }}
|
||||||
|
|||||||
+1
-1
@@ -263,7 +263,7 @@ export default {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.bulk-action__container {
|
.bulk-action__container {
|
||||||
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
|
@apply p-4 relative border-b border-solid border-n-strong dark:border-n-weak;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-action__panel {
|
.bulk-action__panel {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script>
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
NextButton,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
usernameAvatar: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
deleteAvatar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['onAvatarSelect', 'onAvatarDelete'],
|
||||||
|
watch: {},
|
||||||
|
methods: {
|
||||||
|
handleImageUpload(event) {
|
||||||
|
const [file] = event.target.files;
|
||||||
|
|
||||||
|
this.$emit('onAvatarSelect', {
|
||||||
|
file,
|
||||||
|
url: file ? URL.createObjectURL(file) : null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onAvatarDelete() {
|
||||||
|
this.$refs.file.value = null;
|
||||||
|
this.$emit('onAvatarDelete');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<span v-if="label">{{ label }}</span>
|
||||||
|
</label>
|
||||||
|
<woot-thumbnail
|
||||||
|
v-if="src"
|
||||||
|
size="80px"
|
||||||
|
:src="src"
|
||||||
|
:username="usernameAvatar"
|
||||||
|
/>
|
||||||
|
<div v-if="src && deleteAvatar" class="my-1">
|
||||||
|
<NextButton
|
||||||
|
outline
|
||||||
|
xs
|
||||||
|
ruby
|
||||||
|
:label="$t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT')"
|
||||||
|
@click="onAvatarDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
id="file"
|
||||||
|
ref="file"
|
||||||
|
type="file"
|
||||||
|
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||||
|
@change="handleImageUpload"
|
||||||
|
/>
|
||||||
|
<slot />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -196,7 +196,6 @@ describe('useAutomation', () => {
|
|||||||
automationTypes.conversation_created = { conditions: [] };
|
automationTypes.conversation_created = { conditions: [] };
|
||||||
automationTypes.conversation_updated = { conditions: [] };
|
automationTypes.conversation_updated = { conditions: [] };
|
||||||
automationTypes.conversation_opened = { conditions: [] };
|
automationTypes.conversation_opened = { conditions: [] };
|
||||||
automationTypes.conversation_resolved = { conditions: [] };
|
|
||||||
|
|
||||||
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
|
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
|
||||||
automationHelper.generateCustomAttributes.mockReturnValue([]);
|
automationHelper.generateCustomAttributes.mockReturnValue([]);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const DEFAULT_MESSAGE_CREATED_CONDITION = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_CONVERSATION_CONDITION = [
|
export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
|
||||||
{
|
{
|
||||||
attribute_key: 'browser_language',
|
attribute_key: 'browser_language',
|
||||||
filter_operator: 'equal_to',
|
filter_operator: 'equal_to',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from 'dashboard/routes/dashboard/settings/automation/operators';
|
} from 'dashboard/routes/dashboard/settings/automation/operators';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MESSAGE_CREATED_CONDITION,
|
DEFAULT_MESSAGE_CREATED_CONDITION,
|
||||||
DEFAULT_CONVERSATION_CONDITION,
|
DEFAULT_CONVERSATION_OPENED_CONDITION,
|
||||||
DEFAULT_OTHER_CONDITION,
|
DEFAULT_OTHER_CONDITION,
|
||||||
DEFAULT_ACTIONS,
|
DEFAULT_ACTIONS,
|
||||||
} from 'dashboard/constants/automation';
|
} from 'dashboard/constants/automation';
|
||||||
@@ -169,11 +169,8 @@ export const getDefaultConditions = eventName => {
|
|||||||
if (eventName === 'message_created') {
|
if (eventName === 'message_created') {
|
||||||
return DEFAULT_MESSAGE_CREATED_CONDITION;
|
return DEFAULT_MESSAGE_CREATED_CONDITION;
|
||||||
}
|
}
|
||||||
if (
|
if (eventName === 'conversation_opened') {
|
||||||
eventName === 'conversation_opened' ||
|
return DEFAULT_CONVERSATION_OPENED_CONDITION;
|
||||||
eventName === 'conversation_resolved'
|
|
||||||
) {
|
|
||||||
return DEFAULT_CONVERSATION_CONDITION;
|
|
||||||
}
|
}
|
||||||
return DEFAULT_OTHER_CONDITION;
|
return DEFAULT_OTHER_CONDITION;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -139,14 +139,14 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInboxIconByType = (type, medium, variant = 'fill') => {
|
export const getInboxIconByType = (type, phoneNumber, variant = 'fill') => {
|
||||||
const iconMap =
|
const iconMap =
|
||||||
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
|
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
|
||||||
const defaultIcon =
|
const defaultIcon =
|
||||||
variant === 'fill' ? DEFAULT_ICON_FILL : DEFAULT_ICON_LINE;
|
variant === 'fill' ? DEFAULT_ICON_FILL : DEFAULT_ICON_LINE;
|
||||||
|
|
||||||
// Special case for Twilio (whatsapp and sms)
|
// Special case for Twilio (whatsapp and sms)
|
||||||
if (type === INBOX_TYPES.TWILIO && medium === 'whatsapp') {
|
if (type === INBOX_TYPES.TWILIO && phoneNumber?.startsWith('whatsapp')) {
|
||||||
return iconMap[INBOX_TYPES.WHATSAPP];
|
return iconMap[INBOX_TYPES.WHATSAPP];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,13 +112,13 @@ describe('#Inbox Helpers', () => {
|
|||||||
describe('Twilio cases', () => {
|
describe('Twilio cases', () => {
|
||||||
describe('fill variant', () => {
|
describe('fill variant', () => {
|
||||||
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
|
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
|
||||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
|
expect(
|
||||||
'i-ri-whatsapp-fill'
|
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp:+1234567890')
|
||||||
);
|
).toBe('i-ri-whatsapp-fill');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns SMS icon for regular Twilio number', () => {
|
it('returns SMS icon for regular Twilio number', () => {
|
||||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms')).toBe(
|
expect(getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890')).toBe(
|
||||||
'i-ri-chat-1-fill'
|
'i-ri-chat-1-fill'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -133,14 +133,18 @@ describe('#Inbox Helpers', () => {
|
|||||||
describe('line variant', () => {
|
describe('line variant', () => {
|
||||||
it('returns WhatsApp line icon for Twilio WhatsApp number', () => {
|
it('returns WhatsApp line icon for Twilio WhatsApp number', () => {
|
||||||
expect(
|
expect(
|
||||||
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp', 'line')
|
getInboxIconByType(
|
||||||
|
INBOX_TYPES.TWILIO,
|
||||||
|
'whatsapp:+1234567890',
|
||||||
|
'line'
|
||||||
|
)
|
||||||
).toBe('i-ri-whatsapp-line');
|
).toBe('i-ri-whatsapp-line');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns SMS line icon for regular Twilio number', () => {
|
it('returns SMS line icon for regular Twilio number', () => {
|
||||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line')).toBe(
|
expect(
|
||||||
'i-ri-chat-1-line'
|
getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890', 'line')
|
||||||
);
|
).toBe('i-ri-chat-1-line');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
import {
|
|
||||||
replaceTemplateVariables,
|
|
||||||
buildTemplateParameters,
|
|
||||||
processVariable,
|
|
||||||
allKeysRequired,
|
|
||||||
} from '../templateHelper';
|
|
||||||
import { templates } from '../../store/modules/specs/inboxes/templateFixtures';
|
|
||||||
|
|
||||||
describe('templateHelper', () => {
|
|
||||||
const technicianTemplate = templates.find(t => t.name === 'technician_visit');
|
|
||||||
|
|
||||||
describe('processVariable', () => {
|
|
||||||
it('should remove curly braces from variables', () => {
|
|
||||||
expect(processVariable('{{name}}')).toBe('name');
|
|
||||||
expect(processVariable('{{1}}')).toBe('1');
|
|
||||||
expect(processVariable('{{customer_id}}')).toBe('customer_id');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('allKeysRequired', () => {
|
|
||||||
it('should return true when all keys have values', () => {
|
|
||||||
const obj = { name: 'John', age: '30' };
|
|
||||||
expect(allKeysRequired(obj)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when some keys are empty', () => {
|
|
||||||
const obj = { name: 'John', age: '' };
|
|
||||||
expect(allKeysRequired(obj)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for empty object', () => {
|
|
||||||
expect(allKeysRequired({})).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('replaceTemplateVariables', () => {
|
|
||||||
const templateText =
|
|
||||||
"Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.";
|
|
||||||
|
|
||||||
it('should replace all variables with provided values', () => {
|
|
||||||
const processedParams = {
|
|
||||||
body: {
|
|
||||||
1: 'John',
|
|
||||||
2: '123 Main St',
|
|
||||||
3: '2025-01-15',
|
|
||||||
4: '10:00 AM',
|
|
||||||
5: '2:00 PM',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = replaceTemplateVariables(templateText, processedParams);
|
|
||||||
expect(result).toBe(
|
|
||||||
"Hi John, we're scheduling a technician visit to 123 Main St on 2025-01-15 between 10:00 AM and 2:00 PM. Please confirm if this time slot works for you."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should keep original variable format when no replacement value provided', () => {
|
|
||||||
const processedParams = {
|
|
||||||
body: {
|
|
||||||
1: 'John',
|
|
||||||
3: '2025-01-15',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = replaceTemplateVariables(templateText, processedParams);
|
|
||||||
expect(result).toContain('John');
|
|
||||||
expect(result).toContain('2025-01-15');
|
|
||||||
expect(result).toContain('{{2}}');
|
|
||||||
expect(result).toContain('{{4}}');
|
|
||||||
expect(result).toContain('{{5}}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty processedParams', () => {
|
|
||||||
const result = replaceTemplateVariables(templateText, {});
|
|
||||||
expect(result).toBe(templateText);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildTemplateParameters', () => {
|
|
||||||
it('should build parameters for template with body variables', () => {
|
|
||||||
const result = buildTemplateParameters(technicianTemplate, false);
|
|
||||||
|
|
||||||
expect(result.body).toEqual({
|
|
||||||
1: '',
|
|
||||||
2: '',
|
|
||||||
3: '',
|
|
||||||
4: '',
|
|
||||||
5: '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include header parameters when hasMediaHeader is true', () => {
|
|
||||||
const imageTemplate = templates.find(
|
|
||||||
t => t.name === 'order_confirmation'
|
|
||||||
);
|
|
||||||
const result = buildTemplateParameters(imageTemplate, true);
|
|
||||||
|
|
||||||
expect(result.header).toEqual({
|
|
||||||
media_url: '',
|
|
||||||
media_type: 'image',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not include header parameters when hasMediaHeader is false', () => {
|
|
||||||
const result = buildTemplateParameters(technicianTemplate, false);
|
|
||||||
expect(result.header).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle template with no body component', () => {
|
|
||||||
const templateWithoutBody = {
|
|
||||||
components: [{ type: 'HEADER', format: 'TEXT' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = buildTemplateParameters(templateWithoutBody, false);
|
|
||||||
expect(result).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle template with no variables', () => {
|
|
||||||
const templateWithoutVars = templates.find(
|
|
||||||
t => t.name === 'no_variable_template'
|
|
||||||
);
|
|
||||||
const result = buildTemplateParameters(templateWithoutVars, false);
|
|
||||||
|
|
||||||
expect(result.body).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle URL buttons with variables for non-authentication templates', () => {
|
|
||||||
const templateWithUrlButton = {
|
|
||||||
category: 'MARKETING',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
type: 'BODY',
|
|
||||||
text: 'Check out our website at {{site_url}}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'BUTTONS',
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
type: 'URL',
|
|
||||||
url: 'https://example.com/{{campaign_id}}',
|
|
||||||
text: 'Visit Site',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = buildTemplateParameters(templateWithUrlButton, false);
|
|
||||||
expect(result.buttons).toEqual([
|
|
||||||
{
|
|
||||||
type: 'url',
|
|
||||||
parameter: '',
|
|
||||||
url: 'https://example.com/{{campaign_id}}',
|
|
||||||
variables: ['campaign_id'],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle templates with no variables', () => {
|
|
||||||
const emptyTemplate = templates.find(
|
|
||||||
t => t.name === 'no_variable_template'
|
|
||||||
);
|
|
||||||
const result = buildTemplateParameters(emptyTemplate, false);
|
|
||||||
expect(result).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should build parameters for templates with multiple component types', () => {
|
|
||||||
const complexTemplate = {
|
|
||||||
components: [
|
|
||||||
{ type: 'HEADER', format: 'IMAGE' },
|
|
||||||
{ type: 'BODY', text: 'Hi {{1}}, your order {{2}} is ready!' },
|
|
||||||
{ type: 'FOOTER', text: 'Thank you for your business' },
|
|
||||||
{
|
|
||||||
type: 'BUTTONS',
|
|
||||||
buttons: [{ type: 'URL', url: 'https://example.com/{{3}}' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = buildTemplateParameters(complexTemplate, true);
|
|
||||||
|
|
||||||
expect(result.header).toEqual({
|
|
||||||
media_url: '',
|
|
||||||
media_type: 'image',
|
|
||||||
});
|
|
||||||
expect(result.body).toEqual({ 1: '', 2: '' });
|
|
||||||
expect(result.buttons).toEqual([
|
|
||||||
{
|
|
||||||
type: 'url',
|
|
||||||
parameter: '',
|
|
||||||
url: 'https://example.com/{{3}}',
|
|
||||||
variables: ['3'],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle copy code buttons correctly', () => {
|
|
||||||
const copyCodeTemplate = templates.find(
|
|
||||||
t => t.name === 'discount_coupon'
|
|
||||||
);
|
|
||||||
const result = buildTemplateParameters(copyCodeTemplate, false);
|
|
||||||
|
|
||||||
expect(result.body).toBeDefined();
|
|
||||||
expect(result.buttons).toEqual([
|
|
||||||
{
|
|
||||||
type: 'copy_code',
|
|
||||||
parameter: '',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle templates with document headers', () => {
|
|
||||||
const documentTemplate = templates.find(
|
|
||||||
t => t.name === 'purchase_receipt'
|
|
||||||
);
|
|
||||||
const result = buildTemplateParameters(documentTemplate, true);
|
|
||||||
|
|
||||||
expect(result.header).toEqual({
|
|
||||||
media_url: '',
|
|
||||||
media_type: 'document',
|
|
||||||
});
|
|
||||||
expect(result.body).toEqual({
|
|
||||||
1: '',
|
|
||||||
2: '',
|
|
||||||
3: '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle video header templates', () => {
|
|
||||||
const videoTemplate = templates.find(t => t.name === 'training_video');
|
|
||||||
const result = buildTemplateParameters(videoTemplate, true);
|
|
||||||
|
|
||||||
expect(result.header).toEqual({
|
|
||||||
media_url: '',
|
|
||||||
media_type: 'video',
|
|
||||||
});
|
|
||||||
expect(result.body).toEqual({
|
|
||||||
name: '',
|
|
||||||
date: '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('enhanced format validation', () => {
|
|
||||||
it('should validate enhanced format structure', () => {
|
|
||||||
const processedParams = {
|
|
||||||
body: { 1: 'John', 2: 'Order123' },
|
|
||||||
header: {
|
|
||||||
media_url: 'https://example.com/image.jpg',
|
|
||||||
media_type: 'image',
|
|
||||||
},
|
|
||||||
buttons: [{ type: 'copy_code', parameter: 'SAVE20' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test that structure is properly formed
|
|
||||||
expect(processedParams.body).toBeDefined();
|
|
||||||
expect(typeof processedParams.body).toBe('object');
|
|
||||||
expect(processedParams.header).toBeDefined();
|
|
||||||
expect(Array.isArray(processedParams.buttons)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty component sections', () => {
|
|
||||||
const processedParams = {
|
|
||||||
body: {},
|
|
||||||
header: {},
|
|
||||||
buttons: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(allKeysRequired(processedParams.body)).toBe(true);
|
|
||||||
expect(allKeysRequired(processedParams.header)).toBe(true);
|
|
||||||
expect(processedParams.buttons.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate parameter completeness', () => {
|
|
||||||
const incompleteParams = {
|
|
||||||
body: { 1: 'John', 2: '' },
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(allKeysRequired(incompleteParams.body)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge cases in processVariable', () => {
|
|
||||||
expect(processVariable('{{')).toBe('');
|
|
||||||
expect(processVariable('}}')).toBe('');
|
|
||||||
expect(processVariable('')).toBe('');
|
|
||||||
expect(processVariable('{{nested{{variable}}}}')).toBe('nestedvariable');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle special characters in template variables', () => {
|
|
||||||
/* eslint-disable no-template-curly-in-string */
|
|
||||||
const templateText =
|
|
||||||
'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
|
|
||||||
/* eslint-enable no-template-curly-in-string */
|
|
||||||
const processedParams = {
|
|
||||||
body: {
|
|
||||||
user_name: 'John & Jane',
|
|
||||||
order_id: '12345',
|
|
||||||
amount: '99.99',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = replaceTemplateVariables(templateText, processedParams);
|
|
||||||
expect(result).toBe(
|
|
||||||
'Welcome John & Jane, your order #12345 costs $99.99'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle templates with mixed parameter types', () => {
|
|
||||||
const mixedTemplate = {
|
|
||||||
components: [
|
|
||||||
{ type: 'HEADER', format: 'VIDEO' },
|
|
||||||
{ type: 'BODY', text: 'Order {{order_id}} status: {{status}}' },
|
|
||||||
{ type: 'FOOTER', text: 'Thank you' },
|
|
||||||
{
|
|
||||||
type: 'BUTTONS',
|
|
||||||
buttons: [
|
|
||||||
{ type: 'URL', url: 'https://track.com/{{order_id}}' },
|
|
||||||
{ type: 'COPY_CODE' },
|
|
||||||
{ type: 'PHONE_NUMBER', phone_number: '+1234567890' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = buildTemplateParameters(mixedTemplate, true);
|
|
||||||
|
|
||||||
expect(result.header).toEqual({
|
|
||||||
media_url: '',
|
|
||||||
media_type: 'video',
|
|
||||||
});
|
|
||||||
expect(result.body).toEqual({
|
|
||||||
order_id: '',
|
|
||||||
status: '',
|
|
||||||
});
|
|
||||||
expect(result.buttons).toHaveLength(2); // URL and COPY_CODE (PHONE_NUMBER doesn't need parameters)
|
|
||||||
expect(result.buttons[0].type).toBe('url');
|
|
||||||
expect(result.buttons[1].type).toBe('copy_code');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle templates with no processable components', () => {
|
|
||||||
const emptyTemplate = {
|
|
||||||
components: [
|
|
||||||
{ type: 'HEADER', format: 'TEXT', text: 'Static Header' },
|
|
||||||
{ type: 'BODY', text: 'Static body with no variables' },
|
|
||||||
{ type: 'FOOTER', text: 'Static footer' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = buildTemplateParameters(emptyTemplate, false);
|
|
||||||
expect(result).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate that replaceTemplateVariables preserves unreplaced variables', () => {
|
|
||||||
const templateText = 'Hi {{name}}, order {{order_id}} is {{status}}';
|
|
||||||
const partialParams = {
|
|
||||||
body: {
|
|
||||||
name: 'John',
|
|
||||||
// order_id missing
|
|
||||||
status: 'ready',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = replaceTemplateVariables(templateText, partialParams);
|
|
||||||
expect(result).toBe('Hi John, order {{order_id}} is ready');
|
|
||||||
expect(result).toContain('{{order_id}}'); // Unreplaced variable preserved
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
// Constants
|
|
||||||
export const DEFAULT_LANGUAGE = 'en';
|
|
||||||
export const DEFAULT_CATEGORY = 'UTILITY';
|
|
||||||
export const COMPONENT_TYPES = {
|
|
||||||
HEADER: 'HEADER',
|
|
||||||
BODY: 'BODY',
|
|
||||||
BUTTONS: 'BUTTONS',
|
|
||||||
};
|
|
||||||
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
|
|
||||||
|
|
||||||
export const findComponentByType = (template, type) =>
|
|
||||||
template.components?.find(component => component.type === type);
|
|
||||||
|
|
||||||
export const processVariable = str => {
|
|
||||||
return str.replace(/{{|}}/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const allKeysRequired = value => {
|
|
||||||
const keys = Object.keys(value);
|
|
||||||
return keys.every(key => value[key]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const replaceTemplateVariables = (templateText, processedParams) => {
|
|
||||||
return templateText.replace(/{{([^}]+)}}/g, (match, variable) => {
|
|
||||||
const variableKey = processVariable(variable);
|
|
||||||
return processedParams.body?.[variableKey] || `{{${variable}}}`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
|
|
||||||
const allVariables = {};
|
|
||||||
|
|
||||||
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
|
|
||||||
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
|
|
||||||
|
|
||||||
if (!bodyComponent) return allVariables;
|
|
||||||
|
|
||||||
const templateString = bodyComponent.text;
|
|
||||||
|
|
||||||
// Process body variables
|
|
||||||
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
|
|
||||||
if (matchedVariables) {
|
|
||||||
allVariables.body = {};
|
|
||||||
matchedVariables.forEach(variable => {
|
|
||||||
const key = processVariable(variable);
|
|
||||||
allVariables.body[key] = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasMediaHeaderValue) {
|
|
||||||
if (!allVariables.header) allVariables.header = {};
|
|
||||||
allVariables.header.media_url = '';
|
|
||||||
allVariables.header.media_type = headerComponent.format.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process button variables
|
|
||||||
const buttonComponents = template.components.filter(
|
|
||||||
component => component.type === COMPONENT_TYPES.BUTTONS
|
|
||||||
);
|
|
||||||
|
|
||||||
buttonComponents.forEach(buttonComponent => {
|
|
||||||
if (buttonComponent.buttons) {
|
|
||||||
buttonComponent.buttons.forEach((button, index) => {
|
|
||||||
// Handle URL buttons with variables
|
|
||||||
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
|
|
||||||
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
|
|
||||||
if (buttonVars.length > 0) {
|
|
||||||
if (!allVariables.buttons) allVariables.buttons = [];
|
|
||||||
allVariables.buttons[index] = {
|
|
||||||
type: 'url',
|
|
||||||
parameter: '',
|
|
||||||
url: button.url,
|
|
||||||
variables: buttonVars.map(v => processVariable(v)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle copy code buttons
|
|
||||||
if (button.type === 'COPY_CODE') {
|
|
||||||
if (!allVariables.buttons) allVariables.buttons = [];
|
|
||||||
allVariables.buttons[index] = {
|
|
||||||
type: 'copy_code',
|
|
||||||
parameter: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return allVariables;
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import ar from './locale/ar';
|
import ar from './locale/ar';
|
||||||
import bg from './locale/bg';
|
|
||||||
import ca from './locale/ca';
|
import ca from './locale/ca';
|
||||||
import cs from './locale/cs';
|
import cs from './locale/cs';
|
||||||
import da from './locale/da';
|
import da from './locale/da';
|
||||||
@@ -41,7 +40,6 @@ import lt from './locale/lt';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
ar,
|
ar,
|
||||||
bg,
|
|
||||||
ca,
|
ca,
|
||||||
cs,
|
cs,
|
||||||
da,
|
da,
|
||||||
|
|||||||
@@ -131,7 +131,6 @@
|
|||||||
"CONVERSATION_CREATED": "Conversation Created",
|
"CONVERSATION_CREATED": "Conversation Created",
|
||||||
"CONVERSATION_UPDATED": "Conversation Updated",
|
"CONVERSATION_UPDATED": "Conversation Updated",
|
||||||
"MESSAGE_CREATED": "Message Created",
|
"MESSAGE_CREATED": "Message Created",
|
||||||
"CONVERSATION_RESOLVED": "Conversation Resolved",
|
|
||||||
"CONVERSATION_OPENED": "Conversation Opened"
|
"CONVERSATION_OPENED": "Conversation Opened"
|
||||||
},
|
},
|
||||||
"ACTIONS": {
|
"ACTIONS": {
|
||||||
|
|||||||
@@ -144,9 +144,6 @@
|
|||||||
"AGENTS_LOADING": "Loading agents...",
|
"AGENTS_LOADING": "Loading agents...",
|
||||||
"ASSIGN_TEAM": "Assign team",
|
"ASSIGN_TEAM": "Assign team",
|
||||||
"DELETE": "Delete conversation",
|
"DELETE": "Delete conversation",
|
||||||
"OPEN_IN_NEW_TAB": "Open in new tab",
|
|
||||||
"COPY_LINK": "Copy conversation link",
|
|
||||||
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
|
|
||||||
"API": {
|
"API": {
|
||||||
"AGENT_ASSIGNMENT": {
|
"AGENT_ASSIGNMENT": {
|
||||||
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
|
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
|
||||||
|
|||||||
@@ -157,12 +157,6 @@
|
|||||||
"DELETE_SUCCESS": "Portal deleted successfully",
|
"DELETE_SUCCESS": "Portal deleted successfully",
|
||||||
"DELETE_ERROR": "Error while deleting portal"
|
"DELETE_ERROR": "Error while deleting portal"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"SEND_CNAME_INSTRUCTIONS": {
|
|
||||||
"API": {
|
|
||||||
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
|
|
||||||
"ERROR_MESSAGE": "Error while sending CNAME instructions"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EDIT": {
|
"EDIT": {
|
||||||
@@ -732,7 +726,7 @@
|
|||||||
"HOME_PAGE_LINK": {
|
"HOME_PAGE_LINK": {
|
||||||
"LABEL": "Home page link",
|
"LABEL": "Home page link",
|
||||||
"PLACEHOLDER": "Portal home page link",
|
"PLACEHOLDER": "Portal home page link",
|
||||||
"ERROR": "Enter a valid URL. The Home page link must start with 'http://' or 'https://'."
|
"ERROR": "Invalid URL. The Home page link must start with 'http://' or 'https://'."
|
||||||
},
|
},
|
||||||
"SLUG": {
|
"SLUG": {
|
||||||
"LABEL": "Slug",
|
"LABEL": "Slug",
|
||||||
@@ -753,15 +747,9 @@
|
|||||||
"HEADER": "Custom domain",
|
"HEADER": "Custom domain",
|
||||||
"LABEL": "Custom domain:",
|
"LABEL": "Custom domain:",
|
||||||
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
|
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
|
||||||
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
|
|
||||||
"PLACEHOLDER": "Portal custom domain",
|
"PLACEHOLDER": "Portal custom domain",
|
||||||
"EDIT_BUTTON": "Edit",
|
"EDIT_BUTTON": "Edit custom domain",
|
||||||
"ADD_BUTTON": "Add custom domain",
|
"ADD_BUTTON": "Add custom domain",
|
||||||
"STATUS": {
|
|
||||||
"LIVE": "Live",
|
|
||||||
"PENDING": "Awaiting verification",
|
|
||||||
"ERROR": "Verification failed"
|
|
||||||
},
|
|
||||||
"DIALOG": {
|
"DIALOG": {
|
||||||
"ADD_HEADER": "Add custom domain",
|
"ADD_HEADER": "Add custom domain",
|
||||||
"EDIT_HEADER": "Edit custom domain",
|
"EDIT_HEADER": "Edit custom domain",
|
||||||
@@ -769,20 +757,13 @@
|
|||||||
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
|
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
|
||||||
"LABEL": "Custom domain",
|
"LABEL": "Custom domain",
|
||||||
"PLACEHOLDER": "Portal custom domain",
|
"PLACEHOLDER": "Portal custom domain",
|
||||||
"ERROR": "Custom domain is required",
|
"ERROR": "Custom domain is required"
|
||||||
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
|
|
||||||
},
|
},
|
||||||
"DNS_CONFIGURATION_DIALOG": {
|
"DNS_CONFIGURATION_DIALOG": {
|
||||||
"HEADER": "DNS configuration",
|
"HEADER": "DNS configuration",
|
||||||
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
|
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
|
||||||
"COPY": "Successfully copied CNAME",
|
"HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.",
|
||||||
"SEND_INSTRUCTIONS": {
|
"CONFIRM_BUTTON_LABEL": "Got it!"
|
||||||
"HEADER": "Send instructions",
|
|
||||||
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
|
|
||||||
"PLACEHOLDER": "Enter their email",
|
|
||||||
"ERROR": "Enter a valid email address",
|
|
||||||
"SEND_BUTTON": "Send"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"DELETE_PORTAL": {
|
"DELETE_PORTAL": {
|
||||||
|
|||||||
@@ -72,24 +72,6 @@
|
|||||||
"MARK_ALL_READ": "All notifications marked as read",
|
"MARK_ALL_READ": "All notifications marked as read",
|
||||||
"DELETE_ALL": "All notifications deleted",
|
"DELETE_ALL": "All notifications deleted",
|
||||||
"DELETE_ALL_READ": "All read notifications deleted"
|
"DELETE_ALL_READ": "All read notifications deleted"
|
||||||
},
|
|
||||||
"REAUTHORIZE": {
|
|
||||||
"TITLE": "Reauthorization Required",
|
|
||||||
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
|
|
||||||
"BUTTON_TEXT": "Reconnect WhatsApp",
|
|
||||||
"LOADING_FACEBOOK": "Loading Facebook SDK...",
|
|
||||||
"SUCCESS": "WhatsApp reconnected successfully",
|
|
||||||
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
|
|
||||||
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
|
|
||||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
|
|
||||||
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
|
|
||||||
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
|
|
||||||
"TROUBLESHOOTING": {
|
|
||||||
"TITLE": "Troubleshooting",
|
|
||||||
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
|
|
||||||
"COOKIES": "Third-party cookies must be enabled",
|
|
||||||
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,11 +280,6 @@
|
|||||||
"SECURE_AUTH": "Secure OAuth based authentication",
|
"SECURE_AUTH": "Secure OAuth based authentication",
|
||||||
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
|
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
|
||||||
},
|
},
|
||||||
"LEARN_MORE": {
|
|
||||||
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
|
|
||||||
"LINK_TEXT": "this link.",
|
|
||||||
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
|
|
||||||
},
|
|
||||||
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
|
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
|
||||||
"AUTH_PROCESSING": "Authenticating with Meta",
|
"AUTH_PROCESSING": "Authenticating with Meta",
|
||||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||||
@@ -603,21 +598,6 @@
|
|||||||
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
|
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
|
||||||
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
|
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
|
||||||
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
|
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
|
||||||
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
|
|
||||||
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
|
|
||||||
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
|
|
||||||
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
|
|
||||||
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
|
|
||||||
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
|
|
||||||
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
|
|
||||||
"WHATSAPP_CONNECT_BUTTON": "Connect",
|
|
||||||
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
|
|
||||||
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
|
|
||||||
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
|
|
||||||
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
|
|
||||||
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
|
|
||||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
|
|
||||||
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
|
|
||||||
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
|
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
|
||||||
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
||||||
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
|
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
|
||||||
|
|||||||
@@ -472,8 +472,7 @@
|
|||||||
"FEATURES": {
|
"FEATURES": {
|
||||||
"TITLE": "Features",
|
"TITLE": "Features",
|
||||||
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
|
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
|
||||||
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
|
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions."
|
||||||
"ALLOW_CITATIONS": "Include source citations in responses"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EDIT": {
|
"EDIT": {
|
||||||
@@ -558,7 +557,6 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Search..."
|
"SEARCH_PLACEHOLDER": "Search..."
|
||||||
},
|
},
|
||||||
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
|
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
|
||||||
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
|
|
||||||
"API": {
|
"API": {
|
||||||
"ADD": {
|
"ADD": {
|
||||||
"SUCCESS": "Guardrails added successfully",
|
"SUCCESS": "Guardrails added successfully",
|
||||||
@@ -606,7 +604,6 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Search..."
|
"SEARCH_PLACEHOLDER": "Search..."
|
||||||
},
|
},
|
||||||
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
|
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
|
||||||
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
|
|
||||||
"API": {
|
"API": {
|
||||||
"ADD": {
|
"ADD": {
|
||||||
"SUCCESS": "Response Guidelines added successfully",
|
"SUCCESS": "Response Guidelines added successfully",
|
||||||
@@ -621,73 +618,6 @@
|
|||||||
"ERROR": "There was an error deleting response guidelines, please try again."
|
"ERROR": "There was an error deleting response guidelines, please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"SCENARIOS": {
|
|
||||||
"TITLE": "Scenarios",
|
|
||||||
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
|
|
||||||
"BREADCRUMB": {
|
|
||||||
"TITLE": "Scenarios"
|
|
||||||
},
|
|
||||||
"BULK_ACTION": {
|
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
|
||||||
"SELECT_ALL": "Select all ({count})",
|
|
||||||
"UNSELECT_ALL": "Unselect all ({count})",
|
|
||||||
"BULK_DELETE_BUTTON": "Delete"
|
|
||||||
},
|
|
||||||
"ADD": {
|
|
||||||
"SUGGESTED": {
|
|
||||||
"TITLE": "Example scenarios",
|
|
||||||
"ADD": "Add all",
|
|
||||||
"ADD_SINGLE": "Add this",
|
|
||||||
"TOOLS_USED": "Tools used :"
|
|
||||||
},
|
|
||||||
"NEW": {
|
|
||||||
"CREATE": "Add a scenario",
|
|
||||||
"TITLE": "Create a scenario",
|
|
||||||
"FORM": {
|
|
||||||
"TITLE": {
|
|
||||||
"LABEL": "Title",
|
|
||||||
"PLACEHOLDER": "Enter a name for the scenario",
|
|
||||||
"ERROR": "Scenario name is required"
|
|
||||||
},
|
|
||||||
"DESCRIPTION": {
|
|
||||||
"LABEL": "Description",
|
|
||||||
"PLACEHOLDER": "Describe how and where this scenario will be used",
|
|
||||||
"ERROR": "Scenario description is required"
|
|
||||||
},
|
|
||||||
"INSTRUCTION": {
|
|
||||||
"LABEL": "How to handle",
|
|
||||||
"PLACEHOLDER": "Describe how and where this scenario will be handled",
|
|
||||||
"ERROR": "Scenario content is required"
|
|
||||||
},
|
|
||||||
"CREATE": "Create",
|
|
||||||
"CANCEL": "Cancel"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"UPDATE": {
|
|
||||||
"CANCEL": "Cancel",
|
|
||||||
"UPDATE": "Update changes"
|
|
||||||
},
|
|
||||||
"LIST": {
|
|
||||||
"SEARCH_PLACEHOLDER": "Search..."
|
|
||||||
},
|
|
||||||
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
|
|
||||||
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
|
|
||||||
"API": {
|
|
||||||
"ADD": {
|
|
||||||
"SUCCESS": "Scenarios added successfully",
|
|
||||||
"ERROR": "There was an error adding scenarios, please try again."
|
|
||||||
},
|
|
||||||
"UPDATE": {
|
|
||||||
"SUCCESS": "Scenarios updated successfully",
|
|
||||||
"ERROR": "There was an error updating scenarios, please try again."
|
|
||||||
},
|
|
||||||
"DELETE": {
|
|
||||||
"SUCCESS": "Scenarios deleted successfully",
|
|
||||||
"ERROR": "There was an error deleting scenarios, please try again."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"DOCUMENTS": {
|
"DOCUMENTS": {
|
||||||
|
|||||||
@@ -1,46 +1,29 @@
|
|||||||
{
|
{
|
||||||
"WHATSAPP_TEMPLATES": {
|
"WHATSAPP_TEMPLATES": {
|
||||||
"MODAL": {
|
"MODAL": {
|
||||||
"TITLE": "Whatsapp Templates",
|
"TITLE": "Whatsapp Templates",
|
||||||
"SUBTITLE": "Select the whatsapp template you want to send",
|
"SUBTITLE": "Select the whatsapp template you want to send",
|
||||||
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
|
"TEMPLATE_SELECTED_SUBTITLE": "Process {templateName}"
|
||||||
},
|
},
|
||||||
"PICKER": {
|
"PICKER": {
|
||||||
"SEARCH_PLACEHOLDER": "Search Templates",
|
"SEARCH_PLACEHOLDER": "Search Templates",
|
||||||
"NO_TEMPLATES_FOUND": "No templates found for",
|
"NO_TEMPLATES_FOUND": "No templates found for",
|
||||||
"HEADER": "Header",
|
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
||||||
"BODY": "Body",
|
"REFRESH_BUTTON": "Refresh templates",
|
||||||
"FOOTER": "Footer",
|
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||||
"BUTTONS": "Buttons",
|
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||||
"CATEGORY": "Category",
|
"LABELS": {
|
||||||
"MEDIA_CONTENT": "Media Content",
|
"LANGUAGE": "Language",
|
||||||
"MEDIA_CONTENT_FALLBACK": "media content",
|
"TEMPLATE_BODY": "Template Body",
|
||||||
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
"CATEGORY": "Category"
|
||||||
"REFRESH_BUTTON": "Refresh templates",
|
}
|
||||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
},
|
||||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
"PARSER": {
|
||||||
"LABELS": {
|
"VARIABLES_LABEL": "Variables",
|
||||||
"LANGUAGE": "Language",
|
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
||||||
"TEMPLATE_BODY": "Template Body",
|
"GO_BACK_LABEL": "Go Back",
|
||||||
"CATEGORY": "Category"
|
"SEND_MESSAGE_LABEL": "Send Message",
|
||||||
}
|
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
|
||||||
},
|
}
|
||||||
"PARSER": {
|
|
||||||
"VARIABLES_LABEL": "Variables",
|
|
||||||
"LANGUAGE": "Language",
|
|
||||||
"CATEGORY": "Category",
|
|
||||||
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
|
||||||
"GO_BACK_LABEL": "Go Back",
|
|
||||||
"SEND_MESSAGE_LABEL": "Send Message",
|
|
||||||
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
|
|
||||||
"MEDIA_HEADER_LABEL": "{type} Header",
|
|
||||||
"OTP_CODE": "Enter 4-8 digit OTP",
|
|
||||||
"EXPIRY_MINUTES": "Enter expiry minutes",
|
|
||||||
"BUTTON_PARAMETERS": "Button Parameters",
|
|
||||||
"BUTTON_LABEL": "Button {index}",
|
|
||||||
"COUPON_CODE": "Enter coupon code (max 15 chars)",
|
|
||||||
"MEDIA_URL_LABEL": "Enter {type} URL",
|
|
||||||
"BUTTON_PARAMETER": "Enter button parameter"
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,6 @@
|
|||||||
"CONVERSATION_CREATED": "تم إنشاء المحادثة",
|
"CONVERSATION_CREATED": "تم إنشاء المحادثة",
|
||||||
"CONVERSATION_UPDATED": "تم تحديث المحادثة",
|
"CONVERSATION_UPDATED": "تم تحديث المحادثة",
|
||||||
"MESSAGE_CREATED": "Message Created",
|
"MESSAGE_CREATED": "Message Created",
|
||||||
"CONVERSATION_RESOLVED": "Conversation Resolved",
|
|
||||||
"CONVERSATION_OPENED": "Conversation Opened"
|
"CONVERSATION_OPENED": "Conversation Opened"
|
||||||
},
|
},
|
||||||
"ACTIONS": {
|
"ACTIONS": {
|
||||||
|
|||||||
@@ -144,9 +144,6 @@
|
|||||||
"AGENTS_LOADING": "جاري جلب الوكلاء...",
|
"AGENTS_LOADING": "جاري جلب الوكلاء...",
|
||||||
"ASSIGN_TEAM": "تعيين فريق",
|
"ASSIGN_TEAM": "تعيين فريق",
|
||||||
"DELETE": "Delete conversation",
|
"DELETE": "Delete conversation",
|
||||||
"OPEN_IN_NEW_TAB": "Open in new tab",
|
|
||||||
"COPY_LINK": "Copy conversation link",
|
|
||||||
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
|
|
||||||
"API": {
|
"API": {
|
||||||
"AGENT_ASSIGNMENT": {
|
"AGENT_ASSIGNMENT": {
|
||||||
"SUCCESFUL": "معرف المحادثة {conversationId} تم تعيينه لـ \"{agentName}\"",
|
"SUCCESFUL": "معرف المحادثة {conversationId} تم تعيينه لـ \"{agentName}\"",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"FAILURE": "Could not delete account, try again!",
|
"FAILURE": "Could not delete account, try again!",
|
||||||
"SCHEDULED_DELETION": {
|
"SCHEDULED_DELETION": {
|
||||||
"TITLE": "Account Scheduled for Deletion",
|
"TITLE": "Account Scheduled for Deletion",
|
||||||
"MESSAGE_MANUAL": "هذا الحساب مجدول للحذف بتاريخ {deletionDate}. تم طلبه من قبل المسؤول. يمكنك إلغاء الحذف قبل هذا التاريخ.",
|
"MESSAGE_MANUAL": "This account is scheduled for deletion on {deletionDate}. This was requested by an administrator. You can cancel the deletion before this date.",
|
||||||
"MESSAGE_INACTIVITY": "This account is scheduled for deletion on {deletionDate} due to account inactivity. You can cancel the deletion before this date.",
|
"MESSAGE_INACTIVITY": "This account is scheduled for deletion on {deletionDate} due to account inactivity. You can cancel the deletion before this date.",
|
||||||
"CLEAR_BUTTON": "Cancel Scheduled Deletion"
|
"CLEAR_BUTTON": "Cancel Scheduled Deletion"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,12 +157,6 @@
|
|||||||
"DELETE_SUCCESS": "Portal deleted successfully",
|
"DELETE_SUCCESS": "Portal deleted successfully",
|
||||||
"DELETE_ERROR": "حدث خطأ أثناء حذف البوابة"
|
"DELETE_ERROR": "حدث خطأ أثناء حذف البوابة"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"SEND_CNAME_INSTRUCTIONS": {
|
|
||||||
"API": {
|
|
||||||
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
|
|
||||||
"ERROR_MESSAGE": "Error while sending CNAME instructions"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EDIT": {
|
"EDIT": {
|
||||||
@@ -732,7 +726,7 @@
|
|||||||
"HOME_PAGE_LINK": {
|
"HOME_PAGE_LINK": {
|
||||||
"LABEL": "Home page link",
|
"LABEL": "Home page link",
|
||||||
"PLACEHOLDER": "رابط الصفحة الرئيسية للبوابة",
|
"PLACEHOLDER": "رابط الصفحة الرئيسية للبوابة",
|
||||||
"ERROR": "Enter a valid URL. The Home page link must start with 'http://' or 'https://'."
|
"ERROR": "Invalid URL. The Home page link must start with 'http://' or 'https://'."
|
||||||
},
|
},
|
||||||
"SLUG": {
|
"SLUG": {
|
||||||
"LABEL": "Slug",
|
"LABEL": "Slug",
|
||||||
@@ -753,15 +747,9 @@
|
|||||||
"HEADER": "نطاق مخصص",
|
"HEADER": "نطاق مخصص",
|
||||||
"LABEL": "نطاق مخصص:",
|
"LABEL": "نطاق مخصص:",
|
||||||
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
|
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
|
||||||
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
|
|
||||||
"PLACEHOLDER": "نطاق البوابة المخصص",
|
"PLACEHOLDER": "نطاق البوابة المخصص",
|
||||||
"EDIT_BUTTON": "تعديل",
|
"EDIT_BUTTON": "Edit custom domain",
|
||||||
"ADD_BUTTON": "Add custom domain",
|
"ADD_BUTTON": "Add custom domain",
|
||||||
"STATUS": {
|
|
||||||
"LIVE": "مباشر",
|
|
||||||
"PENDING": "Awaiting verification",
|
|
||||||
"ERROR": "Verification failed"
|
|
||||||
},
|
|
||||||
"DIALOG": {
|
"DIALOG": {
|
||||||
"ADD_HEADER": "Add custom domain",
|
"ADD_HEADER": "Add custom domain",
|
||||||
"EDIT_HEADER": "Edit custom domain",
|
"EDIT_HEADER": "Edit custom domain",
|
||||||
@@ -769,20 +757,13 @@
|
|||||||
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
|
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
|
||||||
"LABEL": "نطاق مخصص",
|
"LABEL": "نطاق مخصص",
|
||||||
"PLACEHOLDER": "نطاق البوابة المخصص",
|
"PLACEHOLDER": "نطاق البوابة المخصص",
|
||||||
"ERROR": "Custom domain is required",
|
"ERROR": "Custom domain is required"
|
||||||
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
|
|
||||||
},
|
},
|
||||||
"DNS_CONFIGURATION_DIALOG": {
|
"DNS_CONFIGURATION_DIALOG": {
|
||||||
"HEADER": "DNS configuration",
|
"HEADER": "DNS configuration",
|
||||||
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
|
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
|
||||||
"COPY": "Successfully copied CNAME",
|
"HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.",
|
||||||
"SEND_INSTRUCTIONS": {
|
"CONFIRM_BUTTON_LABEL": "Got it!"
|
||||||
"HEADER": "Send instructions",
|
|
||||||
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
|
|
||||||
"PLACEHOLDER": "Enter their email",
|
|
||||||
"ERROR": "Enter a valid email address",
|
|
||||||
"SEND_BUTTON": "إرسال"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"DELETE_PORTAL": {
|
"DELETE_PORTAL": {
|
||||||
|
|||||||
@@ -72,24 +72,6 @@
|
|||||||
"MARK_ALL_READ": "تم تحديد جميع الإشعارات كمقروءة",
|
"MARK_ALL_READ": "تم تحديد جميع الإشعارات كمقروءة",
|
||||||
"DELETE_ALL": "تم حذف كل الإشعارات",
|
"DELETE_ALL": "تم حذف كل الإشعارات",
|
||||||
"DELETE_ALL_READ": "تم حذف كل الإشعارات المقروءة"
|
"DELETE_ALL_READ": "تم حذف كل الإشعارات المقروءة"
|
||||||
},
|
|
||||||
"REAUTHORIZE": {
|
|
||||||
"TITLE": "Reauthorization Required",
|
|
||||||
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
|
|
||||||
"BUTTON_TEXT": "Reconnect WhatsApp",
|
|
||||||
"LOADING_FACEBOOK": "Loading Facebook SDK...",
|
|
||||||
"SUCCESS": "WhatsApp reconnected successfully",
|
|
||||||
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
|
|
||||||
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
|
|
||||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
|
|
||||||
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
|
|
||||||
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
|
|
||||||
"TROUBLESHOOTING": {
|
|
||||||
"TITLE": "Troubleshooting",
|
|
||||||
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
|
|
||||||
"COOKIES": "Third-party cookies must be enabled",
|
|
||||||
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,11 +280,6 @@
|
|||||||
"SECURE_AUTH": "Secure OAuth based authentication",
|
"SECURE_AUTH": "Secure OAuth based authentication",
|
||||||
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
|
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
|
||||||
},
|
},
|
||||||
"LEARN_MORE": {
|
|
||||||
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
|
|
||||||
"LINK_TEXT": "this link.",
|
|
||||||
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
|
|
||||||
},
|
|
||||||
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
|
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
|
||||||
"AUTH_PROCESSING": "Authenticating with Meta",
|
"AUTH_PROCESSING": "Authenticating with Meta",
|
||||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||||
@@ -603,21 +598,6 @@
|
|||||||
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
|
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
|
||||||
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
|
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
|
||||||
"WHATSAPP_SECTION_UPDATE_BUTTON": "تحديث",
|
"WHATSAPP_SECTION_UPDATE_BUTTON": "تحديث",
|
||||||
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
|
|
||||||
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
|
|
||||||
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
|
|
||||||
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
|
|
||||||
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
|
|
||||||
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
|
|
||||||
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
|
|
||||||
"WHATSAPP_CONNECT_BUTTON": "ربط الاتصال",
|
|
||||||
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
|
|
||||||
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
|
|
||||||
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
|
|
||||||
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
|
|
||||||
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
|
|
||||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
|
|
||||||
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
|
|
||||||
"WHATSAPP_WEBHOOK_TITLE": "رمز التحقق من Webhook",
|
"WHATSAPP_WEBHOOK_TITLE": "رمز التحقق من Webhook",
|
||||||
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
||||||
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
|
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
|
||||||
|
|||||||
@@ -472,8 +472,7 @@
|
|||||||
"FEATURES": {
|
"FEATURES": {
|
||||||
"TITLE": "الخصائص",
|
"TITLE": "الخصائص",
|
||||||
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
|
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
|
||||||
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
|
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions."
|
||||||
"ALLOW_CITATIONS": "Include source citations in responses"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EDIT": {
|
"EDIT": {
|
||||||
@@ -558,7 +557,6 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Search..."
|
"SEARCH_PLACEHOLDER": "Search..."
|
||||||
},
|
},
|
||||||
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
|
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
|
||||||
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
|
|
||||||
"API": {
|
"API": {
|
||||||
"ADD": {
|
"ADD": {
|
||||||
"SUCCESS": "Guardrails added successfully",
|
"SUCCESS": "Guardrails added successfully",
|
||||||
@@ -606,7 +604,6 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Search..."
|
"SEARCH_PLACEHOLDER": "Search..."
|
||||||
},
|
},
|
||||||
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
|
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
|
||||||
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
|
|
||||||
"API": {
|
"API": {
|
||||||
"ADD": {
|
"ADD": {
|
||||||
"SUCCESS": "Response Guidelines added successfully",
|
"SUCCESS": "Response Guidelines added successfully",
|
||||||
@@ -621,73 +618,6 @@
|
|||||||
"ERROR": "There was an error deleting response guidelines, please try again."
|
"ERROR": "There was an error deleting response guidelines, please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"SCENARIOS": {
|
|
||||||
"TITLE": "Scenarios",
|
|
||||||
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
|
|
||||||
"BREADCRUMB": {
|
|
||||||
"TITLE": "Scenarios"
|
|
||||||
},
|
|
||||||
"BULK_ACTION": {
|
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
|
||||||
"SELECT_ALL": "Select all ({count})",
|
|
||||||
"UNSELECT_ALL": "Unselect all ({count})",
|
|
||||||
"BULK_DELETE_BUTTON": "حذف"
|
|
||||||
},
|
|
||||||
"ADD": {
|
|
||||||
"SUGGESTED": {
|
|
||||||
"TITLE": "Example scenarios",
|
|
||||||
"ADD": "Add all",
|
|
||||||
"ADD_SINGLE": "Add this",
|
|
||||||
"TOOLS_USED": "Tools used :"
|
|
||||||
},
|
|
||||||
"NEW": {
|
|
||||||
"CREATE": "Add a scenario",
|
|
||||||
"TITLE": "Create a scenario",
|
|
||||||
"FORM": {
|
|
||||||
"TITLE": {
|
|
||||||
"LABEL": "العنوان",
|
|
||||||
"PLACEHOLDER": "Enter a name for the scenario",
|
|
||||||
"ERROR": "Scenario name is required"
|
|
||||||
},
|
|
||||||
"DESCRIPTION": {
|
|
||||||
"LABEL": "الوصف",
|
|
||||||
"PLACEHOLDER": "Describe how and where this scenario will be used",
|
|
||||||
"ERROR": "Scenario description is required"
|
|
||||||
},
|
|
||||||
"INSTRUCTION": {
|
|
||||||
"LABEL": "How to handle",
|
|
||||||
"PLACEHOLDER": "Describe how and where this scenario will be handled",
|
|
||||||
"ERROR": "Scenario content is required"
|
|
||||||
},
|
|
||||||
"CREATE": "إنشاء",
|
|
||||||
"CANCEL": "إلغاء"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"UPDATE": {
|
|
||||||
"CANCEL": "إلغاء",
|
|
||||||
"UPDATE": "Update changes"
|
|
||||||
},
|
|
||||||
"LIST": {
|
|
||||||
"SEARCH_PLACEHOLDER": "Search..."
|
|
||||||
},
|
|
||||||
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
|
|
||||||
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
|
|
||||||
"API": {
|
|
||||||
"ADD": {
|
|
||||||
"SUCCESS": "Scenarios added successfully",
|
|
||||||
"ERROR": "There was an error adding scenarios, please try again."
|
|
||||||
},
|
|
||||||
"UPDATE": {
|
|
||||||
"SUCCESS": "Scenarios updated successfully",
|
|
||||||
"ERROR": "There was an error updating scenarios, please try again."
|
|
||||||
},
|
|
||||||
"DELETE": {
|
|
||||||
"SUCCESS": "Scenarios deleted successfully",
|
|
||||||
"ERROR": "There was an error deleting scenarios, please try again."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"DOCUMENTS": {
|
"DOCUMENTS": {
|
||||||
|
|||||||
@@ -1,46 +1,29 @@
|
|||||||
{
|
{
|
||||||
"WHATSAPP_TEMPLATES": {
|
"WHATSAPP_TEMPLATES": {
|
||||||
"MODAL": {
|
"MODAL": {
|
||||||
"TITLE": "قوالب الواتساب",
|
"TITLE": "قوالب الواتساب",
|
||||||
"SUBTITLE": "حدد القالب الذي تريد إرساله",
|
"SUBTITLE": "حدد القالب الذي تريد إرساله",
|
||||||
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
|
"TEMPLATE_SELECTED_SUBTITLE": "معالجة {templateName}"
|
||||||
},
|
},
|
||||||
"PICKER": {
|
"PICKER": {
|
||||||
"SEARCH_PLACEHOLDER": "نماذج البحث",
|
"SEARCH_PLACEHOLDER": "نماذج البحث",
|
||||||
"NO_TEMPLATES_FOUND": "لم يتم العثور على قوالب",
|
"NO_TEMPLATES_FOUND": "لم يتم العثور على قوالب",
|
||||||
"HEADER": "Header",
|
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
||||||
"BODY": "Body",
|
"REFRESH_BUTTON": "Refresh templates",
|
||||||
"FOOTER": "Footer",
|
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||||
"BUTTONS": "Buttons",
|
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||||
"CATEGORY": "الفئة",
|
"LABELS": {
|
||||||
"MEDIA_CONTENT": "Media Content",
|
"LANGUAGE": "اللغة",
|
||||||
"MEDIA_CONTENT_FALLBACK": "media content",
|
"TEMPLATE_BODY": "نص القالب",
|
||||||
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
"CATEGORY": "الفئة"
|
||||||
"REFRESH_BUTTON": "Refresh templates",
|
}
|
||||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
},
|
||||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
"PARSER": {
|
||||||
"LABELS": {
|
"VARIABLES_LABEL": "المتغيرات",
|
||||||
"LANGUAGE": "اللغة",
|
"VARIABLE_PLACEHOLDER": "أدخل قيمة {variable}",
|
||||||
"TEMPLATE_BODY": "نص القالب",
|
"GO_BACK_LABEL": "العودة للخلف",
|
||||||
"CATEGORY": "الفئة"
|
"SEND_MESSAGE_LABEL": "إرسال الرسالة",
|
||||||
}
|
"FORM_ERROR_MESSAGE": "يرجى ملء جميع المتغيرات قبل الإرسال"
|
||||||
},
|
}
|
||||||
"PARSER": {
|
|
||||||
"VARIABLES_LABEL": "المتغيرات",
|
|
||||||
"LANGUAGE": "اللغة",
|
|
||||||
"CATEGORY": "الفئة",
|
|
||||||
"VARIABLE_PLACEHOLDER": "أدخل قيمة {variable}",
|
|
||||||
"GO_BACK_LABEL": "العودة للخلف",
|
|
||||||
"SEND_MESSAGE_LABEL": "إرسال الرسالة",
|
|
||||||
"FORM_ERROR_MESSAGE": "يرجى ملء جميع المتغيرات قبل الإرسال",
|
|
||||||
"MEDIA_HEADER_LABEL": "{type} Header",
|
|
||||||
"OTP_CODE": "Enter 4-8 digit OTP",
|
|
||||||
"EXPIRY_MINUTES": "Enter expiry minutes",
|
|
||||||
"BUTTON_PARAMETERS": "Button Parameters",
|
|
||||||
"BUTTON_LABEL": "Button {index}",
|
|
||||||
"COUPON_CODE": "Enter coupon code (max 15 chars)",
|
|
||||||
"MEDIA_URL_LABEL": "Enter {type} URL",
|
|
||||||
"BUTTON_PARAMETER": "Enter button parameter"
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,6 @@
|
|||||||
"CONVERSATION_CREATED": "Conversation Created",
|
"CONVERSATION_CREATED": "Conversation Created",
|
||||||
"CONVERSATION_UPDATED": "Conversation Updated",
|
"CONVERSATION_UPDATED": "Conversation Updated",
|
||||||
"MESSAGE_CREATED": "Message Created",
|
"MESSAGE_CREATED": "Message Created",
|
||||||
"CONVERSATION_RESOLVED": "Conversation Resolved",
|
|
||||||
"CONVERSATION_OPENED": "Conversation Opened"
|
"CONVERSATION_OPENED": "Conversation Opened"
|
||||||
},
|
},
|
||||||
"ACTIONS": {
|
"ACTIONS": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user