Compare commits
189
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fbb93698 | ||
|
|
38d174bda6 | ||
|
|
7ca5d365ae | ||
|
|
6be85a40f7 | ||
|
|
922c989574 | ||
|
|
59928bd85c | ||
|
|
661e905dbd | ||
|
|
270f26e471 | ||
|
|
53fce7be03 | ||
|
|
60a1e9b15d | ||
|
|
65312744c7 | ||
|
|
46c55a53bd | ||
|
|
176e09cde7 | ||
|
|
b09c6b4aac | ||
|
|
96e18d74c5 | ||
|
|
311093f0fb | ||
|
|
54514d9d21 | ||
|
|
4633d2a179 | ||
|
|
73c2690526 | ||
|
|
25b545200e | ||
|
|
23c35c8886 | ||
|
|
3f801fda2c | ||
|
|
51b9fd8eca | ||
|
|
4dc7a653eb | ||
|
|
ea7c5798ff | ||
|
|
1271459874 | ||
|
|
81098b02a8 | ||
|
|
18d6a51d6b | ||
|
|
9c9c1c301d | ||
|
|
5fb40e0b2e | ||
|
|
9ba51a3fd4 | ||
|
|
05d83ce704 | ||
|
|
36b7a86bf7 | ||
|
|
e6d336f727 | ||
|
|
5ab913f7b5 | ||
|
|
feff61755f | ||
|
|
c98c255ed0 | ||
|
|
446a219cd1 | ||
|
|
94829aea8d | ||
|
|
0c4e21bc6a | ||
|
|
a582b8284d | ||
|
|
bbcbc6d52b | ||
|
|
86cb4fd809 | ||
|
|
b434279422 | ||
|
|
d9900e50a0 | ||
|
|
97f1825a14 | ||
|
|
28e837e8f7 | ||
|
|
f4ea90c2dc | ||
|
|
1d9a28ad64 | ||
|
|
f36a3a2e19 | ||
|
|
a14df55210 | ||
|
|
b3633d5c08 | ||
|
|
5ed84af2e3 | ||
|
|
b52caf0341 | ||
|
|
df4de508e7 | ||
|
|
e37d61f836 | ||
|
|
1230d1f251 | ||
|
|
62b36d4aec | ||
|
|
75c57ad039 | ||
|
|
6475a6a593 | ||
|
|
441cc065ae | ||
|
|
0b74be82a3 | ||
|
|
cbc2807296 | ||
|
|
c09e875c83 | ||
|
|
87313ecc35 | ||
|
|
8262123481 | ||
|
|
420be64c45 | ||
|
|
9acb0d86b5 | ||
|
|
2a5ecf84a1 | ||
|
|
286e3a449d | ||
|
|
e5ee6027b4 | ||
|
|
eb412b67bd | ||
|
|
6fb762f96c | ||
|
|
ab1ba1c4c7 | ||
|
|
d276025419 | ||
|
|
94b7154926 | ||
|
|
60951b45fd | ||
|
|
69ad953ae6 | ||
|
|
c4b076dbb2 | ||
|
|
dc49ae2515 | ||
|
|
b71a0da10d | ||
|
|
bb9e3a5495 | ||
|
|
647a65d481 | ||
|
|
99ec18c95b | ||
|
|
64ba23688b | ||
|
|
10673a8d68 | ||
|
|
d488b8ced6 | ||
|
|
0381bdac96 | ||
|
|
87fee33039 | ||
|
|
fe68542547 | ||
|
|
2e1529c755 | ||
|
|
eb20e39b11 | ||
|
|
2fec2e5993 | ||
|
|
090316a078 | ||
|
|
b771652656 | ||
|
|
e6d720082c | ||
|
|
d9a0d4eee0 | ||
|
|
2a0f2a8b1e | ||
|
|
fcd604dcde | ||
|
|
aedb59ea67 | ||
|
|
13b4fdb34c | ||
|
|
45d4d3660c | ||
|
|
4e2abbaa3d | ||
|
|
9db096f046 | ||
|
|
0ea616a6ea | ||
|
|
6b8dd3c86a | ||
|
|
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 | ||
|
|
61d10044a0 | ||
|
|
f2d9895597 | ||
|
|
4378506a35 | ||
|
|
db4e8588cd | ||
|
|
0c05599282 | ||
|
|
00e1f50458 | ||
|
|
ee89943aa2 | ||
|
|
1132556512 | ||
|
|
9dc6b94537 | ||
|
|
e9816f8d3d | ||
|
|
93f18315cc | ||
|
|
51d1c501cf | ||
|
|
1539a8bc36 | ||
|
|
d89fa09359 | ||
|
|
ffa8c0ce8a | ||
|
|
07dd6448f4 | ||
|
|
661326ae51 | ||
|
|
4762edcba3 | ||
|
|
1bd2da4832 | ||
|
|
18eb53acf4 | ||
|
|
1b02ebec68 | ||
|
|
5b9f997fa0 | ||
|
|
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 |
@@ -1,62 +0,0 @@
|
|||||||
version: '2'
|
|
||||||
plugins:
|
|
||||||
rubocop:
|
|
||||||
enabled: false
|
|
||||||
channel: rubocop-0-73
|
|
||||||
eslint:
|
|
||||||
enabled: false
|
|
||||||
csslint:
|
|
||||||
enabled: true
|
|
||||||
scss-lint:
|
|
||||||
enabled: true
|
|
||||||
brakeman:
|
|
||||||
enabled: false
|
|
||||||
checks:
|
|
||||||
similar-code:
|
|
||||||
enabled: false
|
|
||||||
method-count:
|
|
||||||
enabled: true
|
|
||||||
config:
|
|
||||||
threshold: 32
|
|
||||||
file-lines:
|
|
||||||
enabled: true
|
|
||||||
config:
|
|
||||||
threshold: 300
|
|
||||||
method-lines:
|
|
||||||
config:
|
|
||||||
threshold: 50
|
|
||||||
exclude_patterns:
|
|
||||||
- 'spec/'
|
|
||||||
- '**/specs/**/**'
|
|
||||||
- '**/spec/**/**'
|
|
||||||
- 'db/*'
|
|
||||||
- 'bin/**/*'
|
|
||||||
- 'db/**/*'
|
|
||||||
- 'config/**/*'
|
|
||||||
- 'public/**/*'
|
|
||||||
- 'vendor/**/*'
|
|
||||||
- 'node_modules/**/*'
|
|
||||||
- 'lib/tasks/auto_annotate_models.rake'
|
|
||||||
- 'app/test-matchers.js'
|
|
||||||
- 'docs/*'
|
|
||||||
- '**/*.md'
|
|
||||||
- '**/*.yml'
|
|
||||||
- 'app/javascript/dashboard/i18n/locale'
|
|
||||||
- '**/*.stories.js'
|
|
||||||
- 'stories/'
|
|
||||||
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js'
|
|
||||||
- 'app/javascript/shared/constants/countries.js'
|
|
||||||
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js'
|
|
||||||
- 'app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js'
|
|
||||||
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
|
|
||||||
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
|
|
||||||
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
|
|
||||||
- 'app/javascript/dashboard/store/captain/storeFactory.js'
|
|
||||||
- 'app/javascript/dashboard/i18n/index.js'
|
|
||||||
- 'app/javascript/widget/i18n/index.js'
|
|
||||||
- 'app/javascript/survey/i18n/index.js'
|
|
||||||
- 'app/javascript/shared/constants/locales.js'
|
|
||||||
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
|
|
||||||
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
|
|
||||||
- '**/fixtures/**'
|
|
||||||
- '**/*/fixtures.js'
|
|
||||||
@@ -94,3 +94,4 @@ yarn-debug.log*
|
|||||||
.vscode
|
.vscode
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.cursor
|
.cursor
|
||||||
|
CLAUDE.local.md
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
*
|
||||||
|
!configs
|
||||||
|
!configs/**
|
||||||
|
!hooks
|
||||||
|
!hooks/**
|
||||||
|
!qlty.toml
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ignored:
|
||||||
|
- DL3008
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
source-path=SCRIPTDIR
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
rules:
|
||||||
|
document-start: disable
|
||||||
|
quoted-strings:
|
||||||
|
required: only-when-needed
|
||||||
|
extra-allowed: ["{|}"]
|
||||||
|
key-duplicates: {}
|
||||||
|
octal-values:
|
||||||
|
forbid-implicit-octal: true
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# This file was automatically generated by `qlty init`.
|
||||||
|
# You can modify it to suit your needs.
|
||||||
|
# We recommend you to commit this file to your repository.
|
||||||
|
#
|
||||||
|
# This configuration is used by both Qlty CLI and Qlty Cloud.
|
||||||
|
#
|
||||||
|
# Qlty CLI -- Code quality toolkit for developers
|
||||||
|
# Qlty Cloud -- Fully automated Code Health Platform
|
||||||
|
#
|
||||||
|
# Try Qlty Cloud: https://qlty.sh
|
||||||
|
#
|
||||||
|
# For a guide to configuration, visit https://qlty.sh/d/config
|
||||||
|
# Or for a full reference, visit https://qlty.sh/d/qlty-toml
|
||||||
|
config_version = "0"
|
||||||
|
|
||||||
|
exclude_patterns = [
|
||||||
|
"*_min.*",
|
||||||
|
"*-min.*",
|
||||||
|
"*.min.*",
|
||||||
|
"**/.yarn/**",
|
||||||
|
"**/*.d.ts",
|
||||||
|
"**/assets/**",
|
||||||
|
"**/bower_components/**",
|
||||||
|
"**/build/**",
|
||||||
|
"**/cache/**",
|
||||||
|
"**/config/**",
|
||||||
|
"**/db/**",
|
||||||
|
"**/deps/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/extern/**",
|
||||||
|
"**/external/**",
|
||||||
|
"**/generated/**",
|
||||||
|
"**/Godeps/**",
|
||||||
|
"**/gradlew/**",
|
||||||
|
"**/mvnw/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/protos/**",
|
||||||
|
"**/seed/**",
|
||||||
|
"**/target/**",
|
||||||
|
"**/templates/**",
|
||||||
|
"**/testdata/**",
|
||||||
|
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
test_patterns = [
|
||||||
|
"**/test/**",
|
||||||
|
"**/spec/**",
|
||||||
|
"**/*.test.*",
|
||||||
|
"**/*.spec.*",
|
||||||
|
"**/*_test.*",
|
||||||
|
"**/*_spec.*",
|
||||||
|
"**/test_*.*",
|
||||||
|
"**/spec_*.*",
|
||||||
|
]
|
||||||
|
|
||||||
|
[smells]
|
||||||
|
mode = "comment"
|
||||||
|
|
||||||
|
[smells.boolean_logic]
|
||||||
|
threshold = 4
|
||||||
|
|
||||||
|
[smells.file_complexity]
|
||||||
|
threshold = 66
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[smells.return_statements]
|
||||||
|
threshold = 4
|
||||||
|
|
||||||
|
[smells.nested_control_flow]
|
||||||
|
threshold = 4
|
||||||
|
|
||||||
|
[smells.function_parameters]
|
||||||
|
threshold = 4
|
||||||
|
|
||||||
|
[smells.function_complexity]
|
||||||
|
threshold = 5
|
||||||
|
|
||||||
|
[smells.duplication]
|
||||||
|
enabled = true
|
||||||
|
threshold = 20
|
||||||
|
|
||||||
|
[[source]]
|
||||||
|
name = "default"
|
||||||
|
default = true
|
||||||
+1
-1
@@ -283,7 +283,7 @@ Rails/RedundantActiveRecordAllMethod:
|
|||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Layout/TrailingEmptyLines:
|
Layout/TrailingEmptyLines:
|
||||||
Enabled: false
|
Enabled: true
|
||||||
|
|
||||||
Style/SafeNavigationChainLength:
|
Style/SafeNavigationChainLength:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
|||||||
gem 'sidekiq', '>= 7.3.1'
|
gem 'sidekiq', '>= 7.3.1'
|
||||||
# We want cron jobs
|
# We want cron jobs
|
||||||
gem 'sidekiq-cron', '>= 1.12.0'
|
gem 'sidekiq-cron', '>= 1.12.0'
|
||||||
|
# for sidekiq healthcheck
|
||||||
|
gem 'sidekiq_alive'
|
||||||
|
|
||||||
##-- Push notification service --##
|
##-- Push notification service --##
|
||||||
gem 'fcm'
|
gem 'fcm'
|
||||||
@@ -177,6 +179,8 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
|
gem 'ai-agents', '>= 0.4.3'
|
||||||
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|
||||||
|
|||||||
+25
-6
@@ -126,6 +126,8 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
|
ai-agents (0.4.3)
|
||||||
|
ruby_llm (~> 1.3)
|
||||||
annotate (3.2.0)
|
annotate (3.2.0)
|
||||||
activerecord (>= 3.2, < 8.0)
|
activerecord (>= 3.2, < 8.0)
|
||||||
rake (>= 10.4, < 14.0)
|
rake (>= 10.4, < 14.0)
|
||||||
@@ -357,6 +359,7 @@ GEM
|
|||||||
grpc (1.72.0-x86_64-linux)
|
grpc (1.72.0-x86_64-linux)
|
||||||
google-protobuf (>= 3.25, < 5.0)
|
google-protobuf (>= 3.25, < 5.0)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
|
gserver (0.0.1)
|
||||||
haikunator (1.1.1)
|
haikunator (1.1.1)
|
||||||
hairtrigger (1.0.0)
|
hairtrigger (1.0.0)
|
||||||
activerecord (>= 6.0, < 8)
|
activerecord (>= 6.0, < 8)
|
||||||
@@ -475,7 +478,7 @@ GEM
|
|||||||
mime-types-data (3.2023.0218.1)
|
mime-types-data (3.2023.0218.1)
|
||||||
mini_magick (4.12.0)
|
mini_magick (4.12.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.8)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
mock_redis (0.36.0)
|
mock_redis (0.36.0)
|
||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
@@ -506,14 +509,14 @@ GEM
|
|||||||
newrelic_rpm (9.6.0)
|
newrelic_rpm (9.6.0)
|
||||||
base64
|
base64
|
||||||
nio4r (2.7.3)
|
nio4r (2.7.3)
|
||||||
nokogiri (1.18.8)
|
nokogiri (1.18.9)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.8-arm64-darwin)
|
nokogiri (1.18.9-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.8-x86_64-darwin)
|
nokogiri (1.18.9-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
nokogiri (1.18.9-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oauth (1.1.0)
|
oauth (1.1.0)
|
||||||
oauth-tty (~> 1.0, >= 1.0.1)
|
oauth-tty (~> 1.0, >= 1.0.1)
|
||||||
@@ -717,6 +720,16 @@ GEM
|
|||||||
ruby2ruby (2.5.0)
|
ruby2ruby (2.5.0)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
|
ruby_llm (1.5.1)
|
||||||
|
base64
|
||||||
|
event_stream_parser (~> 1)
|
||||||
|
faraday (>= 1.10.0)
|
||||||
|
faraday-multipart (>= 1)
|
||||||
|
faraday-net_http (>= 1)
|
||||||
|
faraday-retry (>= 1)
|
||||||
|
marcel (~> 1.0)
|
||||||
|
zeitwerk (~> 2)
|
||||||
|
ruby_llm-schema (0.1.0)
|
||||||
ruby_parser (3.20.0)
|
ruby_parser (3.20.0)
|
||||||
sexp_processor (~> 4.16)
|
sexp_processor (~> 4.16)
|
||||||
sass (3.7.4)
|
sass (3.7.4)
|
||||||
@@ -774,6 +787,9 @@ GEM
|
|||||||
fugit (~> 1.8)
|
fugit (~> 1.8)
|
||||||
globalid (>= 1.0.1)
|
globalid (>= 1.0.1)
|
||||||
sidekiq (>= 6)
|
sidekiq (>= 6)
|
||||||
|
sidekiq_alive (2.5.0)
|
||||||
|
gserver (~> 0.0.1)
|
||||||
|
sidekiq (>= 5, < 9)
|
||||||
signet (0.17.0)
|
signet (0.17.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
@@ -812,7 +828,7 @@ GEM
|
|||||||
stripe (8.5.0)
|
stripe (8.5.0)
|
||||||
telephone_number (1.4.20)
|
telephone_number (1.4.20)
|
||||||
test-prof (1.2.1)
|
test-prof (1.2.1)
|
||||||
thor (1.3.1)
|
thor (1.4.0)
|
||||||
tilt (2.3.0)
|
tilt (2.3.0)
|
||||||
time_diff (0.3.0)
|
time_diff (0.3.0)
|
||||||
activesupport
|
activesupport
|
||||||
@@ -895,6 +911,7 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
|
ai-agents (>= 0.4.3)
|
||||||
annotate
|
annotate
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
@@ -988,6 +1005,7 @@ DEPENDENCIES
|
|||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
ruby-openai
|
ruby-openai
|
||||||
|
ruby_llm-schema
|
||||||
scout_apm
|
scout_apm
|
||||||
scss_lint
|
scss_lint
|
||||||
seed_dump
|
seed_dump
|
||||||
@@ -998,6 +1016,7 @@ DEPENDENCIES
|
|||||||
shoulda-matchers
|
shoulda-matchers
|
||||||
sidekiq (>= 7.3.1)
|
sidekiq (>= 7.3.1)
|
||||||
sidekiq-cron (>= 1.12.0)
|
sidekiq-cron (>= 1.12.0)
|
||||||
|
sidekiq_alive
|
||||||
simplecov (= 0.17.1)
|
simplecov (= 0.17.1)
|
||||||
slack-ruby-client (~> 2.5.2)
|
slack-ruby-client (~> 2.5.2)
|
||||||
spring
|
spring
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.13.0
|
4.4.0
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.2.0
|
3.4.2
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
# We don't want to update the name of the identified original contact.
|
# We don't want to update the name of the identified original contact.
|
||||||
|
|
||||||
class ContactIdentifyAction
|
class ContactIdentifyAction
|
||||||
|
include UrlHelper
|
||||||
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
|
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
@@ -104,7 +105,14 @@ class ContactIdentifyAction
|
|||||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||||
@contact.save!
|
@contact.save!
|
||||||
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? && !@contact.avatar.attached?
|
enqueue_avatar_job
|
||||||
|
end
|
||||||
|
|
||||||
|
def enqueue_avatar_job
|
||||||
|
return unless params[:avatar_url].present? && !@contact.avatar.attached?
|
||||||
|
return unless url_valid?(params[:avatar_url])
|
||||||
|
|
||||||
|
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url])
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_contact(base_contact, merge_contact)
|
def merge_contact(base_contact, merge_contact)
|
||||||
|
|||||||
@@ -30,7 +30,14 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def facebook_pages
|
def facebook_pages
|
||||||
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
|
pages = []
|
||||||
|
fb_pages = fb_object.get_connections('me', 'accounts')
|
||||||
|
pages.concat(fb_pages)
|
||||||
|
while fb_pages.respond_to?(:next_page) && (next_page = fb_pages.next_page)
|
||||||
|
fb_pages = next_page
|
||||||
|
pages.concat(fb_pages)
|
||||||
|
end
|
||||||
|
@page_details = mark_already_existing_facebook_pages(pages)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_instagram_id(page_access_token, facebook_channel)
|
def set_instagram_id(page_access_token, facebook_channel)
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def campaign_params
|
def campaign_params
|
||||||
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
|
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
|
||||||
:scheduled_at, audience: [:type, :id], trigger_rules: {})
|
:scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
def resolved_contacts
|
def resolved_contacts
|
||||||
return @resolved_contacts if @resolved_contacts
|
return @resolved_contacts if @resolved_contacts
|
||||||
|
|
||||||
@resolved_contacts = Current.account.contacts.resolved_contacts
|
@resolved_contacts = Current.account.contacts.resolved_contacts(use_crm_v2: Current.account.feature_enabled?('crm_v2'))
|
||||||
|
|
||||||
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
|
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
|
||||||
@resolved_contacts
|
@resolved_contacts
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sync_templates
|
||||||
|
unless @inbox.channel.is_a?(Channel::Whatsapp)
|
||||||
|
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' }
|
||||||
|
end
|
||||||
|
|
||||||
|
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||||
|
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||||
|
rescue StandardError => e
|
||||||
|
render status: :internal_server_error, json: { error: e.message }
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fetch_inbox
|
def fetch_inbox
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Bas
|
|||||||
def fetch_hook
|
def fetch_hook
|
||||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
|
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,4 +18,4 @@ class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::O
|
|||||||
render json: { success: false }, status: :unprocessable_entity
|
render json: { success: false }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def send_instructions
|
||||||
|
email = permitted_params[:email]
|
||||||
|
return render_could_not_create_error(I18n.t('portals.send_instructions.email_required')) if email.blank?
|
||||||
|
return render_could_not_create_error(I18n.t('portals.send_instructions.invalid_email_format')) unless valid_email?(email)
|
||||||
|
return render_could_not_create_error(I18n.t('portals.send_instructions.custom_domain_not_configured')) if @portal.custom_domain.blank?
|
||||||
|
|
||||||
|
PortalInstructionsMailer.send_cname_instructions(
|
||||||
|
portal: @portal,
|
||||||
|
recipient_email: email
|
||||||
|
).deliver_later
|
||||||
|
|
||||||
|
render json: { message: I18n.t('portals.send_instructions.instructions_sent_successfully') }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
def process_attached_logo
|
def process_attached_logo
|
||||||
blob_id = params[:blob_id]
|
blob_id = params[:blob_id]
|
||||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||||
@@ -60,12 +74,12 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:id)
|
params.permit(:id, :email)
|
||||||
end
|
end
|
||||||
|
|
||||||
def portal_params
|
def portal_params
|
||||||
params.require(:portal).permit(
|
params.require(:portal).permit(
|
||||||
:account_id, :color, :custom_domain, :header_text, :homepage_link,
|
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
|
||||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -88,4 +102,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
domain = URI.parse(@portal.custom_domain)
|
domain = URI.parse(@portal.custom_domain)
|
||||||
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
|
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def valid_email?(email)
|
||||||
|
ValidEmail2::Address.new(email).valid?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Api::V1::Accounts::PortalsController.prepend_mod_with('Api::V1::Accounts::PortalsController')
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :validate_feature_enabled!
|
||||||
|
|
||||||
|
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||||
|
# Handles the embedded signup callback data from the Facebook SDK
|
||||||
|
def create
|
||||||
|
validate_embedded_signup_params!
|
||||||
|
channel = process_embedded_signup
|
||||||
|
render_success_response(channel.inbox)
|
||||||
|
rescue StandardError => e
|
||||||
|
render_error_response(e)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_embedded_signup
|
||||||
|
service = Whatsapp::EmbeddedSignupService.new(
|
||||||
|
account: Current.account,
|
||||||
|
code: params[:code],
|
||||||
|
business_id: params[:business_id],
|
||||||
|
waba_id: params[:waba_id],
|
||||||
|
phone_number_id: params[:phone_number_id]
|
||||||
|
)
|
||||||
|
service.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_success_response(inbox)
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
id: inbox.id,
|
||||||
|
name: inbox.name,
|
||||||
|
channel_type: 'whatsapp'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_error_response(error)
|
||||||
|
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
|
||||||
|
Rails.logger.error error.backtrace.join("\n")
|
||||||
|
render json: {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
}, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_feature_enabled!
|
||||||
|
return if Current.account.feature_whatsapp_embedded_signup?
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: false,
|
||||||
|
error: 'WhatsApp embedded signup is not enabled for this account'
|
||||||
|
}, status: :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_embedded_signup_params!
|
||||||
|
missing_params = []
|
||||||
|
missing_params << 'code' if params[:code].blank?
|
||||||
|
missing_params << 'business_id' if params[:business_id].blank?
|
||||||
|
missing_params << 'waba_id' if params[:waba_id].blank?
|
||||||
|
|
||||||
|
return if missing_params.empty?
|
||||||
|
|
||||||
|
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -67,6 +67,8 @@ class DashboardController < ActionController::Base
|
|||||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
||||||
|
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||||
|
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||||
GIT_SHA: GIT_HASH
|
GIT_SHA: GIT_HASH
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class MicrosoftController < ApplicationController
|
|||||||
after_action :set_version_header
|
after_action :set_version_header
|
||||||
|
|
||||||
def identity_association
|
def identity_association
|
||||||
microsoft_indentity
|
microsoft_identity
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -11,7 +11,7 @@ class MicrosoftController < ApplicationController
|
|||||||
response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length
|
response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length
|
||||||
end
|
end
|
||||||
|
|
||||||
def microsoft_indentity
|
def microsoft_identity
|
||||||
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
|
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ class Notion::CallbacksController < OauthCallbackController
|
|||||||
def notion_redirect_uri
|
def notion_redirect_uri
|
||||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
|
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
||||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||||
|
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||||
|
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
|
||||||
}
|
}
|
||||||
|
|
||||||
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
class SuperAdmin::ApplicationController < Administrate::ApplicationController
|
class SuperAdmin::ApplicationController < Administrate::ApplicationController
|
||||||
include ActionView::Helpers::TagHelper
|
include ActionView::Helpers::TagHelper
|
||||||
include ActionView::Context
|
include ActionView::Context
|
||||||
|
include SuperAdmin::NavigationHelper
|
||||||
|
|
||||||
helper_method :render_vue_component
|
helper_method :render_vue_component, :settings_open?, :settings_pages
|
||||||
# authenticiation done via devise : SuperAdmin Model
|
# authenticiation done via devise : SuperAdmin Model
|
||||||
before_action :authenticate_super_admin!
|
before_action :authenticate_super_admin!
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ class Webhooks::InstagramController < ActionController::API
|
|||||||
def events
|
def events
|
||||||
Rails.logger.info('Instagram webhook received events')
|
Rails.logger.info('Instagram webhook received events')
|
||||||
if params['object'].casecmp('instagram').zero?
|
if params['object'].casecmp('instagram').zero?
|
||||||
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
|
entry_params = params.to_unsafe_hash[:entry]
|
||||||
|
|
||||||
|
if contains_echo_event?(entry_params)
|
||||||
|
# Add delay to prevent race condition where echo arrives before send message API completes
|
||||||
|
# This avoids duplicate messages when echo comes early during API processing
|
||||||
|
::Webhooks::InstagramEventsJob.set(wait: 2.seconds).perform_later(entry_params)
|
||||||
|
else
|
||||||
|
::Webhooks::InstagramEventsJob.perform_later(entry_params)
|
||||||
|
end
|
||||||
|
|
||||||
render json: :ok
|
render json: :ok
|
||||||
else
|
else
|
||||||
Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}")
|
Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}")
|
||||||
@@ -14,6 +23,16 @@ class Webhooks::InstagramController < ActionController::API
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def contains_echo_event?(entry_params)
|
||||||
|
return false unless entry_params.is_a?(Array)
|
||||||
|
|
||||||
|
entry_params.any? do |entry|
|
||||||
|
# Check messaging array for echo events
|
||||||
|
messaging_events = entry[:messaging] || []
|
||||||
|
messaging_events.any? { |messaging| messaging.dig(:message, :is_echo).present? }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def valid_token?(token)
|
def valid_token?(token)
|
||||||
# Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and
|
# Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and
|
||||||
# INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login)
|
# INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login)
|
||||||
|
|||||||
+32
-9
@@ -1,5 +1,7 @@
|
|||||||
# TODO: Move this values to features.yml itself
|
# TODO: Move this values to features.yml itself
|
||||||
# No need to replicate the same values in two places
|
# No need to replicate the same values in two places
|
||||||
|
|
||||||
|
# ------- Premium Features ------- #
|
||||||
captain:
|
captain:
|
||||||
name: 'Captain'
|
name: 'Captain'
|
||||||
description: 'Enable AI-powered conversations with your customers.'
|
description: 'Enable AI-powered conversations with your customers.'
|
||||||
@@ -32,6 +34,15 @@ disable_branding:
|
|||||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||||
icon: 'icon-sailbot-fill'
|
icon: 'icon-sailbot-fill'
|
||||||
enterprise: true
|
enterprise: true
|
||||||
|
|
||||||
|
# ------- Product Features ------- #
|
||||||
|
help_center:
|
||||||
|
name: 'Help Center'
|
||||||
|
description: 'Allow agents to create help center articles and publish them in a portal.'
|
||||||
|
enabled: true
|
||||||
|
icon: 'icon-book-2-line'
|
||||||
|
|
||||||
|
# ------- Communication Channels ------- #
|
||||||
live_chat:
|
live_chat:
|
||||||
name: 'Live Chat'
|
name: 'Live Chat'
|
||||||
description: 'Improve your customer experience using a live chat on your website.'
|
description: 'Improve your customer experience using a live chat on your website.'
|
||||||
@@ -42,6 +53,12 @@ email:
|
|||||||
description: 'Manage your email customer interactions from Chatwoot.'
|
description: 'Manage your email customer interactions from Chatwoot.'
|
||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-mail-send-fill'
|
icon: 'icon-mail-send-fill'
|
||||||
|
config_key: 'email'
|
||||||
|
sms:
|
||||||
|
name: 'SMS'
|
||||||
|
description: 'Manage your SMS customer interactions from Chatwoot.'
|
||||||
|
enabled: true
|
||||||
|
icon: 'icon-message-line'
|
||||||
messenger:
|
messenger:
|
||||||
name: 'Messenger'
|
name: 'Messenger'
|
||||||
description: 'Stay connected with your customers on Facebook & Instagram.'
|
description: 'Stay connected with your customers on Facebook & Instagram.'
|
||||||
@@ -69,22 +86,22 @@ line:
|
|||||||
description: 'Manage your Line customer interactions from Chatwoot.'
|
description: 'Manage your Line customer interactions from Chatwoot.'
|
||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-line-line'
|
icon: 'icon-line-line'
|
||||||
sms:
|
|
||||||
name: 'SMS'
|
# ------- OAuth & Authentication ------- #
|
||||||
description: 'Manage your SMS customer interactions from Chatwoot.'
|
google:
|
||||||
|
name: 'Google'
|
||||||
|
description: 'Configuration for setting up Google OAuth Integration'
|
||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-message-line'
|
icon: 'icon-google'
|
||||||
help_center:
|
config_key: 'google'
|
||||||
name: 'Help Center'
|
|
||||||
description: 'Allow agents to create help center articles and publish them in a portal.'
|
|
||||||
enabled: true
|
|
||||||
icon: 'icon-book-2-line'
|
|
||||||
microsoft:
|
microsoft:
|
||||||
name: 'Microsoft'
|
name: 'Microsoft'
|
||||||
description: 'Configuration for setting up Microsoft Email'
|
description: 'Configuration for setting up Microsoft Email'
|
||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-microsoft'
|
icon: 'icon-microsoft'
|
||||||
config_key: 'microsoft'
|
config_key: 'microsoft'
|
||||||
|
|
||||||
|
# ------- Third-party Integrations ------- #
|
||||||
linear:
|
linear:
|
||||||
name: 'Linear'
|
name: 'Linear'
|
||||||
description: 'Configuration for setting up Linear Integration'
|
description: 'Configuration for setting up Linear Integration'
|
||||||
@@ -103,6 +120,12 @@ slack:
|
|||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-slack'
|
icon: 'icon-slack'
|
||||||
config_key: 'slack'
|
config_key: 'slack'
|
||||||
|
whatsapp_embedded:
|
||||||
|
name: 'WhatsApp Embedded'
|
||||||
|
description: 'Configuration for setting up WhatsApp Embedded Integration'
|
||||||
|
enabled: true
|
||||||
|
icon: 'icon-whatsapp-line'
|
||||||
|
config_key: 'whatsapp_embedded'
|
||||||
shopify:
|
shopify:
|
||||||
name: 'Shopify'
|
name: 'Shopify'
|
||||||
description: 'Configuration for setting up Shopify Integration'
|
description: 'Configuration for setting up Shopify Integration'
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
module SuperAdmin::FeaturesHelper
|
module SuperAdmin::FeaturesHelper
|
||||||
def self.available_features
|
def self.available_features
|
||||||
YAML.load(ERB.new(Rails.root.join('enterprise/app/helpers/super_admin/features.yml').read).result).with_indifferent_access
|
YAML.load(ERB.new(Rails.root.join('app/helpers/super_admin/features.yml').read).result).with_indifferent_access
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.plan_details
|
def self.plan_details
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module SuperAdmin::NavigationHelper
|
||||||
|
def settings_open?
|
||||||
|
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
|
||||||
|
end
|
||||||
|
|
||||||
|
def settings_pages
|
||||||
|
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
|
||||||
|
attrs['config_key'].present? && attrs['enabled']
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add general at the beginning
|
||||||
|
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
|
||||||
|
|
||||||
|
general_feature + features.to_a
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -136,7 +136,7 @@ export default {
|
|||||||
<div
|
<div
|
||||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||||
id="app"
|
id="app"
|
||||||
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
|
class="flex flex-col w-full h-screen min-h-0"
|
||||||
:class="{ 'app-rtl--wrapper': isRTL }"
|
:class="{ 'app-rtl--wrapper': isRTL }"
|
||||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainScenarios extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/assistants', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get({ assistantId, page = 1, searchKey } = {}) {
|
||||||
|
return axios.get(`${this.url}/${assistantId}/scenarios`, {
|
||||||
|
params: { page, searchKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
show({ assistantId, id }) {
|
||||||
|
return axios.get(`${this.url}/${assistantId}/scenarios/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create({ assistantId, ...data } = {}) {
|
||||||
|
return axios.post(`${this.url}/${assistantId}/scenarios`, {
|
||||||
|
scenario: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update({ assistantId, id }, data = {}) {
|
||||||
|
return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, {
|
||||||
|
scenario: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete({ assistantId, id }) {
|
||||||
|
return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainScenarios();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainTools extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/assistants/tools', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(params = {}) {
|
||||||
|
return axios.get(this.url, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainTools();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class WhatsappChannel extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('whatsapp', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
createEmbeddedSignup(params) {
|
||||||
|
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new WhatsappChannel();
|
||||||
@@ -21,6 +21,14 @@ class PortalsAPI extends ApiClient {
|
|||||||
deleteLogo(portalSlug) {
|
deleteLogo(portalSlug) {
|
||||||
return axios.delete(`${this.url}/${portalSlug}/logo`);
|
return axios.delete(`${this.url}/${portalSlug}/logo`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendCnameInstructions(portalSlug, email) {
|
||||||
|
return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email });
|
||||||
|
}
|
||||||
|
|
||||||
|
sslStatus(portalSlug) {
|
||||||
|
return axios.get(`${this.url}/${portalSlug}/ssl_status`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PortalsAPI;
|
export default PortalsAPI;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
agent_bot: botId,
|
agent_bot: botId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncTemplates(inboxId) {
|
||||||
|
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Inboxes();
|
export default new Inboxes();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe('#InboxesAPI', () => {
|
|||||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||||
|
expect(inboxesAPI).toHaveProperty('syncTemplates');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('API calls', () => {
|
||||||
@@ -40,5 +41,12 @@ describe('#InboxesAPI', () => {
|
|||||||
inboxesAPI.deleteInboxAvatar(2);
|
inboxesAPI.deleteInboxAvatar(2);
|
||||||
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#syncTemplates', () => {
|
||||||
|
inboxesAPI.syncTemplates(2);
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/inboxes/2/sync_templates'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,30 +37,6 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-wrapper {
|
|
||||||
@apply h-screen flex-grow-0 min-h-0 w-full;
|
|
||||||
|
|
||||||
.button--fixed-top {
|
|
||||||
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner + .app-wrapper {
|
|
||||||
// Reduce the height of the dashboard to make room for the banner.
|
|
||||||
// And causing the top right green-action button to be pushed down when scrolling.
|
|
||||||
@apply h-[calc(100%-48px)];
|
|
||||||
|
|
||||||
.button--fixed-top {
|
|
||||||
@apply top-14;
|
|
||||||
}
|
|
||||||
|
|
||||||
.off-canvas-content {
|
|
||||||
.button--fixed-top {
|
|
||||||
@apply top-2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
.tooltip {
|
||||||
@apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96;
|
@apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96;
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
|
||||||
|
|
||||||
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
|
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||||
|
<template #empty-state-item>
|
||||||
|
<div class="flex flex-col gap-4 p-px">
|
||||||
|
<CampaignCard
|
||||||
|
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
|
||||||
|
:key="campaign.id"
|
||||||
|
:title="campaign.title"
|
||||||
|
:message="campaign.message"
|
||||||
|
:is-enabled="campaign.enabled"
|
||||||
|
:status="campaign.campaign_status"
|
||||||
|
:sender="campaign.sender"
|
||||||
|
:inbox="campaign.inbox"
|
||||||
|
:scheduled-at="campaign.scheduled_at"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</EmptyStateLayout>
|
||||||
|
</template>
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
|
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||||
|
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
|
||||||
|
|
||||||
|
import WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const addCampaign = async campaignDetails => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('campaigns/create', campaignDetails);
|
||||||
|
|
||||||
|
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
|
||||||
|
type: CAMPAIGN_TYPES.ONE_OFF,
|
||||||
|
});
|
||||||
|
|
||||||
|
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error?.response?.message ||
|
||||||
|
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
|
||||||
|
useAlert(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = campaignDetails => {
|
||||||
|
addCampaign(campaignDetails);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => emit('close');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
|
||||||
|
>
|
||||||
|
<h3 class="text-base font-medium text-n-slate-12">
|
||||||
|
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
||||||
|
</h3>
|
||||||
|
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+357
@@ -0,0 +1,357 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, computed, watch, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { required, minLength } from '@vuelidate/validators';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const formState = {
|
||||||
|
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||||
|
labels: useMapGetter('labels/getLabels'),
|
||||||
|
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
||||||
|
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
title: '',
|
||||||
|
inboxId: null,
|
||||||
|
templateId: null,
|
||||||
|
scheduledAt: null,
|
||||||
|
selectedAudience: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = reactive({ ...initialState });
|
||||||
|
const processedParams = ref({});
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
title: { required, minLength: minLength(1) },
|
||||||
|
inboxId: { required },
|
||||||
|
templateId: { required },
|
||||||
|
scheduledAt: { required },
|
||||||
|
selectedAudience: { required },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(rules, state);
|
||||||
|
|
||||||
|
const isCreating = computed(() => formState.uiFlags.value.isCreating);
|
||||||
|
|
||||||
|
const currentDateTime = computed(() => {
|
||||||
|
// Added to disable the scheduled at field from being set to the current time
|
||||||
|
const now = new Date();
|
||||||
|
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||||
|
return localTime.toISOString().slice(0, 16);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapToOptions = (items, valueKey, labelKey) =>
|
||||||
|
items?.map(item => ({
|
||||||
|
value: item[valueKey],
|
||||||
|
label: item[labelKey],
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
const audienceList = computed(() =>
|
||||||
|
mapToOptions(formState.labels.value, 'id', 'title')
|
||||||
|
);
|
||||||
|
|
||||||
|
const inboxOptions = computed(() =>
|
||||||
|
mapToOptions(formState.inboxes.value, 'id', 'name')
|
||||||
|
);
|
||||||
|
|
||||||
|
const templateOptions = computed(() => {
|
||||||
|
if (!state.inboxId) return [];
|
||||||
|
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
|
||||||
|
return templates.map(template => {
|
||||||
|
// Create a more user-friendly label from template name
|
||||||
|
const friendlyName = template.name
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, l => l.toUpperCase());
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: template.id,
|
||||||
|
label: `${friendlyName} (${template.language || 'en'})`,
|
||||||
|
template: template,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedTemplate = computed(() => {
|
||||||
|
if (!state.templateId) return null;
|
||||||
|
return templateOptions.value.find(option => option.value === state.templateId)
|
||||||
|
?.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 baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
|
||||||
|
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formErrors = computed(() => ({
|
||||||
|
title: getErrorMessage('title', 'TITLE'),
|
||||||
|
inbox: getErrorMessage('inboxId', 'INBOX'),
|
||||||
|
template: getErrorMessage('templateId', 'TEMPLATE'),
|
||||||
|
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
|
||||||
|
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hasRequiredTemplateParams = computed(() => {
|
||||||
|
const params = Object.values(processedParams.value);
|
||||||
|
return params.length === 0 || params.every(param => param.trim() !== '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSubmitDisabled = computed(
|
||||||
|
() => v$.value.$invalid || !hasRequiredTemplateParams.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatToUTCString = localDateTime =>
|
||||||
|
localDateTime ? new Date(localDateTime).toISOString() : null;
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
Object.assign(state, initialState);
|
||||||
|
processedParams.value = {};
|
||||||
|
v$.value.$reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
// Find the selected template to get its content
|
||||||
|
const currentTemplate = selectedTemplate.value;
|
||||||
|
|
||||||
|
// Extract template content - this should be the template message body
|
||||||
|
const templateContent = templateString.value;
|
||||||
|
|
||||||
|
// Prepare template_params object with the same structure as used in contacts
|
||||||
|
const templateParams = {
|
||||||
|
name: currentTemplate?.name || '',
|
||||||
|
namespace: currentTemplate?.namespace || '',
|
||||||
|
category: currentTemplate?.category || 'UTILITY',
|
||||||
|
language: currentTemplate?.language || 'en_US',
|
||||||
|
processed_params: processedParams.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: state.title,
|
||||||
|
message: templateContent,
|
||||||
|
template_params: templateParams,
|
||||||
|
inbox_id: state.inboxId,
|
||||||
|
scheduled_at: formatToUTCString(state.scheduledAt),
|
||||||
|
audience: state.selectedAudience?.map(id => ({
|
||||||
|
id,
|
||||||
|
type: 'Label',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const isFormValid = await v$.value.$validate();
|
||||||
|
if (!isFormValid) return;
|
||||||
|
|
||||||
|
emit('submit', prepareCampaignDetails());
|
||||||
|
resetState();
|
||||||
|
handleCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset template selection when inbox changes
|
||||||
|
watch(
|
||||||
|
() => state.inboxId,
|
||||||
|
() => {
|
||||||
|
state.templateId = null;
|
||||||
|
processedParams.value = {};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate variables when template changes
|
||||||
|
watch(
|
||||||
|
() => state.templateId,
|
||||||
|
() => {
|
||||||
|
generateVariables();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
|
<Input
|
||||||
|
v-model="state.title"
|
||||||
|
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
|
||||||
|
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
|
||||||
|
:message="formErrors.title"
|
||||||
|
:message-type="formErrors.title ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<ComboBox
|
||||||
|
id="inbox"
|
||||||
|
v-model="state.inboxId"
|
||||||
|
:options="inboxOptions"
|
||||||
|
:has-error="!!formErrors.inbox"
|
||||||
|
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.PLACEHOLDER')"
|
||||||
|
:message="formErrors.inbox"
|
||||||
|
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<ComboBox
|
||||||
|
id="template"
|
||||||
|
v-model="state.templateId"
|
||||||
|
:options="templateOptions"
|
||||||
|
:has-error="!!formErrors.template"
|
||||||
|
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
|
||||||
|
:message="formErrors.template"
|
||||||
|
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-n-slate-11">
|
||||||
|
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Template Preview -->
|
||||||
|
<div
|
||||||
|
v-if="selectedTemplate"
|
||||||
|
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<TagMultiSelectComboBox
|
||||||
|
v-model="state.selectedAudience"
|
||||||
|
:options="audienceList"
|
||||||
|
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
|
||||||
|
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
|
||||||
|
:has-error="!!formErrors.audience"
|
||||||
|
:message="formErrors.audience"
|
||||||
|
class="[&>div>button]:bg-n-alpha-black2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="state.scheduledAt"
|
||||||
|
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
|
||||||
|
type="datetime-local"
|
||||||
|
:min="currentDateTime"
|
||||||
|
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
|
||||||
|
:message="formErrors.scheduledAt"
|
||||||
|
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-between items-center w-full">
|
||||||
|
<Button
|
||||||
|
variant="faded"
|
||||||
|
color="slate"
|
||||||
|
type="button"
|
||||||
|
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
|
||||||
|
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||||
|
@click="handleCancel"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
|
||||||
|
class="w-full"
|
||||||
|
type="submit"
|
||||||
|
:is-loading="isCreating"
|
||||||
|
:disabled="isCreating || isSubmitDisabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -218,10 +218,13 @@ const resetForm = () => {
|
|||||||
Object.assign(state, defaultState);
|
Object.assign(state, defaultState);
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => props.contactData, prepareStateBasedOnProps, {
|
watch(
|
||||||
immediate: true,
|
() => props.contactData?.id,
|
||||||
deep: true,
|
id => {
|
||||||
});
|
if (id) prepareStateBasedOnProps();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
// Expose state to parent component for avatar upload
|
// Expose state to parent component for avatar upload
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const props = defineProps({
|
|||||||
enableVariables: { type: Boolean, default: false },
|
enableVariables: { type: Boolean, default: false },
|
||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
enabledMenuOptions: { type: Array, default: () => [] },
|
enabledMenuOptions: { type: Array, default: () => [] },
|
||||||
|
enableCaptainTools: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
@@ -98,6 +99,7 @@ watch(
|
|||||||
:enable-variables="enableVariables"
|
:enable-variables="enableVariables"
|
||||||
:enable-canned-responses="enableCannedResponses"
|
:enable-canned-responses="enableCannedResponses"
|
||||||
:enabled-menu-options="enabledMenuOptions"
|
:enabled-menu-options="enabledMenuOptions"
|
||||||
|
:enable-captain-tools="enableCaptainTools"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
|
|||||||
+26
-1
@@ -1,6 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, watch } from 'vue';
|
import { ref, reactive, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { helpers } from '@vuelidate/validators';
|
||||||
|
import { isValidDomain } from '@chatwoot/utils';
|
||||||
|
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
@@ -26,6 +29,20 @@ const formState = reactive({
|
|||||||
customDomain: props.customDomain,
|
customDomain: props.customDomain,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
customDomain: {
|
||||||
|
isValidDomain: helpers.withMessage(
|
||||||
|
() =>
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.FORMAT_ERROR'
|
||||||
|
),
|
||||||
|
isValidDomain
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(rules, formState);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.customDomain,
|
() => props.customDomain,
|
||||||
newVal => {
|
newVal => {
|
||||||
@@ -33,7 +50,10 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDialogConfirm = () => {
|
const handleDialogConfirm = async () => {
|
||||||
|
const isFormCorrect = await v$.value.$validate();
|
||||||
|
if (!isFormCorrect) return;
|
||||||
|
|
||||||
emit('addCustomDomain', formState.customDomain);
|
emit('addCustomDomain', formState.customDomain);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +87,11 @@ defineExpose({ dialogRef });
|
|||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
:message="
|
||||||
|
v$.customDomain.$error ? v$.customDomain.$errors[0].$message : ''
|
||||||
|
"
|
||||||
|
:message-type="v$.customDomain.$error ? 'error' : 'info'"
|
||||||
|
@blur="v$.customDomain.$touch()"
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+138
-37
@@ -1,9 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { reactive, computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||||
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
|
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
|
||||||
|
import { email, required } from '@vuelidate/validators';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
customDomain: {
|
customDomain: {
|
||||||
@@ -12,10 +18,20 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['confirm']);
|
const emit = defineEmits(['send', 'close']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
email: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const validationRules = {
|
||||||
|
email: { email, required },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
const domain = computed(() => {
|
const domain = computed(() => {
|
||||||
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
|
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
|
||||||
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
|
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
|
||||||
@@ -25,10 +41,34 @@ const subdomainCNAME = computed(
|
|||||||
() => `${props.customDomain} CNAME ${domain.value}`
|
() => `${props.customDomain} CNAME ${domain.value}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCopy = async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await copyTextToClipboard(subdomainCNAME.value);
|
||||||
|
useAlert(
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.COPY'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
const handleDialogConfirm = () => {
|
const resetForm = () => {
|
||||||
emit('confirm');
|
v$.value.$reset();
|
||||||
|
state.email = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
resetForm();
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const isFormCorrect = await v$.value.$validate();
|
||||||
|
if (!isFormCorrect) return;
|
||||||
|
|
||||||
|
emit('send', state.email);
|
||||||
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
defineExpose({ dialogRef });
|
defineExpose({ dialogRef });
|
||||||
@@ -37,42 +77,103 @@ defineExpose({ dialogRef });
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
:title="
|
|
||||||
t(
|
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:confirm-button-label="
|
|
||||||
t(
|
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:show-cancel-button="false"
|
:show-cancel-button="false"
|
||||||
@confirm="handleDialogConfirm"
|
:show-confirm-button="false"
|
||||||
|
@close="resetForm"
|
||||||
>
|
>
|
||||||
<template #description>
|
<NextButton
|
||||||
<p class="mb-0 text-sm text-n-slate-12">
|
icon="i-lucide-x"
|
||||||
{{
|
sm
|
||||||
t(
|
ghost
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
|
slate
|
||||||
)
|
class="flex-shrink-0 absolute top-2 ltr:right-2 rtl:left-2"
|
||||||
}}
|
@click="onClose"
|
||||||
</p>
|
/>
|
||||||
</template>
|
<div class="flex flex-col gap-6 divide-y divide-n-strong">
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
|
||||||
|
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<p class="mb-0 text-sm text-n-slate-12">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 w-full">
|
||||||
|
<span
|
||||||
|
class="min-h-10 px-3 py-2.5 inline-flex items-center w-full text-sm bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
|
||||||
|
>
|
||||||
|
{{ subdomainCNAME }}
|
||||||
|
</span>
|
||||||
|
<NextButton
|
||||||
|
faded
|
||||||
|
slate
|
||||||
|
type="button"
|
||||||
|
icon="i-lucide-copy"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
@click="handleCopy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6 pt-6">
|
||||||
<span
|
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
|
||||||
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
|
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||||
>
|
{{
|
||||||
{{ subdomainCNAME }}
|
t(
|
||||||
</span>
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.HEADER'
|
||||||
<p class="text-sm text-n-slate-12">
|
)
|
||||||
{{
|
}}
|
||||||
t(
|
</h3>
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
|
<p class="mb-0 text-sm text-n-slate-12">
|
||||||
)
|
{{
|
||||||
}}
|
t(
|
||||||
</p>
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.DESCRIPTION'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
class="flex items-start gap-3 w-full"
|
||||||
|
@submit.prevent="handleSend"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="state.email"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.PLACEHOLDER'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:message="
|
||||||
|
v$.email.$error
|
||||||
|
? t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.ERROR'
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
:message-type="v$.email.$error ? 'error' : 'info'"
|
||||||
|
class="w-full"
|
||||||
|
@blur="v$.email.$touch()"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
:label="
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.SEND_BUTTON'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
type="submit"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+159
-23
@@ -1,6 +1,7 @@
|
|||||||
<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 { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
|
||||||
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
|
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
|
||||||
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
|
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
|
||||||
@@ -11,11 +12,52 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
isFetchingStatus: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['updatePortalConfiguration']);
|
const emit = defineEmits([
|
||||||
|
'updatePortalConfiguration',
|
||||||
|
'refreshStatus',
|
||||||
|
'sendCnameInstructions',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SSL_STATUS = {
|
||||||
|
LIVE: ['active', 'staging_active'],
|
||||||
|
PENDING: [
|
||||||
|
'provisioned',
|
||||||
|
'pending',
|
||||||
|
'initializing',
|
||||||
|
'pending_validation',
|
||||||
|
'pending_deployment',
|
||||||
|
'pending_issuance',
|
||||||
|
'holding_deployment',
|
||||||
|
'holding_validation',
|
||||||
|
'pending_expiration',
|
||||||
|
'pending_cleanup',
|
||||||
|
'pending_deletion',
|
||||||
|
'staging_deployment',
|
||||||
|
'backup_issued',
|
||||||
|
],
|
||||||
|
ERROR: [
|
||||||
|
'blocked',
|
||||||
|
'inactive',
|
||||||
|
'moved',
|
||||||
|
'expired',
|
||||||
|
'deleted',
|
||||||
|
'timed_out_initializing',
|
||||||
|
'timed_out_validation',
|
||||||
|
'timed_out_issuance',
|
||||||
|
'timed_out_deployment',
|
||||||
|
'timed_out_deletion',
|
||||||
|
'deactivating',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
|
||||||
const addCustomDomainDialogRef = ref(null);
|
const addCustomDomainDialogRef = ref(null);
|
||||||
const dnsConfigurationDialogRef = ref(null);
|
const dnsConfigurationDialogRef = ref(null);
|
||||||
@@ -25,6 +67,45 @@ const customDomainAddress = computed(
|
|||||||
() => props.activePortal?.custom_domain || ''
|
() => props.activePortal?.custom_domain || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sslSettings = computed(() => props.activePortal?.ssl_settings || {});
|
||||||
|
const verificationErrors = computed(
|
||||||
|
() => sslSettings.value.verification_errors || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLive = computed(() =>
|
||||||
|
SSL_STATUS.LIVE.includes(sslSettings.value.status)
|
||||||
|
);
|
||||||
|
const isPending = computed(() =>
|
||||||
|
SSL_STATUS.PENDING.includes(sslSettings.value.status)
|
||||||
|
);
|
||||||
|
const isError = computed(() =>
|
||||||
|
SSL_STATUS.ERROR.includes(sslSettings.value.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
if (isLive.value)
|
||||||
|
return t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.LIVE'
|
||||||
|
);
|
||||||
|
if (isPending.value)
|
||||||
|
return t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.PENDING'
|
||||||
|
);
|
||||||
|
if (isError.value)
|
||||||
|
return t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.ERROR'
|
||||||
|
);
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColors = computed(() => {
|
||||||
|
if (isLive.value)
|
||||||
|
return { text: 'text-n-teal-11', bubble: 'outline-n-teal-6 bg-n-teal-9' };
|
||||||
|
if (isError.value)
|
||||||
|
return { text: 'text-n-ruby-11', bubble: 'outline-n-ruby-6 bg-n-ruby-9' };
|
||||||
|
return { text: 'text-n-amber-11', bubble: 'outline-n-amber-6 bg-n-amber-9' };
|
||||||
|
});
|
||||||
|
|
||||||
const updatePortalConfiguration = customDomain => {
|
const updatePortalConfiguration = customDomain => {
|
||||||
const portal = {
|
const portal = {
|
||||||
id: props.activePortal?.id,
|
id: props.activePortal?.id,
|
||||||
@@ -42,6 +123,17 @@ const closeDNSConfigurationDialog = () => {
|
|||||||
updatedDomainAddress.value = '';
|
updatedDomainAddress.value = '';
|
||||||
dnsConfigurationDialogRef.value.dialogRef.close();
|
dnsConfigurationDialogRef.value.dialogRef.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onClickRefreshSSLStatus = () => {
|
||||||
|
emit('refreshStatus');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickSend = email => {
|
||||||
|
emit('sendCnameInstructions', {
|
||||||
|
portalSlug: props.activePortal?.slug,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -63,33 +155,76 @@ const closeDNSConfigurationDialog = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col w-full gap-4">
|
<div class="flex flex-col w-full gap-4">
|
||||||
<div class="flex justify-between w-full gap-2">
|
<div class="flex items-center justify-between w-full gap-2">
|
||||||
<div
|
<div v-if="customDomainAddress" class="flex flex-col gap-1">
|
||||||
v-if="customDomainAddress"
|
<div class="flex items-center w-full h-8 gap-4">
|
||||||
class="flex items-center w-full h-8 gap-4"
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
>
|
{{
|
||||||
<label class="text-sm font-medium text-n-slate-12">
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
<span class="text-sm text-n-slate-12">
|
||||||
|
{{ customDomainAddress }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="!isLive && isOnChatwootCloud"
|
||||||
|
class="text-sm text-n-slate-11"
|
||||||
|
>
|
||||||
{{
|
{{
|
||||||
t(
|
t(
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION'
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</label>
|
|
||||||
<span class="text-sm text-n-slate-12">
|
|
||||||
{{ customDomainAddress }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end w-full">
|
<div class="flex items-center">
|
||||||
<Button
|
<div v-if="customDomainAddress" class="flex items-center gap-3">
|
||||||
v-if="customDomainAddress"
|
<div
|
||||||
color="slate"
|
v-if="statusText && isOnChatwootCloud"
|
||||||
:label="
|
v-tooltip="verificationErrors"
|
||||||
t(
|
class="flex items-center gap-3 flex-shrink-0"
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
|
>
|
||||||
)
|
<span
|
||||||
"
|
class="size-1.5 rounded-full outline outline-2 block flex-shrink-0"
|
||||||
@click="addCustomDomainDialogRef.dialogRef.open()"
|
:class="statusColors.bubble"
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
:class="statusColors.text"
|
||||||
|
class="text-sm leading-[16px] font-medium"
|
||||||
|
>
|
||||||
|
{{ statusText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="statusText && isOnChatwootCloud"
|
||||||
|
class="w-px h-3 bg-n-weak"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
link
|
||||||
|
:label="
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="hover:!no-underline flex-shrink-0"
|
||||||
|
@click="addCustomDomainDialogRef.dialogRef.open()"
|
||||||
|
/>
|
||||||
|
<div v-if="isOnChatwootCloud" class="w-px h-3 bg-n-weak" />
|
||||||
|
<Button
|
||||||
|
v-if="isOnChatwootCloud"
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
link
|
||||||
|
icon="i-lucide-refresh-ccw"
|
||||||
|
:class="isFetchingStatus && 'animate-spin'"
|
||||||
|
@click="onClickRefreshSSLStatus"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-else
|
v-else
|
||||||
:label="
|
:label="
|
||||||
@@ -112,7 +247,8 @@ const closeDNSConfigurationDialog = () => {
|
|||||||
<DNSConfigurationDialog
|
<DNSConfigurationDialog
|
||||||
ref="dnsConfigurationDialogRef"
|
ref="dnsConfigurationDialogRef"
|
||||||
:custom-domain="updatedDomainAddress || customDomainAddress"
|
:custom-domain="updatedDomainAddress || customDomainAddress"
|
||||||
@confirm="closeDNSConfigurationDialog"
|
@close="closeDNSConfigurationDialog"
|
||||||
|
@send="onClickSend"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+14
@@ -26,6 +26,8 @@ const emit = defineEmits([
|
|||||||
'updatePortal',
|
'updatePortal',
|
||||||
'updatePortalConfiguration',
|
'updatePortalConfiguration',
|
||||||
'deletePortal',
|
'deletePortal',
|
||||||
|
'refreshStatus',
|
||||||
|
'sendCnameInstructions',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -36,6 +38,7 @@ const confirmDeletePortalDialogRef = ref(null);
|
|||||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||||
|
|
||||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||||
|
const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus');
|
||||||
|
|
||||||
const activePortal = computed(() => {
|
const activePortal = computed(() => {
|
||||||
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
|
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
|
||||||
@@ -53,6 +56,14 @@ const handleUpdatePortalConfiguration = portal => {
|
|||||||
emit('updatePortalConfiguration', portal);
|
emit('updatePortalConfiguration', portal);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchSSLStatus = () => {
|
||||||
|
emit('refreshStatus');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendCnameInstructions = payload => {
|
||||||
|
emit('sendCnameInstructions', payload);
|
||||||
|
};
|
||||||
|
|
||||||
const openConfirmDeletePortalDialog = () => {
|
const openConfirmDeletePortalDialog = () => {
|
||||||
confirmDeletePortalDialogRef.value.dialogRef.open();
|
confirmDeletePortalDialogRef.value.dialogRef.open();
|
||||||
};
|
};
|
||||||
@@ -85,7 +96,10 @@ const handleDeletePortal = () => {
|
|||||||
<PortalConfigurationSettings
|
<PortalConfigurationSettings
|
||||||
:active-portal="activePortal"
|
:active-portal="activePortal"
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
|
:is-fetching-status="isFetchingSSLStatus"
|
||||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||||
|
@refresh-status="fetchSSLStatus"
|
||||||
|
@send-cname-instructions="handleSendCnameInstructions"
|
||||||
/>
|
/>
|
||||||
<div class="w-full h-px bg-n-weak" />
|
<div class="w-full h-px bg-n-weak" />
|
||||||
<div class="flex items-end justify-between w-full gap-4">
|
<div class="flex items-end justify-between w-full gap-4">
|
||||||
|
|||||||
@@ -63,11 +63,12 @@ const lastActivityAt = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const menuItems = computed(() => [
|
const menuItems = computed(() => [
|
||||||
{ key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
|
|
||||||
{
|
{
|
||||||
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
|
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
|
||||||
|
icon: isUnread.value ? 'mail' : 'mail-unread',
|
||||||
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
|
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
|
||||||
},
|
},
|
||||||
|
{ key: 'delete', icon: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const messageClasses = computed(() => ({
|
const messageClasses = computed(() => ({
|
||||||
@@ -153,7 +154,7 @@ onBeforeMount(contextMenuActions.close);
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
class="flex flex-col w-full gap-2 p-3 transition-all duration-300 ease-in-out cursor-pointer"
|
class="flex flex-col w-full gap-1 p-3 transition-all duration-300 ease-in-out cursor-pointer"
|
||||||
@contextmenu="contextMenuActions.open($event)"
|
@contextmenu="contextMenuActions.open($event)"
|
||||||
@click="emit('click')"
|
@click="emit('click')"
|
||||||
>
|
>
|
||||||
@@ -232,7 +233,7 @@ onBeforeMount(contextMenuActions.close);
|
|||||||
class="flex-shrink-0 text-n-slate-11 size-2.5"
|
class="flex-shrink-0 text-n-slate-11 size-2.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-n-slate-10">
|
<span class="text-xs text-n-slate-10">
|
||||||
{{ lastActivityAt }}
|
{{ lastActivityAt }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const emit = defineEmits(['click']);
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const onClick = event => {
|
const onClick = (item, index) => {
|
||||||
emit('click', event);
|
emit('click', item, index);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -24,22 +24,25 @@ const onClick = event => {
|
|||||||
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
|
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
|
||||||
<ol class="flex items-center mb-0">
|
<ol class="flex items-center mb-0">
|
||||||
<li v-for="(item, index) in items" :key="index" class="flex items-center">
|
<li v-for="(item, index) in items" :key="index" class="flex items-center">
|
||||||
|
<Icon
|
||||||
|
v-if="index > 0"
|
||||||
|
icon="i-lucide-chevron-right"
|
||||||
|
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Render as button for all except the last item -->
|
||||||
<button
|
<button
|
||||||
v-if="index === 0"
|
v-if="index !== items.length - 1"
|
||||||
class="inline-flex items-center justify-center min-w-0 gap-2 p-0 text-sm font-medium transition-all duration-200 ease-in-out border-0 rounded-lg text-n-slate-11 hover:text-n-slate-12 outline-transparent max-w-56"
|
class="inline-flex items-center justify-center min-w-0 gap-2 p-0 text-sm font-medium transition-all duration-200 ease-in-out border-0 rounded-lg text-n-slate-11 hover:text-n-slate-12 outline-transparent max-w-56"
|
||||||
@click="onClick"
|
@click="onClick(item, index)"
|
||||||
>
|
>
|
||||||
<span class="min-w-0 truncate">{{ item.label }}</span>
|
<span class="min-w-0 truncate">{{ item.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
<template v-else>
|
|
||||||
<Icon
|
<!-- The last breadcrumb item is plain text -->
|
||||||
icon="i-lucide-chevron-right"
|
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
|
||||||
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
|
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
||||||
/>
|
</span>
|
||||||
<span class="text-sm truncate text-n-slate-12 max-w-56">
|
|
||||||
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
isFetching: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isEmpty: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
currentPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
totalCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 100,
|
||||||
|
},
|
||||||
|
itemsPerPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 25,
|
||||||
|
},
|
||||||
|
showPaginationFooter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
breadcrumbItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:currentPage']);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handlePageChange = event => {
|
||||||
|
emit('update:currentPage', event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBreadcrumbClick = item => {
|
||||||
|
router.push({
|
||||||
|
name: item.routeName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
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">
|
||||||
|
<header class="mb-7 sticky top-0 z-10 bg-n-background">
|
||||||
|
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||||
|
</header>
|
||||||
|
<main class="flex gap-16 w-full flex-1 pb-16">
|
||||||
|
<section
|
||||||
|
v-if="$slots.body || $slots.emptyState || isFetching"
|
||||||
|
class="flex flex-col w-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isFetching"
|
||||||
|
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||||
|
>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isEmpty">
|
||||||
|
<slot name="emptyState" />
|
||||||
|
</div>
|
||||||
|
<slot v-else name="body" />
|
||||||
|
</section>
|
||||||
|
<section v-if="$slots.controls" class="flex w-full">
|
||||||
|
<slot name="controls" />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
|
||||||
|
<PaginationFooter
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-items="totalCount"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
@update:current-page="handlePageChange"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import AddNewRulesDialog from './AddNewRulesDialog.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Captain/Assistant/AddNewRulesDialog"
|
||||||
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
>
|
||||||
|
<Variant title="Default">
|
||||||
|
<div class="px-4 py-4 bg-n-background h-[200px]">
|
||||||
|
<AddNewRulesDialog
|
||||||
|
button-label="Add a guardrail"
|
||||||
|
placeholder="Type in another guardrail..."
|
||||||
|
confirm-label="Create"
|
||||||
|
cancel-label="Cancel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
buttonLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
confirmLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
cancelLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['add']);
|
||||||
|
|
||||||
|
const modelValue = defineModel({
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showPopover, togglePopover] = useToggle();
|
||||||
|
const onClickAdd = () => {
|
||||||
|
if (!modelValue.value?.trim()) return;
|
||||||
|
emit('add', modelValue.value.trim());
|
||||||
|
modelValue.value = '';
|
||||||
|
togglePopover(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickCancel = () => {
|
||||||
|
togglePopover(false);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-on-click-outside="() => togglePopover(false)"
|
||||||
|
class="inline-flex relative"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
:label="buttonLabel"
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
class="flex-shrink-0"
|
||||||
|
@click="togglePopover(!showPopover)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="showPopover"
|
||||||
|
class="absolute w-[26.5rem] top-9 z-50 ltr:left-0 rtl:right-0 flex flex-col gap-5 bg-n-alpha-3 backdrop-blur-[100px] p-4 rounded-xl border border-n-weak shadow-md"
|
||||||
|
>
|
||||||
|
<InlineInput
|
||||||
|
v-model="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@keyup.enter="onClickAdd"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2 justify-between">
|
||||||
|
<Button
|
||||||
|
:label="cancelLabel"
|
||||||
|
sm
|
||||||
|
link
|
||||||
|
slate
|
||||||
|
class="h-10 hover:!no-underline"
|
||||||
|
@click="onClickCancel"
|
||||||
|
/>
|
||||||
|
<Button :label="confirmLabel" sm @click="onClickAdd" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
import AddNewRulesInput from './AddNewRulesInput.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Captain/Assistant/AddNewRulesInput"
|
||||||
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
>
|
||||||
|
<Variant title="Default">
|
||||||
|
<div class="px-6 py-4 bg-n-background">
|
||||||
|
<AddNewRulesInput
|
||||||
|
placeholder="Type in another response guideline..."
|
||||||
|
label="Add and save (↵)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup>
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['add']);
|
||||||
|
|
||||||
|
const modelValue = defineModel({
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClickAdd = () => {
|
||||||
|
if (!modelValue.value?.trim()) return;
|
||||||
|
emit('add', modelValue.value.trim());
|
||||||
|
modelValue.value = '';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex py-3 ltr:pl-3 h-16 rtl:pr-3 ltr:pr-4 rtl:pl-4 items-center gap-3 rounded-xl bg-n-solid-2 outline-1 outline outline-n-container"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-plus" class="text-n-slate-10 size-5 flex-shrink-0" />
|
||||||
|
|
||||||
|
<InlineInput
|
||||||
|
v-model="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@keyup.enter="onClickAdd"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="label"
|
||||||
|
ghost
|
||||||
|
xs
|
||||||
|
slate
|
||||||
|
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||||
|
@click="onClickAdd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { required, minLength } from '@vuelidate/validators';
|
||||||
|
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||||
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['add']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const [showPopover, togglePopover] = useToggle();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
instruction: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
title: { required, minLength: minLength(1) },
|
||||||
|
description: { required },
|
||||||
|
instruction: { required },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(rules, state);
|
||||||
|
|
||||||
|
const titleError = computed(() =>
|
||||||
|
v$.value.title.$error
|
||||||
|
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.ERROR')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const descriptionError = computed(() =>
|
||||||
|
v$.value.description.$error
|
||||||
|
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.ERROR')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const instructionError = computed(() =>
|
||||||
|
v$.value.instruction.$error
|
||||||
|
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
Object.assign(state, {
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
instruction: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickAdd = async () => {
|
||||||
|
v$.value.$touch();
|
||||||
|
if (v$.value.$invalid) return;
|
||||||
|
|
||||||
|
await emit('add', state);
|
||||||
|
resetState();
|
||||||
|
togglePopover(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickCancel = () => {
|
||||||
|
togglePopover(false);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-on-click-outside="() => togglePopover(false)"
|
||||||
|
class="inline-flex relative"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.CREATE')"
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
class="flex-shrink-0"
|
||||||
|
@click="togglePopover(!showPopover)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showPopover"
|
||||||
|
class="w-[31.25rem] absolute top-10 ltr:left-0 rtl:right-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6 z-50"
|
||||||
|
>
|
||||||
|
<h3 class="text-base font-medium text-n-slate-12">
|
||||||
|
{{ t(`CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.TITLE`) }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
v-model="state.title"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:message="titleError"
|
||||||
|
:message-type="titleError ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
v-model="state.description"
|
||||||
|
:label="
|
||||||
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
||||||
|
"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.PLACEHOLDER'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:message="descriptionError"
|
||||||
|
:message-type="descriptionError ? 'error' : 'info'"
|
||||||
|
show-character-count
|
||||||
|
/>
|
||||||
|
<Editor
|
||||||
|
v-model="state.instruction"
|
||||||
|
:label="
|
||||||
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.LABEL')
|
||||||
|
"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.PLACEHOLDER'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:message="instructionError"
|
||||||
|
:message-type="instructionError ? 'error' : 'info'"
|
||||||
|
:show-character-count="false"
|
||||||
|
enable-captain-tools
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between w-full gap-3">
|
||||||
|
<Button
|
||||||
|
variant="faded"
|
||||||
|
color="slate"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
|
||||||
|
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
|
||||||
|
@click="onClickCancel"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CREATE')"
|
||||||
|
class="w-full"
|
||||||
|
@click="onClickAdd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
allItems: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
selectAllLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
selectedCountLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
deleteLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'Delete',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['bulkDelete']);
|
||||||
|
|
||||||
|
const modelValue = defineModel({
|
||||||
|
type: Set,
|
||||||
|
default: () => new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCount = computed(() => modelValue.value.size);
|
||||||
|
const totalCount = computed(() => props.allItems.length);
|
||||||
|
|
||||||
|
const hasSelected = computed(() => selectedCount.value > 0);
|
||||||
|
const isIndeterminate = computed(
|
||||||
|
() => hasSelected.value && selectedCount.value < totalCount.value
|
||||||
|
);
|
||||||
|
const allSelected = computed(
|
||||||
|
() => totalCount.value > 0 && selectedCount.value === totalCount.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const bulkCheckboxState = computed({
|
||||||
|
get: () => allSelected.value,
|
||||||
|
set: shouldSelectAll => {
|
||||||
|
const newSelectedIds = shouldSelectAll
|
||||||
|
? new Set(props.allItems.map(item => item.id))
|
||||||
|
: new Set();
|
||||||
|
modelValue.value = newSelectedIds;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<transition
|
||||||
|
name="slide-fade"
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
|
||||||
|
enter-to-class="opacity-100 transform translate-x-0"
|
||||||
|
leave-active-class="hidden opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="hasSelected"
|
||||||
|
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Checkbox
|
||||||
|
v-model="bulkCheckboxState"
|
||||||
|
:indeterminate="isIndeterminate"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
|
||||||
|
{{ selectAllLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||||
|
{{ selectedCountLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-4 w-px bg-n-strong" />
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<slot name="actions" :selected-count="selectedCount">
|
||||||
|
<Button
|
||||||
|
:label="deleteLabel"
|
||||||
|
sm
|
||||||
|
ruby
|
||||||
|
ghost
|
||||||
|
class="!px-1.5"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
@click="emit('bulkDelete')"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-3">
|
||||||
|
<slot name="default-actions" />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
@@ -55,6 +55,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
showMenu: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||||
@@ -130,7 +134,7 @@ const handleDocumentableClick = () => {
|
|||||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||||
{{ question }}
|
{{ question }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="!compact" class="flex items-center gap-2">
|
<div v-if="!compact && showMenu" class="flex items-center gap-2">
|
||||||
<Policy
|
<Policy
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
:permissions="['administrator']"
|
:permissions="['administrator']"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup>
|
||||||
|
import RuleCard from './RuleCard.vue';
|
||||||
|
|
||||||
|
const sampleRules = [
|
||||||
|
{ id: 1, content: 'Block sensitive personal information', selectable: true },
|
||||||
|
{ id: 2, content: 'Reject offensive language', selectable: true },
|
||||||
|
{ id: 3, content: 'Deflect legal or medical advice', selectable: true },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Captain/Assistant/RuleCard"
|
||||||
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
>
|
||||||
|
<Variant title="Selectable List">
|
||||||
|
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
|
||||||
|
<RuleCard
|
||||||
|
v-for="rule in sampleRules"
|
||||||
|
:id="rule.id"
|
||||||
|
:key="rule.id"
|
||||||
|
:content="rule.content"
|
||||||
|
:selectable="rule.selectable"
|
||||||
|
@select="id => console.log('Selected rule', id)"
|
||||||
|
@edit="id => console.log('Edit', id)"
|
||||||
|
@delete="id => console.log('Delete', id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Non-Selectable">
|
||||||
|
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
|
||||||
|
<RuleCard id="4" content="Replies should be friendly and clear." />
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
|
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
selectable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isSelected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select', 'hover', 'edit', 'delete']);
|
||||||
|
|
||||||
|
const modelValue = computed({
|
||||||
|
get: () => props.isSelected,
|
||||||
|
set: () => emit('select', props.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEditing = ref(false);
|
||||||
|
const editedContent = ref(props.content);
|
||||||
|
|
||||||
|
// Local content to display to avoid flicker until parent prop updates on inline edit
|
||||||
|
const localContent = ref(props.content);
|
||||||
|
|
||||||
|
// Keeps localContent in sync when parent updates content prop
|
||||||
|
watch(
|
||||||
|
() => props.content,
|
||||||
|
newVal => {
|
||||||
|
localContent.value = newVal;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
isEditing.value = true;
|
||||||
|
editedContent.value = props.content;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
isEditing.value = false;
|
||||||
|
// Update local content
|
||||||
|
localContent.value = editedContent.value;
|
||||||
|
emit('edit', { id: props.id, content: editedContent.value });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CardLayout
|
||||||
|
selectable
|
||||||
|
class="relative [&>div]:!py-5 [&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4"
|
||||||
|
layout="row"
|
||||||
|
@mouseenter="emit('hover', true)"
|
||||||
|
@mouseleave="emit('hover', false)"
|
||||||
|
>
|
||||||
|
<div v-show="selectable" class="absolute top-6 ltr:left-3 rtl:right-3">
|
||||||
|
<Checkbox v-model="modelValue" />
|
||||||
|
</div>
|
||||||
|
<InlineInput
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="editedContent"
|
||||||
|
focus-on-mount
|
||||||
|
@keyup.enter="saveEdit"
|
||||||
|
/>
|
||||||
|
<span v-else class="flex items-center gap-2 text-sm text-n-slate-12">
|
||||||
|
{{ localContent }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button icon="i-lucide-pen" slate xs ghost @click="startEdit" />
|
||||||
|
<span class="w-px h-4 bg-n-weak" />
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
slate
|
||||||
|
xs
|
||||||
|
ghost
|
||||||
|
@click="emit('delete', id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup>
|
||||||
|
import ScenariosCard from './ScenariosCard.vue';
|
||||||
|
|
||||||
|
const sampleScenarios = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Refund Order',
|
||||||
|
description: 'User requests a refund for a recent purchase.',
|
||||||
|
instruction:
|
||||||
|
'Gather order details and reason for refund. Use [Order Search](tool://order_search) then submit with [Refund Payment](tool://refund_payment).',
|
||||||
|
tools: ['order_search', 'refund_payment'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Bug Report',
|
||||||
|
description: 'Customer reports a bug in the mobile app.',
|
||||||
|
instruction:
|
||||||
|
'Ask for reproduction steps and environment. Check [Known Issues](tool://known_issues) then create ticket with [Create Bug Report](tool://bug_report_create).',
|
||||||
|
tools: ['known_issues', 'bug_report_create'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Captain/Assistant/ScenariosCard"
|
||||||
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
>
|
||||||
|
<Variant title="Default">
|
||||||
|
<div
|
||||||
|
v-for="scenario in sampleScenarios"
|
||||||
|
:key="scenario.id"
|
||||||
|
class="px-4 py-4 bg-n-background"
|
||||||
|
>
|
||||||
|
<ScenariosCard
|
||||||
|
:id="scenario.id"
|
||||||
|
:title="scenario.title"
|
||||||
|
:description="scenario.description"
|
||||||
|
:instruction="scenario.instruction"
|
||||||
|
:tools="scenario.tools"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, h, reactive } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { required, minLength } from '@vuelidate/validators';
|
||||||
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||||
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
instruction: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
selectable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isSelected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select', 'hover', 'delete', 'update']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { formatMessage } = useMessageFormatter();
|
||||||
|
|
||||||
|
const modelValue = computed({
|
||||||
|
get: () => props.isSelected,
|
||||||
|
set: () => emit('select', props.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
instruction: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isEditing, toggleEditing] = useToggle();
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
Object.assign(state, {
|
||||||
|
id: props.id,
|
||||||
|
title: props.title,
|
||||||
|
description: props.description,
|
||||||
|
instruction: props.instruction,
|
||||||
|
tools: props.tools,
|
||||||
|
});
|
||||||
|
toggleEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
title: { required, minLength: minLength(1) },
|
||||||
|
description: { required },
|
||||||
|
instruction: { required },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(rules, state);
|
||||||
|
|
||||||
|
const titleError = computed(() =>
|
||||||
|
v$.value.title.$error
|
||||||
|
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.ERROR')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const descriptionError = computed(() =>
|
||||||
|
v$.value.description.$error
|
||||||
|
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.ERROR')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClickUpdate = () => {
|
||||||
|
v$.value.$touch();
|
||||||
|
if (v$.value.$invalid) return;
|
||||||
|
emit('update', { ...state });
|
||||||
|
toggleEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const instructionError = computed(() =>
|
||||||
|
v$.value.instruction.$error
|
||||||
|
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const LINK_INSTRUCTION_CLASS =
|
||||||
|
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
|
||||||
|
|
||||||
|
const renderInstruction = instruction => () =>
|
||||||
|
h('p', {
|
||||||
|
class: `text-sm text-n-slate-12 py-4 mb-0 [&_ol]:list-decimal ${LINK_INSTRUCTION_CLASS}`,
|
||||||
|
innerHTML: instruction,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CardLayout
|
||||||
|
selectable
|
||||||
|
class="relative [&>div]:!py-4"
|
||||||
|
:class="{
|
||||||
|
'[&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4': !isEditing,
|
||||||
|
'[&>div]:ltr:!pr-10 [&>div]:rtl:!pl-10': isEditing,
|
||||||
|
}"
|
||||||
|
layout="row"
|
||||||
|
@mouseenter="emit('hover', true)"
|
||||||
|
@mouseleave="emit('hover', false)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-show="selectable && !isEditing"
|
||||||
|
class="absolute top-[1.125rem] ltr:left-3 rtl:right-3"
|
||||||
|
>
|
||||||
|
<Checkbox v-model="modelValue" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isEditing" class="flex flex-col w-full">
|
||||||
|
<div class="flex items-start justify-between w-full gap-2">
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<span class="text-sm text-n-slate-12 font-medium">{{ title }}</span>
|
||||||
|
<span class="text-sm text-n-slate-11 mt-2">
|
||||||
|
{{ description }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- <Button label="Test" slate xs ghost class="!text-sm" />
|
||||||
|
<span class="w-px h-4 bg-n-weak" /> -->
|
||||||
|
<Button icon="i-lucide-pen" slate xs ghost @click="startEdit" />
|
||||||
|
<span class="w-px h-4 bg-n-weak" />
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
slate
|
||||||
|
xs
|
||||||
|
ghost
|
||||||
|
@click="emit('delete', id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<component :is="renderInstruction(formatMessage(instruction, false))" />
|
||||||
|
<span class="text-sm text-n-slate-11 font-medium mb-1">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
||||||
|
{{ tools?.map(tool => `@${tool}`).join(', ') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="overflow-hidden flex flex-col gap-4 w-full">
|
||||||
|
<Input
|
||||||
|
v-model="state.title"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:message="titleError"
|
||||||
|
:message-type="titleError ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
v-model="state.description"
|
||||||
|
:label="
|
||||||
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
||||||
|
"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:message="descriptionError"
|
||||||
|
:message-type="descriptionError ? 'error' : 'info'"
|
||||||
|
show-character-count
|
||||||
|
/>
|
||||||
|
<Editor
|
||||||
|
v-model="state.instruction"
|
||||||
|
:label="
|
||||||
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.LABEL')
|
||||||
|
"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:message="instructionError"
|
||||||
|
:message-type="instructionError ? 'error' : 'info'"
|
||||||
|
:show-character-count="false"
|
||||||
|
enable-captain-tools
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
faded
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.UPDATE.CANCEL')"
|
||||||
|
@click="toggleEditing(false)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.UPDATE.UPDATE')"
|
||||||
|
@click="onClickUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup>
|
||||||
|
import SuggestedRules from './SuggestedRules.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const guidelinesExample = [
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
'Reject queries that include offensive, discriminatory, or threatening language.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Captain/Assistant/SuggestedRules"
|
||||||
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
>
|
||||||
|
<Variant title="Suggested Rules List">
|
||||||
|
<div class="px-20 py-4 bg-n-background">
|
||||||
|
<SuggestedRules
|
||||||
|
title="Example response guidelines"
|
||||||
|
:items="guidelinesExample"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<span class="text-sm text-n-slate-12">{{ item.content }}</span>
|
||||||
|
<Button
|
||||||
|
label="Add this"
|
||||||
|
ghost
|
||||||
|
xs
|
||||||
|
slate
|
||||||
|
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SuggestedRules>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['add', 'close']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const onAddClick = () => {
|
||||||
|
emit('add');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-start self-stretch rounded-xl w-full overflow-hidden border border-dashed border-n-strong"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between w-full gap-3 px-4 pb-1 pt-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h5 class="text-sm font-medium text-n-slate-11">{{ title }}</h5>
|
||||||
|
<span class="h-3 w-px bg-n-weak" />
|
||||||
|
<Button
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.ADD')"
|
||||||
|
ghost
|
||||||
|
xs
|
||||||
|
slate
|
||||||
|
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||||
|
@click="onAddClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
xs
|
||||||
|
slate
|
||||||
|
icon="i-lucide-x"
|
||||||
|
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||||
|
@click="onClickClose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-start divide-y divide-n-strong divide-dashed w-full"
|
||||||
|
>
|
||||||
|
<div v-for="item in items" :key="item.content" class="w-full px-4 py-4">
|
||||||
|
<slot :item="item" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import ToolsDropdown from './ToolsDropdown.vue';
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: 'order_search',
|
||||||
|
title: 'Order Search',
|
||||||
|
description: 'Lookup orders by customer ID, email, or order number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'refund_payment',
|
||||||
|
title: 'Refund Payment',
|
||||||
|
description: 'Initiates a refund on a specific payment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fetch_customer',
|
||||||
|
title: 'Fetch Customer',
|
||||||
|
description: 'Pulls customer details (email, tags, last seen, etc.)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedIndex = ref(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Captain/Assistant/ToolsDropdown"
|
||||||
|
:layout="{ type: 'grid', width: '600px' }"
|
||||||
|
>
|
||||||
|
<Variant title="Default">
|
||||||
|
<div class="relative h-80 bg-n-background p-4">
|
||||||
|
<ToolsDropdown :items="items" :selected-index="selectedIndex" />
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
selectedIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select']);
|
||||||
|
|
||||||
|
const toolsDropdownRef = ref(null);
|
||||||
|
|
||||||
|
const onItemClick = idx => emit('select', idx);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selectedIndex,
|
||||||
|
() => {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = toolsDropdownRef.value?.querySelector(
|
||||||
|
`#tool-item-${props.selectedIndex}`
|
||||||
|
);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="toolsDropdownRef"
|
||||||
|
class="w-[22.5rem] p-2 flex flex-col gap-1 z-50 absolute rounded-xl bg-n-alpha-3 shadow outline outline-1 outline-n-weak backdrop-blur-[50px] max-h-[20rem] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(tool, idx) in items"
|
||||||
|
:id="`tool-item-${idx}`"
|
||||||
|
:key="tool.id || idx"
|
||||||
|
:class="{ 'bg-n-alpha-black2': idx === selectedIndex }"
|
||||||
|
class="flex flex-col gap-1 rounded-md py-2 px-2 cursor-pointer hover:bg-n-alpha-black2"
|
||||||
|
@click="onItemClick(idx)"
|
||||||
|
>
|
||||||
|
<span class="text-n-slate-12 font-medium text-sm">{{ tool.title }}</span>
|
||||||
|
<span class="text-n-slate-11 text-sm">{{ tool.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { required, minLength } from '@vuelidate/validators';
|
||||||
|
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assistant: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
productName: '',
|
||||||
|
features: {
|
||||||
|
conversationFaqs: false,
|
||||||
|
memories: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = reactive({ ...initialState });
|
||||||
|
|
||||||
|
const validationRules = {
|
||||||
|
name: { required, minLength: minLength(1) },
|
||||||
|
description: { required, minLength: minLength(1) },
|
||||||
|
productName: { required, minLength: minLength(1) },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
|
const getErrorMessage = field => {
|
||||||
|
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formErrors = computed(() => ({
|
||||||
|
name: getErrorMessage('name'),
|
||||||
|
description: getErrorMessage('description'),
|
||||||
|
productName: getErrorMessage('productName'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateStateFromAssistant = assistant => {
|
||||||
|
const { config = {} } = assistant;
|
||||||
|
state.name = assistant.name;
|
||||||
|
state.description = assistant.description;
|
||||||
|
state.productName = config.product_name;
|
||||||
|
state.features = {
|
||||||
|
conversationFaqs: config.feature_faq || false,
|
||||||
|
memories: config.feature_memory || false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBasicInfoUpdate = async () => {
|
||||||
|
const result = await Promise.all([
|
||||||
|
v$.value.name.$validate(),
|
||||||
|
v$.value.description.$validate(),
|
||||||
|
v$.value.productName.$validate(),
|
||||||
|
]).then(results => results.every(Boolean));
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: state.name,
|
||||||
|
description: state.description,
|
||||||
|
config: {
|
||||||
|
...props.assistant.config,
|
||||||
|
product_name: state.productName,
|
||||||
|
feature_faq: state.features.conversationFaqs,
|
||||||
|
feature_memory: state.features.memories,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('submit', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.assistant,
|
||||||
|
newAssistant => {
|
||||||
|
if (newAssistant) updateStateFromAssistant(newAssistant);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<Input
|
||||||
|
v-model="state.name"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
|
||||||
|
:message="formErrors.name"
|
||||||
|
:message-type="formErrors.name ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="state.productName"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
|
||||||
|
:message="formErrors.productName"
|
||||||
|
:message-type="formErrors.productName ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Editor
|
||||||
|
v-model="state.description"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
|
:message="formErrors.description"
|
||||||
|
:message-type="formErrors.description ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="state.features.conversationFaqs"
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
/>
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS') }}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="state.features.memories"
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
/>
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||||
|
@click="handleBasicInfoUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
controlItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const onClick = name => {
|
||||||
|
router.push({ name });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:key="controlItem.name"
|
||||||
|
class="pt-3 ltr:pl-4 rtl:pr-4 ltr:pr-2 rtl:pl-2 pb-5 gap-2 flex flex-col w-full shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 cursor-pointer"
|
||||||
|
@click="onClick(controlItem.routeName)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between w-full gap-1 h-8">
|
||||||
|
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||||
|
{{ controlItem.name }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-chevron-right"
|
||||||
|
slate
|
||||||
|
ghost
|
||||||
|
xs
|
||||||
|
@click="onClick(controlItem.routeName)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-n-slate-11 text-sm leading-[21px] line-clamp-5">
|
||||||
|
{{ controlItem.description }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { minLength } from '@vuelidate/validators';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assistant: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
handoffMessage: '',
|
||||||
|
resolutionMessage: '',
|
||||||
|
temperature: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = reactive({ ...initialState });
|
||||||
|
|
||||||
|
const validationRules = {
|
||||||
|
handoffMessage: { minLength: minLength(1) },
|
||||||
|
resolutionMessage: { minLength: minLength(1) },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
|
const getErrorMessage = field => {
|
||||||
|
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formErrors = computed(() => ({
|
||||||
|
handoffMessage: getErrorMessage('handoffMessage'),
|
||||||
|
resolutionMessage: getErrorMessage('resolutionMessage'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateStateFromAssistant = assistant => {
|
||||||
|
const { config = {} } = assistant;
|
||||||
|
state.handoffMessage = config.handoff_message;
|
||||||
|
state.resolutionMessage = config.resolution_message;
|
||||||
|
state.temperature = config.temperature || 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSystemMessagesUpdate = async () => {
|
||||||
|
const result = await Promise.all([
|
||||||
|
v$.value.handoffMessage.$validate(),
|
||||||
|
v$.value.resolutionMessage.$validate(),
|
||||||
|
]).then(results => results.every(Boolean));
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
config: {
|
||||||
|
...props.assistant.config,
|
||||||
|
handoff_message: state.handoffMessage,
|
||||||
|
resolution_message: state.resolutionMessage,
|
||||||
|
temperature: state.temperature || 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('submit', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.assistant,
|
||||||
|
newAssistant => {
|
||||||
|
if (newAssistant) updateStateFromAssistant(newAssistant);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<Editor
|
||||||
|
v-model="state.handoffMessage"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
||||||
|
:message="formErrors.handoffMessage"
|
||||||
|
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Editor
|
||||||
|
v-model="state.resolutionMessage"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
||||||
|
:message="formErrors.resolutionMessage"
|
||||||
|
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
v-model="state.temperature"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-n-slate-11 italic">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||||
|
@click="handleSystemMessagesUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -6,7 +6,7 @@ import DropdownBody from './base/DropdownBody.vue';
|
|||||||
import DropdownSection from './base/DropdownSection.vue';
|
import DropdownSection from './base/DropdownSection.vue';
|
||||||
import DropdownItem from './base/DropdownItem.vue';
|
import DropdownItem from './base/DropdownItem.vue';
|
||||||
import DropdownSeparator from './base/DropdownSeparator.vue';
|
import DropdownSeparator from './base/DropdownSeparator.vue';
|
||||||
import WootSwitch from 'components/ui/Switch.vue';
|
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||||
|
|
||||||
const currentUserAutoOffline = ref(false);
|
const currentUserAutoOffline = ref(false);
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ const menuItems = ref([
|
|||||||
<DropdownItem label="Contact Support" class="justify-between">
|
<DropdownItem label="Contact Support" class="justify-between">
|
||||||
<span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span>
|
<span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span>
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<WootSwitch v-model="currentUserAutoOffline" />
|
<ToggleSwitch v-model="currentUserAutoOffline" />
|
||||||
</div>
|
</div>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownSection>
|
</DropdownSection>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, defineModel, h, watch, ref } from 'vue';
|
import { computed, defineModel, h, watch, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import FilterSelect from './inputs/FilterSelect.vue';
|
import FilterSelect from './inputs/FilterSelect.vue';
|
||||||
import MultiSelect from './inputs/MultiSelect.vue';
|
import MultiSelect from './inputs/MultiSelect.vue';
|
||||||
import SingleSelect from './inputs/SingleSelect.vue';
|
import SingleSelect from './inputs/SingleSelect.vue';
|
||||||
@@ -178,11 +179,11 @@ defineExpose({ validate });
|
|||||||
disable-search
|
disable-search
|
||||||
:options="booleanOptions"
|
:options="booleanOptions"
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
v-else
|
v-else
|
||||||
v-model="values"
|
v-model="values"
|
||||||
:type="inputType === 'date' ? 'date' : 'text'"
|
:type="inputType === 'date' ? 'date' : 'text'"
|
||||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
|
class="[&>input]:h-8 [&>input]:py-1.5 [&>input]:outline-offset-0"
|
||||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useContactFilterContext } from './contactProvider.js';
|
|||||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||||
|
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import ConditionRow from './ConditionRow.vue';
|
import ConditionRow from './ConditionRow.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -109,16 +110,13 @@ const outsideClickHandler = [
|
|||||||
{{ filterModalHeaderTitle }}
|
{{ filterModalHeaderTitle }}
|
||||||
</h3>
|
</h3>
|
||||||
<div v-if="props.isSegmentView">
|
<div v-if="props.isSegmentView">
|
||||||
<label class="pb-6 border-b border-n-weak">
|
<div class="pb-6 border-b border-n-weak">
|
||||||
<div class="mb-2 text-sm text-n-slate-11">
|
<Input
|
||||||
{{ $t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL') }}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-model="segmentNameLocal"
|
v-model="segmentNameLocal"
|
||||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
|
:label="$t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL')"
|
||||||
:placeholder="t('CONTACTS_LAYOUT.FILTER.SEGMENT.INPUT_PLACEHOLDER')"
|
:placeholder="t('CONTACTS_LAYOUT.FILTER.SEGMENT.INPUT_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="grid gap-4 list-none">
|
<ul class="grid gap-4 list-none">
|
||||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useConversationFilterContext } from './provider.js';
|
|||||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||||
|
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import ConditionRow from './ConditionRow.vue';
|
import ConditionRow from './ConditionRow.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -110,16 +111,13 @@ const outsideClickHandler = [
|
|||||||
{{ filterModalHeaderTitle }}
|
{{ filterModalHeaderTitle }}
|
||||||
</h3>
|
</h3>
|
||||||
<div v-if="props.isFolderView">
|
<div v-if="props.isFolderView">
|
||||||
<label class="border-b border-n-weak pb-6">
|
<div class="border-b border-n-weak pb-6">
|
||||||
<div class="text-n-slate-11 text-sm mb-2">
|
<Input
|
||||||
{{ t('FILTER.FOLDER_LABEL') }}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-model="folderNameLocal"
|
v-model="folderNameLocal"
|
||||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
|
:label="t('FILTER.FOLDER_LABEL')"
|
||||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="grid gap-4 list-none">
|
<ul class="grid gap-4 list-none">
|
||||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
|||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import NextButton from 'next/button/Button.vue';
|
import NextButton from 'next/button/Button.vue';
|
||||||
|
import NextInput from 'dashboard/components-next/input/Input.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
NextButton,
|
NextButton,
|
||||||
|
NextInput,
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
onClickOutside: vOnClickOutside,
|
onClickOutside: vOnClickOutside,
|
||||||
@@ -103,20 +105,13 @@ export default {
|
|||||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
|
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
|
||||||
</h3>
|
</h3>
|
||||||
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
|
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
|
||||||
<label :class="{ error: v$.name.$error }">
|
<NextInput
|
||||||
<input
|
v-model="name"
|
||||||
v-model="name"
|
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
|
||||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
|
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
|
||||||
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
|
:message-type="v$.name.$error && 'error'"
|
||||||
@blur="v$.name.$touch"
|
@blur="v$.name.$touch"
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
v-if="v$.name.$error"
|
|
||||||
class="text-xs text-n-ruby-11 ml-1 rtl:mr-1"
|
|
||||||
>
|
|
||||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-row justify-end w-full gap-2">
|
<div class="flex flex-row justify-end w-full gap-2">
|
||||||
<NextButton faded slate sm @click.prevent="onClose">
|
<NextButton faded slate sm @click.prevent="onClose">
|
||||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
|
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
|
||||||
|
|||||||
@@ -119,7 +119,13 @@ const handleSeeOriginal = () => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isExpandable && !isExpanded"
|
v-if="isExpandable && !isExpanded"
|
||||||
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent"
|
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end"
|
||||||
|
:class="{
|
||||||
|
'bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent':
|
||||||
|
isIncoming,
|
||||||
|
'bg-gradient-to-t from-n-solid-blue via-n-solid-blue via-20% to-transparent':
|
||||||
|
isOutgoing,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
|
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
isMobileSidebarOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle']);
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const isConversationRoute = computed(() => {
|
||||||
|
const CONVERSATION_ROUTES = [
|
||||||
|
'inbox_conversation',
|
||||||
|
'conversation_through_inbox',
|
||||||
|
'conversations_through_label',
|
||||||
|
'team_conversations_through_label',
|
||||||
|
'conversations_through_folders',
|
||||||
|
'conversation_through_mentions',
|
||||||
|
'conversation_through_unattended',
|
||||||
|
'conversation_through_participating',
|
||||||
|
'inbox_view_conversation',
|
||||||
|
];
|
||||||
|
return CONVERSATION_ROUTES.includes(route.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
emit('toggle');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="!isConversationRoute"
|
||||||
|
id="mobile-sidebar-launcher"
|
||||||
|
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-out block md:hidden"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="rounded-full bg-n-alpha-2 p-1">
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-menu"
|
||||||
|
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
||||||
|
lg
|
||||||
|
@click="toggleSidebar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else />
|
||||||
|
</template>
|
||||||
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import SidebarGroup from './SidebarGroup.vue';
|
import SidebarGroup from './SidebarGroup.vue';
|
||||||
@@ -17,10 +18,18 @@ import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
|||||||
import Logo from 'next/icon/Logo.vue';
|
import Logo from 'next/icon/Logo.vue';
|
||||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isMobileSidebarOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'closeKeyShortcutModal',
|
'closeKeyShortcutModal',
|
||||||
'openKeyShortcutModal',
|
'openKeyShortcutModal',
|
||||||
'showCreateAccountModal',
|
'showCreateAccountModal',
|
||||||
|
'closeMobileSidebar',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { accountScopedRoute } = useAccount();
|
const { accountScopedRoute } = useAccount();
|
||||||
@@ -77,6 +86,11 @@ const sortedInboxes = computed(() =>
|
|||||||
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const closeMobileSidebar = () => {
|
||||||
|
if (!props.isMobileSidebarOpen) return;
|
||||||
|
emit('closeMobileSidebar');
|
||||||
|
};
|
||||||
|
|
||||||
const newReportRoutes = () => [
|
const newReportRoutes = () => [
|
||||||
{
|
{
|
||||||
name: 'Reports Agent',
|
name: 'Reports Agent',
|
||||||
@@ -331,6 +345,11 @@ const menuItems = computed(() => {
|
|||||||
label: t('SIDEBAR.SMS'),
|
label: t('SIDEBAR.SMS'),
|
||||||
to: accountScopedRoute('campaigns_sms_index'),
|
to: accountScopedRoute('campaigns_sms_index'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'WhatsApp',
|
||||||
|
label: t('SIDEBAR.WHATSAPP'),
|
||||||
|
to: accountScopedRoute('campaigns_whatsapp_index'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -483,7 +502,19 @@ const menuItems = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
|
v-on-click-outside="[
|
||||||
|
closeMobileSidebar,
|
||||||
|
{ ignore: ['#mobile-sidebar-launcher'] },
|
||||||
|
]"
|
||||||
|
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="[
|
||||||
|
{
|
||||||
|
'ltr:translate-x-0 rtl:-translate-x-0 shadow-lg md:shadow-none':
|
||||||
|
isMobileSidebarOpen,
|
||||||
|
'ltr:-translate-x-full rtl:translate-x-full md:translate-x-0':
|
||||||
|
!isMobileSidebarOpen,
|
||||||
|
},
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<section class="grid gap-2 mt-2 mb-4">
|
<section class="grid gap-2 mt-2 mb-4">
|
||||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from 'next/dropdown-menu/base';
|
} from 'next/dropdown-menu/base';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
|
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -48,6 +49,16 @@ const activeStatus = computed(() => {
|
|||||||
return availabilityStatuses.value.find(status => status.active);
|
return availabilityStatuses.value.find(status => status.active);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const autoOfflineToggle = computed({
|
||||||
|
get: () => currentUserAutoOffline.value,
|
||||||
|
set: autoOffline => {
|
||||||
|
store.dispatch('updateAutoOffline', {
|
||||||
|
accountId: currentAccountId.value,
|
||||||
|
autoOffline,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function changeAvailabilityStatus(availability) {
|
function changeAvailabilityStatus(availability) {
|
||||||
if (isImpersonating.value) {
|
if (isImpersonating.value) {
|
||||||
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
|
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
|
||||||
@@ -62,13 +73,6 @@ function changeAvailabilityStatus(availability) {
|
|||||||
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR'));
|
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAutoOffline(autoOffline) {
|
|
||||||
store.dispatch('updateAutoOffline', {
|
|
||||||
accountId: currentAccountId.value,
|
|
||||||
autoOffline,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -118,11 +122,7 @@ function updateAutoOffline(autoOffline) {
|
|||||||
class="size-4 text-n-slate-10"
|
class="size-4 text-n-slate-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<woot-switch
|
<ToggleSwitch v-model="autoOfflineToggle" />
|
||||||
class="flex-shrink-0"
|
|
||||||
:model-value="currentUserAutoOffline"
|
|
||||||
@input="updateAutoOffline"
|
|
||||||
/>
|
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</div>
|
</div>
|
||||||
</DropdownSection>
|
</DropdownSection>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const updateValue = () => {
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2"
|
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
|
||||||
:class="modelValue ? 'bg-n-brand' : 'bg-n-alpha-1 dark:bg-n-alpha-2'"
|
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
|
||||||
role="switch"
|
role="switch"
|
||||||
:aria-checked="modelValue"
|
:aria-checked="modelValue"
|
||||||
@click="updateValue"
|
@click="updateValue"
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ const onCopy = async e => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative text-left">
|
<div class="relative text-left">
|
||||||
<div class="top-1.5 absolute right-1.5 flex items-center gap-1">
|
<div
|
||||||
|
class="top-1.5 absolute ltr:right-1.5 rtl:left-1.5 flex backdrop-blur-sm rounded-lg items-center gap-1"
|
||||||
|
>
|
||||||
<form
|
<form
|
||||||
v-if="enableCodePen"
|
v-if="enableCodePen"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
@@ -86,6 +88,11 @@ const onCopy = async e => {
|
|||||||
@click="onCopy"
|
@click="onCopy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<highlightjs v-if="script" :language="lang" :code="scrubbedScript" />
|
<highlightjs
|
||||||
|
v-if="script"
|
||||||
|
:language="lang"
|
||||||
|
:code="scrubbedScript"
|
||||||
|
class="[&_code]:text-start"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
<script>
|
|
||||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
|
||||||
import { mapGetters } from 'vuex';
|
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
|
||||||
import { differenceInDays } from 'date-fns';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: { Banner },
|
|
||||||
setup() {
|
|
||||||
const { accountId } = useAccount();
|
|
||||||
return {
|
|
||||||
accountId,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return { conversationMeta: {} };
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
|
||||||
getAccount: 'accounts/getAccount',
|
|
||||||
}),
|
|
||||||
bannerMessage() {
|
|
||||||
return this.$t('GENERAL_SETTINGS.LIMITS_UPGRADE');
|
|
||||||
},
|
|
||||||
actionButtonMessage() {
|
|
||||||
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
|
|
||||||
},
|
|
||||||
shouldShowBanner() {
|
|
||||||
if (!this.isOnChatwootCloud) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isTrialAccount()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.isLimitExceeded();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
if (this.isOnChatwootCloud) {
|
|
||||||
this.fetchLimits();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
fetchLimits() {
|
|
||||||
this.$store.dispatch('accounts/limits');
|
|
||||||
},
|
|
||||||
routeToBilling() {
|
|
||||||
this.$router.push({
|
|
||||||
name: 'billing_settings_index',
|
|
||||||
params: { accountId: this.accountId },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
isTrialAccount() {
|
|
||||||
// check if account is less than 15 days old
|
|
||||||
const account = this.getAccount(this.accountId);
|
|
||||||
if (!account) return false;
|
|
||||||
|
|
||||||
const createdAt = new Date(account.created_at);
|
|
||||||
|
|
||||||
const diffDays = differenceInDays(new Date(), createdAt);
|
|
||||||
|
|
||||||
return diffDays <= 15;
|
|
||||||
},
|
|
||||||
isLimitExceeded() {
|
|
||||||
const account = this.getAccount(this.accountId);
|
|
||||||
if (!account) return false;
|
|
||||||
|
|
||||||
const { limits } = account;
|
|
||||||
if (!limits) return false;
|
|
||||||
|
|
||||||
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
|
|
||||||
return this.testLimit(conversation) || this.testLimit(nonWebInboxes);
|
|
||||||
},
|
|
||||||
testLimit({ allowed, consumed }) {
|
|
||||||
return consumed > allowed;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
|
||||||
<template>
|
|
||||||
<Banner
|
|
||||||
v-if="shouldShowBanner"
|
|
||||||
color-scheme="alert"
|
|
||||||
:banner-message="bannerMessage"
|
|
||||||
:action-button-label="actionButtonMessage"
|
|
||||||
has-action-button
|
|
||||||
@primary-action="routeToBilling"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
@@ -4,7 +4,12 @@ import { useStore } from 'dashboard/composables/store';
|
|||||||
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
|
import { useWindowSize } from '@vueuse/core';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
conversationInboxType: {
|
conversationInboxType: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -13,12 +18,20 @@ defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
const { isEnterprise } = useConfig();
|
||||||
|
const { width: windowWidth } = useWindowSize();
|
||||||
|
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||||
const inboxAssistant = useMapGetter('getCopilotAssistant');
|
const inboxAssistant = useMapGetter('getCopilotAssistant');
|
||||||
const currentChat = useMapGetter('getSelectedChat');
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
|
||||||
|
const isSmallScreen = computed(
|
||||||
|
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
||||||
|
);
|
||||||
|
|
||||||
const selectedCopilotThreadId = ref(null);
|
const selectedCopilotThreadId = ref(null);
|
||||||
const messages = computed(() =>
|
const messages = computed(() =>
|
||||||
store.getters['copilotMessages/getMessagesByThreadId'](
|
store.getters['copilotMessages/getMessagesByThreadId'](
|
||||||
@@ -32,7 +45,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectedAssistantId = ref(null);
|
const selectedAssistantId = ref(null);
|
||||||
const { uiSettings, updateUISettings } = useUISettings();
|
|
||||||
|
|
||||||
const activeAssistant = computed(() => {
|
const activeAssistant = computed(() => {
|
||||||
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
||||||
@@ -55,6 +67,15 @@ const activeAssistant = computed(() => {
|
|||||||
return assistants.value[0];
|
return assistants.value[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const closeCopilotPanel = () => {
|
||||||
|
if (isSmallScreen.value && uiSettings.value?.is_copilot_panel_open) {
|
||||||
|
updateUISettings({
|
||||||
|
is_contact_sidebar_open: false,
|
||||||
|
is_copilot_panel_open: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setAssistant = async assistant => {
|
const setAssistant = async assistant => {
|
||||||
selectedAssistantId.value = assistant.id;
|
selectedAssistantId.value = assistant.id;
|
||||||
await updateUISettings({
|
await updateUISettings({
|
||||||
@@ -63,6 +84,9 @@ const setAssistant = async assistant => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const shouldShowCopilotPanel = computed(() => {
|
const shouldShowCopilotPanel = computed(() => {
|
||||||
|
if (!isEnterprise) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const isCaptainEnabled = isFeatureEnabledonAccount.value(
|
const isCaptainEnabled = isFeatureEnabledonAccount.value(
|
||||||
currentAccountId.value,
|
currentAccountId.value,
|
||||||
FEATURE_FLAGS.CAPTAIN
|
FEATURE_FLAGS.CAPTAIN
|
||||||
@@ -94,14 +118,23 @@ const sendMessage = async message => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.dispatch('captainAssistants/get');
|
if (isEnterprise) {
|
||||||
|
store.dispatch('captainAssistants/get');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="shouldShowCopilotPanel"
|
v-if="shouldShowCopilotPanel"
|
||||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
|
v-on-click-outside="() => closeCopilotPanel()"
|
||||||
|
class="bg-n-background h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
'md:flex': shouldShowCopilotPanel,
|
||||||
|
'md:hidden': !shouldShowCopilotPanel,
|
||||||
|
},
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<Copilot
|
<Copilot
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
modelValue: { type: Boolean, default: false },
|
|
||||||
size: { type: String, default: '' },
|
|
||||||
},
|
|
||||||
emits: ['update:modelValue', 'input'],
|
|
||||||
methods: {
|
|
||||||
onClick() {
|
|
||||||
this.$emit('update:modelValue', !this.modelValue);
|
|
||||||
this.$emit('input', !this.modelValue);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="toggle-button p-0"
|
|
||||||
:class="{ active: modelValue, small: size === 'small' }"
|
|
||||||
role="switch"
|
|
||||||
:aria-checked="modelValue.toString()"
|
|
||||||
@click="onClick"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" :class="{ active: modelValue }" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.toggle-button {
|
|
||||||
@apply bg-n-slate-5;
|
|
||||||
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
|
|
||||||
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
|
|
||||||
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
|
|
||||||
border-radius: 0.5625rem;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 1.188rem;
|
|
||||||
position: relative;
|
|
||||||
transition-duration: 200ms;
|
|
||||||
transition-property: background-color;
|
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
width: 2.125rem;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
@apply bg-n-brand;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.small {
|
|
||||||
width: 1.375rem;
|
|
||||||
height: 0.875rem;
|
|
||||||
|
|
||||||
span {
|
|
||||||
@apply size-2.5;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
@apply ltr:translate-x-[0.5rem] ltr:translate-y-0 rtl:translate-x-[-0.5rem] rtl:translate-y-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
@apply bg-n-background;
|
|
||||||
|
|
||||||
border-radius: 100%;
|
|
||||||
box-shadow: var(--toggle-button-box-shadow);
|
|
||||||
display: inline-block;
|
|
||||||
height: 0.9375rem;
|
|
||||||
transform: translate(0, 0);
|
|
||||||
transition-duration: 200ms;
|
|
||||||
transition-property: transform;
|
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
width: 0.9375rem;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
@apply ltr:translate-x-[0.9375rem] ltr:translate-y-0 rtl:translate-x-[-0.9375rem] rtl:translate-y-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -15,6 +15,7 @@ import CannedResponse from '../conversation/CannedResponse.vue';
|
|||||||
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
|
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
|
||||||
import TagAgents from '../conversation/TagAgents.vue';
|
import TagAgents from '../conversation/TagAgents.vue';
|
||||||
import VariableList from '../conversation/VariableList.vue';
|
import VariableList from '../conversation/VariableList.vue';
|
||||||
|
import TagTools from '../conversation/TagTools.vue';
|
||||||
|
|
||||||
import { useEmitter } from 'dashboard/composables/emitter';
|
import { useEmitter } from 'dashboard/composables/emitter';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@@ -72,6 +73,7 @@ const props = defineProps({
|
|||||||
updateSelectionWith: { type: String, default: '' },
|
updateSelectionWith: { type: String, default: '' },
|
||||||
enableVariables: { type: Boolean, default: false },
|
enableVariables: { type: Boolean, default: false },
|
||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
|
enableCaptainTools: { type: Boolean, default: false },
|
||||||
variables: { type: Object, default: () => ({}) },
|
variables: { type: Object, default: () => ({}) },
|
||||||
enabledMenuOptions: { type: Array, default: () => [] },
|
enabledMenuOptions: { type: Array, default: () => [] },
|
||||||
signature: { type: String, default: '' },
|
signature: { type: String, default: '' },
|
||||||
@@ -89,6 +91,7 @@ const emit = defineEmits([
|
|||||||
'toggleUserMention',
|
'toggleUserMention',
|
||||||
'toggleCannedMenu',
|
'toggleCannedMenu',
|
||||||
'toggleVariablesMenu',
|
'toggleVariablesMenu',
|
||||||
|
'toggleToolsMenu',
|
||||||
'clearSelection',
|
'clearSelection',
|
||||||
'blur',
|
'blur',
|
||||||
'focus',
|
'focus',
|
||||||
@@ -140,7 +143,9 @@ const showUserMentions = ref(false);
|
|||||||
const showCannedMenu = ref(false);
|
const showCannedMenu = ref(false);
|
||||||
const showVariables = ref(false);
|
const showVariables = ref(false);
|
||||||
const showEmojiMenu = ref(false);
|
const showEmojiMenu = ref(false);
|
||||||
|
const showToolsMenu = ref(false);
|
||||||
const mentionSearchKey = ref('');
|
const mentionSearchKey = ref('');
|
||||||
|
const toolSearchKey = ref('');
|
||||||
const cannedSearchTerm = ref('');
|
const cannedSearchTerm = ref('');
|
||||||
const variableSearchTerm = ref('');
|
const variableSearchTerm = ref('');
|
||||||
const emojiSearchTerm = ref('');
|
const emojiSearchTerm = ref('');
|
||||||
@@ -216,11 +221,17 @@ const plugins = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
createSuggestionPlugin({
|
||||||
|
trigger: '@',
|
||||||
|
showMenu: showToolsMenu,
|
||||||
|
searchTerm: toolSearchKey,
|
||||||
|
isAllowed: () => props.enableCaptainTools,
|
||||||
|
}),
|
||||||
createSuggestionPlugin({
|
createSuggestionPlugin({
|
||||||
trigger: '@',
|
trigger: '@',
|
||||||
showMenu: showUserMentions,
|
showMenu: showUserMentions,
|
||||||
searchTerm: mentionSearchKey,
|
searchTerm: mentionSearchKey,
|
||||||
isAllowed: () => props.isPrivate,
|
isAllowed: () => props.isPrivate || !props.enableCaptainTools,
|
||||||
}),
|
}),
|
||||||
createSuggestionPlugin({
|
createSuggestionPlugin({
|
||||||
trigger: '/',
|
trigger: '/',
|
||||||
@@ -262,6 +273,9 @@ watch(showCannedMenu, updatedValue => {
|
|||||||
watch(showVariables, updatedValue => {
|
watch(showVariables, updatedValue => {
|
||||||
emit('toggleVariablesMenu', !props.isPrivate && updatedValue);
|
emit('toggleVariablesMenu', !props.isPrivate && updatedValue);
|
||||||
});
|
});
|
||||||
|
watch(showToolsMenu, updatedValue => {
|
||||||
|
emit('toggleToolsMenu', props.enableCaptainTools && updatedValue);
|
||||||
|
});
|
||||||
|
|
||||||
function focusEditorInputField(pos = 'end') {
|
function focusEditorInputField(pos = 'end') {
|
||||||
const { tr } = editorView.state;
|
const { tr } = editorView.state;
|
||||||
@@ -538,6 +552,7 @@ function insertSpecialContent(type, content) {
|
|||||||
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
|
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
|
||||||
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
|
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
|
||||||
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
|
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
|
||||||
|
tool: CONVERSATION_EVENTS.INSERTED_A_TOOL,
|
||||||
};
|
};
|
||||||
|
|
||||||
useTrack(event_map[type]);
|
useTrack(event_map[type]);
|
||||||
@@ -699,6 +714,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
:search-key="emojiSearchTerm"
|
:search-key="emojiSearchTerm"
|
||||||
@select-emoji="emoji => insertSpecialContent('emoji', emoji)"
|
@select-emoji="emoji => insertSpecialContent('emoji', emoji)"
|
||||||
/>
|
/>
|
||||||
|
<TagTools
|
||||||
|
v-if="showToolsMenu"
|
||||||
|
:search-key="toolSearchKey"
|
||||||
|
@select-tool="content => insertSpecialContent('tool', content)"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
ref="imageUpload"
|
ref="imageUpload"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -812,6 +832,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prosemirror-tools-node {
|
||||||
|
@apply font-medium text-n-slate-12 py-0;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-wrap {
|
.editor-wrap {
|
||||||
@apply mb-4;
|
@apply mb-4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
hasWhatsappTemplates: {
|
enableWhatsAppTemplates: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -333,7 +333,7 @@ export default {
|
|||||||
@click="toggleMessageSignature"
|
@click="toggleMessageSignature"
|
||||||
/>
|
/>
|
||||||
<NextButton
|
<NextButton
|
||||||
v-if="hasWhatsappTemplates"
|
v-if="enableWhatsAppTemplates"
|
||||||
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
|
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
|
||||||
icon="i-ph-whatsapp-logo"
|
icon="i-ph-whatsapp-logo"
|
||||||
slate
|
slate
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
allowedContextMenuOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
'contextMenuToggle',
|
'contextMenuToggle',
|
||||||
@@ -151,11 +155,9 @@ export default {
|
|||||||
hasSlaPolicyId() {
|
hasSlaPolicyId() {
|
||||||
return this.chat?.sla_policy_id;
|
return this.chat?.sla_policy_id;
|
||||||
},
|
},
|
||||||
},
|
conversationPath() {
|
||||||
methods: {
|
|
||||||
onCardClick(e) {
|
|
||||||
const { activeInbox, chat } = this;
|
const { activeInbox, chat } = this;
|
||||||
const path = frontendURL(
|
return frontendURL(
|
||||||
conversationUrl({
|
conversationUrl({
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
activeInbox,
|
activeInbox,
|
||||||
@@ -166,18 +168,26 @@ export default {
|
|||||||
conversationType: this.conversationType,
|
conversationType: this.conversationType,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onCardClick(e) {
|
||||||
|
const path = this.conversationPath;
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
|
// Handle Ctrl/Cmd + Click for new tab
|
||||||
if (e.metaKey || e.ctrlKey) {
|
if (e.metaKey || e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
window.open(
|
window.open(
|
||||||
window.chatwootConfig.hostURL + path,
|
`${window.chatwootConfig.hostURL}${path}`,
|
||||||
'_blank',
|
'_blank',
|
||||||
'noopener noreferrer nofollow'
|
'noopener,noreferrer'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.isActiveChat) {
|
|
||||||
return;
|
// Skip if already active
|
||||||
}
|
if (this.isActiveChat) return;
|
||||||
|
|
||||||
router.push({ path });
|
router.push({ path });
|
||||||
},
|
},
|
||||||
@@ -359,6 +369,8 @@ export default {
|
|||||||
:priority="chat.priority"
|
:priority="chat.priority"
|
||||||
:chat-id="chat.id"
|
:chat-id="chat.id"
|
||||||
:has-unread-messages="hasUnread"
|
:has-unread-messages="hasUnread"
|
||||||
|
:conversation-url="conversationPath"
|
||||||
|
:allowed-options="allowedContextMenuOptions"
|
||||||
@update-conversation="onUpdateConversation"
|
@update-conversation="onUpdateConversation"
|
||||||
@assign-agent="onAssignAgent"
|
@assign-agent="onAssignAgent"
|
||||||
@assign-label="onAssignLabel"
|
@assign-label="onAssignLabel"
|
||||||
@@ -367,6 +379,7 @@ export default {
|
|||||||
@mark-as-read="markAsRead"
|
@mark-as-read="markAsRead"
|
||||||
@assign-priority="assignPriority"
|
@assign-priority="assignPriority"
|
||||||
@delete-conversation="deleteConversation"
|
@delete-conversation="deleteConversation"
|
||||||
|
@close="closeContextMenu"
|
||||||
/>
|
/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useWindowSize } from '@vueuse/core';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
currentChat: {
|
currentChat: {
|
||||||
@@ -10,7 +13,8 @@ defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { uiSettings } = useUISettings();
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
const { width: windowWidth } = useWindowSize();
|
||||||
|
|
||||||
const activeTab = computed(() => {
|
const activeTab = computed(() => {
|
||||||
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
|
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
|
||||||
@@ -20,11 +24,31 @@ const activeTab = computed(() => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isSmallScreen = computed(
|
||||||
|
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeContactPanel = () => {
|
||||||
|
if (isSmallScreen.value && uiSettings.value?.is_contact_sidebar_open) {
|
||||||
|
updateUISettings({
|
||||||
|
is_contact_sidebar_open: false,
|
||||||
|
is_copilot_panel_open: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
|
v-on-click-outside="() => closeContactPanel()"
|
||||||
|
class="bg-n-background h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
'md:flex': activeTab === 0,
|
||||||
|
'md:hidden': activeTab !== 0,
|
||||||
|
},
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex flex-1 overflow-auto">
|
<div class="flex flex-1 overflow-auto">
|
||||||
<ContactPanel
|
<ContactPanel
|
||||||
|
|||||||
@@ -184,9 +184,8 @@ export default {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
hasWhatsappTemplates() {
|
showWhatsappTemplates() {
|
||||||
return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
|
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||||
.length;
|
|
||||||
},
|
},
|
||||||
isPrivate() {
|
isPrivate() {
|
||||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||||
@@ -373,6 +372,7 @@ export default {
|
|||||||
const variables = getMessageVariables({
|
const variables = getMessageVariables({
|
||||||
conversation: this.currentChat,
|
conversation: this.currentChat,
|
||||||
contact: this.currentContact,
|
contact: this.currentContact,
|
||||||
|
inbox: this.inbox,
|
||||||
});
|
});
|
||||||
return variables;
|
return variables;
|
||||||
},
|
},
|
||||||
@@ -1212,7 +1212,7 @@ export default {
|
|||||||
<ReplyBottomPanel
|
<ReplyBottomPanel
|
||||||
:conversation-id="conversationId"
|
:conversation-id="conversationId"
|
||||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||||
:has-whatsapp-templates="hasWhatsappTemplates"
|
:enable-whats-app-templates="showWhatsappTemplates"
|
||||||
:inbox="inbox"
|
:inbox="inbox"
|
||||||
:is-on-private-note="isOnPrivateNote"
|
:is-on-private-note="isOnPrivateNote"
|
||||||
:is-recording-audio="isRecordingAudio"
|
:is-recording-audio="isRecordingAudio"
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import ToolsDropdown from 'dashboard/components-next/captain/assistant/ToolsDropdown.vue';
|
||||||
|
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
searchKey: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['selectTool']);
|
||||||
|
|
||||||
|
const tools = useMapGetter('captainTools/getRecords');
|
||||||
|
|
||||||
|
const selectedIndex = ref(0);
|
||||||
|
|
||||||
|
const filteredTools = computed(() => {
|
||||||
|
const search = props.searchKey?.trim().toLowerCase() || '';
|
||||||
|
|
||||||
|
return tools.value.filter(tool => tool.title.toLowerCase().includes(search));
|
||||||
|
});
|
||||||
|
|
||||||
|
const adjustScroll = () => {};
|
||||||
|
|
||||||
|
const onSelect = idx => {
|
||||||
|
if (idx) selectedIndex.value = idx;
|
||||||
|
emit('selectTool', filteredTools.value[selectedIndex.value]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useKeyboardNavigableList({
|
||||||
|
items: filteredTools,
|
||||||
|
onSelect,
|
||||||
|
adjustScroll,
|
||||||
|
selectedIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(filteredTools, newListOfTools => {
|
||||||
|
if (newListOfTools.length < selectedIndex.value + 1) {
|
||||||
|
selectedIndex.value = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ToolsDropdown
|
||||||
|
v-if="filteredTools.length"
|
||||||
|
:items="filteredTools"
|
||||||
|
:selected-index="selectedIndex"
|
||||||
|
class="bottom-20"
|
||||||
|
@select="onSelect"
|
||||||
|
/>
|
||||||
|
<template v-else />
|
||||||
|
</template>
|
||||||
+56
-18
@@ -1,8 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
// TODO: Remove this when we support all formats
|
// TODO: Remove this when we support all formats
|
||||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: {
|
||||||
|
Icon,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
inboxId: {
|
inboxId: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -13,6 +18,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
query: '',
|
query: '',
|
||||||
|
isRefreshing: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -37,38 +43,63 @@ export default {
|
|||||||
return template.components.find(component => component.type === 'BODY')
|
return template.components.find(component => component.type === 'BODY')
|
||||||
.text;
|
.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;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div
|
<div class="flex gap-2 mb-2.5">
|
||||||
class="gap-1 bg-n-alpha-black2 items-center flex mb-2.5 py-0 px-2.5 rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
|
<div
|
||||||
>
|
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
|
||||||
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
|
>
|
||||||
<input
|
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
|
||||||
v-model="query"
|
<input
|
||||||
type="search"
|
v-model="query"
|
||||||
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
type="search"
|
||||||
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||||
/>
|
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
: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"
|
||||||
|
:title="$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||||
|
@click="refreshTemplates"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-refresh-ccw"
|
||||||
|
class="text-n-slate-12 size-4"
|
||||||
|
:class="{ 'animate-spin': isRefreshing }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
|
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
|
||||||
>
|
>
|
||||||
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
|
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
|
||||||
<button
|
<button
|
||||||
class="rounded-lg cursor-pointer block p-2.5 text-left w-full 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 items-center justify-between mb-2.5">
|
<div class="flex justify-between items-center mb-2.5">
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
{{ template.name }}
|
{{ template.name }}
|
||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
class="inline-block px-2 py-1 text-xs leading-none bg-n-slate-3 rounded-lg cursor-default 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 }}
|
||||||
@@ -94,11 +125,18 @@ export default {
|
|||||||
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
|
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!filteredTemplateMessages.length">
|
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
||||||
<p>
|
<div v-if="query && whatsAppTemplateMessages.length">
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
<p>
|
||||||
<strong>{{ query }}</strong>
|
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||||
</p>
|
<strong>{{ query }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
|
||||||
|
<p class="text-n-slate-11">
|
||||||
|
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||||
import {
|
import {
|
||||||
getSortedAgentsByAvailability,
|
getSortedAgentsByAvailability,
|
||||||
getAgentsByUpdatedPresence,
|
getAgentsByUpdatedPresence,
|
||||||
@@ -8,7 +11,20 @@ import MenuItem from './menuItem.vue';
|
|||||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
|
||||||
|
const MENU = {
|
||||||
|
MARK_AS_READ: 'mark-as-read',
|
||||||
|
MARK_AS_UNREAD: 'mark-as-unread',
|
||||||
|
PRIORITY: 'priority',
|
||||||
|
STATUS: 'status',
|
||||||
|
SNOOZE: 'snooze',
|
||||||
|
AGENT: 'agent',
|
||||||
|
TEAM: 'team',
|
||||||
|
LABEL: 'label',
|
||||||
|
DELETE: 'delete',
|
||||||
|
OPEN_NEW_TAB: 'open-new-tab',
|
||||||
|
COPY_LINK: 'copy-link',
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -37,6 +53,14 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
conversationUrl: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
allowedOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
'updateConversation',
|
'updateConversation',
|
||||||
@@ -47,6 +71,7 @@ export default {
|
|||||||
'assignTeam',
|
'assignTeam',
|
||||||
'assignLabel',
|
'assignLabel',
|
||||||
'deleteConversation',
|
'deleteConversation',
|
||||||
|
'close',
|
||||||
],
|
],
|
||||||
setup() {
|
setup() {
|
||||||
const { isAdmin } = useAdmin();
|
const { isAdmin } = useAdmin();
|
||||||
@@ -56,6 +81,7 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
MENU,
|
||||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||||
readOption: {
|
readOption: {
|
||||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
|
||||||
@@ -63,7 +89,7 @@ export default {
|
|||||||
},
|
},
|
||||||
unreadOption: {
|
unreadOption: {
|
||||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
|
||||||
icon: 'mail',
|
icon: 'mail-unread',
|
||||||
},
|
},
|
||||||
statusMenuConfig: [
|
statusMenuConfig: [
|
||||||
{
|
{
|
||||||
@@ -88,7 +114,7 @@ export default {
|
|||||||
icon: 'snooze',
|
icon: 'snooze',
|
||||||
},
|
},
|
||||||
priorityConfig: {
|
priorityConfig: {
|
||||||
key: 'priority',
|
key: MENU.PRIORITY,
|
||||||
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
|
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
options: [
|
options: [
|
||||||
@@ -115,25 +141,35 @@ export default {
|
|||||||
].filter(item => item.key !== this.priority),
|
].filter(item => item.key !== this.priority),
|
||||||
},
|
},
|
||||||
labelMenuConfig: {
|
labelMenuConfig: {
|
||||||
key: 'label',
|
key: MENU.LABEL,
|
||||||
icon: 'tag',
|
icon: 'tag',
|
||||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
|
||||||
},
|
},
|
||||||
agentMenuConfig: {
|
agentMenuConfig: {
|
||||||
key: 'agent',
|
key: MENU.AGENT,
|
||||||
icon: 'person-add',
|
icon: 'person-add',
|
||||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
|
||||||
},
|
},
|
||||||
teamMenuConfig: {
|
teamMenuConfig: {
|
||||||
key: 'team',
|
key: MENU.TEAM,
|
||||||
icon: 'people-team-add',
|
icon: 'people-team-add',
|
||||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
|
||||||
},
|
},
|
||||||
deleteOption: {
|
deleteOption: {
|
||||||
key: 'delete',
|
key: MENU.DELETE,
|
||||||
icon: 'delete',
|
icon: 'delete',
|
||||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
|
||||||
},
|
},
|
||||||
|
openInNewTabOption: {
|
||||||
|
key: MENU.OPEN_NEW_TAB,
|
||||||
|
icon: 'open',
|
||||||
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.OPEN_IN_NEW_TAB'),
|
||||||
|
},
|
||||||
|
copyLinkOption: {
|
||||||
|
key: MENU.COPY_LINK,
|
||||||
|
icon: 'copy',
|
||||||
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK'),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -180,6 +216,10 @@ export default {
|
|||||||
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
isAllowed(keys) {
|
||||||
|
if (!this.allowedOptions.length) return true;
|
||||||
|
return keys.some(key => this.allowedOptions.includes(key));
|
||||||
|
},
|
||||||
toggleStatus(status, snoozedUntil) {
|
toggleStatus(status, snoozedUntil) {
|
||||||
this.$emit('updateConversation', status, snoozedUntil);
|
this.$emit('updateConversation', status, snoozedUntil);
|
||||||
},
|
},
|
||||||
@@ -194,6 +234,24 @@ export default {
|
|||||||
deleteConversation() {
|
deleteConversation() {
|
||||||
this.$emit('deleteConversation', this.chatId);
|
this.$emit('deleteConversation', this.chatId);
|
||||||
},
|
},
|
||||||
|
openInNewTab() {
|
||||||
|
if (!this.conversationUrl) return;
|
||||||
|
|
||||||
|
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
this.$emit('close');
|
||||||
|
},
|
||||||
|
async copyConversationLink() {
|
||||||
|
if (!this.conversationUrl) return;
|
||||||
|
try {
|
||||||
|
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
|
||||||
|
await copyTextToClipboard(url);
|
||||||
|
useAlert(this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK_SUCCESS'));
|
||||||
|
this.$emit('close');
|
||||||
|
} catch (error) {
|
||||||
|
// error
|
||||||
|
}
|
||||||
|
},
|
||||||
show(key) {
|
show(key) {
|
||||||
// If the conversation status is same as the action, then don't display the option
|
// If the conversation status is same as the action, then don't display the option
|
||||||
// i.e.: Don't show an option to resolve if the conversation is already resolved.
|
// i.e.: Don't show an option to resolve if the conversation is already resolved.
|
||||||
@@ -217,83 +275,114 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px]">
|
<div
|
||||||
<MenuItem
|
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
|
||||||
v-if="!hasUnreadMessages"
|
>
|
||||||
:option="unreadOption"
|
<template v-if="isAllowed([MENU.MARK_AS_READ, MENU.MARK_AS_UNREAD])">
|
||||||
variant="icon"
|
|
||||||
@click.stop="$emit('markAsUnread')"
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
v-else
|
|
||||||
:option="readOption"
|
|
||||||
variant="icon"
|
|
||||||
@click.stop="$emit('markAsRead')"
|
|
||||||
/>
|
|
||||||
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
|
||||||
<template v-for="option in statusMenuConfig">
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
v-if="show(option.key)"
|
v-if="!hasUnreadMessages"
|
||||||
:key="option.key"
|
:option="unreadOption"
|
||||||
:option="option"
|
|
||||||
variant="icon"
|
variant="icon"
|
||||||
@click.stop="toggleStatus(option.key, null)"
|
@click.stop="$emit('markAsUnread')"
|
||||||
/>
|
/>
|
||||||
|
<MenuItem
|
||||||
|
v-else
|
||||||
|
:option="readOption"
|
||||||
|
variant="icon"
|
||||||
|
@click.stop="$emit('markAsRead')"
|
||||||
|
/>
|
||||||
|
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||||
</template>
|
</template>
|
||||||
<MenuItem
|
<template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
|
||||||
v-if="showSnooze"
|
<template v-for="option in statusMenuConfig">
|
||||||
:option="snoozeOption"
|
|
||||||
variant="icon"
|
|
||||||
@click.stop="snoozeConversation()"
|
|
||||||
/>
|
|
||||||
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
|
||||||
<MenuItemWithSubmenu :option="priorityConfig">
|
|
||||||
<MenuItem
|
|
||||||
v-for="(option, i) in priorityConfig.options"
|
|
||||||
:key="i"
|
|
||||||
:option="option"
|
|
||||||
@click.stop="assignPriority(option.key)"
|
|
||||||
/>
|
|
||||||
</MenuItemWithSubmenu>
|
|
||||||
<MenuItemWithSubmenu
|
|
||||||
:option="labelMenuConfig"
|
|
||||||
:sub-menu-available="!!labels.length"
|
|
||||||
>
|
|
||||||
<MenuItem
|
|
||||||
v-for="label in labels"
|
|
||||||
:key="label.id"
|
|
||||||
:option="generateMenuLabelConfig(label, 'label')"
|
|
||||||
variant="label"
|
|
||||||
@click.stop="$emit('assignLabel', label)"
|
|
||||||
/>
|
|
||||||
</MenuItemWithSubmenu>
|
|
||||||
<MenuItemWithSubmenu
|
|
||||||
:option="agentMenuConfig"
|
|
||||||
:sub-menu-available="!!assignableAgents.length"
|
|
||||||
>
|
|
||||||
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
|
|
||||||
<template v-else>
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
v-for="agent in assignableAgents"
|
v-if="show(option.key) && isAllowed([MENU.STATUS])"
|
||||||
:key="agent.id"
|
:key="option.key"
|
||||||
:option="generateMenuLabelConfig(agent, 'agent')"
|
:option="option"
|
||||||
variant="agent"
|
variant="icon"
|
||||||
@click.stop="$emit('assignAgent', agent)"
|
@click.stop="toggleStatus(option.key, null)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</MenuItemWithSubmenu>
|
|
||||||
<MenuItemWithSubmenu
|
|
||||||
:option="teamMenuConfig"
|
|
||||||
:sub-menu-available="!!teams.length"
|
|
||||||
>
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
v-for="team in teams"
|
v-if="showSnooze && isAllowed([MENU.SNOOZE])"
|
||||||
:key="team.id"
|
:option="snoozeOption"
|
||||||
:option="generateMenuLabelConfig(team, 'team')"
|
variant="icon"
|
||||||
@click.stop="$emit('assignTeam', team)"
|
@click.stop="snoozeConversation()"
|
||||||
/>
|
/>
|
||||||
</MenuItemWithSubmenu>
|
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||||
<template v-if="isAdmin">
|
</template>
|
||||||
|
<template
|
||||||
|
v-if="isAllowed([MENU.PRIORITY, MENU.LABEL, MENU.AGENT, MENU.TEAM])"
|
||||||
|
>
|
||||||
|
<MenuItemWithSubmenu
|
||||||
|
v-if="isAllowed([MENU.PRIORITY])"
|
||||||
|
:option="priorityConfig"
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
v-for="(option, i) in priorityConfig.options"
|
||||||
|
:key="i"
|
||||||
|
:option="option"
|
||||||
|
@click.stop="assignPriority(option.key)"
|
||||||
|
/>
|
||||||
|
</MenuItemWithSubmenu>
|
||||||
|
<MenuItemWithSubmenu
|
||||||
|
v-if="isAllowed([MENU.LABEL])"
|
||||||
|
:option="labelMenuConfig"
|
||||||
|
:sub-menu-available="!!labels.length"
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
v-for="label in labels"
|
||||||
|
:key="label.id"
|
||||||
|
:option="generateMenuLabelConfig(label, 'label')"
|
||||||
|
variant="label"
|
||||||
|
@click.stop="$emit('assignLabel', label)"
|
||||||
|
/>
|
||||||
|
</MenuItemWithSubmenu>
|
||||||
|
<MenuItemWithSubmenu
|
||||||
|
v-if="isAllowed([MENU.AGENT])"
|
||||||
|
:option="agentMenuConfig"
|
||||||
|
:sub-menu-available="!!assignableAgents.length"
|
||||||
|
>
|
||||||
|
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
|
||||||
|
<template v-else>
|
||||||
|
<MenuItem
|
||||||
|
v-for="agent in assignableAgents"
|
||||||
|
:key="agent.id"
|
||||||
|
:option="generateMenuLabelConfig(agent, 'agent')"
|
||||||
|
variant="agent"
|
||||||
|
@click.stop="$emit('assignAgent', agent)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MenuItemWithSubmenu>
|
||||||
|
<MenuItemWithSubmenu
|
||||||
|
v-if="isAllowed([MENU.TEAM])"
|
||||||
|
:option="teamMenuConfig"
|
||||||
|
:sub-menu-available="!!teams.length"
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
v-for="team in teams"
|
||||||
|
:key="team.id"
|
||||||
|
:option="generateMenuLabelConfig(team, 'team')"
|
||||||
|
@click.stop="$emit('assignTeam', team)"
|
||||||
|
/>
|
||||||
|
</MenuItemWithSubmenu>
|
||||||
|
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||||
|
</template>
|
||||||
|
<template v-if="isAllowed([MENU.OPEN_NEW_TAB, MENU.COPY_LINK])">
|
||||||
|
<MenuItem
|
||||||
|
v-if="isAllowed([MENU.OPEN_NEW_TAB])"
|
||||||
|
:option="openInNewTabOption"
|
||||||
|
variant="icon"
|
||||||
|
@click.stop="openInNewTab"
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
v-if="isAllowed([MENU.COPY_LINK])"
|
||||||
|
:option="copyLinkOption"
|
||||||
|
variant="icon"
|
||||||
|
@click.stop="copyConversationLink"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-if="isAdmin && isAllowed([MENU.DELETE])">
|
||||||
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
:option="deleteOption"
|
:option="deleteOption"
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
export default {
|
|
||||||
components: {
|
defineProps({
|
||||||
Thumbnail,
|
option: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
},
|
},
|
||||||
props: {
|
variant: {
|
||||||
option: {
|
type: String,
|
||||||
type: Object,
|
default: 'default',
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
variant: {
|
|
||||||
type: String,
|
|
||||||
default: 'default',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -30,12 +26,12 @@ export default {
|
|||||||
class="label-pill flex-shrink-0"
|
class="label-pill flex-shrink-0"
|
||||||
:style="{ backgroundColor: option.color }"
|
:style="{ backgroundColor: option.color }"
|
||||||
/>
|
/>
|
||||||
<Thumbnail
|
<Avatar
|
||||||
v-if="variant === 'agent'"
|
v-if="variant === 'agent'"
|
||||||
:username="option.label"
|
:name="option.label"
|
||||||
:src="option.thumbnail"
|
:src="option.thumbnail"
|
||||||
:status="option.status"
|
:status="option.status === 'online' ? option.status : null"
|
||||||
size="20px"
|
:size="20"
|
||||||
class="flex-shrink-0"
|
class="flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<p class="menu-label truncate min-w-0 flex-1">
|
<p class="menu-label truncate min-w-0 flex-1">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed, nextTick } from 'vue';
|
||||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -19,19 +19,16 @@ const mentionsListContainerRef = ref(null);
|
|||||||
const selectedIndex = ref(0);
|
const selectedIndex = ref(0);
|
||||||
|
|
||||||
const adjustScroll = () => {
|
const adjustScroll = () => {
|
||||||
const container = mentionsListContainerRef.value;
|
nextTick(() => {
|
||||||
const item = container.querySelector(`#mention-item-${selectedIndex.value}`);
|
const container = mentionsListContainerRef.value;
|
||||||
if (item) {
|
if (!container) return;
|
||||||
const itemTop = item.offsetTop;
|
const selectedElement = container.querySelector(
|
||||||
const itemBottom = itemTop + item.offsetHeight;
|
`#mention-item-${selectedIndex.value}`
|
||||||
const containerTop = container.scrollTop;
|
);
|
||||||
const containerBottom = containerTop + container.offsetHeight;
|
if (selectedElement) {
|
||||||
if (itemTop < containerTop) {
|
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||||
container.scrollTop = itemTop;
|
|
||||||
} else if (itemBottom + 34 > containerBottom) {
|
|
||||||
container.scrollTop = itemBottom - container.offsetHeight + 34;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelect = () => {
|
const onSelect = () => {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useStore } from 'dashboard/composables/store.js';
|
import { useStore } from 'dashboard/composables/store.js';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
export function useCaptain() {
|
export function useCaptain() {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
||||||
|
const { isEnterprise } = useConfig();
|
||||||
|
|
||||||
const captainEnabled = computed(() => {
|
const captainEnabled = computed(() => {
|
||||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
||||||
@@ -33,7 +35,9 @@ export function useCaptain() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fetchLimits = () => {
|
const fetchLimits = () => {
|
||||||
store.dispatch('accounts/limits');
|
if (isEnterprise) {
|
||||||
|
store.dispatch('accounts/limits');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default {
|
|||||||
HELP_CENTER_DOCS_URL:
|
HELP_CENTER_DOCS_URL:
|
||||||
'https://www.chatwoot.com/docs/product/others/help-center',
|
'https://www.chatwoot.com/docs/product/others/help-center',
|
||||||
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
|
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
|
||||||
SMALL_SCREEN_BREAKPOINT: 1200,
|
SMALL_SCREEN_BREAKPOINT: 768,
|
||||||
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
|
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
|
||||||
SNOOZE_OPTIONS: {
|
SNOOZE_OPTIONS: {
|
||||||
UNTIL_NEXT_REPLY: 'until_next_reply',
|
UNTIL_NEXT_REPLY: 'until_next_reply',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const FEATURE_FLAGS = {
|
|||||||
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
||||||
AUTOMATIONS: 'automations',
|
AUTOMATIONS: 'automations',
|
||||||
CAMPAIGNS: 'campaigns',
|
CAMPAIGNS: 'campaigns',
|
||||||
|
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||||
CANNED_RESPONSES: 'canned_responses',
|
CANNED_RESPONSES: 'canned_responses',
|
||||||
CRM: 'crm',
|
CRM: 'crm',
|
||||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||||
@@ -36,6 +37,8 @@ export const FEATURE_FLAGS = {
|
|||||||
REPORT_V4: 'report_v4',
|
REPORT_V4: 'report_v4',
|
||||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||||
|
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
|
||||||
|
CAPTAIN_V2: 'captain_integration_v2',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PREMIUM_FEATURES = [
|
export const PREMIUM_FEATURES = [
|
||||||
@@ -44,4 +47,5 @@ export const PREMIUM_FEATURES = [
|
|||||||
FEATURE_FLAGS.CUSTOM_ROLES,
|
FEATURE_FLAGS.CUSTOM_ROLES,
|
||||||
FEATURE_FLAGS.AUDIT_LOGS,
|
FEATURE_FLAGS.AUDIT_LOGS,
|
||||||
FEATURE_FLAGS.HELP_CENTER,
|
FEATURE_FLAGS.HELP_CENTER,
|
||||||
|
FEATURE_FLAGS.CAPTAIN_V2,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
|
|||||||
TRANSLATE_A_MESSAGE: 'Translated a message',
|
TRANSLATE_A_MESSAGE: 'Translated a message',
|
||||||
INSERTED_A_VARIABLE: 'Inserted a variable',
|
INSERTED_A_VARIABLE: 'Inserted a variable',
|
||||||
INSERTED_AN_EMOJI: 'Inserted an emoji',
|
INSERTED_AN_EMOJI: 'Inserted an emoji',
|
||||||
|
INSERTED_A_TOOL: 'Inserted a tool',
|
||||||
USED_MENTIONS: 'Used mentions',
|
USED_MENTIONS: 'Used mentions',
|
||||||
SEARCH_CONVERSATION: 'Searched conversations',
|
SEARCH_CONVERSATION: 'Searched conversations',
|
||||||
APPLY_FILTER: 'Applied filters in the conversation list',
|
APPLY_FILTER: 'Applied filters in the conversation list',
|
||||||
|
|||||||
@@ -319,6 +319,12 @@ const createNode = (editorView, nodeType, content) => {
|
|||||||
return state.schema.text(`{{${content}}}`);
|
return state.schema.text(`{{${content}}}`);
|
||||||
case 'emoji':
|
case 'emoji':
|
||||||
return state.schema.text(content);
|
return state.schema.text(content);
|
||||||
|
case 'tool': {
|
||||||
|
return state.schema.nodes.tools.create({
|
||||||
|
id: content.id,
|
||||||
|
name: content.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -355,6 +361,11 @@ const nodeCreators = {
|
|||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
}),
|
}),
|
||||||
|
tool: (editorView, content, from, to) => ({
|
||||||
|
node: createNode(editorView, 'tool', content),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,18 @@
|
|||||||
"LOADING": "Fetching custom attributes",
|
"LOADING": "Fetching custom attributes",
|
||||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||||
"LEARN_MORE": "Learn more about custom attributes",
|
"LEARN_MORE": "Learn more about custom attributes",
|
||||||
|
"ATTRIBUTE_MODELS": {
|
||||||
|
"CONVERSATION": "Conversation",
|
||||||
|
"CONTACT": "Contact"
|
||||||
|
},
|
||||||
|
"ATTRIBUTE_TYPES": {
|
||||||
|
"TEXT": "Text",
|
||||||
|
"NUMBER": "Number",
|
||||||
|
"LINK": "Link",
|
||||||
|
"DATE": "Date",
|
||||||
|
"LIST": "List",
|
||||||
|
"CHECKBOX": "Checkbox"
|
||||||
|
},
|
||||||
"ADD": {
|
"ADD": {
|
||||||
"TITLE": "Add Custom Attribute",
|
"TITLE": "Add Custom Attribute",
|
||||||
"SUBMIT": "Create",
|
"SUBMIT": "Create",
|
||||||
|
|||||||
@@ -146,10 +146,22 @@
|
|||||||
"SEND_WEBHOOK_EVENT": "Send Webhook Event",
|
"SEND_WEBHOOK_EVENT": "Send Webhook Event",
|
||||||
"SEND_ATTACHMENT": "Send Attachment",
|
"SEND_ATTACHMENT": "Send Attachment",
|
||||||
"SEND_MESSAGE": "Send a Message",
|
"SEND_MESSAGE": "Send a Message",
|
||||||
|
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||||
"CHANGE_PRIORITY": "Change Priority",
|
"CHANGE_PRIORITY": "Change Priority",
|
||||||
"ADD_SLA": "Add SLA",
|
"ADD_SLA": "Add SLA",
|
||||||
"OPEN_CONVERSATION": "Open conversation"
|
"OPEN_CONVERSATION": "Open conversation"
|
||||||
},
|
},
|
||||||
|
"MESSAGE_TYPES": {
|
||||||
|
"INCOMING": "Incoming Message",
|
||||||
|
"OUTGOING": "Outgoing Message"
|
||||||
|
},
|
||||||
|
"PRIORITY_TYPES": {
|
||||||
|
"NONE": "None",
|
||||||
|
"LOW": "Low",
|
||||||
|
"MEDIUM": "Medium",
|
||||||
|
"HIGH": "High",
|
||||||
|
"URGENT": "Urgent"
|
||||||
|
},
|
||||||
"ATTRIBUTES": {
|
"ATTRIBUTES": {
|
||||||
"MESSAGE_TYPE": "Message Type",
|
"MESSAGE_TYPE": "Message Type",
|
||||||
"MESSAGE_CONTAINS": "Message Contains",
|
"MESSAGE_CONTAINS": "Message Contains",
|
||||||
|
|||||||
@@ -137,6 +137,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"WHATSAPP": {
|
||||||
|
"HEADER_TITLE": "WhatsApp campaigns",
|
||||||
|
"NEW_CAMPAIGN": "Create campaign",
|
||||||
|
"EMPTY_STATE": {
|
||||||
|
"TITLE": "No WhatsApp campaigns are available",
|
||||||
|
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
|
||||||
|
},
|
||||||
|
"CARD": {
|
||||||
|
"STATUS": {
|
||||||
|
"COMPLETED": "Completed",
|
||||||
|
"SCHEDULED": "Scheduled"
|
||||||
|
},
|
||||||
|
"CAMPAIGN_DETAILS": {
|
||||||
|
"SENT_FROM": "Sent from",
|
||||||
|
"ON": "on"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CREATE": {
|
||||||
|
"TITLE": "Create WhatsApp campaign",
|
||||||
|
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||||
|
"CREATE_BUTTON_TEXT": "Create",
|
||||||
|
"FORM": {
|
||||||
|
"TITLE": {
|
||||||
|
"LABEL": "Title",
|
||||||
|
"PLACEHOLDER": "Please enter the title of campaign",
|
||||||
|
"ERROR": "Title is required"
|
||||||
|
},
|
||||||
|
"INBOX": {
|
||||||
|
"LABEL": "Select Inbox",
|
||||||
|
"PLACEHOLDER": "Select Inbox",
|
||||||
|
"ERROR": "Inbox is required"
|
||||||
|
},
|
||||||
|
"TEMPLATE": {
|
||||||
|
"LABEL": "WhatsApp Template",
|
||||||
|
"PLACEHOLDER": "Select a template",
|
||||||
|
"INFO": "Select a template to use for this campaign.",
|
||||||
|
"ERROR": "Template is required",
|
||||||
|
"PREVIEW_TITLE": "Process {templateName}",
|
||||||
|
"LANGUAGE": "Language",
|
||||||
|
"CATEGORY": "Category",
|
||||||
|
"VARIABLES_LABEL": "Variables",
|
||||||
|
"VARIABLE_PLACEHOLDER": "Enter value for {variable}"
|
||||||
|
},
|
||||||
|
"AUDIENCE": {
|
||||||
|
"LABEL": "Audience",
|
||||||
|
"PLACEHOLDER": "Select the customer labels",
|
||||||
|
"ERROR": "Audience is required"
|
||||||
|
},
|
||||||
|
"SCHEDULED_AT": {
|
||||||
|
"LABEL": "Scheduled time",
|
||||||
|
"PLACEHOLDER": "Please select the time",
|
||||||
|
"ERROR": "Scheduled time is required"
|
||||||
|
},
|
||||||
|
"BUTTONS": {
|
||||||
|
"CREATE": "Create",
|
||||||
|
"CANCEL": "Cancel"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "WhatsApp campaign created successfully",
|
||||||
|
"ERROR_MESSAGE": "There was an error. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CONFIRM_DELETE": {
|
"CONFIRM_DELETE": {
|
||||||
"TITLE": "Are you sure to delete?",
|
"TITLE": "Are you sure to delete?",
|
||||||
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
|
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user