Compare commits
169
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68b2aa1e8c | ||
|
|
021aafac0a | ||
|
|
472493b9af | ||
|
|
f801c1928d | ||
|
|
b18341ea6e | ||
|
|
068538e3d6 | ||
|
|
ad90deb709 | ||
|
|
39dfa35229 | ||
|
|
19faa7fdfa | ||
|
|
f6062f992e | ||
|
|
be721c2b50 | ||
|
|
7d6a43fc72 | ||
|
|
655db56be9 | ||
|
|
7f56cd92f8 | ||
|
|
c07529c2b0 | ||
|
|
d48503bdcf | ||
|
|
d35b0a9465 | ||
|
|
83873d49ae | ||
|
|
6ca38e10e9 | ||
|
|
1a1dfd09cb | ||
|
|
35d0a7f1a7 | ||
|
|
47867c0b8a | ||
|
|
530125d4c5 | ||
|
|
7679004bb4 | ||
|
|
ae3ac33049 | ||
|
|
714f24de11 | ||
|
|
3038e672f8 | ||
|
|
efe5609ba4 | ||
|
|
7aad3c9c6b | ||
|
|
c6113852d7 | ||
|
|
94e930a141 | ||
|
|
7fe94dc1a2 | ||
|
|
cae097c5fa | ||
|
|
faf35738b3 | ||
|
|
341487b93e | ||
|
|
f569ed93ad | ||
|
|
eac292b11b | ||
|
|
75119d4a08 | ||
|
|
67d7ee0185 | ||
|
|
7e33e033cd | ||
|
|
02fa76f3df | ||
|
|
91d80004a6 | ||
|
|
b809cd2f15 | ||
|
|
ee773f31eb | ||
|
|
1f03fc4dc3 | ||
|
|
a42b99ada0 | ||
|
|
c6be04cdc1 | ||
|
|
14471cc20c | ||
|
|
6b42ff8d39 | ||
|
|
a88fef2e1d | ||
|
|
ee9f1d7adb | ||
|
|
b711bfd2ca | ||
|
|
42af4b1d01 | ||
|
|
9a7318a9db | ||
|
|
48fa7bf72b | ||
|
|
469e724e3a | ||
|
|
0c101b1f6b | ||
|
|
ad4ec9e93b | ||
|
|
5c560c7628 | ||
|
|
dbb164a37d | ||
|
|
fdcfed2cd7 | ||
|
|
b4f758f644 | ||
|
|
4df58501e3 | ||
|
|
693309202e | ||
|
|
ecd9cf0326 | ||
|
|
81d8d3862d | ||
|
|
c31325e982 | ||
|
|
28452b300d | ||
|
|
6784eb9b3d | ||
|
|
1baf5cbe19 | ||
|
|
c2e6ad6376 | ||
|
|
d908c880d2 | ||
|
|
fcc6e2b8b2 | ||
|
|
6cab741392 | ||
|
|
f3bc2476fc | ||
|
|
3214d06a83 | ||
|
|
8ea21eeefd | ||
|
|
fd28ed8d83 | ||
|
|
b5f5c5c1bc | ||
|
|
4ebfae8b44 | ||
|
|
d0824cc86a | ||
|
|
d2583d32e9 | ||
|
|
462ab5241c | ||
|
|
f984d745e7 | ||
|
|
ca13664ef9 | ||
|
|
4939508da6 | ||
|
|
304c938260 | ||
|
|
c310fe2f76 | ||
|
|
d5286c9535 | ||
|
|
693bed20df | ||
|
|
8627400e6a | ||
|
|
855dd590ab | ||
|
|
7e70f7a68a | ||
|
|
84fd769570 | ||
|
|
661e905dbd | ||
|
|
270f26e471 | ||
|
|
53fce7be03 | ||
|
|
60a1e9b15d | ||
|
|
65312744c7 | ||
|
|
6063a9e8a4 | ||
|
|
634782ea65 | ||
|
|
51b9fd8eca | ||
|
|
4dc7a653eb | ||
|
|
5ab913f7b5 | ||
|
|
c98c255ed0 | ||
|
|
446a219cd1 | ||
|
|
94829aea8d | ||
|
|
86cb4fd809 | ||
|
|
b434279422 | ||
|
|
d9900e50a0 | ||
|
|
97f1825a14 | ||
|
|
5ed84af2e3 | ||
|
|
df4de508e7 | ||
|
|
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 | ||
|
|
2fec2e5993 | ||
|
|
090316a078 | ||
|
|
fcd604dcde | ||
|
|
13b4fdb34c | ||
|
|
45d4d3660c | ||
|
|
0ea616a6ea | ||
|
|
6b8dd3c86a | ||
|
|
61d10044a0 | ||
|
|
4378506a35 | ||
|
|
1132556512 | ||
|
|
93f18315cc | ||
|
|
d89fa09359 | ||
|
|
07dd6448f4 | ||
|
|
661326ae51 | ||
|
|
18eb53acf4 | ||
|
|
1b02ebec68 | ||
|
|
5b9f997fa0 | ||
|
|
802f0694ed | ||
|
|
17500cc62d | ||
|
|
5140deb6f6 | ||
|
|
3e5ca9bca9 | ||
|
|
5ebe8c71ec | ||
|
|
8c78573d9d | ||
|
|
c553997af8 | ||
|
|
30a3a35281 | ||
|
|
e4ba80e2f0 | ||
|
|
b72848513f |
@@ -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'
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Auto-assign PR to Author
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-assign:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Auto-assign PR to author
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const pull_number = context.payload.pull_request.number;
|
||||||
|
const author = context.payload.pull_request.user.login;
|
||||||
|
|
||||||
|
await github.rest.issues.addAssignees({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: pull_number,
|
||||||
|
assignees: [author]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Assigned PR #${pull_number} to ${author}`);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -56,3 +56,20 @@
|
|||||||
## Ruby Best Practices
|
## Ruby Best Practices
|
||||||
|
|
||||||
- Use compact `module/class` definitions; avoid nested styles
|
- Use compact `module/class` definitions; avoid nested styles
|
||||||
|
|
||||||
|
## Enterprise Edition Notes
|
||||||
|
|
||||||
|
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.
|
||||||
|
- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible.
|
||||||
|
- Follow the Enterprise development practices documented here:
|
||||||
|
- https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38
|
||||||
|
|
||||||
|
Practical checklist for any change impacting core logic or public APIs
|
||||||
|
- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`).
|
||||||
|
- If adding new endpoints, services, or models, consider whether Enterprise needs:
|
||||||
|
- An override (e.g., `enterprise/app/...`), or
|
||||||
|
- An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks.
|
||||||
|
- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise.
|
||||||
|
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
||||||
|
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||||
|
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ gem 'wisper', '2.0.0'
|
|||||||
##--- gems for channels ---##
|
##--- gems for channels ---##
|
||||||
gem 'facebook-messenger'
|
gem 'facebook-messenger'
|
||||||
gem 'line-bot-api'
|
gem 'line-bot-api'
|
||||||
gem 'twilio-ruby', '~> 5.66'
|
gem 'twilio-ruby'
|
||||||
# twitty will handle subscription of twitter account events
|
# twitty will handle subscription of twitter account events
|
||||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||||
gem 'twitty', '~> 0.1.5'
|
gem 'twitty', '~> 0.1.5'
|
||||||
@@ -108,7 +108,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
|
|||||||
##-- apm and error monitoring ---#
|
##-- apm and error monitoring ---#
|
||||||
# loaded only when environment variables are set.
|
# loaded only when environment variables are set.
|
||||||
# ref application.rb
|
# ref application.rb
|
||||||
gem 'ddtrace', require: false
|
gem 'datadog', '~> 2.0', require: false
|
||||||
gem 'elastic-apm', require: false
|
gem 'elastic-apm', require: false
|
||||||
gem 'newrelic_rpm', require: false
|
gem 'newrelic_rpm', require: false
|
||||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||||
@@ -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,10 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
|
gem 'ai-agents', '>= 0.4.3'
|
||||||
|
|
||||||
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|
||||||
@@ -206,6 +212,8 @@ group :development do
|
|||||||
gem 'stackprof'
|
gem 'stackprof'
|
||||||
# Should install the associated chrome extension to view query logs
|
# Should install the associated chrome extension to view query logs
|
||||||
gem 'meta_request', '>= 0.8.3'
|
gem 'meta_request', '>= 0.8.3'
|
||||||
|
|
||||||
|
gem 'tidewave'
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
|
|||||||
+159
-83
@@ -25,35 +25,35 @@ GIT
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (7.1.5.1)
|
actioncable (7.1.5.2)
|
||||||
actionpack (= 7.1.5.1)
|
actionpack (= 7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (7.1.5.1)
|
actionmailbox (7.1.5.2)
|
||||||
actionpack (= 7.1.5.1)
|
actionpack (= 7.1.5.2)
|
||||||
activejob (= 7.1.5.1)
|
activejob (= 7.1.5.2)
|
||||||
activerecord (= 7.1.5.1)
|
activerecord (= 7.1.5.2)
|
||||||
activestorage (= 7.1.5.1)
|
activestorage (= 7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
actionmailer (7.1.5.1)
|
actionmailer (7.1.5.2)
|
||||||
actionpack (= 7.1.5.1)
|
actionpack (= 7.1.5.2)
|
||||||
actionview (= 7.1.5.1)
|
actionview (= 7.1.5.2)
|
||||||
activejob (= 7.1.5.1)
|
activejob (= 7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (7.1.5.1)
|
actionpack (7.1.5.2)
|
||||||
actionview (= 7.1.5.1)
|
actionview (= 7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
racc
|
racc
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
@@ -61,38 +61,38 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
actiontext (7.1.5.1)
|
actiontext (7.1.5.2)
|
||||||
actionpack (= 7.1.5.1)
|
actionpack (= 7.1.5.2)
|
||||||
activerecord (= 7.1.5.1)
|
activerecord (= 7.1.5.2)
|
||||||
activestorage (= 7.1.5.1)
|
activestorage (= 7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (7.1.5.1)
|
actionview (7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
active_record_query_trace (1.8)
|
active_record_query_trace (1.8)
|
||||||
activejob (7.1.5.1)
|
activejob (7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (7.1.5.1)
|
activemodel (7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
activerecord (7.1.5.1)
|
activerecord (7.1.5.2)
|
||||||
activemodel (= 7.1.5.1)
|
activemodel (= 7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activerecord-import (2.1.0)
|
activerecord-import (2.1.0)
|
||||||
activerecord (>= 4.2)
|
activerecord (>= 4.2)
|
||||||
activestorage (7.1.5.1)
|
activestorage (7.1.5.2)
|
||||||
actionpack (= 7.1.5.1)
|
actionpack (= 7.1.5.2)
|
||||||
activejob (= 7.1.5.1)
|
activejob (= 7.1.5.2)
|
||||||
activerecord (= 7.1.5.1)
|
activerecord (= 7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (7.1.5.1)
|
activesupport (7.1.5.2)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -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)
|
||||||
@@ -153,10 +155,10 @@ GEM
|
|||||||
barnes (0.0.9)
|
barnes (0.0.9)
|
||||||
multi_json (~> 1)
|
multi_json (~> 1)
|
||||||
statsd-ruby (~> 1.1)
|
statsd-ruby (~> 1.1)
|
||||||
base64 (0.2.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
benchmark (0.4.0)
|
benchmark (0.4.1)
|
||||||
bigdecimal (3.1.9)
|
bigdecimal (3.2.2)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.16.0)
|
bootsnap (1.16.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
@@ -192,10 +194,14 @@ GEM
|
|||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
date (3.4.1)
|
datadog (2.19.0)
|
||||||
ddtrace (0.48.0)
|
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
|
||||||
ffi (~> 1.0)
|
libdatadog (~> 18.1.0.1.0)
|
||||||
|
libddwaf (~> 1.24.1.0.3)
|
||||||
|
logger
|
||||||
msgpack
|
msgpack
|
||||||
|
datadog-ruby_core_source (3.4.1)
|
||||||
|
date (3.4.1)
|
||||||
debug (1.8.0)
|
debug (1.8.0)
|
||||||
irb (>= 1.5.0)
|
irb (>= 1.5.0)
|
||||||
reline (>= 0.3.1)
|
reline (>= 0.3.1)
|
||||||
@@ -224,6 +230,35 @@ GEM
|
|||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
dry-cli (1.1.0)
|
dry-cli (1.1.0)
|
||||||
|
dry-configurable (1.3.0)
|
||||||
|
dry-core (~> 1.1)
|
||||||
|
zeitwerk (~> 2.6)
|
||||||
|
dry-core (1.1.0)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
logger
|
||||||
|
zeitwerk (~> 2.6)
|
||||||
|
dry-inflector (1.2.0)
|
||||||
|
dry-initializer (3.2.0)
|
||||||
|
dry-logic (1.6.0)
|
||||||
|
bigdecimal
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
dry-core (~> 1.1)
|
||||||
|
zeitwerk (~> 2.6)
|
||||||
|
dry-schema (1.14.1)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
dry-configurable (~> 1.0, >= 1.0.1)
|
||||||
|
dry-core (~> 1.1)
|
||||||
|
dry-initializer (~> 3.2)
|
||||||
|
dry-logic (~> 1.5)
|
||||||
|
dry-types (~> 1.8)
|
||||||
|
zeitwerk (~> 2.6)
|
||||||
|
dry-types (1.8.3)
|
||||||
|
bigdecimal (~> 3.0)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
dry-core (~> 1.0)
|
||||||
|
dry-inflector (~> 1.0)
|
||||||
|
dry-logic (~> 1.4)
|
||||||
|
zeitwerk (~> 2.6)
|
||||||
ecma-re-validator (0.4.0)
|
ecma-re-validator (0.4.0)
|
||||||
regexp_parser (~> 2.2)
|
regexp_parser (~> 2.2)
|
||||||
elastic-apm (4.6.2)
|
elastic-apm (4.6.2)
|
||||||
@@ -246,8 +281,10 @@ GEM
|
|||||||
railties (>= 5.0.0)
|
railties (>= 5.0.0)
|
||||||
faker (3.2.0)
|
faker (3.2.0)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.9.0)
|
faraday (2.13.1)
|
||||||
faraday-net_http (>= 2.0, < 3.2)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
|
json
|
||||||
|
logger
|
||||||
faraday-follow_redirects (0.3.0)
|
faraday-follow_redirects (0.3.0)
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
faraday-mashify (0.1.1)
|
faraday-mashify (0.1.1)
|
||||||
@@ -255,13 +292,20 @@ GEM
|
|||||||
hashie
|
hashie
|
||||||
faraday-multipart (1.0.4)
|
faraday-multipart (1.0.4)
|
||||||
multipart-post (~> 2)
|
multipart-post (~> 2)
|
||||||
faraday-net_http (3.1.0)
|
faraday-net_http (3.4.0)
|
||||||
net-http
|
net-http (>= 0.5.0)
|
||||||
faraday-net_http_persistent (2.1.0)
|
faraday-net_http_persistent (2.1.0)
|
||||||
faraday (~> 2.5)
|
faraday (~> 2.5)
|
||||||
net-http-persistent (~> 4.0)
|
net-http-persistent (~> 4.0)
|
||||||
faraday-retry (2.2.1)
|
faraday-retry (2.2.1)
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
|
fast-mcp (1.5.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
base64
|
||||||
|
dry-schema (~> 1.14)
|
||||||
|
json (~> 2.0)
|
||||||
|
mime-types (~> 3.4)
|
||||||
|
rack (~> 3.1)
|
||||||
fcm (1.0.8)
|
fcm (1.0.8)
|
||||||
faraday (>= 1.0.0, < 3.0)
|
faraday (>= 1.0.0, < 3.0)
|
||||||
googleauth (~> 1)
|
googleauth (~> 1)
|
||||||
@@ -357,6 +401,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)
|
||||||
@@ -414,7 +459,7 @@ GEM
|
|||||||
judoscale-sidekiq (1.8.2)
|
judoscale-sidekiq (1.8.2)
|
||||||
judoscale-ruby (= 1.8.2)
|
judoscale-ruby (= 1.8.2)
|
||||||
sidekiq (>= 5.0)
|
sidekiq (>= 5.0)
|
||||||
jwt (2.8.1)
|
jwt (2.10.1)
|
||||||
base64
|
base64
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
@@ -441,6 +486,16 @@ GEM
|
|||||||
logger (~> 1.6)
|
logger (~> 1.6)
|
||||||
letter_opener (1.10.0)
|
letter_opener (1.10.0)
|
||||||
launchy (>= 2.2, < 4)
|
launchy (>= 2.2, < 4)
|
||||||
|
libdatadog (18.1.0.1.0)
|
||||||
|
libdatadog (18.1.0.1.0-x86_64-linux)
|
||||||
|
libddwaf (1.24.1.0.3)
|
||||||
|
ffi (~> 1.0)
|
||||||
|
libddwaf (1.24.1.0.3-arm64-darwin)
|
||||||
|
ffi (~> 1.0)
|
||||||
|
libddwaf (1.24.1.0.3-x86_64-darwin)
|
||||||
|
ffi (~> 1.0)
|
||||||
|
libddwaf (1.24.1.0.3-x86_64-linux)
|
||||||
|
ffi (~> 1.0)
|
||||||
line-bot-api (1.28.0)
|
line-bot-api (1.28.0)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
liquid (5.4.0)
|
liquid (5.4.0)
|
||||||
@@ -475,7 +530,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
|
||||||
@@ -486,7 +541,7 @@ GEM
|
|||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
neighbor (0.2.3)
|
neighbor (0.2.3)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
net-http (0.4.1)
|
net-http (0.6.0)
|
||||||
uri
|
uri
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
@@ -506,14 +561,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)
|
||||||
@@ -574,7 +629,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (2.2.15)
|
rack (3.2.0)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-contrib (2.5.0)
|
rack-contrib (2.5.0)
|
||||||
@@ -583,33 +638,34 @@ GEM
|
|||||||
rack (>= 2.0.0)
|
rack (>= 2.0.0)
|
||||||
rack-mini-profiler (3.2.0)
|
rack-mini-profiler (3.2.0)
|
||||||
rack (>= 1.2.0)
|
rack (>= 1.2.0)
|
||||||
rack-protection (3.2.0)
|
rack-protection (4.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (~> 2.2, >= 2.2.4)
|
logger (>= 1.6.0)
|
||||||
|
rack (>= 3.0.0, < 4)
|
||||||
rack-proxy (0.7.7)
|
rack-proxy (0.7.7)
|
||||||
rack
|
rack
|
||||||
rack-session (1.0.2)
|
rack-session (2.1.1)
|
||||||
rack (< 3)
|
base64 (>= 0.1.0)
|
||||||
|
rack (>= 3.0.0)
|
||||||
rack-test (2.1.0)
|
rack-test (2.1.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rack-timeout (0.6.3)
|
rack-timeout (0.6.3)
|
||||||
rackup (1.0.1)
|
rackup (2.2.1)
|
||||||
rack (< 3)
|
rack (>= 3)
|
||||||
webrick
|
rails (7.1.5.2)
|
||||||
rails (7.1.5.1)
|
actioncable (= 7.1.5.2)
|
||||||
actioncable (= 7.1.5.1)
|
actionmailbox (= 7.1.5.2)
|
||||||
actionmailbox (= 7.1.5.1)
|
actionmailer (= 7.1.5.2)
|
||||||
actionmailer (= 7.1.5.1)
|
actionpack (= 7.1.5.2)
|
||||||
actionpack (= 7.1.5.1)
|
actiontext (= 7.1.5.2)
|
||||||
actiontext (= 7.1.5.1)
|
actionview (= 7.1.5.2)
|
||||||
actionview (= 7.1.5.1)
|
activejob (= 7.1.5.2)
|
||||||
activejob (= 7.1.5.1)
|
activemodel (= 7.1.5.2)
|
||||||
activemodel (= 7.1.5.1)
|
activerecord (= 7.1.5.2)
|
||||||
activerecord (= 7.1.5.1)
|
activestorage (= 7.1.5.2)
|
||||||
activestorage (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 7.1.5.1)
|
railties (= 7.1.5.2)
|
||||||
rails-dom-testing (2.2.0)
|
rails-dom-testing (2.2.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -617,9 +673,9 @@ GEM
|
|||||||
rails-html-sanitizer (1.6.1)
|
rails-html-sanitizer (1.6.1)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.21)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
railties (7.1.5.1)
|
railties (7.1.5.2)
|
||||||
actionpack (= 7.1.5.1)
|
actionpack (= 7.1.5.2)
|
||||||
activesupport (= 7.1.5.1)
|
activesupport (= 7.1.5.2)
|
||||||
irb
|
irb
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@@ -717,6 +773,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 +840,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,14 +881,18 @@ 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)
|
||||||
|
tidewave (0.2.0)
|
||||||
|
fast-mcp (~> 1.5.0)
|
||||||
|
rack (>= 2.0)
|
||||||
|
rails (>= 7.1.0)
|
||||||
tilt (2.3.0)
|
tilt (2.3.0)
|
||||||
time_diff (0.3.0)
|
time_diff (0.3.0)
|
||||||
activesupport
|
activesupport
|
||||||
i18n
|
i18n
|
||||||
timeout (0.4.3)
|
timeout (0.4.3)
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
twilio-ruby (5.77.0)
|
twilio-ruby (7.6.0)
|
||||||
faraday (>= 0.9, < 3.0)
|
faraday (>= 0.9, < 3.0)
|
||||||
jwt (>= 1.5, < 3.0)
|
jwt (>= 1.5, < 3.0)
|
||||||
nokogiri (>= 1.6, < 2.0)
|
nokogiri (>= 1.6, < 2.0)
|
||||||
@@ -866,7 +939,6 @@ GEM
|
|||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
webrick (1.9.1)
|
|
||||||
websocket-driver (0.7.7)
|
websocket-driver (0.7.7)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
@@ -895,6 +967,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)
|
||||||
@@ -911,7 +984,7 @@ DEPENDENCIES
|
|||||||
commonmarker
|
commonmarker
|
||||||
csv-safe
|
csv-safe
|
||||||
database_cleaner
|
database_cleaner
|
||||||
ddtrace
|
datadog (~> 2.0)
|
||||||
debug (~> 1.8)
|
debug (~> 1.8)
|
||||||
devise (>= 4.9.4)
|
devise (>= 4.9.4)
|
||||||
devise-secure_password!
|
devise-secure_password!
|
||||||
@@ -988,6 +1061,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 +1072,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
|
||||||
@@ -1007,8 +1082,9 @@ DEPENDENCIES
|
|||||||
stripe
|
stripe
|
||||||
telephone_number
|
telephone_number
|
||||||
test-prof
|
test-prof
|
||||||
|
tidewave
|
||||||
time_diff
|
time_diff
|
||||||
twilio-ruby (~> 5.66)
|
twilio-ruby
|
||||||
twitty (~> 0.1.5)
|
twitty (~> 0.1.5)
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
uglifier
|
uglifier
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.13.0
|
4.4.0
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.2.0
|
3.4.3
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :fetch_assignment_policy
|
||||||
|
before_action -> { check_authorization(AssignmentPolicy) }
|
||||||
|
|
||||||
|
def index
|
||||||
|
@inboxes = @assignment_policy.inboxes
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_assignment_policy
|
||||||
|
@assignment_policy = Current.account.assignment_policies.find(
|
||||||
|
params[:assignment_policy_id]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:assignment_policy_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
|
||||||
|
before_action :check_authorization
|
||||||
|
|
||||||
|
def index
|
||||||
|
@assignment_policies = Current.account.assignment_policies
|
||||||
|
end
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@assignment_policy.update!(assignment_policy_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@assignment_policy.destroy!
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_assignment_policy
|
||||||
|
@assignment_policy = Current.account.assignment_policies.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def assignment_policy_params
|
||||||
|
params.require(:assignment_policy).permit(
|
||||||
|
:name, :description, :assignment_order, :conversation_priority,
|
||||||
|
:fair_distribution_limit, :fair_distribution_window, :enabled
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :fetch_inbox
|
||||||
|
before_action :fetch_assignment_policy, only: [:create]
|
||||||
|
before_action -> { check_authorization(AssignmentPolicy) }
|
||||||
|
before_action :validate_assignment_policy, only: [:show, :destroy]
|
||||||
|
|
||||||
|
def show
|
||||||
|
@assignment_policy = @inbox.assignment_policy
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
# There should be only one assignment policy for an inbox.
|
||||||
|
# If there is a new request to add an assignment policy, we will
|
||||||
|
# delete the old one and attach the new policy
|
||||||
|
remove_inbox_assignment_policy
|
||||||
|
@inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy)
|
||||||
|
@assignment_policy = @inbox.assignment_policy
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
remove_inbox_assignment_policy
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def remove_inbox_assignment_policy
|
||||||
|
@inbox.inbox_assignment_policy&.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_inbox
|
||||||
|
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_assignment_policy
|
||||||
|
@assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:assignment_policy_id, :inbox_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_assignment_policy
|
||||||
|
return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -69,6 +69,15 @@ 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
|
||||||
|
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||||
|
|
||||||
|
trigger_template_sync
|
||||||
|
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
|
||||||
@@ -174,6 +183,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def whatsapp_channel?
|
||||||
|
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def trigger_template_sync
|
||||||
|
if @inbox.whatsapp?
|
||||||
|
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||||
|
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||||
|
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||||
|
|||||||
@@ -26,9 +26,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
||||||
# @portal.custom_domain = parsed_custom_domain
|
# @portal.custom_domain = parsed_custom_domain
|
||||||
process_attached_logo if params[:blob_id].present?
|
process_attached_logo if params[:blob_id].present?
|
||||||
rescue StandardError => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
Rails.logger.error e
|
render_record_invalid(e)
|
||||||
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,6 +46,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 +73,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 +101,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,87 @@
|
|||||||
|
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :validate_feature_enabled!
|
||||||
|
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
||||||
|
|
||||||
|
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||||
|
# Handles both initial authorization and reauthorization
|
||||||
|
# If inbox_id is present in params, it performs reauthorization
|
||||||
|
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,
|
||||||
|
params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys,
|
||||||
|
inbox_id: params[:inbox_id]
|
||||||
|
)
|
||||||
|
service.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_and_validate_inbox
|
||||||
|
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||||
|
validate_reauthorization_required
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_reauthorization_required
|
||||||
|
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: false,
|
||||||
|
message: I18n.t('inbox.reauthorization.not_required')
|
||||||
|
}, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_upgrade_to_embedded_signup?
|
||||||
|
channel = @inbox.channel
|
||||||
|
return false unless channel.provider == 'whatsapp_cloud'
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_success_response(inbox)
|
||||||
|
response = {
|
||||||
|
success: true,
|
||||||
|
id: inbox.id,
|
||||||
|
name: inbox.name,
|
||||||
|
channel_type: 'whatsapp'
|
||||||
|
}
|
||||||
|
response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present?
|
||||||
|
render json: response
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
class Platform::Api::V1::AccountsController < PlatformController
|
class Platform::Api::V1::AccountsController < PlatformController
|
||||||
|
def index
|
||||||
|
@resources = @platform_app.platform_app_permissibles
|
||||||
|
.where(permissible_type: 'Account')
|
||||||
|
.includes(:permissible)
|
||||||
|
.map(&:permissible)
|
||||||
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ class SlackUploadsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def blob_url
|
def blob_url
|
||||||
url_for(@blob.representation(resize_to_fill: [250, nil]))
|
# Only generate representations for images
|
||||||
|
if @blob.content_type.start_with?('image/')
|
||||||
|
url_for(@blob.representation(resize_to_fill: [250, nil]))
|
||||||
|
else
|
||||||
|
url_for(@blob)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def avatar_url
|
def avatar_url
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class Twilio::CallbackController < ApplicationController
|
|||||||
:NumMedia,
|
:NumMedia,
|
||||||
:Latitude,
|
:Latitude,
|
||||||
:Longitude,
|
:Longitude,
|
||||||
:MessageType
|
:MessageType,
|
||||||
|
:ProfileName
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ class NotificationFinder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def unread_count
|
def unread_count
|
||||||
@notifications.where(read_at: nil).count
|
if type_included?('read')
|
||||||
|
# If we're including read notifications, filter to unread
|
||||||
|
@notifications.where(read_at: nil).count
|
||||||
|
else
|
||||||
|
# Already filtered to unread notifications, just count
|
||||||
|
@notifications.count
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def count
|
def count
|
||||||
@@ -27,7 +33,7 @@ class NotificationFinder
|
|||||||
def set_up
|
def set_up
|
||||||
find_all_notifications
|
find_all_notifications
|
||||||
filter_snoozed_notifications
|
filter_snoozed_notifications
|
||||||
fitler_read_notifications
|
filter_read_notifications
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_all_notifications
|
def find_all_notifications
|
||||||
@@ -38,7 +44,7 @@ class NotificationFinder
|
|||||||
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
|
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
|
||||||
end
|
end
|
||||||
|
|
||||||
def fitler_read_notifications
|
def filter_read_notifications
|
||||||
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
|
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,25 @@ module ReportingEventHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def last_non_human_activity(conversation)
|
def last_non_human_activity(conversation)
|
||||||
# check if a handoff event already exists
|
# Try to get either a handoff or reopened event first
|
||||||
handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
|
# These will always take precedence over any other activity
|
||||||
|
# Also, any of these events can happen at any time in the course of a conversation lifecycle.
|
||||||
|
# So we pick the latest event
|
||||||
|
event = ReportingEvent.where(
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
name: %w[conversation_bot_handoff conversation_opened]
|
||||||
|
).order(event_end_time: :desc).first
|
||||||
|
|
||||||
# if a handoff exists, last non human activity is when the handoff ended,
|
return event.event_end_time if event&.event_end_time
|
||||||
# otherwise it's when the conversation was created
|
|
||||||
handoff_event&.event_end_time || conversation.created_at
|
# Fallback to bot resolved event
|
||||||
|
# Because this will be closest to the most accurate activity instead of conversation.created_at
|
||||||
|
bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last
|
||||||
|
|
||||||
|
return bot_event.event_end_time if bot_event&.event_end_time
|
||||||
|
|
||||||
|
# If no events found, return conversation creation time
|
||||||
|
conversation.created_at
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
+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,8 +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 }"
|
|
||||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||||
>
|
>
|
||||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||||
|
|||||||
@@ -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,21 @@
|
|||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
reauthorizeWhatsApp({ inboxId, ...params }) {
|
||||||
|
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, {
|
||||||
|
...params,
|
||||||
|
inbox_id: inboxId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new WhatsappChannel();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class PdfDocumentsAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('portals', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadContent(portalSlug, formData) {
|
||||||
|
return axios.post(`${this.url}/${portalSlug}/upload_content`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getGeneratedContent(portalSlug) {
|
||||||
|
return axios.get(`${this.url}/${portalSlug}/generated_content`);
|
||||||
|
}
|
||||||
|
|
||||||
|
publishContent(portalSlug, responseIds, categoryId = null) {
|
||||||
|
return axios.post(`${this.url}/${portalSlug}/publish_content`, {
|
||||||
|
response_ids: responseIds,
|
||||||
|
category_id: categoryId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PdfDocumentsAPI;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ const campaignStatus = computed(() => {
|
|||||||
const inboxName = computed(() => props.inbox?.name || '');
|
const inboxName = computed(() => props.inbox?.name || '');
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
|
const { medium, channel_type: type } = props.inbox;
|
||||||
return getInboxIconByType(type, phoneNumber);
|
return getInboxIconByType(type, medium);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+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>
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
<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] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="p-6 flex flex-col gap-6">
|
||||||
|
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
|
||||||
|
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
||||||
|
</h3>
|
||||||
|
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
<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';
|
||||||
|
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const formState = {
|
||||||
|
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||||
|
labels: useMapGetter('labels/getLabels'),
|
||||||
|
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
||||||
|
getFilteredWhatsAppTemplates: useMapGetter(
|
||||||
|
'inboxes/getFilteredWhatsAppTemplates'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
title: '',
|
||||||
|
inboxId: null,
|
||||||
|
templateId: null,
|
||||||
|
scheduledAt: null,
|
||||||
|
selectedAudience: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = reactive({ ...initialState });
|
||||||
|
const templateParserRef = ref(null);
|
||||||
|
|
||||||
|
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.getFilteredWhatsAppTemplates.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 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(() => {
|
||||||
|
return templateParserRef.value?.v$?.$invalid === false || true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSubmitDisabled = computed(
|
||||||
|
() => v$.value.$invalid || !hasRequiredTemplateParams.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatToUTCString = localDateTime =>
|
||||||
|
localDateTime ? new Date(localDateTime).toISOString() : null;
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
Object.assign(state, initialState);
|
||||||
|
v$.value.$reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => emit('cancel');
|
||||||
|
|
||||||
|
const prepareCampaignDetails = () => {
|
||||||
|
// Find the selected template to get its content
|
||||||
|
const currentTemplate = selectedTemplate.value;
|
||||||
|
const parserData = templateParserRef.value;
|
||||||
|
|
||||||
|
// Extract template content - this should be the template message body
|
||||||
|
const templateContent = parserData?.renderedTemplate || '';
|
||||||
|
|
||||||
|
// 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: parserData?.processedParams || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</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 Parser -->
|
||||||
|
<WhatsAppTemplateParser
|
||||||
|
v-if="selectedTemplate"
|
||||||
|
ref="templateParserRef"
|
||||||
|
:template="selectedTemplate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -98,6 +98,7 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
|||||||
:src="thumbnail"
|
:src="thumbnail"
|
||||||
:size="48"
|
:size="48"
|
||||||
:status="availabilityStatus"
|
:status="availabilityStatus"
|
||||||
|
hide-offline-status
|
||||||
rounded-full
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-0.5 flex-1">
|
<div class="flex flex-col gap-0.5 flex-1">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, useSlots } from 'vue';
|
import { computed, useSlots, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||||
|
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedContact: {
|
selectedContact: {
|
||||||
@@ -24,6 +26,8 @@ const { t } = useI18n();
|
|||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
const isContactSidebarOpen = ref(false);
|
||||||
|
|
||||||
const contactId = computed(() => route.params.contactId);
|
const contactId = computed(() => route.params.contactId);
|
||||||
|
|
||||||
const selectedContactName = computed(() => {
|
const selectedContactName = computed(() => {
|
||||||
@@ -56,6 +60,15 @@ const handleBreadcrumbClick = () => {
|
|||||||
const toggleBlock = () => {
|
const toggleBlock = () => {
|
||||||
emit('toggleBlock', isContactBlocked.value);
|
emit('toggleBlock', isContactBlocked.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConversationSidebarToggle = () => {
|
||||||
|
isContactSidebarOpen.value = !isContactSidebarOpen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMobileSidebar = () => {
|
||||||
|
if (!isContactSidebarOpen.value) return;
|
||||||
|
isContactSidebarOpen.value = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -67,7 +80,9 @@ const toggleBlock = () => {
|
|||||||
>
|
>
|
||||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
<div
|
||||||
|
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||||
|
>
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
:items="breadcrumbItems"
|
:items="breadcrumbItems"
|
||||||
@click="handleBreadcrumbClick"
|
@click="handleBreadcrumbClick"
|
||||||
@@ -85,6 +100,11 @@ const toggleBlock = () => {
|
|||||||
:disabled="isUpdating"
|
:disabled="isUpdating"
|
||||||
@click="toggleBlock"
|
@click="toggleBlock"
|
||||||
/>
|
/>
|
||||||
|
<VoiceCallButton
|
||||||
|
:phone="selectedContact?.phoneNumber"
|
||||||
|
:label="$t('CONTACT_PANEL.CALL')"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
<ComposeConversation :contact-id="contactId">
|
<ComposeConversation :contact-id="contactId">
|
||||||
<template #trigger="{ toggle }">
|
<template #trigger="{ toggle }">
|
||||||
<Button
|
<Button
|
||||||
@@ -105,11 +125,65 @@ const toggleBlock = () => {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop sidebar -->
|
||||||
<div
|
<div
|
||||||
v-if="slots.sidebar"
|
v-if="slots.sidebar"
|
||||||
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||||
>
|
>
|
||||||
<slot name="sidebar" />
|
<slot name="sidebar" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile sidebar container -->
|
||||||
|
<div
|
||||||
|
v-if="slots.sidebar"
|
||||||
|
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
|
||||||
|
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
|
||||||
|
>
|
||||||
|
<!-- Toggle button -->
|
||||||
|
<div
|
||||||
|
v-on-click-outside="[
|
||||||
|
closeMobileSidebar,
|
||||||
|
{ ignore: ['#contact-sidebar-content'] },
|
||||||
|
]"
|
||||||
|
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
|
||||||
|
:class="[
|
||||||
|
isContactSidebarOpen
|
||||||
|
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
|
||||||
|
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
class="!rounded-full rtl:rotate-180"
|
||||||
|
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
|
||||||
|
:icon="
|
||||||
|
isContactSidebarOpen
|
||||||
|
? 'i-lucide-panel-right-close'
|
||||||
|
: 'i-lucide-panel-right-open'
|
||||||
|
"
|
||||||
|
data-contact-sidebar-toggle
|
||||||
|
@click="handleConversationSidebarToggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||||
|
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||||
|
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||||
|
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||||
|
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||||
|
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isContactSidebarOpen"
|
||||||
|
id="contact-sidebar-content"
|
||||||
|
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
||||||
|
>
|
||||||
|
<slot name="sidebar" />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
+57
-54
@@ -34,13 +34,13 @@ const emit = defineEmits([
|
|||||||
<template>
|
<template>
|
||||||
<header class="sticky top-0 z-10">
|
<header class="sticky top-0 z-10">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
|
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||||
>
|
>
|
||||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center flex-shrink-0 gap-4">
|
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
|
||||||
<div v-if="showSearch" class="flex items-center gap-2">
|
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
||||||
<Input
|
<Input
|
||||||
:model-value="searchValue"
|
:model-value="searchValue"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -48,6 +48,7 @@ const emit = defineEmits([
|
|||||||
:custom-input-class="[
|
:custom-input-class="[
|
||||||
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||||
]"
|
]"
|
||||||
|
class="w-full"
|
||||||
@input="emit('search', $event.target.value)"
|
@input="emit('search', $event.target.value)"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -58,64 +59,66 @@ const emit = defineEmits([
|
|||||||
</template>
|
</template>
|
||||||
</Input>
|
</Input>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center flex-shrink-0 gap-4">
|
||||||
<div v-if="!isLabelView && !isActiveView" class="relative">
|
<div class="flex items-center gap-2">
|
||||||
|
<div v-if="!isLabelView && !isActiveView" class="relative">
|
||||||
|
<Button
|
||||||
|
id="toggleContactsFilterButton"
|
||||||
|
:icon="
|
||||||
|
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||||
|
"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
class="relative w-8"
|
||||||
|
variant="ghost"
|
||||||
|
@click="emit('filter')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="hasActiveFilters && !isSegmentsView"
|
||||||
|
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<slot name="filter" />
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
id="toggleContactsFilterButton"
|
v-if="
|
||||||
:icon="
|
hasActiveFilters &&
|
||||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
!isSegmentsView &&
|
||||||
|
!isLabelView &&
|
||||||
|
!isActiveView
|
||||||
"
|
"
|
||||||
|
icon="i-lucide-save"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="relative w-8"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@click="emit('filter')"
|
@click="emit('createSegment')"
|
||||||
>
|
/>
|
||||||
<div
|
<Button
|
||||||
v-if="hasActiveFilters && !isSegmentsView"
|
v-if="isSegmentsView && !isLabelView && !isActiveView"
|
||||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
icon="i-lucide-trash"
|
||||||
/>
|
color="slate"
|
||||||
</Button>
|
size="sm"
|
||||||
<slot name="filter" />
|
variant="ghost"
|
||||||
|
@click="emit('deleteSegment')"
|
||||||
|
/>
|
||||||
|
<ContactSortMenu
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
@update:sort="emit('update:sort', $event)"
|
||||||
|
/>
|
||||||
|
<ContactMoreActions
|
||||||
|
@add="emit('add')"
|
||||||
|
@import="emit('import')"
|
||||||
|
@export="emit('export')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div class="w-px h-4 bg-n-strong" />
|
||||||
v-if="
|
<ComposeConversation>
|
||||||
hasActiveFilters &&
|
<template #trigger="{ toggle }">
|
||||||
!isSegmentsView &&
|
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||||
!isLabelView &&
|
</template>
|
||||||
!isActiveView
|
</ComposeConversation>
|
||||||
"
|
|
||||||
icon="i-lucide-save"
|
|
||||||
color="slate"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
@click="emit('createSegment')"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
v-if="isSegmentsView && !isLabelView && !isActiveView"
|
|
||||||
icon="i-lucide-trash"
|
|
||||||
color="slate"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
@click="emit('deleteSegment')"
|
|
||||||
/>
|
|
||||||
<ContactSortMenu
|
|
||||||
:active-sort="activeSort"
|
|
||||||
:active-ordering="activeOrdering"
|
|
||||||
@update:sort="emit('update:sort', $event)"
|
|
||||||
/>
|
|
||||||
<ContactMoreActions
|
|
||||||
@add="emit('add')"
|
|
||||||
@import="emit('import')"
|
|
||||||
@export="emit('export')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-px h-4 bg-n-strong" />
|
|
||||||
<ComposeConversation>
|
|
||||||
<template #trigger="{ toggle }">
|
|
||||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
|
||||||
</template>
|
|
||||||
</ComposeConversation>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+14
-11
@@ -291,17 +291,20 @@ defineExpose({
|
|||||||
@delete-segment="openDeleteSegmentDialog"
|
@delete-segment="openDeleteSegmentDialog"
|
||||||
>
|
>
|
||||||
<template #filter>
|
<template #filter>
|
||||||
<ContactsFilter
|
<div
|
||||||
v-if="showFiltersModal"
|
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
|
||||||
v-model="appliedFilter"
|
>
|
||||||
:segment-name="activeSegmentName"
|
<ContactsFilter
|
||||||
:is-segment-view="hasActiveSegments"
|
v-if="showFiltersModal"
|
||||||
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
|
v-model="appliedFilter"
|
||||||
@apply-filter="onApplyFilter"
|
:segment-name="activeSegmentName"
|
||||||
@update-segment="onUpdateSegment"
|
:is-segment-view="hasActiveSegments"
|
||||||
@close="closeAdvanceFiltersModal"
|
@apply-filter="onApplyFilter"
|
||||||
@clear-filters="clearFilters"
|
@update-segment="onUpdateSegment"
|
||||||
/>
|
@close="closeAdvanceFiltersModal"
|
||||||
|
@clear-filters="clearFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ContactsHeader>
|
</ContactsHeader>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
|
|||||||
<div
|
<div
|
||||||
v-if="isMenuOpen"
|
v-if="isMenuOpen"
|
||||||
v-on-clickaway="() => (isMenuOpen = false)"
|
v-on-clickaway="() => (isMenuOpen = false)"
|
||||||
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="text-sm text-n-slate-12">
|
<span class="text-sm text-n-slate-12">
|
||||||
|
|||||||
@@ -96,10 +96,7 @@ const openFilter = () => {
|
|||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||||
v-if="showPaginationFooter"
|
|
||||||
class="sticky bottom-0 z-10 px-4 pb-4"
|
|
||||||
>
|
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
|
|||||||
+3
-3
@@ -4,8 +4,8 @@ export default [
|
|||||||
city: 'Los Angeles',
|
city: 'Los Angeles',
|
||||||
country: 'United States',
|
country: 'United States',
|
||||||
description:
|
description:
|
||||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
|
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Lumora.",
|
||||||
companyName: 'Chatwoot',
|
companyName: 'Lumora',
|
||||||
countryCode: 'US',
|
countryCode: 'US',
|
||||||
socialProfiles: {
|
socialProfiles: {
|
||||||
github: 'candice-dev',
|
github: 'candice-dev',
|
||||||
@@ -16,7 +16,7 @@ export default [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
availabilityStatus: 'offline',
|
availabilityStatus: 'offline',
|
||||||
email: 'candice.matherson@chatwoot.com',
|
email: 'candice.matherson@lumora.com',
|
||||||
id: 22,
|
id: 22,
|
||||||
name: 'Candice Matherson',
|
name: 'Candice Matherson',
|
||||||
phoneNumber: '+14155552671',
|
phoneNumber: '+14155552671',
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, useAttrs } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
phone: { type: String, default: '' },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
|
size: { type: String, default: 'sm' },
|
||||||
|
tooltipLabel: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
const attrs = useAttrs();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
|
const voiceInboxes = computed(() =>
|
||||||
|
(inboxesList.value || []).filter(
|
||||||
|
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||||
|
|
||||||
|
// Unified behavior: hide when no phone
|
||||||
|
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
if (voiceInboxes.value.length > 1) {
|
||||||
|
dialogRef.value?.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickInbox = () => {
|
||||||
|
// Placeholder until actual call wiring happens
|
||||||
|
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||||
|
dialogRef.value?.close();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="contents">
|
||||||
|
<Button
|
||||||
|
v-if="shouldRender"
|
||||||
|
v-tooltip.top-end="tooltipLabel || null"
|
||||||
|
v-bind="attrs"
|
||||||
|
:label="label"
|
||||||
|
:icon="icon"
|
||||||
|
:size="size"
|
||||||
|
@click="onClick"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
v-if="shouldRender && voiceInboxes.length > 1"
|
||||||
|
ref="dialogRef"
|
||||||
|
:title="$t('CONTACT_PANEL.VOICE_INBOX_PICKER.TITLE')"
|
||||||
|
show-cancel-button
|
||||||
|
:show-confirm-button="false"
|
||||||
|
width="md"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
v-for="inbox in voiceInboxes"
|
||||||
|
:key="inbox.id"
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-between w-full px-4 py-2 text-left rounded-lg hover:bg-n-alpha-2"
|
||||||
|
@click="onPickInbox(inbox)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="i-ri-phone-fill text-n-slate-10" />
|
||||||
|
<span class="text-sm text-n-slate-12">{{ inbox.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="inbox.phone_number" class="text-xs text-n-slate-10">
|
||||||
|
{{ inbox.phone_number }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
+1
@@ -47,6 +47,7 @@ const unreadMessagesCount = computed(() => {
|
|||||||
</p>
|
</p>
|
||||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
v-if="assignee.name"
|
||||||
:name="assignee.name"
|
:name="assignee.name"
|
||||||
:src="assignee.thumbnail"
|
:src="assignee.thumbnail"
|
||||||
:size="20"
|
:size="20"
|
||||||
|
|||||||
+1
@@ -96,6 +96,7 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
v-if="assignee.name"
|
||||||
:name="assignee.name"
|
:name="assignee.name"
|
||||||
:src="assignee.thumbnail"
|
:src="assignee.thumbnail"
|
||||||
:size="20"
|
:size="20"
|
||||||
|
|||||||
+2
-2
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
|
|||||||
const inboxName = computed(() => inbox.value?.name);
|
const inboxName = computed(() => inbox.value?.name);
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { phoneNumber, channelType } = inbox.value;
|
const { channelType, medium } = inbox.value;
|
||||||
return getInboxIconByType(channelType, phoneNumber);
|
return getInboxIconByType(channelType, medium);
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastActivityAt = computed(() => {
|
const lastActivityAt = computed(() => {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.TITLE') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Section -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h4 class="text-base font-medium text-slate-900 dark:text-slate-100 mb-4">
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.UPLOAD_TITLE') }}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-6 text-center"
|
||||||
|
:class="{
|
||||||
|
'border-woot-500 bg-woot-25': isDragOver,
|
||||||
|
'border-green-500 bg-green-25': uploadSuccess
|
||||||
|
}"
|
||||||
|
@drop="handleDrop"
|
||||||
|
@dragover="handleDragOver"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
>
|
||||||
|
<div v-if="!isUploading && !uploadSuccess">
|
||||||
|
<DocumentPlusIcon class="mx-auto h-12 w-12 text-slate-400 mb-4" />
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-300 mb-2">
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.DRAG_DROP') }}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-woot-700 bg-woot-100 hover:bg-woot-200"
|
||||||
|
@click="$refs.fileInput.click()"
|
||||||
|
>
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.SELECT_FILE') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="isUploading" class="py-4">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-woot-500 mx-auto mb-2"></div>
|
||||||
|
<p class="text-sm text-slate-600">{{ $t('HELP_CENTER.CONTENT_GENERATION.UPLOADING') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="uploadSuccess" class="py-4">
|
||||||
|
<CheckCircleIcon class="mx-auto h-8 w-8 text-green-500 mb-2" />
|
||||||
|
<p class="text-sm text-green-600">{{ $t('HELP_CENTER.CONTENT_GENERATION.UPLOAD_SUCCESS') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uploadError" class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p class="text-sm text-red-800">{{ uploadError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generated Content Section -->
|
||||||
|
<div v-if="generatedResponses.length > 0" class="mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h4 class="text-base font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.GENERATED_CONTENT') }}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
v-if="selectedResponses.length > 0"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded text-white bg-woot-500 hover:bg-woot-600"
|
||||||
|
@click="publishSelectedContent"
|
||||||
|
:disabled="isPublishing"
|
||||||
|
>
|
||||||
|
<template v-if="isPublishing">
|
||||||
|
<div class="animate-spin rounded-full h-4 w-4 border-b border-white mr-2"></div>
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.PUBLISHING') }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.PUBLISH_SELECTED') }} ({{ selectedResponses.length }})
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="response in generatedResponses"
|
||||||
|
:key="response.id"
|
||||||
|
class="border border-slate-200 dark:border-slate-600 rounded-lg p-4 hover:bg-slate-50 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="response.id"
|
||||||
|
v-model="selectedResponses"
|
||||||
|
class="mt-1 rounded border-slate-300 text-woot-500 focus:ring-woot-500"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h5 class="text-sm font-medium text-slate-900 dark:text-slate-100 mb-1">
|
||||||
|
{{ response.question }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-xs text-slate-600 dark:text-slate-400 line-clamp-2">
|
||||||
|
{{ response.answer }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 text-xs text-slate-500">
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.FROM_DOCUMENT') }}: {{ response.documentable?.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="!isLoading" class="text-center py-8">
|
||||||
|
<DocumentIcon class="mx-auto h-12 w-12 text-slate-400 mb-4" />
|
||||||
|
<p class="text-slate-600 dark:text-slate-400">
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.NO_CONTENT') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="text-center py-8">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-woot-500 mx-auto mb-2"></div>
|
||||||
|
<p class="text-slate-600">{{ $t('HELP_CENTER.CONTENT_GENERATION.LOADING') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
DocumentPlusIcon,
|
||||||
|
DocumentIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
import PdfDocumentsAPI from '../../../../api/helpCenter/pdfDocuments';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
portalSlug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['content-published']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const pdfDocumentsApi = new PdfDocumentsAPI();
|
||||||
|
|
||||||
|
// Reactive data
|
||||||
|
const generatedResponses = ref([]);
|
||||||
|
const selectedResponses = ref([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isUploading = ref(false);
|
||||||
|
const isPublishing = ref(false);
|
||||||
|
const uploadSuccess = ref(false);
|
||||||
|
const uploadError = ref('');
|
||||||
|
const isDragOver = ref(false);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadGeneratedContent = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
const response = await pdfDocumentsApi.getGeneratedContent(props.portalSlug);
|
||||||
|
generatedResponses.value = response.data.responses || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load generated content:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
uploadFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragOver.value = false;
|
||||||
|
|
||||||
|
const files = event.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
uploadFile(files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragOver.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragOver.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file) => {
|
||||||
|
if (!validateFile(file)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isUploading.value = true;
|
||||||
|
uploadError.value = '';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('pdf_file', file);
|
||||||
|
|
||||||
|
await pdfDocumentsApi.uploadContent(props.portalSlug, formData);
|
||||||
|
|
||||||
|
uploadSuccess.value = true;
|
||||||
|
|
||||||
|
// Wait a bit then reload content
|
||||||
|
setTimeout(async () => {
|
||||||
|
uploadSuccess.value = false;
|
||||||
|
await loadGeneratedContent();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
uploadError.value = error.response?.data?.error || t('HELP_CENTER.CONTENT_GENERATION.UPLOAD_ERROR');
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
if (file.type !== 'application/pdf') {
|
||||||
|
uploadError.value = t('HELP_CENTER.CONTENT_GENERATION.INVALID_FILE_TYPE');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 512 * 1024 * 1024) {
|
||||||
|
uploadError.value = t('HELP_CENTER.CONTENT_GENERATION.FILE_TOO_LARGE');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishSelectedContent = async () => {
|
||||||
|
if (selectedResponses.value.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isPublishing.value = true;
|
||||||
|
|
||||||
|
await pdfDocumentsApi.publishContent(props.portalSlug, selectedResponses.value);
|
||||||
|
|
||||||
|
// Remove published responses from the list
|
||||||
|
generatedResponses.value = generatedResponses.value.filter(
|
||||||
|
response => !selectedResponses.value.includes(response.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
selectedResponses.value = [];
|
||||||
|
emit('content-published');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to publish content:', error);
|
||||||
|
} finally {
|
||||||
|
isPublishing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadGeneratedContent();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.TITLE') }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{{ $t('HELP_CENTER.CONTENT_GENERATION.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-auto px-6 py-6">
|
||||||
|
<ContentGeneration
|
||||||
|
:portal-slug="portalSlug"
|
||||||
|
@content-published="handleContentPublished"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import ContentGeneration from './ContentGeneration.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const portalSlug = computed(() => route.params.portalId);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const handleContentPublished = () => {
|
||||||
|
// Could show a success notification here
|
||||||
|
console.log('Content has been published successfully');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
+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>
|
||||||
|
|||||||
+6
-4
@@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
|||||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, minLength, helpers } from '@vuelidate/validators';
|
import { required, minLength, helpers, url } from '@vuelidate/validators';
|
||||||
import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators';
|
import { isValidSlug } from 'shared/helpers/Validators';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
@@ -71,7 +71,7 @@ const rules = {
|
|||||||
isValidSlug
|
isValidSlug
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
homePageLink: { shouldBeUrl },
|
homePageLink: { url },
|
||||||
};
|
};
|
||||||
|
|
||||||
const v$ = useVuelidate(rules, state);
|
const v$ = useVuelidate(rules, state);
|
||||||
@@ -315,7 +315,9 @@ const handleAvatarDelete = () => {
|
|||||||
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start justify-between w-full gap-2">
|
<div
|
||||||
|
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||||
|
>
|
||||||
<label
|
<label
|
||||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||||
>
|
>
|
||||||
|
|||||||
+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">
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
|
|||||||
const inbox = computed(() => props.stateInbox);
|
const inbox = computed(() => props.stateInbox);
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { phoneNumber, channelType } = inbox.value;
|
const { channelType, medium } = inbox.value;
|
||||||
return getInboxIconByType(channelType, phoneNumber);
|
return getInboxIconByType(channelType, medium);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasSlaThreshold = computed(() => {
|
const hasSlaThreshold = computed(() => {
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useWindowSize } from '@vueuse/core';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
processContactableInboxes,
|
processContactableInboxes,
|
||||||
mergeInboxDetails,
|
mergeInboxDetails,
|
||||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||||
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||||
|
|
||||||
@@ -37,9 +39,16 @@ const emit = defineEmits(['close']);
|
|||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { width: windowWidth } = useWindowSize();
|
||||||
|
|
||||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const isSmallScreen = computed(
|
||||||
|
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
||||||
|
);
|
||||||
|
|
||||||
|
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
|
||||||
|
|
||||||
const contacts = ref([]);
|
const contacts = ref([]);
|
||||||
const selectedContact = ref(null);
|
const selectedContact = ref(null);
|
||||||
const targetInbox = ref(null);
|
const targetInbox = ref(null);
|
||||||
@@ -67,7 +76,7 @@ const directUploadsEnabled = computed(
|
|||||||
const activeContact = computed(() => contactById.value(props.contactId));
|
const activeContact = computed(() => contactById.value(props.contactId));
|
||||||
|
|
||||||
const composePopoverClass = computed(() => {
|
const composePopoverClass = computed(() => {
|
||||||
if (props.isModal) return '';
|
if (viewInModal.value) return '';
|
||||||
|
|
||||||
return props.alignPosition === 'right'
|
return props.alignPosition === 'right'
|
||||||
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
||||||
@@ -179,14 +188,18 @@ const toggle = () => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
activeContact,
|
activeContact,
|
||||||
() => {
|
(currentContact, previousContact) => {
|
||||||
if (activeContact.value && props.contactId) {
|
if (currentContact && props.contactId) {
|
||||||
const contactInboxes = activeContact.value?.contactInboxes || [];
|
// Reset on contact change
|
||||||
|
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
|
||||||
|
|
||||||
// First process the contactable inboxes to get the right structure
|
// First process the contactable inboxes to get the right structure
|
||||||
const processedInboxes = processContactableInboxes(contactInboxes);
|
const processedInboxes = processContactableInboxes(
|
||||||
|
currentContact.contactInboxes || []
|
||||||
|
);
|
||||||
// Then Merge processedInboxes with the inboxes list
|
// Then Merge processedInboxes with the inboxes list
|
||||||
selectedContact.value = {
|
selectedContact.value = {
|
||||||
...activeContact.value,
|
...currentContact,
|
||||||
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
|
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -202,7 +215,7 @@ const handleClickOutside = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onModalBackdropClick = () => {
|
const onModalBackdropClick = () => {
|
||||||
if (!props.isModal) return;
|
if (!viewInModal.value) return;
|
||||||
handleClickOutside();
|
handleClickOutside();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,7 +244,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
]"
|
]"
|
||||||
class="relative"
|
class="relative"
|
||||||
:class="{
|
:class="{
|
||||||
'z-40': showComposeNewConversation,
|
'z-50': showComposeNewConversation && !viewInModal,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<slot
|
<slot
|
||||||
@@ -243,12 +256,12 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
v-if="showComposeNewConversation"
|
v-if="showComposeNewConversation"
|
||||||
:class="{
|
:class="{
|
||||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
||||||
isModal,
|
viewInModal,
|
||||||
}"
|
}"
|
||||||
@click.self="onModalBackdropClick"
|
@click.self="onModalBackdropClick"
|
||||||
>
|
>
|
||||||
<ComposeNewConversationForm
|
<ComposeNewConversationForm
|
||||||
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
|
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
:contact-id="contactId"
|
:contact-id="contactId"
|
||||||
:is-loading="isSearching"
|
:is-loading="isSearching"
|
||||||
|
|||||||
+5
-3
@@ -25,6 +25,7 @@ const props = defineProps({
|
|||||||
hasNoInbox: { type: Boolean, default: false },
|
hasNoInbox: { type: Boolean, default: false },
|
||||||
isDropdownActive: { type: Boolean, default: false },
|
isDropdownActive: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
|
inboxId: { type: Number, default: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -150,9 +151,10 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
<div
|
<div
|
||||||
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
|
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex gap-2 items-center">
|
||||||
<WhatsAppOptions
|
<WhatsAppOptions
|
||||||
v-if="isWhatsappInbox"
|
v-if="isWhatsappInbox"
|
||||||
|
:inbox-id="inboxId"
|
||||||
:message-templates="messageTemplates"
|
:message-templates="messageTemplates"
|
||||||
@send-message="emit('sendWhatsappMessage', $event)"
|
@send-message="emit('sendWhatsappMessage', $event)"
|
||||||
/>
|
/>
|
||||||
@@ -170,7 +172,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
/>
|
/>
|
||||||
<EmojiInput
|
<EmojiInput
|
||||||
v-if="isEmojiPickerOpen"
|
v-if="isEmojiPickerOpen"
|
||||||
class="left-0 top-full mt-1.5"
|
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
|
||||||
:on-click="onClickInsertEmoji"
|
:on-click="onClickInsertEmoji"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +208,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex gap-2 items-center">
|
||||||
<Button
|
<Button
|
||||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
||||||
variant="faded"
|
variant="faded"
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ const removeAttachment = id => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon="i-lucide-trash"
|
icon="i-lucide-trash"
|
||||||
color="slate"
|
color="slate"
|
||||||
class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
|
class="absolute top-1 ltr:right-1 rtl:left-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
|
||||||
@click="removeAttachment(attachment.resource.id)"
|
@click="removeAttachment(attachment.resource.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-1
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
|
||||||
>
|
>
|
||||||
<ContactSelector
|
<ContactSelector
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
@@ -336,6 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
|||||||
:is-loading="isCreating"
|
:is-loading="isCreating"
|
||||||
:disable-send-button="isCreating"
|
:disable-send-button="isCreating"
|
||||||
:has-selected-inbox="!!targetInbox"
|
:has-selected-inbox="!!targetInbox"
|
||||||
|
:inbox-id="targetInbox?.id"
|
||||||
:has-no-inbox="showNoInboxAlert"
|
:has-no-inbox="showNoInboxAlert"
|
||||||
:is-dropdown-active="isAnyDropdownActive"
|
:is-dropdown-active="isAnyDropdownActive"
|
||||||
:message-signature="messageSignature"
|
:message-signature="messageSignature"
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
|
|||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||||
:menu-items="contactableInboxesList"
|
:menu-items="contactableInboxesList"
|
||||||
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||||
@action="emit('handleInboxAction', $event)"
|
@action="emit('handleInboxAction', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+19
-26
@@ -1,23 +1,25 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
|
import WhatsappTemplate from './WhatsappTemplate.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
messageTemplates: {
|
inboxId: {
|
||||||
type: Array,
|
type: Number,
|
||||||
default: () => [],
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['sendMessage']);
|
const emit = defineEmits(['sendMessage']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const getFilteredWhatsAppTemplates = useMapGetter(
|
||||||
// TODO: Remove this when we support all formats
|
'inboxes/getFilteredWhatsAppTemplates'
|
||||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
);
|
||||||
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const selectedTemplate = ref(null);
|
const selectedTemplate = ref(null);
|
||||||
@@ -25,19 +27,7 @@ const selectedTemplate = ref(null);
|
|||||||
const showTemplatesMenu = ref(false);
|
const showTemplatesMenu = ref(false);
|
||||||
|
|
||||||
const whatsAppTemplateMessages = computed(() => {
|
const whatsAppTemplateMessages = computed(() => {
|
||||||
// Add null check and ensure it's an array
|
return getFilteredWhatsAppTemplates.value(props.inboxId);
|
||||||
const templates = Array.isArray(props.messageTemplates)
|
|
||||||
? props.messageTemplates
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// TODO: Remove the last filter when we support all formats
|
|
||||||
return templates
|
|
||||||
.filter(template => template?.status?.toLowerCase() === 'approved')
|
|
||||||
.filter(template => {
|
|
||||||
return template?.components?.every(component => {
|
|
||||||
return !formatsToRemove.includes(component.format);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTemplates = computed(() => {
|
const filteredTemplates = computed(() => {
|
||||||
@@ -84,10 +74,13 @@ const handleSendMessage = template => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="showTemplatesMenu"
|
v-if="showTemplatesMenu"
|
||||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||||
>
|
>
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
<Icon
|
||||||
|
icon="i-lucide-search"
|
||||||
|
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -96,13 +89,13 @@ const handleSendMessage = template => {
|
|||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="template in filteredTemplates"
|
v-for="template in filteredTemplates"
|
||||||
:key="template.id"
|
:key="template.id"
|
||||||
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||||
@click="handleTemplateClick(template)"
|
@click="handleTemplateClick(template)"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||||
@@ -111,12 +104,12 @@ const handleSendMessage = template => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="filteredTemplates.length === 0">
|
<template v-if="filteredTemplates.length === 0">
|
||||||
<p class="w-full pt-2 text-sm text-n-slate-11">
|
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<WhatsappTemplateParser
|
<WhatsappTemplate
|
||||||
v-if="selectedTemplate"
|
v-if="selectedTemplate"
|
||||||
:template="selectedTemplate"
|
:template="selectedTemplate"
|
||||||
@send-message="handleSendMessage"
|
@send-message="handleSendMessage"
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup>
|
||||||
|
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
template: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['sendMessage', 'back']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const handleSendMessage = payload => {
|
||||||
|
emit('sendMessage', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
emit('back');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="w-full">
|
||||||
|
<WhatsAppTemplateParser
|
||||||
|
:template="template"
|
||||||
|
@send-message="handleSendMessage"
|
||||||
|
@back="handleBack"
|
||||||
|
>
|
||||||
|
<template #actions="{ sendMessage, goBack, disabled }">
|
||||||
|
<div class="flex gap-3 justify-between items-end w-full h-14">
|
||||||
|
<Button
|
||||||
|
:label="
|
||||||
|
t(
|
||||||
|
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
color="slate"
|
||||||
|
variant="faded"
|
||||||
|
class="w-full font-medium"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="
|
||||||
|
t(
|
||||||
|
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="w-full font-medium"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="sendMessage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</WhatsAppTemplateParser>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
-176
@@ -1,176 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, ref, onMounted } from 'vue';
|
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
|
||||||
import { requiredIf } from '@vuelidate/validators';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
template: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['sendMessage', 'back']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const processedParams = ref({});
|
|
||||||
|
|
||||||
const templateName = computed(() => {
|
|
||||||
return props.template?.name || '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const templateString = computed(() => {
|
|
||||||
return props.template?.components?.find(
|
|
||||||
component => component.type === 'BODY'
|
|
||||||
).text;
|
|
||||||
});
|
|
||||||
|
|
||||||
const processVariable = str => {
|
|
||||||
return str.replace(/{{|}}/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const processedString = computed(() => {
|
|
||||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
|
||||||
const variableKey = processVariable(variable);
|
|
||||||
return processedParams.value[variableKey] || `{{${variable}}}`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const processedStringWithVariableHighlight = computed(() => {
|
|
||||||
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
|
|
||||||
|
|
||||||
return variables.reduce((result, variable) => {
|
|
||||||
const variableKey = processVariable(variable);
|
|
||||||
const value = processedParams.value[variableKey] || variable;
|
|
||||||
return result.replace(
|
|
||||||
variable,
|
|
||||||
`<span class="break-all text-n-slate-12">${value}</span>`
|
|
||||||
);
|
|
||||||
}, templateString.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules = computed(() => {
|
|
||||||
const paramRules = {};
|
|
||||||
Object.keys(processedParams.value).forEach(key => {
|
|
||||||
paramRules[key] = { required: requiredIf(true) };
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
processedParams: paramRules,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const v$ = useVuelidate(rules, { processedParams });
|
|
||||||
|
|
||||||
const getFieldErrorType = key => {
|
|
||||||
if (!v$.value.processedParams[key]?.$error) return 'info';
|
|
||||||
return 'error';
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateVariables = () => {
|
|
||||||
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
|
||||||
if (!matchedVariables) return;
|
|
||||||
|
|
||||||
const finalVars = matchedVariables.map(i => processVariable(i));
|
|
||||||
processedParams.value = finalVars.reduce((acc, variable) => {
|
|
||||||
acc[variable] = '';
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
|
||||||
const isValid = await v$.value.$validate();
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
message: processedString.value,
|
|
||||||
templateParams: {
|
|
||||||
name: props.template.name,
|
|
||||||
category: props.template.category,
|
|
||||||
language: props.template.language,
|
|
||||||
namespace: props.template.namespace,
|
|
||||||
processed_params: processedParams.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
emit('sendMessage', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
generateVariables();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
|
||||||
>
|
|
||||||
<span class="text-sm text-n-slate-12">
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
|
|
||||||
{ templateName: templateName }
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<p
|
|
||||||
class="mb-0 text-sm text-n-slate-11"
|
|
||||||
v-html="processedStringWithVariableHighlight"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span
|
|
||||||
v-if="Object.keys(processedParams).length"
|
|
||||||
class="text-sm font-medium text-n-slate-12"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(variable, key) in processedParams"
|
|
||||||
:key="key"
|
|
||||||
class="flex items-center w-full gap-2"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
|
|
||||||
>
|
|
||||||
{{ key }}
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
v-model="processedParams[key]"
|
|
||||||
custom-input-class="!h-8 w-full !bg-transparent"
|
|
||||||
class="w-full"
|
|
||||||
:message-type="getFieldErrorType(key)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end justify-between w-full gap-3 h-14">
|
|
||||||
<Button
|
|
||||||
:label="
|
|
||||||
t(
|
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
color="slate"
|
|
||||||
variant="faded"
|
|
||||||
class="w-full font-medium"
|
|
||||||
@click="emit('back')"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
:label="
|
|
||||||
t(
|
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="w-full font-medium"
|
|
||||||
@click="sendMessage"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
+2
-1
@@ -36,10 +36,11 @@ const transformInbox = ({
|
|||||||
email,
|
email,
|
||||||
channelType,
|
channelType,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
|
medium,
|
||||||
...rest
|
...rest
|
||||||
}) => ({
|
}) => ({
|
||||||
id,
|
id,
|
||||||
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
|
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||||
label: generateLabelForContactableInboxesList({
|
label: generateLabelForContactableInboxesList({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { removeEmoji } from 'shared/helpers/emoji';
|
import { removeEmoji } from 'shared/helpers/emoji';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -33,10 +34,18 @@ const props = defineProps({
|
|||||||
validator: value =>
|
validator: value =>
|
||||||
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
||||||
},
|
},
|
||||||
|
inbox: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
iconName: {
|
iconName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
hideOfflineStatus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['upload', 'delete']);
|
const emit = defineEmits(['upload', 'delete']);
|
||||||
@@ -66,11 +75,11 @@ const AVATAR_COLORS = {
|
|||||||
default: { bg: '#E8E8E8', text: '#60646C' },
|
default: { bg: '#E8E8E8', text: '#60646C' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_CLASSES = {
|
const STATUS_CLASSES = computed(() => ({
|
||||||
online: 'bg-n-teal-10',
|
online: 'bg-n-teal-10',
|
||||||
busy: 'bg-n-amber-10',
|
busy: 'bg-n-amber-10',
|
||||||
offline: 'bg-n-slate-10',
|
...(props.hideOfflineStatus ? {} : { offline: 'bg-n-slate-10' }),
|
||||||
};
|
}));
|
||||||
|
|
||||||
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
||||||
|
|
||||||
@@ -174,21 +183,31 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="relative inline-flex group/avatar z-0" :style="containerStyles">
|
<span
|
||||||
|
class="relative inline-flex group/avatar z-0 flex-shrink-0"
|
||||||
|
:style="containerStyles"
|
||||||
|
>
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
<slot name="badge" :size="size">
|
<slot name="badge" :size="size">
|
||||||
<div
|
<div
|
||||||
v-if="status"
|
v-if="status && STATUS_CLASSES[status]"
|
||||||
class="absolute z-20 border rounded-full border-n-slate-3"
|
class="absolute z-20 border rounded-full border-n-slate-3"
|
||||||
:style="badgeStyles"
|
:style="badgeStyles"
|
||||||
:class="STATUS_CLASSES[status]"
|
:class="STATUS_CLASSES[status]"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-if="inbox && !(status && STATUS_CLASSES[status])"
|
||||||
|
:style="badgeStyles"
|
||||||
|
class="absolute z-20 flex items-center justify-center rounded-full bg-n-solid-1 border border-transparent flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ChannelIcon :inbox="inbox" class="w-full h-full text-n-slate-11" />
|
||||||
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<!-- Delete Avatar Button -->
|
<!-- Delete Avatar Button -->
|
||||||
<div
|
<div
|
||||||
v-if="src && allowUpload"
|
v-if="src && allowUpload"
|
||||||
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 -right-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 ltr:-right-2 rtl:-left-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||||
@click="handleDismiss"
|
@click="handleDismiss"
|
||||||
>
|
>
|
||||||
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
|
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
|
||||||
@@ -232,31 +251,40 @@ watch(
|
|||||||
<!-- Fallback Icon if no name or image -->
|
<!-- Fallback Icon if no name or image -->
|
||||||
<Icon
|
<Icon
|
||||||
v-else
|
v-else
|
||||||
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
:title="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
||||||
icon="i-lucide-user"
|
icon="i-lucide-user"
|
||||||
:style="iconStyles"
|
:style="iconStyles"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Upload Overlay and Input -->
|
<!-- Upload Overlay and Input -->
|
||||||
<div
|
<slot
|
||||||
v-if="allowUpload"
|
v-if="allowUpload || $slots.overlay"
|
||||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
name="overlay"
|
||||||
@click="handleUploadAvatar"
|
:size="size"
|
||||||
|
:handle-upload="handleUploadAvatar"
|
||||||
|
:file-input-ref="fileInput"
|
||||||
|
:handle-image-upload="handleImageUpload"
|
||||||
>
|
>
|
||||||
<Icon
|
<div
|
||||||
icon="i-lucide-upload"
|
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||||
class="text-white"
|
@click="handleUploadAvatar"
|
||||||
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
|
>
|
||||||
/>
|
<Icon
|
||||||
<input
|
icon="i-lucide-upload"
|
||||||
ref="fileInput"
|
class="text-white"
|
||||||
type="file"
|
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
|
||||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
/>
|
||||||
class="hidden"
|
<input
|
||||||
@change="handleImageUpload"
|
v-if="allowUpload"
|
||||||
/>
|
ref="fileInput"
|
||||||
</div>
|
type="file"
|
||||||
|
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleImageUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,31 +15,42 @@ 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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
|
<nav
|
||||||
<ol class="flex items-center mb-0">
|
:aria-label="t('BREADCRUMB.ARIA_LABEL')"
|
||||||
<li v-for="(item, index) in items" :key="index" class="flex items-center">
|
class="flex items-center h-8 min-w-0"
|
||||||
|
>
|
||||||
|
<ol class="flex items-center mb-0 min-w-0">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center"
|
||||||
|
:class="{ 'min-w-0 flex-1': index === items.length - 1 }"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
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 min-w-0 block">
|
||||||
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="px-6 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 bg-n-background pt-4 z-20">
|
||||||
|
<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>
|
||||||
@@ -63,6 +63,20 @@ const menuItems = computed(() => {
|
|||||||
|
|
||||||
const createdAt = computed(() => dynamicTime(props.createdAt));
|
const createdAt = computed(() => dynamicTime(props.createdAt));
|
||||||
|
|
||||||
|
const isPdfDocument = computed(() => props.externalLink?.startsWith('PDF:'));
|
||||||
|
const displayLink = computed(() => {
|
||||||
|
if (isPdfDocument.value) {
|
||||||
|
// Remove 'PDF: ' prefix and timestamp suffix for display
|
||||||
|
const fullName = props.externalLink.substring(5);
|
||||||
|
// Remove timestamp suffix (_YYYYMMDDHHMMSS) if present
|
||||||
|
return fullName.replace(/_\d{14}$/, '');
|
||||||
|
}
|
||||||
|
return props.externalLink;
|
||||||
|
});
|
||||||
|
const linkIcon = computed(() =>
|
||||||
|
isPdfDocument.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
|
||||||
|
);
|
||||||
|
|
||||||
const handleAction = ({ action, value }) => {
|
const handleAction = ({ action, value }) => {
|
||||||
toggleDropdown(false);
|
toggleDropdown(false);
|
||||||
emit('action', { action, value, id: props.id });
|
emit('action', { action, value, id: props.id });
|
||||||
@@ -106,8 +120,8 @@ const handleAction = ({ action, value }) => {
|
|||||||
<span
|
<span
|
||||||
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1"
|
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1"
|
||||||
>
|
>
|
||||||
<i class="i-ph-link-simple shrink-0" />
|
<i :class="linkIcon" class="shrink-0" />
|
||||||
<span class="truncate">{{ externalLink }}</span>
|
<span class="truncate">{{ displayLink }}</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
|
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
|
||||||
{{ createdAt }}
|
{{ createdAt }}
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ const menuItems = computed(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const icon = computed(() =>
|
const icon = computed(() => {
|
||||||
getInboxIconByType(props.inbox.channel_type, '', 'outline')
|
const { medium, channel_type: type } = props.inbox;
|
||||||
);
|
return getInboxIconByType(type, medium, 'outline');
|
||||||
|
});
|
||||||
|
|
||||||
const handleAction = ({ action, value }) => {
|
const handleAction = ({ action, value }) => {
|
||||||
toggleDropdown(false);
|
toggleDropdown(false);
|
||||||
|
|||||||
@@ -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,255 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, h, reactive, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useToggle, useElementSize } 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';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.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,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
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 instructionContentRef = ref();
|
||||||
|
|
||||||
|
const [isEditing, toggleEditing] = useToggle();
|
||||||
|
const [isInstructionExpanded, toggleInstructionExpanded] = useToggle();
|
||||||
|
|
||||||
|
const { height: contentHeight } = useElementSize(instructionContentRef);
|
||||||
|
const needsOverlay = computed(() => contentHeight.value > 160);
|
||||||
|
|
||||||
|
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 prose prose-sm min-w-0 break-words max-w-none ${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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden transition-all duration-300 ease-in-out group/expandable"
|
||||||
|
:class="{ 'cursor-pointer': needsOverlay }"
|
||||||
|
:style="{
|
||||||
|
maxHeight: isInstructionExpanded ? `${contentHeight}px` : '10rem',
|
||||||
|
}"
|
||||||
|
@click="needsOverlay ? toggleInstructionExpanded() : null"
|
||||||
|
>
|
||||||
|
<div ref="instructionContentRef">
|
||||||
|
<component
|
||||||
|
:is="renderInstruction(formatMessage(instruction, false))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 w-full flex items-end justify-center text-xs text-n-slate-11 bg-gradient-to-t h-40 from-n-solid-2 via-n-solid-2 via-10% to-transparent transition-all duration-500 ease-in-out px-2 py-1 rounded pointer-events-none"
|
||||||
|
:class="{
|
||||||
|
'visible opacity-100': !isInstructionExpanded,
|
||||||
|
'invisible opacity-0': isInstructionExpanded || !needsOverlay,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
class="text-n-slate-7 mb-4 size-4 group-hover/expandable:text-n-slate-11 transition-colors duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="tools?.length"
|
||||||
|
class="text-sm text-n-slate-11 font-medium mb-1"
|
||||||
|
>
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
||||||
|
{{ 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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user