Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fbb93698 | ||
|
|
38d174bda6 | ||
|
|
7ca5d365ae | ||
|
|
6be85a40f7 | ||
|
|
922c989574 | ||
|
|
59928bd85c | ||
|
|
46c55a53bd | ||
|
|
176e09cde7 | ||
|
|
b09c6b4aac | ||
|
|
96e18d74c5 | ||
|
|
311093f0fb | ||
|
|
54514d9d21 | ||
|
|
4633d2a179 | ||
|
|
73c2690526 | ||
|
|
25b545200e | ||
|
|
23c35c8886 | ||
|
|
3f801fda2c | ||
|
|
ea7c5798ff | ||
|
|
1271459874 | ||
|
|
81098b02a8 | ||
|
|
18d6a51d6b | ||
|
|
9c9c1c301d | ||
|
|
5fb40e0b2e | ||
|
|
9ba51a3fd4 | ||
|
|
05d83ce704 | ||
|
|
36b7a86bf7 | ||
|
|
e6d336f727 | ||
|
|
feff61755f | ||
|
|
0c4e21bc6a | ||
|
|
a582b8284d | ||
|
|
bbcbc6d52b | ||
|
|
28e837e8f7 | ||
|
|
f4ea90c2dc | ||
|
|
1d9a28ad64 | ||
|
|
f36a3a2e19 | ||
|
|
a14df55210 | ||
|
|
b3633d5c08 | ||
|
|
b52caf0341 | ||
|
|
e37d61f836 | ||
|
|
10673a8d68 | ||
|
|
d488b8ced6 | ||
|
|
0381bdac96 | ||
|
|
87fee33039 | ||
|
|
fe68542547 | ||
|
|
2e1529c755 | ||
|
|
eb20e39b11 | ||
|
|
b771652656 | ||
|
|
e6d720082c | ||
|
|
d9a0d4eee0 | ||
|
|
2a0f2a8b1e | ||
|
|
aedb59ea67 | ||
|
|
4e2abbaa3d | ||
|
|
9db096f046 | ||
|
|
59b2679641 | ||
|
|
b1d26887f0 | ||
|
|
89a748eef6 | ||
|
|
c00230c8af | ||
|
|
2f6d581384 | ||
|
|
468ef34f85 | ||
|
|
7f4005cdcc | ||
|
|
57fdfe26f0 | ||
|
|
88ba4e4562 | ||
|
|
394ba4ff73 | ||
|
|
c0d1efa0ad | ||
|
|
dfc80e6f20 | ||
|
|
187b435eb9 | ||
|
|
1fce8d1183 | ||
|
|
c51d2d3c93 | ||
|
|
4450bebf35 | ||
|
|
b3ce7bd278 | ||
|
|
345a212447 | ||
|
|
83450b7c24 | ||
|
|
b58ec24d81 | ||
|
|
5e799e7e6b | ||
|
|
01ad380822 | ||
|
|
84d2c0fd25 | ||
|
|
87212d3b6e | ||
|
|
4ca6ec1812 | ||
|
|
4452d84097 | ||
|
|
9a81927154 | ||
|
|
daed6eb1e5 | ||
|
|
673669cd25 | ||
|
|
bdd5a77f45 | ||
|
|
92150cb7c8 | ||
|
|
e52becbc40 | ||
|
|
7f3d4aaaa7 | ||
|
|
7748ef2787 | ||
|
|
f2d9895597 | ||
|
|
db4e8588cd | ||
|
|
0c05599282 | ||
|
|
00e1f50458 | ||
|
|
ee89943aa2 | ||
|
|
9dc6b94537 | ||
|
|
e9816f8d3d | ||
|
|
51d1c501cf | ||
|
|
1539a8bc36 | ||
|
|
ffa8c0ce8a | ||
|
|
4762edcba3 | ||
|
|
1bd2da4832 | ||
|
|
b923bfecca | ||
|
|
46e758cc84 | ||
|
|
9165cab1ec | ||
|
|
bda09cc064 | ||
|
|
408cc4dd3f | ||
|
|
c6b636baee | ||
|
|
bc24d1f02c | ||
|
|
62a7cfa188 | ||
|
|
c4fa5cd4d9 | ||
|
|
d40fa27289 | ||
|
|
c789ef555c | ||
|
|
0674edf81b | ||
|
|
9362d126aa | ||
|
|
35797bd393 | ||
|
|
68ff52c432 | ||
|
|
5d2afc065f | ||
|
|
cd8f245483 | ||
|
|
735979c834 | ||
|
|
5cd6996d5e | ||
|
|
ff49996950 | ||
|
|
14417ef14f | ||
|
|
1393ad5143 | ||
|
|
4f262fc041 | ||
|
|
aa639dd78e | ||
|
|
aa9c0b9f6b | ||
|
|
526d6e2a0a | ||
|
|
96746cc592 |
+22
-14
@@ -1,7 +1,6 @@
|
||||
version: 2.1
|
||||
orbs:
|
||||
node: circleci/node@6.1.0
|
||||
qlty-orb: qltysh/qlty-orb@0.0
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/build
|
||||
@@ -90,6 +89,14 @@ jobs:
|
||||
command: |
|
||||
source ~/.rvm/scripts/rvm
|
||||
bundle install
|
||||
# pnpm install
|
||||
|
||||
- run:
|
||||
name: Download cc-test-reporter
|
||||
command: |
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
||||
chmod +x ~/tmp/cc-test-reporter
|
||||
|
||||
# Swagger verification
|
||||
- run:
|
||||
@@ -101,11 +108,10 @@ jobs:
|
||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||
|
||||
# Configure environment and database
|
||||
# we remove the FRONTED_URL from the .env before running the tests
|
||||
- run:
|
||||
name: Database Setup and Configure Environment Variables
|
||||
command: |
|
||||
@@ -143,11 +149,17 @@ jobs:
|
||||
command: pnpm run eslint
|
||||
|
||||
- run:
|
||||
name: Run frontend tests (with coverage)
|
||||
name: Run frontend tests
|
||||
command: |
|
||||
mkdir -p ~/build/coverage/frontend
|
||||
~/tmp/cc-test-reporter before-build
|
||||
pnpm run test:coverage
|
||||
|
||||
- run:
|
||||
name: Code Climate Test Coverage (Frontend)
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
# Run backend tests
|
||||
- run:
|
||||
name: Run backend tests
|
||||
@@ -155,18 +167,18 @@ jobs:
|
||||
mkdir -p ~/tmp/test-results/rspec
|
||||
mkdir -p ~/tmp/test-artifacts
|
||||
mkdir -p ~/build/coverage/backend
|
||||
~/tmp/cc-test-reporter before-build
|
||||
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
||||
bundle exec rspec --format progress \
|
||||
--format RspecJunitFormatter \
|
||||
--out ~/tmp/test-results/rspec.xml \
|
||||
-- ${TESTFILES}
|
||||
no_output_timeout: 30m
|
||||
|
||||
# Qlty coverage publish
|
||||
- qlty-orb/coverage_publish:
|
||||
files: |
|
||||
coverage/coverage.json
|
||||
coverage/lcov.info
|
||||
- run:
|
||||
name: Code Climate Test Coverage (Backend)
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
- run:
|
||||
name: List coverage directory contents
|
||||
@@ -177,7 +189,3 @@ jobs:
|
||||
root: ~/build
|
||||
paths:
|
||||
- coverage
|
||||
|
||||
- store_artifacts:
|
||||
path: coverage
|
||||
destination: coverage
|
||||
|
||||
@@ -6,13 +6,6 @@
|
||||
# Use `rake secret` to generate this variable
|
||||
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
|
||||
|
||||
# Active Record Encryption keys (required for MFA/2FA functionality)
|
||||
# Generate these keys by running: rails db:encryption:init
|
||||
# IMPORTANT: Use different keys for each environment (development, staging, production)
|
||||
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
|
||||
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
|
||||
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
|
||||
|
||||
# Replace with the URL you are planning to use for your app
|
||||
FRONTEND_URL=http://0.0.0.0:3000
|
||||
# To use a dedicated URL for help center pages
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
name: Run MFA Tests
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
# Only run if MFA test keys are available
|
||||
if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ''
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
POSTGRES_HOST: localhost
|
||||
# Active Record encryption keys required for MFA - test keys only, not for production use
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
|
||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
|
||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Install pgvector extension
|
||||
run: |
|
||||
PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Run MFA-related backend tests
|
||||
run: |
|
||||
bundle exec rspec \
|
||||
spec/services/mfa/token_service_spec.rb \
|
||||
spec/services/mfa/authentication_service_spec.rb \
|
||||
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
||||
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Run MFA-related tests in user_spec
|
||||
run: |
|
||||
# Run specific MFA-related tests from user_spec
|
||||
bundle exec rspec spec/models/user_spec.rb \
|
||||
-e "two factor" \
|
||||
-e "2FA" \
|
||||
-e "MFA" \
|
||||
-e "otp" \
|
||||
-e "backup code" \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload test logs
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: mfa-test-logs
|
||||
path: |
|
||||
log/test.log
|
||||
tmp/screenshots/
|
||||
@@ -95,7 +95,3 @@ yarn-debug.log*
|
||||
.claude/settings.local.json
|
||||
.cursor
|
||||
CLAUDE.local.md
|
||||
|
||||
# Histoire deployment
|
||||
.netlify
|
||||
.histoire
|
||||
|
||||
@@ -55,21 +55,4 @@
|
||||
|
||||
## Ruby Best Practices
|
||||
|
||||
- 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.
|
||||
- Use compact `module/class` definitions; avoid nested styles
|
||||
@@ -62,10 +62,6 @@ gem 'redis-namespace'
|
||||
# super fast record imports in bulk
|
||||
gem 'activerecord-import'
|
||||
|
||||
gem 'searchkick'
|
||||
gem 'opensearch-ruby'
|
||||
gem 'faraday_middleware-aws-sigv4'
|
||||
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
@@ -78,12 +74,9 @@ gem 'barnes'
|
||||
gem 'devise', '>= 4.9.4'
|
||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||
gem 'devise_token_auth', '>= 1.2.3'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.20.1'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.3'
|
||||
@@ -96,14 +89,14 @@ gem 'wisper', '2.0.0'
|
||||
##--- gems for channels ---##
|
||||
gem 'facebook-messenger'
|
||||
gem 'line-bot-api'
|
||||
gem 'twilio-ruby'
|
||||
gem 'twilio-ruby', '~> 5.66'
|
||||
# twitty will handle subscription of twitter account events
|
||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
gem 'twitty', '~> 0.1.5'
|
||||
# facebook client
|
||||
gem 'koala'
|
||||
# slack client
|
||||
gem 'slack-ruby-client', '~> 2.7.0'
|
||||
gem 'slack-ruby-client', '~> 2.5.2'
|
||||
# for dialogflow integrations
|
||||
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
|
||||
gem 'grpc'
|
||||
@@ -115,7 +108,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
|
||||
##-- apm and error monitoring ---#
|
||||
# loaded only when environment variables are set.
|
||||
# ref application.rb
|
||||
gem 'datadog', '~> 2.0', require: false
|
||||
gem 'ddtrace', require: false
|
||||
gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||
@@ -174,7 +167,6 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-saml'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.3'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
||||
|
||||
@@ -188,8 +180,6 @@ gem 'reverse_markdown'
|
||||
gem 'iso-639'
|
||||
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'
|
||||
@@ -220,8 +210,6 @@ group :development do
|
||||
gem 'stackprof'
|
||||
# Should install the associated chrome extension to view query logs
|
||||
gem 'meta_request', '>= 0.8.3'
|
||||
|
||||
gem 'tidewave'
|
||||
end
|
||||
|
||||
group :test do
|
||||
@@ -231,7 +219,6 @@ group :test do
|
||||
gem 'webmock'
|
||||
# test profiling
|
||||
gem 'test-prof'
|
||||
gem 'simplecov_json_formatter', require: false
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
@@ -256,7 +243,7 @@ group :development, :test do
|
||||
gem 'rubocop-factory_bot', require: false
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '>= 0.21', require: false
|
||||
gem 'simplecov', '0.17.1', require: false
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
+90
-176
@@ -25,35 +25,35 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
actioncable (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activestorage (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
actionmailbox (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activestorage (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
actionview (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
actionmailer (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
actionview (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.1.5.2)
|
||||
actionview (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
actionpack (7.1.5.1)
|
||||
actionview (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
@@ -61,38 +61,38 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
actiontext (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activestorage (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
actiontext (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activestorage (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
actionview (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
activejob (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
activerecord (7.1.5.2)
|
||||
activemodel (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
activemodel (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
activerecord (7.1.5.1)
|
||||
activemodel (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-import (2.1.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
activestorage (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.1.5.2)
|
||||
activesupport (7.1.5.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -155,10 +155,10 @@ GEM
|
||||
barnes (0.0.9)
|
||||
multi_json (~> 1)
|
||||
statsd-ruby (~> 1.1)
|
||||
base64 (0.3.0)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -194,14 +194,10 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
datadog (2.19.0)
|
||||
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
|
||||
libdatadog (~> 18.1.0.1.0)
|
||||
libddwaf (~> 1.24.1.0.3)
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
date (3.4.1)
|
||||
ddtrace (0.48.0)
|
||||
ffi (~> 1.0)
|
||||
msgpack
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -212,11 +208,6 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (6.1.0)
|
||||
activesupport (>= 7.0, < 8.1)
|
||||
devise (~> 4.0)
|
||||
railties (>= 7.0, < 8.1)
|
||||
rotp (~> 6.0)
|
||||
devise_token_auth (1.2.5)
|
||||
bcrypt (~> 3.0)
|
||||
devise (> 3.5.2, < 5)
|
||||
@@ -224,7 +215,7 @@ GEM
|
||||
diff-lcs (1.5.1)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
docile (1.4.1)
|
||||
docile (1.4.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (3.1.2)
|
||||
@@ -235,35 +226,6 @@ GEM
|
||||
addressable (~> 2.8)
|
||||
drb (2.2.3)
|
||||
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)
|
||||
regexp_parser (~> 2.2)
|
||||
elastic-apm (4.6.2)
|
||||
@@ -286,34 +248,22 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday (2.9.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-mashify (1.0.0)
|
||||
faraday-mashify (0.1.1)
|
||||
faraday (~> 2.0)
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
faraday_middleware-aws-sigv4 (1.0.1)
|
||||
aws-sigv4 (~> 1.0)
|
||||
faraday (>= 2.0, < 3)
|
||||
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)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
googleauth (~> 1)
|
||||
@@ -452,7 +402,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.13.2)
|
||||
json (2.12.0)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -467,7 +417,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.10.1)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -494,16 +444,6 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
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)
|
||||
lint_roller (1.1.0)
|
||||
liquid (5.4.0)
|
||||
@@ -549,7 +489,7 @@ GEM
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.6.0)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
@@ -594,9 +534,8 @@ GEM
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.3)
|
||||
omniauth (2.1.2)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-google-oauth2 (1.1.3)
|
||||
@@ -610,12 +549,6 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-saml (2.2.4)
|
||||
omniauth (~> 2.1)
|
||||
ruby-saml (~> 1.18)
|
||||
opensearch-ruby (3.4.0)
|
||||
faraday (>= 1.0, < 3)
|
||||
multi_json (>= 1.0)
|
||||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
@@ -644,7 +577,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.0)
|
||||
rack (2.2.15)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -653,34 +586,33 @@ GEM
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (4.1.1)
|
||||
rack-protection (3.2.0)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-session (1.0.2)
|
||||
rack (< 3)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (7.1.5.2)
|
||||
actioncable (= 7.1.5.2)
|
||||
actionmailbox (= 7.1.5.2)
|
||||
actionmailer (= 7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
actiontext (= 7.1.5.2)
|
||||
actionview (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activemodel (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activestorage (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
rackup (1.0.1)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rails (7.1.5.1)
|
||||
actioncable (= 7.1.5.1)
|
||||
actionmailbox (= 7.1.5.1)
|
||||
actionmailer (= 7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
actiontext (= 7.1.5.1)
|
||||
actionview (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activemodel (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activestorage (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.1.5.2)
|
||||
railties (= 7.1.5.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -688,9 +620,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.1)
|
||||
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)
|
||||
railties (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
railties (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
irb
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -727,8 +659,7 @@ GEM
|
||||
retriable (3.1.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rexml (3.4.1)
|
||||
rspec-core (3.13.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.2)
|
||||
@@ -783,9 +714,6 @@ GEM
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-saml (1.18.1)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
ruby-vips (2.1.4)
|
||||
ffi (~> 1.12)
|
||||
ruby2_keywords (0.0.5)
|
||||
@@ -821,9 +749,6 @@ GEM
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
searchkick (5.5.2)
|
||||
activemodel (>= 7.1)
|
||||
hashie
|
||||
securerandom (0.4.1)
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
@@ -870,14 +795,13 @@ GEM
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simplecov (0.22.0)
|
||||
simplecov (0.17.1)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
slack-ruby-client (2.7.0)
|
||||
faraday (>= 2.0.1)
|
||||
json (>= 1.8, < 3)
|
||||
simplecov-html (~> 0.10.0)
|
||||
simplecov-html (0.10.2)
|
||||
slack-ruby-client (2.5.2)
|
||||
faraday (>= 2.0)
|
||||
faraday-mashify
|
||||
faraday-multipart
|
||||
gli
|
||||
@@ -905,17 +829,13 @@ GEM
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.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)
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (7.6.0)
|
||||
twilio-ruby (5.77.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
nokogiri (>= 1.6, < 2.0)
|
||||
@@ -962,6 +882,7 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
@@ -1007,11 +928,10 @@ DEPENDENCIES
|
||||
commonmarker
|
||||
csv-safe
|
||||
database_cleaner
|
||||
datadog (~> 2.0)
|
||||
ddtrace
|
||||
debug (~> 1.8)
|
||||
devise (>= 4.9.4)
|
||||
devise-secure_password!
|
||||
devise-two-factor (>= 5.0.0)
|
||||
devise_token_auth (>= 1.2.3)
|
||||
dotenv-rails (>= 3.0.0)
|
||||
down
|
||||
@@ -1020,7 +940,6 @@ DEPENDENCIES
|
||||
facebook-messenger
|
||||
factory_bot_rails (>= 6.4.3)
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
@@ -1061,8 +980,6 @@ DEPENDENCIES
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
omniauth-saml
|
||||
opensearch-ruby
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
@@ -1091,7 +1008,6 @@ DEPENDENCIES
|
||||
ruby_llm-schema
|
||||
scout_apm
|
||||
scss_lint
|
||||
searchkick
|
||||
seed_dump
|
||||
sentry-rails (>= 5.19.0)
|
||||
sentry-ruby
|
||||
@@ -1101,9 +1017,8 @@ DEPENDENCIES
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
slack-ruby-client (~> 2.7.0)
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client (~> 2.5.2)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
@@ -1111,9 +1026,8 @@ DEPENDENCIES
|
||||
stripe
|
||||
telephone_number
|
||||
test-prof
|
||||
tidewave
|
||||
time_diff
|
||||
twilio-ruby
|
||||
twilio-ruby (~> 5.66)
|
||||
twitty (~> 0.1.5)
|
||||
tzinfo-data
|
||||
uglifier
|
||||
|
||||
@@ -2,8 +2,5 @@
|
||||
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
|
||||
|
||||
require_relative 'config/application'
|
||||
# Load Enterprise Edition rake tasks if they exist
|
||||
enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s
|
||||
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
|
||||
|
||||
Rails.application.load_tasks
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.4.3
|
||||
3.4.2
|
||||
|
||||
@@ -52,5 +52,3 @@ class AgentBuilder
|
||||
}.compact))
|
||||
end
|
||||
end
|
||||
|
||||
AgentBuilder.prepend_mod_with('AgentBuilder')
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
class Email::BaseBuilder
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
private
|
||||
|
||||
def channel
|
||||
@channel ||= inbox.channel
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= inbox.account
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= message.conversation
|
||||
end
|
||||
|
||||
def custom_sender_name
|
||||
message&.sender&.available_name || I18n.t('conversations.reply.email.header.notifications')
|
||||
end
|
||||
|
||||
def sender_name(sender_email)
|
||||
# Friendly: <agent_name> from <business_name>
|
||||
# Professional: <business_name>
|
||||
if inbox.friendly?
|
||||
I18n.t(
|
||||
'conversations.reply.email.header.friendly_name',
|
||||
sender_name: custom_sender_name,
|
||||
business_name: business_name,
|
||||
from_email: sender_email
|
||||
)
|
||||
else
|
||||
I18n.t(
|
||||
'conversations.reply.email.header.professional_name',
|
||||
business_name: business_name,
|
||||
from_email: sender_email
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def business_name
|
||||
inbox.business_name || inbox.sanitized_name
|
||||
end
|
||||
|
||||
def account_support_email
|
||||
# Parse the email to ensure it's in the correct format, the user
|
||||
# can save it in the format "Name <email@domain.com>"
|
||||
parse_email(account.support_email)
|
||||
end
|
||||
|
||||
def parse_email(email_string)
|
||||
Mail::Address.new(email_string).address
|
||||
end
|
||||
end
|
||||
@@ -1,51 +0,0 @@
|
||||
class Email::FromBuilder < Email::BaseBuilder
|
||||
pattr_initialize [:inbox!, :message!]
|
||||
|
||||
def build
|
||||
return sender_name(account_support_email) unless inbox.email?
|
||||
|
||||
from_email = case email_channel_type
|
||||
when :standard_imap_smtp,
|
||||
:google_oauth,
|
||||
:microsoft_oauth,
|
||||
:forwarding_own_smtp
|
||||
channel.email
|
||||
when :imap_chatwoot_smtp,
|
||||
:forwarding_chatwoot_smtp
|
||||
channel.verified_for_sending ? channel.email : account_support_email
|
||||
else
|
||||
account_support_email
|
||||
end
|
||||
|
||||
sender_name(from_email)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def email_channel_type
|
||||
return :google_oauth if channel.google?
|
||||
return :microsoft_oauth if channel.microsoft?
|
||||
return :standard_imap_smtp if imap_and_smtp_enabled?
|
||||
return :imap_chatwoot_smtp if imap_enabled_without_smtp?
|
||||
return :forwarding_own_smtp if forwarding_with_own_smtp?
|
||||
return :forwarding_chatwoot_smtp if forwarding_without_smtp?
|
||||
|
||||
:unknown
|
||||
end
|
||||
|
||||
def imap_and_smtp_enabled?
|
||||
channel.imap_enabled && channel.smtp_enabled
|
||||
end
|
||||
|
||||
def imap_enabled_without_smtp?
|
||||
channel.imap_enabled && !channel.smtp_enabled
|
||||
end
|
||||
|
||||
def forwarding_with_own_smtp?
|
||||
!channel.imap_enabled && channel.smtp_enabled
|
||||
end
|
||||
|
||||
def forwarding_without_smtp?
|
||||
!channel.imap_enabled && !channel.smtp_enabled
|
||||
end
|
||||
end
|
||||
@@ -1,21 +0,0 @@
|
||||
class Email::ReplyToBuilder < Email::BaseBuilder
|
||||
pattr_initialize [:inbox!, :message!]
|
||||
|
||||
def build
|
||||
reply_to = if inbox.email?
|
||||
channel.email
|
||||
elsif inbound_email_enabled?
|
||||
"reply+#{conversation.uuid}@#{account.inbound_email_domain}"
|
||||
else
|
||||
account_support_email
|
||||
end
|
||||
|
||||
sender_name(reply_to)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbound_email_enabled?
|
||||
account.feature_enabled?('inbound_emails') && account.inbound_email_domain.present?
|
||||
end
|
||||
end
|
||||
@@ -28,7 +28,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
|
||||
{
|
||||
conversation_counts: fetch_conversation_counts(conversation_filter),
|
||||
resolved_counts: fetch_resolved_counts,
|
||||
resolved_counts: fetch_resolved_counts(conversation_filter),
|
||||
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
|
||||
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
|
||||
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
|
||||
@@ -62,21 +62,10 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
fetch_counts(conversation_filter)
|
||||
end
|
||||
|
||||
def fetch_resolved_counts
|
||||
# Count resolution events, not conversations currently in resolved status
|
||||
# Filter by reporting_event.created_at, not conversation.created_at
|
||||
reporting_event_filter = { name: 'conversation_resolved', account_id: account.id }
|
||||
reporting_event_filter[:created_at] = range if range.present?
|
||||
|
||||
ReportingEvent
|
||||
.joins(conversation: { taggings: :tag })
|
||||
.where(
|
||||
reporting_event_filter.merge(
|
||||
taggings: { taggable_type: 'Conversation', context: 'labels' }
|
||||
)
|
||||
)
|
||||
.group('tags.name')
|
||||
.count
|
||||
def fetch_resolved_counts(conversation_filter)
|
||||
# since the base query is ActsAsTaggableOn,
|
||||
# the status :resolved won't automatically be converted to integer status
|
||||
fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
|
||||
end
|
||||
|
||||
def fetch_counts(conversation_filter)
|
||||
@@ -95,7 +84,9 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def fetch_metrics(conversation_filter, event_name, use_business_hours)
|
||||
ReportingEvent
|
||||
.joins(conversation: { taggings: :tag })
|
||||
.joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
|
||||
.joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
|
||||
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||
.where(
|
||||
conversations: conversation_filter,
|
||||
name: event_name,
|
||||
|
||||
@@ -38,34 +38,27 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas
|
||||
end
|
||||
|
||||
def scope_for_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
conversations: { status: :resolved }, created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def scope_for_bot_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_bot_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
conversations: { status: :resolved }, created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def scope_for_bot_handoffs_count
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_bot_handoff,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
|
||||
# It converts timestamps to the target timezone before grouping, which means
|
||||
# the same event can fall into different day buckets depending on timezone
|
||||
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy
|
||||
before_action -> { check_authorization(AssignmentPolicy) }
|
||||
|
||||
def index
|
||||
@inboxes = @assignment_policy.inboxes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(
|
||||
params[:assignment_policy_id]
|
||||
)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:assignment_policy_id)
|
||||
end
|
||||
end
|
||||
@@ -1,36 +0,0 @@
|
||||
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@assignment_policies = Current.account.assignment_policies
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
|
||||
end
|
||||
|
||||
def update
|
||||
@assignment_policy.update!(assignment_policy_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@assignment_policy.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(params[:id])
|
||||
end
|
||||
|
||||
def assignment_policy_params
|
||||
params.require(:assignment_policy).permit(
|
||||
:name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -17,8 +17,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
|
||||
|
||||
def index
|
||||
@contacts_count = resolved_contacts.count
|
||||
@contacts = fetch_contacts(resolved_contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def search
|
||||
@@ -29,8 +29,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
|
||||
search: "%#{params[:q].strip}%"
|
||||
)
|
||||
@contacts_count = contacts.count
|
||||
@contacts = fetch_contacts(contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def import
|
||||
@@ -55,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
def active
|
||||
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
|
||||
.get_available_contact_ids(Current.account.id))
|
||||
@contacts_count = contacts.count
|
||||
@contacts = fetch_contacts(contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -133,14 +133,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_contacts(contacts)
|
||||
# Build includes hash to avoid separate query when contact_inboxes are needed
|
||||
includes_hash = { avatar_attachment: [:blob] }
|
||||
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
|
||||
contacts_with_avatar = filtrate(contacts)
|
||||
.includes([{ avatar_attachment: [:blob] }])
|
||||
.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
|
||||
filtrate(contacts)
|
||||
.includes(includes_hash)
|
||||
.page(@current_page)
|
||||
.per(RESULTS_PER_PAGE)
|
||||
return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
|
||||
|
||||
contacts_with_avatar
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :fetch_assignment_policy, only: [:create]
|
||||
before_action -> { check_authorization(AssignmentPolicy) }
|
||||
before_action :validate_assignment_policy, only: [:show, :destroy]
|
||||
|
||||
def show
|
||||
@assignment_policy = @inbox.assignment_policy
|
||||
end
|
||||
|
||||
def create
|
||||
# There should be only one assignment policy for an inbox.
|
||||
# If there is a new request to add an assignment policy, we will
|
||||
# delete the old one and attach the new policy
|
||||
remove_inbox_assignment_policy
|
||||
@inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy)
|
||||
@assignment_policy = @inbox.assignment_policy
|
||||
end
|
||||
|
||||
def destroy
|
||||
remove_inbox_assignment_policy
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_inbox_assignment_policy
|
||||
@inbox.inbox_assignment_policy&.destroy
|
||||
end
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||
end
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:assignment_policy_id, :inbox_id)
|
||||
end
|
||||
|
||||
def validate_assignment_policy
|
||||
return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy
|
||||
end
|
||||
end
|
||||
@@ -70,9 +70,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
unless @inbox.channel.is_a?(Channel::Whatsapp)
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' }
|
||||
end
|
||||
|
||||
trigger_template_sync
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
@@ -183,18 +185,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
[]
|
||||
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
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
@@ -26,8 +26,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
||||
# @portal.custom_domain = parsed_custom_domain
|
||||
process_attached_logo if params[:blob_id].present?
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_record_invalid(e)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -85,8 +86,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(:inbox_id)
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
|
||||
return {} if permitted_params[:inbox_id].blank?
|
||||
|
||||
inbox = Inbox.find(permitted_params[:inbox_id])
|
||||
return {} unless inbox.web_widget?
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
||||
before_action :validate_feature_enabled!
|
||||
|
||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
# Handles both initial authorization and reauthorization
|
||||
# If inbox_id is present in params, it performs reauthorization
|
||||
# Handles the embedded signup callback data from the Facebook SDK
|
||||
def create
|
||||
validate_embedded_signup_params!
|
||||
channel = process_embedded_signup
|
||||
@@ -17,42 +16,21 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
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]
|
||||
code: params[:code],
|
||||
business_id: params[:business_id],
|
||||
waba_id: params[:waba_id],
|
||||
phone_number_id: params[:phone_number_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 = {
|
||||
render json: {
|
||||
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)
|
||||
@@ -64,6 +42,15 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
}, 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?
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
class Api::V1::Profile::MfaController < Api::BaseController
|
||||
before_action :check_mfa_feature_available
|
||||
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
|
||||
before_action :check_mfa_disabled, only: [:create, :verify]
|
||||
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
||||
before_action :validate_password, only: [:destroy]
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
mfa_service.enable_two_factor!
|
||||
end
|
||||
|
||||
def verify
|
||||
@backup_codes = mfa_service.verify_and_activate!
|
||||
end
|
||||
|
||||
def destroy
|
||||
mfa_service.disable_two_factor!
|
||||
end
|
||||
|
||||
def backup_codes
|
||||
@backup_codes = mfa_service.generate_backup_codes!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mfa_service
|
||||
@mfa_service ||= Mfa::ManagementService.new(user: current_user)
|
||||
end
|
||||
|
||||
def check_mfa_enabled
|
||||
render_could_not_create_error(I18n.t('errors.mfa.not_enabled')) unless current_user.mfa_enabled?
|
||||
end
|
||||
|
||||
def check_mfa_feature_available
|
||||
return if Chatwoot.mfa_enabled?
|
||||
|
||||
render json: {
|
||||
error: I18n.t('errors.mfa.feature_unavailable')
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def check_mfa_disabled
|
||||
render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled?
|
||||
end
|
||||
|
||||
def validate_otp
|
||||
authenticated = Mfa::AuthenticationService.new(
|
||||
user: current_user,
|
||||
otp_code: mfa_params[:otp_code]
|
||||
).authenticate
|
||||
|
||||
return if authenticated
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.mfa.invalid_code'))
|
||||
end
|
||||
|
||||
def validate_password
|
||||
return if current_user.valid_password?(mfa_params[:password])
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.mfa.invalid_credentials'))
|
||||
end
|
||||
|
||||
def mfa_params
|
||||
params.permit(:otp_code, :password)
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
|
||||
private
|
||||
|
||||
def set_global_config
|
||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
|
||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
|
||||
end
|
||||
|
||||
def set_contact
|
||||
|
||||
@@ -4,28 +4,17 @@ module SwitchLocale
|
||||
private
|
||||
|
||||
def switch_locale(&)
|
||||
# Priority is for locale set in query string (mostly for widget/from js sdk)
|
||||
# priority is for locale set in query string (mostly for widget/from js sdk)
|
||||
locale ||= params[:locale]
|
||||
|
||||
# Use the user's locale if available
|
||||
locale ||= locale_from_user
|
||||
|
||||
# Use the locale from a custom domain if applicable
|
||||
locale ||= locale_from_custom_domain
|
||||
|
||||
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
|
||||
locale ||= ENV.fetch('DEFAULT_LOCALE', nil)
|
||||
|
||||
set_locale(locale, &)
|
||||
end
|
||||
|
||||
def switch_locale_using_account_locale(&)
|
||||
# Get the locale from the user first
|
||||
locale = locale_from_user
|
||||
|
||||
# Fallback to the account's locale if the user's locale is not set
|
||||
locale ||= locale_from_account(@current_account)
|
||||
|
||||
locale = locale_from_account(@current_account)
|
||||
set_locale(locale, &)
|
||||
end
|
||||
|
||||
@@ -43,12 +32,6 @@ module SwitchLocale
|
||||
@portal.default_locale
|
||||
end
|
||||
|
||||
def locale_from_user
|
||||
return unless @user
|
||||
|
||||
@user.ui_settings&.dig('locale')
|
||||
end
|
||||
|
||||
def set_locale(locale, &)
|
||||
safe_locale = validate_and_get_locale(locale)
|
||||
# Ensure locale won't bleed into other requests
|
||||
|
||||
@@ -66,7 +66,7 @@ class DashboardController < ActionController::Base
|
||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.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?,
|
||||
|
||||
@@ -47,8 +47,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
|
||||
email = auth_hash.dig('info', 'email')
|
||||
@resource = resource_class.from_email(email)
|
||||
# find the user with their email instead of UID and token
|
||||
@resource = resource_class.where(
|
||||
email: auth_hash['info']['email']
|
||||
).first
|
||||
end
|
||||
|
||||
def validate_signup_email_is_business_domain?
|
||||
@@ -73,5 +75,3 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
'user'
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController')
|
||||
|
||||
@@ -44,5 +44,3 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
}, status: status
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController')
|
||||
|
||||
@@ -9,14 +9,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def create
|
||||
return handle_mfa_verification if mfa_verification_request?
|
||||
return handle_sso_authentication if sso_authentication_request?
|
||||
|
||||
user = find_user_for_authentication
|
||||
return handle_mfa_required(user) if user&.mfa_enabled?
|
||||
|
||||
# Only proceed with standard authentication if no MFA is required
|
||||
super
|
||||
# Authenticate user via the temporary sso auth token
|
||||
if params[:sso_auth_token].present? && @resource.present?
|
||||
authenticate_resource_with_sso_token
|
||||
yield @resource if block_given?
|
||||
render_create_success
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
@@ -25,31 +25,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
|
||||
private
|
||||
|
||||
def find_user_for_authentication
|
||||
return nil unless params[:email].present? && params[:password].present?
|
||||
|
||||
normalized_email = params[:email].strip.downcase
|
||||
user = User.from_email(normalized_email)
|
||||
return nil unless user&.valid_password?(params[:password])
|
||||
return nil unless user.active_for_authentication?
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def mfa_verification_request?
|
||||
params[:mfa_token].present?
|
||||
end
|
||||
|
||||
def sso_authentication_request?
|
||||
params[:sso_auth_token].present? && @resource.present?
|
||||
end
|
||||
|
||||
def handle_sso_authentication
|
||||
authenticate_resource_with_sso_token
|
||||
yield @resource if block_given?
|
||||
render_create_success
|
||||
end
|
||||
|
||||
def login_page_url(error: nil)
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
|
||||
@@ -71,41 +46,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
user = User.from_email(params[:email])
|
||||
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
end
|
||||
|
||||
def handle_mfa_required(user)
|
||||
render json: {
|
||||
mfa_required: true,
|
||||
mfa_token: Mfa::TokenService.new(user: user).generate_token
|
||||
}, status: :partial_content
|
||||
end
|
||||
|
||||
def handle_mfa_verification
|
||||
user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token
|
||||
return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user
|
||||
|
||||
authenticated = Mfa::AuthenticationService.new(
|
||||
user: user,
|
||||
otp_code: params[:otp_code],
|
||||
backup_code: params[:backup_code]
|
||||
).authenticate
|
||||
|
||||
return render_mfa_error('errors.mfa.invalid_code') unless authenticated
|
||||
|
||||
sign_in_mfa_user(user)
|
||||
end
|
||||
|
||||
def sign_in_mfa_user(user)
|
||||
@resource = user
|
||||
@token = @resource.create_token
|
||||
@resource.save!
|
||||
|
||||
sign_in(:user, @resource, store: false, bypass: false)
|
||||
render_create_success
|
||||
end
|
||||
|
||||
def render_mfa_error(message_key, status = :bad_request)
|
||||
render json: { error: I18n.t(message_key) }, status: status
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
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 create
|
||||
|
||||
@@ -3,7 +3,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
|
||||
before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
|
||||
|
||||
def index
|
||||
@conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations
|
||||
@conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -58,6 +58,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
end
|
||||
|
||||
def set_global_config
|
||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL', 'INSTALLATION_NAME')
|
||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,12 +17,7 @@ class SlackUploadsController < ApplicationController
|
||||
end
|
||||
|
||||
def blob_url
|
||||
# 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
|
||||
url_for(@blob.representation(resize_to_fill: [250, nil]))
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
|
||||
@@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
|
||||
redirect_to new_super_admin_user_path, notice: notice
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present?
|
||||
super
|
||||
end
|
||||
#
|
||||
# def update
|
||||
# super
|
||||
# send_foo_updated_email(requested_resource)
|
||||
# end
|
||||
|
||||
# Override this method to specify custom lookup behavior.
|
||||
# This will be used to set the resource for the `show`, `edit`, and `update`
|
||||
|
||||
@@ -5,6 +5,6 @@ class Survey::ResponsesController < ActionController::Base
|
||||
private
|
||||
|
||||
def set_global_config
|
||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
|
||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,8 +30,7 @@ class Twilio::CallbackController < ApplicationController
|
||||
:NumMedia,
|
||||
:Latitude,
|
||||
:Longitude,
|
||||
:MessageType,
|
||||
:ProfileName
|
||||
:MessageType
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ class WidgetsController < ActionController::Base
|
||||
private
|
||||
|
||||
def set_global_config
|
||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME')
|
||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED')
|
||||
end
|
||||
|
||||
def set_web_widget
|
||||
@@ -70,12 +70,7 @@ class WidgetsController < ActionController::Base
|
||||
end
|
||||
|
||||
def allow_iframe_requests
|
||||
if @web_widget.allowed_domains.blank?
|
||||
response.headers.delete('X-Frame-Options')
|
||||
else
|
||||
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
|
||||
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
|
||||
end
|
||||
response.headers.delete('X-Frame-Options')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -59,11 +59,11 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
SHOW_PAGE_ATTRIBUTES = %i[
|
||||
id
|
||||
avatar_url
|
||||
unconfirmed_email
|
||||
name
|
||||
type
|
||||
display_name
|
||||
email
|
||||
unconfirmed_email
|
||||
created_at
|
||||
updated_at
|
||||
confirmed_at
|
||||
|
||||
@@ -6,54 +6,19 @@ class EmailChannelFinder
|
||||
end
|
||||
|
||||
def perform
|
||||
channel_from_primary_recipients || channel_from_bcc_recipients
|
||||
end
|
||||
channel = nil
|
||||
|
||||
private
|
||||
recipient_mails.each do |email|
|
||||
normalized_email = normalize_email_with_plus_addressing(email)
|
||||
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
|
||||
|
||||
def channel_from_primary_recipients
|
||||
primary_recipient_emails.each do |email|
|
||||
channel = channel_from_email(email)
|
||||
return channel if channel.present?
|
||||
break if channel.present?
|
||||
end
|
||||
|
||||
nil
|
||||
channel
|
||||
end
|
||||
|
||||
def channel_from_bcc_recipients
|
||||
bcc_recipient_emails.each do |email|
|
||||
channel = channel_from_email(email)
|
||||
|
||||
# Skip if BCC processing is disabled for this account
|
||||
next if channel && !allow_bcc_processing?(channel.account_id)
|
||||
|
||||
return channel if channel.present?
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def primary_recipient_emails
|
||||
(@email_object.to.to_a + @email_object.cc.to_a + [@email_object['X-Original-To'].try(:value)]).flatten.compact
|
||||
end
|
||||
|
||||
def bcc_recipient_emails
|
||||
@email_object.bcc.to_a.flatten.compact
|
||||
end
|
||||
|
||||
def channel_from_email(email)
|
||||
normalized_email = normalize_email_with_plus_addressing(email)
|
||||
Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
|
||||
end
|
||||
|
||||
def bcc_processing_skipped_accounts
|
||||
config_value = GlobalConfigService.load('SKIP_INCOMING_BCC_PROCESSING', '')
|
||||
return [] if config_value.blank?
|
||||
|
||||
config_value.split(',').map(&:to_i)
|
||||
end
|
||||
|
||||
def allow_bcc_processing?(account_id)
|
||||
bcc_processing_skipped_accounts.exclude?(account_id)
|
||||
def recipient_mails
|
||||
recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
|
||||
recipient_addresses.flatten.compact
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,13 +15,7 @@ class NotificationFinder
|
||||
end
|
||||
|
||||
def unread_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
|
||||
@notifications.where(read_at: nil).count
|
||||
end
|
||||
|
||||
def count
|
||||
@@ -33,7 +27,7 @@ class NotificationFinder
|
||||
def set_up
|
||||
find_all_notifications
|
||||
filter_snoozed_notifications
|
||||
filter_read_notifications
|
||||
fitler_read_notifications
|
||||
end
|
||||
|
||||
def find_all_notifications
|
||||
@@ -44,7 +38,7 @@ class NotificationFinder
|
||||
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
|
||||
end
|
||||
|
||||
def filter_read_notifications
|
||||
def fitler_read_notifications
|
||||
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
module PortalHelper
|
||||
include UrlHelper
|
||||
def set_og_image_url(portal_name, title)
|
||||
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
|
||||
return if cdn_url.blank?
|
||||
@@ -75,17 +74,6 @@ module PortalHelper
|
||||
end
|
||||
end
|
||||
|
||||
def generate_portal_brand_url(brand_url, referer)
|
||||
url = URI.parse(brand_url.to_s)
|
||||
query_params = Rack::Utils.parse_query(url.query)
|
||||
query_params['utm_medium'] = 'helpcenter'
|
||||
query_params['utm_campaign'] = 'branding'
|
||||
query_params['utm_source'] = URI.parse(referer).host if url_valid?(referer)
|
||||
|
||||
url.query = query_params.to_query
|
||||
url.to_s
|
||||
end
|
||||
|
||||
def render_category_content(content)
|
||||
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
|
||||
end
|
||||
|
||||
@@ -53,13 +53,13 @@ module ReportHelper
|
||||
end
|
||||
|
||||
def resolutions
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
|
||||
created_at: range)
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
|
||||
conversations: { status: :resolved }, created_at: range).distinct
|
||||
end
|
||||
|
||||
def bot_resolutions
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range)
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
conversations: { status: :resolved }, created_at: range).distinct
|
||||
end
|
||||
|
||||
def bot_handoffs
|
||||
|
||||
@@ -18,25 +18,12 @@ module ReportingEventHelper
|
||||
end
|
||||
|
||||
def last_non_human_activity(conversation)
|
||||
# Try to get either a handoff or reopened event first
|
||||
# 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
|
||||
# check if a handoff event already exists
|
||||
handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
|
||||
|
||||
return event.event_end_time if event&.event_end_time
|
||||
|
||||
# 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
|
||||
# if a handoff exists, last non human activity is when the handoff ended,
|
||||
# otherwise it's when the conversation was created
|
||||
handoff_event&.event_end_time || conversation.created_at
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
verifyServiceWorkerExistence,
|
||||
} from './helper/pushHelper';
|
||||
import ReconnectService from 'dashboard/helper/ReconnectService';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
@@ -39,14 +38,12 @@ export default {
|
||||
const { accountId } = useAccount();
|
||||
// Use the font size composable (it automatically sets up the watcher)
|
||||
const { currentFontSize } = useFontSize();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
return {
|
||||
router,
|
||||
store,
|
||||
currentAccountId: accountId,
|
||||
currentFontSize,
|
||||
uiSettings,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -91,10 +88,7 @@ export default {
|
||||
mounted() {
|
||||
this.initializeColorTheme();
|
||||
this.listenToThemeChanges();
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(
|
||||
this.uiSettings?.locale || window.chatwootConfig.selectedLocale
|
||||
);
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
},
|
||||
unmounted() {
|
||||
if (this.reconnectService) {
|
||||
@@ -120,8 +114,7 @@ export default {
|
||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||
this.getAccount(this.currentAccountId);
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(this.uiSettings?.locale || locale);
|
||||
this.setLocale(locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(this.store, pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.store, this.router);
|
||||
@@ -144,6 +137,7 @@ export default {
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
id="app"
|
||||
class="flex flex-col w-full h-screen min-h-0"
|
||||
:class="{ 'app-rtl--wrapper': isRTL }"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AgentCapacityPolicies extends ApiClient {
|
||||
constructor() {
|
||||
super('agent_capacity_policies', { accountScoped: true });
|
||||
}
|
||||
|
||||
getUsers(policyId) {
|
||||
return axios.get(`${this.url}/${policyId}/users`);
|
||||
}
|
||||
|
||||
addUser(policyId, userData) {
|
||||
return axios.post(`${this.url}/${policyId}/users`, {
|
||||
user_id: userData.id,
|
||||
capacity: userData.capacity,
|
||||
});
|
||||
}
|
||||
|
||||
removeUser(policyId, userId) {
|
||||
return axios.delete(`${this.url}/${policyId}/users/${userId}`);
|
||||
}
|
||||
|
||||
createInboxLimit(policyId, limitData) {
|
||||
return axios.post(`${this.url}/${policyId}/inbox_limits`, {
|
||||
inbox_id: limitData.inboxId,
|
||||
conversation_limit: limitData.conversationLimit,
|
||||
});
|
||||
}
|
||||
|
||||
updateInboxLimit(policyId, limitId, limitData) {
|
||||
return axios.put(`${this.url}/${policyId}/inbox_limits/${limitId}`, {
|
||||
conversation_limit: limitData.conversationLimit,
|
||||
});
|
||||
}
|
||||
|
||||
deleteInboxLimit(policyId, limitId) {
|
||||
return axios.delete(`${this.url}/${policyId}/inbox_limits/${limitId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AgentCapacityPolicies();
|
||||
@@ -1,36 +0,0 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AssignmentPolicies extends ApiClient {
|
||||
constructor() {
|
||||
super('assignment_policies', { accountScoped: true });
|
||||
}
|
||||
|
||||
getInboxes(policyId) {
|
||||
return axios.get(`${this.url}/${policyId}/inboxes`);
|
||||
}
|
||||
|
||||
setInboxPolicy(inboxId, policyId) {
|
||||
return axios.post(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`,
|
||||
{
|
||||
assignment_policy_id: policyId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getInboxPolicy(inboxId) {
|
||||
return axios.get(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`
|
||||
);
|
||||
}
|
||||
|
||||
removeInboxPolicy(inboxId) {
|
||||
return axios.delete(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AssignmentPolicies();
|
||||
@@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient {
|
||||
super('captain/assistant_responses', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, search, assistantId, documentId, status } = {}) {
|
||||
get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
search,
|
||||
searchKey,
|
||||
assistant_id: assistantId,
|
||||
document_id: documentId,
|
||||
status,
|
||||
|
||||
@@ -9,13 +9,6 @@ class WhatsappChannel extends ApiClient {
|
||||
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();
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class MfaAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('profile/mfa', { accountScoped: false });
|
||||
}
|
||||
|
||||
enable() {
|
||||
return axios.post(`${this.url}`);
|
||||
}
|
||||
|
||||
verify(otpCode) {
|
||||
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
||||
}
|
||||
|
||||
disable(password, otpCode) {
|
||||
return axios.delete(this.url, {
|
||||
data: { password, otp_code: otpCode },
|
||||
});
|
||||
}
|
||||
|
||||
regenerateBackupCodes(otpCode) {
|
||||
return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MfaAPI();
|
||||
@@ -1,26 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class SamlSettingsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('saml_settings', { accountScoped: true });
|
||||
}
|
||||
|
||||
get() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, { saml_settings: data });
|
||||
}
|
||||
|
||||
update(data) {
|
||||
return axios.put(this.url, { saml_settings: data });
|
||||
}
|
||||
|
||||
delete() {
|
||||
return axios.delete(this.url);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SamlSettingsAPI();
|
||||
@@ -1,98 +0,0 @@
|
||||
import agentCapacityPolicies from '../agentCapacityPolicies';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#AgentCapacityPoliciesAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(agentCapacityPolicies).toBeInstanceOf(ApiClient);
|
||||
expect(agentCapacityPolicies).toHaveProperty('get');
|
||||
expect(agentCapacityPolicies).toHaveProperty('show');
|
||||
expect(agentCapacityPolicies).toHaveProperty('create');
|
||||
expect(agentCapacityPolicies).toHaveProperty('update');
|
||||
expect(agentCapacityPolicies).toHaveProperty('delete');
|
||||
expect(agentCapacityPolicies).toHaveProperty('getUsers');
|
||||
expect(agentCapacityPolicies).toHaveProperty('addUser');
|
||||
expect(agentCapacityPolicies).toHaveProperty('removeUser');
|
||||
expect(agentCapacityPolicies).toHaveProperty('createInboxLimit');
|
||||
expect(agentCapacityPolicies).toHaveProperty('updateInboxLimit');
|
||||
expect(agentCapacityPolicies).toHaveProperty('deleteInboxLimit');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
post: vi.fn(() => Promise.resolve()),
|
||||
put: vi.fn(() => Promise.resolve()),
|
||||
delete: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
// Mock accountIdFromRoute
|
||||
Object.defineProperty(agentCapacityPolicies, 'accountIdFromRoute', {
|
||||
get: () => '1',
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#getUsers', () => {
|
||||
agentCapacityPolicies.getUsers(123);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/users'
|
||||
);
|
||||
});
|
||||
|
||||
it('#addUser', () => {
|
||||
const userData = { id: 456, capacity: 20 };
|
||||
agentCapacityPolicies.addUser(123, userData);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/users',
|
||||
{
|
||||
user_id: 456,
|
||||
capacity: 20,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#removeUser', () => {
|
||||
agentCapacityPolicies.removeUser(123, 456);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/users/456'
|
||||
);
|
||||
});
|
||||
|
||||
it('#createInboxLimit', () => {
|
||||
const limitData = { inboxId: 1, conversationLimit: 10 };
|
||||
agentCapacityPolicies.createInboxLimit(123, limitData);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits',
|
||||
{
|
||||
inbox_id: 1,
|
||||
conversation_limit: 10,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#updateInboxLimit', () => {
|
||||
const limitData = { conversationLimit: 15 };
|
||||
agentCapacityPolicies.updateInboxLimit(123, 789, limitData);
|
||||
expect(axiosMock.put).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789',
|
||||
{
|
||||
conversation_limit: 15,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#deleteInboxLimit', () => {
|
||||
agentCapacityPolicies.deleteInboxLimit(123, 789);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import assignmentPolicies from '../assignmentPolicies';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#AssignmentPoliciesAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(assignmentPolicies).toBeInstanceOf(ApiClient);
|
||||
expect(assignmentPolicies).toHaveProperty('get');
|
||||
expect(assignmentPolicies).toHaveProperty('show');
|
||||
expect(assignmentPolicies).toHaveProperty('create');
|
||||
expect(assignmentPolicies).toHaveProperty('update');
|
||||
expect(assignmentPolicies).toHaveProperty('delete');
|
||||
expect(assignmentPolicies).toHaveProperty('getInboxes');
|
||||
expect(assignmentPolicies).toHaveProperty('setInboxPolicy');
|
||||
expect(assignmentPolicies).toHaveProperty('getInboxPolicy');
|
||||
expect(assignmentPolicies).toHaveProperty('removeInboxPolicy');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
post: vi.fn(() => Promise.resolve()),
|
||||
delete: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
// Mock accountIdFromRoute
|
||||
Object.defineProperty(assignmentPolicies, 'accountIdFromRoute', {
|
||||
get: () => '1',
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#getInboxes', () => {
|
||||
assignmentPolicies.getInboxes(123);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/assignment_policies/123/inboxes'
|
||||
);
|
||||
});
|
||||
|
||||
it('#setInboxPolicy', () => {
|
||||
assignmentPolicies.setInboxPolicy(456, 123);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/inboxes/456/assignment_policy',
|
||||
{
|
||||
assignment_policy_id: 123,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getInboxPolicy', () => {
|
||||
assignmentPolicies.getInboxPolicy(456);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/inboxes/456/assignment_policy'
|
||||
);
|
||||
});
|
||||
|
||||
it('#removeInboxPolicy', () => {
|
||||
assignmentPolicies.removeInboxPolicy(456);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/inboxes/456/assignment_policy'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
<script setup>
|
||||
import AgentCapacityPolicyCard from './AgentCapacityPolicyCard.vue';
|
||||
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Smith',
|
||||
email: 'john.smith@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sarah Johnson',
|
||||
email: 'sarah.johnson@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Mike Chen',
|
||||
email: 'mike.chen@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=3',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Emily Davis',
|
||||
email: 'emily.davis@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=4',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Alex Rodriguez',
|
||||
email: 'alex.rodriguez@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=5',
|
||||
},
|
||||
];
|
||||
|
||||
const withCount = policy => ({
|
||||
...policy,
|
||||
assignedAgentCount: policy.users.length,
|
||||
});
|
||||
|
||||
const policyA = withCount({
|
||||
id: 1,
|
||||
name: 'High Volume Support',
|
||||
description:
|
||||
'Capacity-based policy for handling high conversation volumes with experienced agents',
|
||||
users: [mockUsers[0], mockUsers[1], mockUsers[2]],
|
||||
isFetchingUsers: false,
|
||||
});
|
||||
|
||||
const policyB = withCount({
|
||||
id: 2,
|
||||
name: 'Specialized Team',
|
||||
description: 'Custom capacity limits for specialized support team members',
|
||||
users: [mockUsers[3], mockUsers[4]],
|
||||
isFetchingUsers: false,
|
||||
});
|
||||
|
||||
const emptyPolicy = withCount({
|
||||
id: 3,
|
||||
name: 'New Policy',
|
||||
description: 'Recently created policy with no assigned agents yet',
|
||||
users: [],
|
||||
isFetchingUsers: false,
|
||||
});
|
||||
|
||||
const loadingPolicy = withCount({
|
||||
id: 4,
|
||||
name: 'Loading Policy',
|
||||
description: 'Policy currently loading agent information',
|
||||
users: [],
|
||||
isFetchingUsers: true,
|
||||
});
|
||||
|
||||
const onEdit = id => console.log('Edit policy:', id);
|
||||
const onDelete = id => console.log('Delete policy:', id);
|
||||
const onFetchUsers = id => console.log('Fetch users for policy:', id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AgentCapacityPolicyCard"
|
||||
:layout="{ type: 'grid', width: '1200px' }"
|
||||
>
|
||||
<Variant title="Multiple Cards (Various States)">
|
||||
<div class="p-4 bg-n-background">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="policyA"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="policyB"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="emptyPolicy"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="loadingPolicy"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import CardPopover from '../components/CardPopover.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
assignedAgentCount: { type: Number, default: 0 },
|
||||
users: { type: Array, default: () => [] },
|
||||
isFetchingUsers: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'fetchUsers']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const users = computed(() => {
|
||||
return props.users.map(user => {
|
||||
return {
|
||||
name: user.name,
|
||||
key: user.id,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit', props.id);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.id);
|
||||
};
|
||||
|
||||
const handleFetchUsers = () => {
|
||||
if (props.users?.length > 0) return;
|
||||
emit('fetchUsers', props.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="[&>div]:px-5">
|
||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
||||
<div class="flex items-center gap-3 justify-between w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<CardPopover
|
||||
:title="
|
||||
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.POPOVER')
|
||||
"
|
||||
icon="i-lucide-users-round"
|
||||
:count="assignedAgentCount"
|
||||
:items="users"
|
||||
:is-fetching="isFetchingUsers"
|
||||
@fetch="handleFetchUsers"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="
|
||||
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.EDIT')
|
||||
"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="px-2"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
<script setup>
|
||||
import AssignmentCard from './AssignmentCard.vue';
|
||||
|
||||
const agentAssignments = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Assignment policy',
|
||||
description: 'Manage how conversations get assigned in inboxes.',
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-circle-fading-arrow-up',
|
||||
label: 'Assign by conversations evenly or by available capacity',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
label: 'Add fair distribution rules to avoid overloading any agent',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-inbox',
|
||||
label: 'Add inboxes to a policy - one policy per inbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Agent capacity policy',
|
||||
description: 'Manage workload for agents.',
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-glass-water',
|
||||
label: 'Define maximum conversations per inbox',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-circle-minus',
|
||||
label: 'Create exceptions based on labels and time',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-users-round',
|
||||
label: 'Add agents to a policy - one policy per agent',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AssignmentCard"
|
||||
:layout="{ type: 'grid', width: '1000px' }"
|
||||
>
|
||||
<Variant title="Assignment Card">
|
||||
<div class="px-4 py-4 bg-n-background flex gap-6 justify-between">
|
||||
<AssignmentCard
|
||||
v-for="(item, index) in agentAssignments"
|
||||
:key="index"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:features="item.features"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
features: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex justify-between w-full items-center">
|
||||
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
|
||||
<Button
|
||||
xs
|
||||
slate
|
||||
ghost
|
||||
icon="i-lucide-chevron-right"
|
||||
@click.stop="handleClick"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col items-start gap-3 mt-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature.id"
|
||||
class="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<Icon
|
||||
:icon="feature.icon"
|
||||
class="text-n-slate-11 size-4 flex-shrink-0"
|
||||
/>
|
||||
{{ feature.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</CardLayout>
|
||||
</template>
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
<script setup>
|
||||
import AssignmentPolicyCard from './AssignmentPolicyCard.vue';
|
||||
|
||||
const mockInboxes = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
inbox_type: 'Website',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
channel_type: 'Channel::Email',
|
||||
inbox_type: 'Email',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
inbox_type: 'WhatsApp',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Facebook Messenger',
|
||||
channel_type: 'Channel::FacebookPage',
|
||||
inbox_type: 'Messenger',
|
||||
},
|
||||
];
|
||||
|
||||
const withCount = policy => ({
|
||||
...policy,
|
||||
assignedInboxCount: policy.inboxes.length,
|
||||
});
|
||||
|
||||
const policyA = withCount({
|
||||
id: 1,
|
||||
name: 'Website & Email',
|
||||
description: 'Distributes conversations evenly among available agents',
|
||||
assignmentOrder: 'round_robin',
|
||||
conversationPriority: 'high',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
const policyB = withCount({
|
||||
id: 2,
|
||||
name: 'WhatsApp & Messenger',
|
||||
description: 'Assigns based on capacity and workload',
|
||||
assignmentOrder: 'capacity_based',
|
||||
conversationPriority: 'medium',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
const emptyPolicy = withCount({
|
||||
id: 3,
|
||||
name: 'No Inboxes Yet',
|
||||
description: 'Policy with no assigned inboxes',
|
||||
assignmentOrder: 'manual',
|
||||
conversationPriority: 'low',
|
||||
enabled: false,
|
||||
inboxes: [],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
const onEdit = id => console.log('Edit policy:', id);
|
||||
const onDelete = id => console.log('Delete policy:', id);
|
||||
const onFetch = () => console.log('Fetch inboxes');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AssignmentPolicyCard"
|
||||
:layout="{ type: 'grid', width: '1200px' }"
|
||||
>
|
||||
<Variant title="Three Cards (Two with inboxes, One empty)">
|
||||
<div class="p-4 bg-n-background">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<AssignmentPolicyCard
|
||||
v-bind="policyA"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-inboxes="onFetch"
|
||||
/>
|
||||
<AssignmentPolicyCard
|
||||
v-bind="policyB"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-inboxes="onFetch"
|
||||
/>
|
||||
<AssignmentPolicyCard
|
||||
v-bind="emptyPolicy"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-inboxes="onFetch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { formatToTitleCase } from 'dashboard/helper/commons';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import CardPopover from '../components/CardPopover.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
assignmentOrder: { type: String, default: '' },
|
||||
conversationPriority: { type: String, default: '' },
|
||||
assignedInboxCount: { type: Number, default: 0 },
|
||||
enabled: { type: Boolean, default: false },
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
isFetchingInboxes: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'fetchInboxes']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const inboxes = computed(() => {
|
||||
return props.inboxes.map(inbox => {
|
||||
return {
|
||||
name: inbox.name,
|
||||
id: inbox.id,
|
||||
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const order = computed(() => {
|
||||
return formatToTitleCase(props.assignmentOrder);
|
||||
});
|
||||
|
||||
const priority = computed(() => {
|
||||
return formatToTitleCase(props.conversationPriority);
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit', props.id);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.id);
|
||||
};
|
||||
|
||||
const handleFetchInboxes = () => {
|
||||
if (props.inboxes?.length > 0) return;
|
||||
emit('fetchInboxes', props.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="[&>div]:px-5">
|
||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
||||
<div class="flex items-center gap-3 justify-between w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
|
||||
>
|
||||
{{
|
||||
enabled
|
||||
? t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
|
||||
)
|
||||
: t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<CardPopover
|
||||
:title="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
|
||||
)
|
||||
"
|
||||
icon="i-lucide-inbox"
|
||||
:count="assignedInboxCount"
|
||||
:items="inboxes"
|
||||
:is-fetching="isFetchingInboxes"
|
||||
@fetch="handleFetchInboxes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="
|
||||
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.EDIT')
|
||||
"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="px-2"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<div v-if="order" class="w-px h-2.5 bg-n-slate-5" />
|
||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||
{{ description }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 py-1.5">
|
||||
<span v-if="order" class="text-n-slate-11 text-sm">
|
||||
{{
|
||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
|
||||
}}
|
||||
<span class="text-n-slate-12">{{ order }}</span>
|
||||
</span>
|
||||
<div v-if="order" class="w-px h-3 bg-n-strong" />
|
||||
<span v-if="priority" class="text-n-slate-11 text-sm">
|
||||
{{
|
||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
|
||||
}}
|
||||
<span class="text-n-slate-12">{{ priority }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useToggle, useWindowSize, useElementBounding } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add']);
|
||||
|
||||
const BUFFER_SPACE = 20;
|
||||
|
||||
const [showPopover, togglePopover] = useToggle();
|
||||
const buttonRef = ref();
|
||||
const dropdownRef = ref();
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const {
|
||||
top: buttonTop,
|
||||
left: buttonLeft,
|
||||
width: buttonWidth,
|
||||
height: buttonHeight,
|
||||
} = useElementBounding(buttonRef);
|
||||
const { width: dropdownWidth, height: dropdownHeight } =
|
||||
useElementBounding(dropdownRef);
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!searchValue.value) return props.items;
|
||||
const query = searchValue.value.toLowerCase();
|
||||
|
||||
return picoSearch(props.items, query, ['name']);
|
||||
});
|
||||
|
||||
const handleAdd = item => {
|
||||
emit('add', item);
|
||||
togglePopover(false);
|
||||
};
|
||||
|
||||
const shouldShowAbove = computed(() => {
|
||||
if (!buttonRef.value || !dropdownRef.value) return false;
|
||||
const spaceBelow =
|
||||
windowHeight.value - (buttonTop.value + buttonHeight.value);
|
||||
const spaceAbove = buttonTop.value;
|
||||
return (
|
||||
spaceBelow < dropdownHeight.value + BUFFER_SPACE && spaceAbove > spaceBelow
|
||||
);
|
||||
});
|
||||
|
||||
const shouldAlignRight = computed(() => {
|
||||
if (!buttonRef.value || !dropdownRef.value) return false;
|
||||
const spaceRight = windowWidth.value - buttonLeft.value;
|
||||
const spaceLeft = buttonLeft.value + buttonWidth.value;
|
||||
|
||||
return (
|
||||
spaceRight < dropdownWidth.value + BUFFER_SPACE && spaceLeft > spaceRight
|
||||
);
|
||||
});
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (showPopover.value) {
|
||||
togglePopover(false);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="handleClickOutside"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
ref="buttonRef"
|
||||
slate
|
||||
type="button"
|
||||
icon="i-lucide-plus"
|
||||
sm
|
||||
:label="label"
|
||||
@click="togglePopover(!showPopover)"
|
||||
/>
|
||||
<div
|
||||
v-if="showPopover"
|
||||
ref="dropdownRef"
|
||||
class="z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
|
||||
:class="[
|
||||
shouldShowAbove ? 'bottom-full mb-2' : 'top-full mt-2',
|
||||
shouldAlignRight ? 'right-0' : 'left-0',
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col divide-y divide-n-slate-4 w-full">
|
||||
<Input
|
||||
v-model="searchValue"
|
||||
:placeholder="searchPlaceholder"
|
||||
custom-input-class="bg-transparent !outline-none w-full ltr:!pl-10 rtl:!pr-10 h-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="flex gap-3 min-w-0 w-full py-4 px-3 hover:bg-n-alpha-2 cursor-pointer"
|
||||
:class="{ 'items-center': item.color, 'items-start': !item.color }"
|
||||
@click="handleAdd(item)"
|
||||
>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="size-4 text-n-slate-12 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<span
|
||||
v-else-if="item.color"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
class="size-3 rounded-sm"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:title="item.name"
|
||||
:src="item.avatarUrl"
|
||||
:name="item.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col items-start gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1 min-w-0 w-full">
|
||||
<span
|
||||
:title="item.name || item.title"
|
||||
class="text-sm text-n-slate-12 truncate min-w-0 flex-1"
|
||||
>
|
||||
{{ item.name || item.title }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.email || item.phoneNumber"
|
||||
:title="item.email || item.phoneNumber"
|
||||
class="text-sm text-n-slate-11 truncate min-w-0 w-full block"
|
||||
>
|
||||
{{ item.email || item.phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,127 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
defineProps({
|
||||
nameLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
namePlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
descriptionLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
descriptionPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
statusLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
statusPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['validationChange']);
|
||||
|
||||
const policyName = defineModel('policyName', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const description = defineModel('description', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const enabled = defineModel('enabled', {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
});
|
||||
|
||||
const validationRules = {
|
||||
policyName: { required, minLength: minLength(1) },
|
||||
description: { required, minLength: minLength(1) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, { policyName, description });
|
||||
|
||||
const isValid = computed(() => !v$.value.$invalid);
|
||||
|
||||
watch(
|
||||
isValid,
|
||||
() => {
|
||||
emit('validationChange', {
|
||||
isValid: isValid.value,
|
||||
section: 'baseInfo',
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 pb-4">
|
||||
<!-- Policy Name Field -->
|
||||
<div class="flex items-center gap-6">
|
||||
<WithLabel
|
||||
:label="nameLabel"
|
||||
name="policyName"
|
||||
class="flex items-center w-full [&>label]:min-w-[120px]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
v-model="policyName"
|
||||
type="text"
|
||||
:placeholder="namePlaceholder"
|
||||
/>
|
||||
</div>
|
||||
</WithLabel>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="flex items-center gap-6">
|
||||
<WithLabel
|
||||
:label="descriptionLabel"
|
||||
name="description"
|
||||
class="flex items-center w-full [&>label]:min-w-[120px]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
v-model="description"
|
||||
type="text"
|
||||
:placeholder="descriptionPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
</WithLabel>
|
||||
</div>
|
||||
|
||||
<!-- Status Field -->
|
||||
<div v-if="statusLabel" class="flex items-center gap-6">
|
||||
<WithLabel
|
||||
:label="statusLabel"
|
||||
name="enabled"
|
||||
class="flex items-center w-full [&>label]:min-w-[120px]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch v-model="enabled" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ statusPlaceholder }}
|
||||
</span>
|
||||
</div>
|
||||
</WithLabel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script setup>
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
defineProps({
|
||||
count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['fetch']);
|
||||
|
||||
const [showPopover, togglePopover] = useToggle();
|
||||
|
||||
const handleButtonClick = () => {
|
||||
emit('fetch');
|
||||
togglePopover(!showPopover.value);
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (showPopover.value) {
|
||||
togglePopover(false);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="handleClickOutside"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<button
|
||||
v-if="count"
|
||||
class="h-6 px-2 rounded-md bg-n-alpha-2 gap-1.5 flex items-center"
|
||||
@click="handleButtonClick()"
|
||||
>
|
||||
<Icon :icon="icon" class="size-3.5 text-n-slate-12" />
|
||||
<span class="text-n-slate-12 text-sm">
|
||||
{{ count }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showPopover"
|
||||
class="top-full mt-1 ltr:left-0 rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak p-3 rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 pb-2">
|
||||
<Icon :icon="icon" class="size-3.5" />
|
||||
<span class="text-sm text-n-slate-12 font-medium">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-3 w-full text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4 w-full">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="flex items-center justify-between gap-2 min-w-0 w-full"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 w-full">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="size-4 text-n-slate-12 flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:title="item.name"
|
||||
:src="item.avatarUrl"
|
||||
:name="item.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<span
|
||||
:title="item.name"
|
||||
class="text-sm text-n-slate-12 truncate min-w-0"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.id"
|
||||
class="text-sm text-n-slate-11 flex-shrink-0"
|
||||
>
|
||||
{{ `#${item.id}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="item.email" class="text-sm text-n-slate-11 flex-shrink-0">
|
||||
{{ item.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,90 +0,0 @@
|
||||
<script setup>
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emptyStateMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const handleDelete = itemId => {
|
||||
emit('delete', itemId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-3 w-full text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length === 0 && emptyStateMessage"
|
||||
class="custom-dashed-border flex items-center justify-center py-6 w-full"
|
||||
>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ emptyStateMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col divide-y divide-n-weak">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 col-span-2">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="size-4 text-n-slate-12 flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:title="item.name"
|
||||
:src="item.avatarUrl"
|
||||
:name="item.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 truncate min-w-0">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2 col-span-1">
|
||||
<span
|
||||
:title="item.email || item.phoneNumber"
|
||||
class="text-sm text-n-slate-12 truncate min-w-0"
|
||||
>
|
||||
{{ item.email || item.phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 justify-end flex items-center">
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
slate
|
||||
ghost
|
||||
sm
|
||||
type="button"
|
||||
@click="handleDelete(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
|
||||
const props = defineProps({
|
||||
tagsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const excludedLabels = defineModel('excludedLabels', {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
|
||||
type: Number,
|
||||
default: 10,
|
||||
});
|
||||
|
||||
// Duration limits: 10 minutes to 999 days (in minutes)
|
||||
const MIN_DURATION_MINUTES = 10;
|
||||
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hoveredLabel = ref(null);
|
||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
const addedTags = computed(() =>
|
||||
props.tagsList
|
||||
.filter(label => excludedLabels.value.includes(label.name))
|
||||
.map(label => ({ id: label.id, title: label.name, ...label }))
|
||||
);
|
||||
|
||||
const filteredTags = computed(() =>
|
||||
props.tagsList.filter(
|
||||
label => !addedTags.value.some(tag => tag.id === label.id)
|
||||
)
|
||||
);
|
||||
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
|
||||
if (m % 60 === 0) return DURATION_UNITS.HOURS;
|
||||
return DURATION_UNITS.MINUTES;
|
||||
};
|
||||
|
||||
const onClickAddTag = tag => {
|
||||
excludedLabels.value = [...excludedLabels.value, tag.name];
|
||||
};
|
||||
|
||||
const onClickRemoveTag = tag => {
|
||||
excludedLabels.value = excludedLabels.value.filter(
|
||||
name => name !== tag.title
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowUnit.value = detectUnit(excludeOlderThanMinutes.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4 flex-col flex gap-6">
|
||||
<div class="flex flex-col items-start gap-1 py-1">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mb-0 text-n-slate-11 text-sm">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<div
|
||||
class="flex items-start gap-2 flex-wrap"
|
||||
@mouseleave="hoveredLabel = null"
|
||||
>
|
||||
<LabelItem
|
||||
v-for="tag in addedTags"
|
||||
:key="tag.id"
|
||||
:label="tag"
|
||||
:is-hovered="hoveredLabel === tag.id"
|
||||
class="h-8"
|
||||
@remove="onClickRemoveTag"
|
||||
@hover="hoveredLabel = tag.id"
|
||||
/>
|
||||
<AddDataDropdown
|
||||
:label="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.ADD_TAG'
|
||||
)
|
||||
"
|
||||
:search-placeholder="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.DROPDOWN.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:items="filteredTags"
|
||||
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
|
||||
@add="onClickAddTag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DURATION.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<div
|
||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model:unit="windowUnit"
|
||||
v-model:model-value="excludeOlderThanMinutes"
|
||||
:min="MIN_DURATION_MINUTES"
|
||||
:max="MAX_DURATION_MINUTES"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fairDistributionLimit = defineModel('fairDistributionLimit', {
|
||||
type: Number,
|
||||
default: 100,
|
||||
set(value) {
|
||||
return Number(value) || 0;
|
||||
},
|
||||
});
|
||||
|
||||
const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||
type: Number,
|
||||
default: 3600,
|
||||
set(value) {
|
||||
return Number(value) || 0;
|
||||
},
|
||||
});
|
||||
|
||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
|
||||
if (m % 60 === 0) return DURATION_UNITS.HOURS;
|
||||
return DURATION_UNITS.MINUTES;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowUnit.value = detectUnit(fairDistributionWindow.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-start xl:items-center flex-col md:flex-row gap-4 lg:gap-3 bg-n-solid-1 p-4 outline outline-1 outline-n-weak rounded-xl"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.INPUT_MAX'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
v-model="fairDistributionLimit"
|
||||
type="number"
|
||||
placeholder="100"
|
||||
max="100000"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex sm:flex-row flex-col items-start sm:items-center gap-4">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.DURATION'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model:model-value="fairDistributionWindow"
|
||||
v-model:unit="windowUnit"
|
||||
:min="10"
|
||||
:max="1438560"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete', 'add', 'update']);
|
||||
|
||||
const inboxCapacityLimits = defineModel('inboxCapacityLimits', {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
|
||||
const DEFAULT_CONVERSATION_LIMIT = 10;
|
||||
const MIN_CONVERSATION_LIMIT = 1;
|
||||
const MAX_CONVERSATION_LIMIT = 100000;
|
||||
|
||||
const selectedInboxIds = computed(
|
||||
() => new Set(inboxCapacityLimits.value.map(limit => limit.inboxId))
|
||||
);
|
||||
|
||||
const availableInboxes = computed(() =>
|
||||
props.inboxList.filter(
|
||||
inbox => inbox && !selectedInboxIds.value.has(inbox.id)
|
||||
)
|
||||
);
|
||||
|
||||
const isLimitValid = limit => {
|
||||
return (
|
||||
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
|
||||
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
|
||||
);
|
||||
};
|
||||
|
||||
const inboxMap = computed(
|
||||
() => new Map(props.inboxList.map(inbox => [inbox.id, inbox]))
|
||||
);
|
||||
|
||||
const handleAddInbox = inbox => {
|
||||
emit('add', {
|
||||
inboxId: inbox.id,
|
||||
conversationLimit: DEFAULT_CONVERSATION_LIMIT,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLimit = limitId => {
|
||||
emit('delete', limitId);
|
||||
};
|
||||
|
||||
const handleLimitChange = limit => {
|
||||
if (isLimitValid(limit)) {
|
||||
emit('update', limit);
|
||||
}
|
||||
};
|
||||
|
||||
const getInboxName = inboxId => {
|
||||
return inboxMap.value.get(inboxId)?.name || '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4 flex-col flex gap-3">
|
||||
<div class="flex items-center w-full gap-8 justify-between pt-1 pb-3">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.LABEL`) }}
|
||||
</label>
|
||||
|
||||
<AddDataDropdown
|
||||
:label="t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.ADD_BUTTON`)"
|
||||
:search-placeholder="
|
||||
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SELECT_INBOX`)
|
||||
"
|
||||
:items="availableInboxes"
|
||||
@add="handleAddInbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-3 w-full text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!inboxCapacityLimits.length"
|
||||
class="custom-dashed-border flex items-center justify-center py-6 w-full"
|
||||
>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.EMPTY_STATE`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-col flex gap-3">
|
||||
<div
|
||||
v-for="(limit, index) in inboxCapacityLimits"
|
||||
:key="limit.id || `temp-${index}`"
|
||||
class="flex flex-col xs:flex-row items-stretch gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full min-w-0"
|
||||
:title="getInboxName(limit.inboxId)"
|
||||
>
|
||||
<span class="truncate min-w-0">
|
||||
{{ getInboxName(limit.inboxId) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 w-full xs:w-auto">
|
||||
<div
|
||||
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-1 xs:flex-shrink-0 flex items-center min-w-0"
|
||||
:class="[
|
||||
!isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
|
||||
]"
|
||||
>
|
||||
<label
|
||||
class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2 truncate min-w-0 flex-shrink"
|
||||
:title="
|
||||
t(
|
||||
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
|
||||
<div class="h-5 w-px bg-n-weak" />
|
||||
|
||||
<input
|
||||
v-model.number="limit.conversationLimit"
|
||||
type="number"
|
||||
:min="MIN_CONVERSATION_LIMIT"
|
||||
:max="MAX_CONVERSATION_LIMIT"
|
||||
class="reset-base bg-transparent focus:outline-none min-w-16 w-24 text-sm flex-shrink-0"
|
||||
:class="[
|
||||
!isLimitValid(limit)
|
||||
? 'placeholder:text-n-ruby-9 !text-n-ruby-9'
|
||||
: 'placeholder:text-n-slate-10 text-n-slate-12',
|
||||
]"
|
||||
:placeholder="
|
||||
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SET_LIMIT`)
|
||||
"
|
||||
@blur="handleLimitChange(limit)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="flex-shrink-0"
|
||||
@click="handleRemoveLimit(limit.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,60 +0,0 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const handleChange = () => {
|
||||
if (!props.isActive) {
|
||||
emit('select', props.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
:class="[
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
|
||||
]"
|
||||
@click="handleChange"
|
||||
>
|
||||
<div class="absolute top-4 right-4">
|
||||
<input
|
||||
:id="`${id}`"
|
||||
:checked="isActive"
|
||||
:value="id"
|
||||
:name="id"
|
||||
type="radio"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-3 items-start">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
<script setup>
|
||||
import AddDataDropdown from '../AddDataDropdown.vue';
|
||||
|
||||
const mockInboxes = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
email: 'support@company.com',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
email: 'help@company.com',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
phoneNumber: '+1 555-0123',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Facebook Messenger',
|
||||
email: 'messenger@company.com',
|
||||
icon: 'i-lucide-facebook',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Twitter DM',
|
||||
email: 'twitter@company.com',
|
||||
icon: 'i-lucide-twitter',
|
||||
},
|
||||
];
|
||||
|
||||
const mockTags = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'urgent',
|
||||
color: '#ff4757',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'bug',
|
||||
color: '#ff6b6b',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'feature-request',
|
||||
color: '#4834d4',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'documentation',
|
||||
color: '#26de81',
|
||||
},
|
||||
];
|
||||
|
||||
const handleAdd = item => {
|
||||
console.log('Add item:', item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AddDataDropdown"
|
||||
:layout="{ type: 'grid', width: '500px' }"
|
||||
>
|
||||
<Variant title="Basic Usage - Inboxes">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
|
||||
<AddDataDropdown
|
||||
label="Add Inbox"
|
||||
search-placeholder="Search inboxes..."
|
||||
:items="mockInboxes"
|
||||
@add="handleAdd"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Basic Usage - Tags">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
|
||||
<AddDataDropdown
|
||||
label="Add Tag"
|
||||
search-placeholder="Search tags..."
|
||||
:items="mockTags"
|
||||
@add="handleAdd"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import BaseInfo from '../BaseInfo.vue';
|
||||
|
||||
const policyName = ref('Round Robin Policy');
|
||||
const description = ref(
|
||||
'Distributes conversations evenly among available agents'
|
||||
);
|
||||
const enabled = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/BaseInfo"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background">
|
||||
<BaseInfo
|
||||
v-model:policy-name="policyName"
|
||||
v-model:description="description"
|
||||
v-model:enabled="enabled"
|
||||
name-label="Policy Name"
|
||||
name-placeholder="Enter policy name"
|
||||
description-label="Description"
|
||||
description-placeholder="Enter policy description"
|
||||
status-label="Status"
|
||||
status-placeholder="Active"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
<script setup>
|
||||
import CardPopover from '../CardPopover.vue';
|
||||
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Facebook Messenger',
|
||||
icon: 'i-lucide-facebook',
|
||||
},
|
||||
];
|
||||
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Smith',
|
||||
email: 'john.smith@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sarah Johnson',
|
||||
email: 'sarah.johnson@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Mike Chen',
|
||||
email: 'mike.chen@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=3',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Emily Davis',
|
||||
email: 'emily.davis@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=4',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Alex Rodriguez',
|
||||
email: 'alex.rodriguez@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=5',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/CardPopover"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-96 items-start">
|
||||
<CardPopover
|
||||
:count="3"
|
||||
title="Added Inboxes"
|
||||
icon="i-lucide-inbox"
|
||||
:items="mockItems.slice(0, 3)"
|
||||
@fetch="() => console.log('Fetch triggered')"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-96 items-start">
|
||||
<CardPopover
|
||||
:count="3"
|
||||
title="Added Agents"
|
||||
icon="i-lucide-users-round"
|
||||
:items="mockUsers.slice(0, 3)"
|
||||
@fetch="() => console.log('Fetch triggered')"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
<script setup>
|
||||
import DataTable from '../DataTable.vue';
|
||||
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
email: 'support@company.com',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
email: 'help@company.com',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
phoneNumber: '+1 555-0123',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
];
|
||||
|
||||
const mockAgentList = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Jane Smith',
|
||||
email: 'jane.smith@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
];
|
||||
|
||||
const handleDelete = itemId => {
|
||||
console.log('Delete item:', itemId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/DataTable"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="With Data">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable
|
||||
:items="mockItems"
|
||||
:is-fetching="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Agents">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable
|
||||
:items="mockAgentList"
|
||||
:is-fetching="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Loading State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable :items="[]" is-fetching @delete="handleDelete" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Empty State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable
|
||||
:items="[]"
|
||||
:is-fetching="false"
|
||||
empty-state-message="No items found"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
<script setup>
|
||||
import ExclusionRules from '../ExclusionRules.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const mockTagsList = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'urgent',
|
||||
color: '#ff4757',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'bug',
|
||||
color: '#ff6b6b',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'feature-request',
|
||||
color: '#4834d4',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'documentation',
|
||||
color: '#26de81',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'enhancement',
|
||||
color: '#2ed573',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'question',
|
||||
color: '#ffa502',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'duplicate',
|
||||
color: '#747d8c',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'wontfix',
|
||||
color: '#57606f',
|
||||
},
|
||||
];
|
||||
|
||||
const excludedLabelsBasic = ref([]);
|
||||
const excludeOlderThanHoursBasic = ref(10);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/ExclusionRules"
|
||||
:layout="{ type: 'grid', width: '1200px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background h-[600px]">
|
||||
<ExclusionRules
|
||||
v-model:excluded-labels="excludedLabelsBasic"
|
||||
v-model:exclude-older-than-minutes="excludeOlderThanHoursBasic"
|
||||
:tags-list="mockTagsList"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import FairDistribution from '../FairDistribution.vue';
|
||||
|
||||
const fairDistributionLimit = ref(100);
|
||||
const fairDistributionWindow = ref(3600);
|
||||
const windowUnit = ref('minutes');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/FairDistribution"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background">
|
||||
<FairDistribution
|
||||
v-model:fair-distribution-limit="fairDistributionLimit"
|
||||
v-model:fair-distribution-window="fairDistributionWindow"
|
||||
v-model:window-unit="windowUnit"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
<script setup>
|
||||
import InboxCapacityLimits from '../InboxCapacityLimits.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const mockInboxList = [
|
||||
{
|
||||
value: 1,
|
||||
label: 'Website Support',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: 'Email Support',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: 'WhatsApp Business',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
label: 'Facebook Messenger',
|
||||
icon: 'i-lucide-facebook',
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: 'Twitter DM',
|
||||
icon: 'i-lucide-twitter',
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
label: 'Telegram',
|
||||
icon: 'i-lucide-send',
|
||||
},
|
||||
];
|
||||
|
||||
const inboxCapacityLimitsEmpty = ref([]);
|
||||
const inboxCapacityLimitsNew = ref([
|
||||
{ id: 1, inboxId: 1, conversationLimit: 5 },
|
||||
{ inboxId: null, conversationLimit: null },
|
||||
]);
|
||||
|
||||
const handleDelete = id => {
|
||||
console.log('Delete capacity limit:', id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/InboxCapacityLimits"
|
||||
:layout="{ type: 'grid', width: '900px' }"
|
||||
>
|
||||
<Variant title="Empty State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
|
||||
:inbox-list="mockInboxList"
|
||||
:is-fetching="false"
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Loading State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
|
||||
:inbox-list="mockInboxList"
|
||||
is-fetching
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With New Row and existing data">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsNew"
|
||||
:inbox-list="mockInboxList"
|
||||
:is-fetching="false"
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Interactive Demo">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
|
||||
:inbox-list="mockInboxList"
|
||||
:is-fetching="false"
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<div class="mt-4 p-4 bg-n-alpha-2 rounded-lg">
|
||||
<h4 class="text-sm font-medium mb-2">Current Limits:</h4>
|
||||
<pre class="text-xs">{{
|
||||
JSON.stringify(inboxCapacityLimitsEmpty, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import RadioCard from '../RadioCard.vue';
|
||||
|
||||
const selectedOption = ref('round_robin');
|
||||
|
||||
const handleSelect = value => {
|
||||
selectedOption.value = value;
|
||||
console.log('Selected:', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/RadioCard"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background space-y-4">
|
||||
<RadioCard
|
||||
id="round_robin"
|
||||
label="Round Robin"
|
||||
description="Distributes conversations evenly among all available agents in a rotating manner"
|
||||
:is-active="selectedOption === 'round_robin'"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
<RadioCard
|
||||
id="balanced"
|
||||
label="Balanced Assignment"
|
||||
description="Assigns conversations based on agent workload to maintain balance"
|
||||
:is-active="selectedOption === 'balanced'"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Active State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<RadioCard
|
||||
id="active_option"
|
||||
label="Active Option"
|
||||
description="This option is currently selected and active"
|
||||
is-active
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Inactive State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<RadioCard
|
||||
id="inactive_option"
|
||||
label="Inactive Option"
|
||||
description="This option is not selected and can be clicked to activate"
|
||||
is-active
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -76,8 +76,8 @@ const campaignStatus = computed(() => {
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { medium, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, medium);
|
||||
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, phoneNumber);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+5
-7
@@ -38,13 +38,11 @@ const handleClose = () => emit('close');
|
||||
|
||||
<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"
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
|
||||
>
|
||||
<div class="p-6 flex flex-col gap-6">
|
||||
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
|
||||
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+106
-15
@@ -9,7 +9,6 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import 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']);
|
||||
|
||||
@@ -19,9 +18,7 @@ const formState = {
|
||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||
labels: useMapGetter('labels/getLabels'),
|
||||
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
||||
getFilteredWhatsAppTemplates: useMapGetter(
|
||||
'inboxes/getFilteredWhatsAppTemplates'
|
||||
),
|
||||
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
@@ -33,7 +30,7 @@ const initialState = {
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
const templateParserRef = ref(null);
|
||||
const processedParams = ref({});
|
||||
|
||||
const rules = {
|
||||
title: { required, minLength: minLength(1) },
|
||||
@@ -70,7 +67,7 @@ const inboxOptions = computed(() =>
|
||||
|
||||
const templateOptions = computed(() => {
|
||||
if (!state.inboxId) return [];
|
||||
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
|
||||
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
|
||||
return templates.map(template => {
|
||||
// Create a more user-friendly label from template name
|
||||
const friendlyName = template.name
|
||||
@@ -91,6 +88,26 @@ const selectedTemplate = computed(() => {
|
||||
?.template;
|
||||
});
|
||||
|
||||
const templateString = computed(() => {
|
||||
if (!selectedTemplate.value) return '';
|
||||
try {
|
||||
return (
|
||||
selectedTemplate.value.components?.find(
|
||||
component => component.type === 'BODY'
|
||||
)?.text || ''
|
||||
);
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const processedString = computed(() => {
|
||||
if (!templateString.value) return '';
|
||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||
return processedParams.value[variable] || `{{${variable}}}`;
|
||||
});
|
||||
});
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
|
||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
||||
@@ -105,7 +122,8 @@ const formErrors = computed(() => ({
|
||||
}));
|
||||
|
||||
const hasRequiredTemplateParams = computed(() => {
|
||||
return templateParserRef.value?.v$?.$invalid === false || true;
|
||||
const params = Object.values(processedParams.value);
|
||||
return params.length === 0 || params.every(param => param.trim() !== '');
|
||||
});
|
||||
|
||||
const isSubmitDisabled = computed(
|
||||
@@ -117,18 +135,32 @@ const formatToUTCString = localDateTime =>
|
||||
|
||||
const resetState = () => {
|
||||
Object.assign(state, initialState);
|
||||
processedParams.value = {};
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const generateVariables = () => {
|
||||
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
||||
if (!matchedVariables) {
|
||||
processedParams.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
|
||||
processedParams.value = finalVars.reduce((acc, variable) => {
|
||||
acc[variable] = processedParams.value[variable] || '';
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const prepareCampaignDetails = () => {
|
||||
// Find the selected template to get its content
|
||||
const currentTemplate = selectedTemplate.value;
|
||||
const parserData = templateParserRef.value;
|
||||
|
||||
// Extract template content - this should be the template message body
|
||||
const templateContent = parserData?.renderedTemplate || '';
|
||||
const templateContent = templateString.value;
|
||||
|
||||
// Prepare template_params object with the same structure as used in contacts
|
||||
const templateParams = {
|
||||
@@ -136,7 +168,7 @@ const prepareCampaignDetails = () => {
|
||||
namespace: currentTemplate?.namespace || '',
|
||||
category: currentTemplate?.category || 'UTILITY',
|
||||
language: currentTemplate?.language || 'en_US',
|
||||
processed_params: parserData?.processedParams || {},
|
||||
processed_params: processedParams.value,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -166,6 +198,15 @@ watch(
|
||||
() => state.inboxId,
|
||||
() => {
|
||||
state.templateId = null;
|
||||
processedParams.value = {};
|
||||
}
|
||||
);
|
||||
|
||||
// Generate variables when template changes
|
||||
watch(
|
||||
() => state.templateId,
|
||||
() => {
|
||||
generateVariables();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -213,12 +254,62 @@ watch(
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Template Parser -->
|
||||
<WhatsAppTemplateParser
|
||||
<!-- Template Preview -->
|
||||
<div
|
||||
v-if="selectedTemplate"
|
||||
ref="templateParserRef"
|
||||
:template="selectedTemplate"
|
||||
/>
|
||||
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ selectedTemplate.name }}
|
||||
</h3>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
|
||||
{{ selectedTemplate.language || 'en' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="rounded-md bg-n-alpha-black3">
|
||||
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
||||
{{ processedString }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
|
||||
{{ selectedTemplate.category || 'UTILITY' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Variables -->
|
||||
<div
|
||||
v-if="Object.keys(processedParams).length > 0"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(value, key) in processedParams"
|
||||
:key="key"
|
||||
class="flex gap-2 items-center"
|
||||
>
|
||||
<Input
|
||||
v-model="processedParams[key]"
|
||||
type="text"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
|
||||
variable: key,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
|
||||
@@ -86,8 +86,8 @@ const handleLabelAction = async ({ value }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLabel = label => {
|
||||
return handleLabelAction({ value: label.id });
|
||||
const handleRemoveLabel = labelId => {
|
||||
return handleLabelAction({ value: labelId });
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -98,7 +98,6 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, useSlots, ref } from 'vue';
|
||||
import { computed, useSlots } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
@@ -26,8 +24,6 @@ const { t } = useI18n();
|
||||
const slots = useSlots();
|
||||
const route = useRoute();
|
||||
|
||||
const isContactSidebarOpen = ref(false);
|
||||
|
||||
const contactId = computed(() => route.params.contactId);
|
||||
|
||||
const selectedContactName = computed(() => {
|
||||
@@ -60,15 +56,6 @@ const handleBreadcrumbClick = () => {
|
||||
const toggleBlock = () => {
|
||||
emit('toggleBlock', isContactBlocked.value);
|
||||
};
|
||||
|
||||
const handleConversationSidebarToggle = () => {
|
||||
isContactSidebarOpen.value = !isContactSidebarOpen.value;
|
||||
};
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
if (!isContactSidebarOpen.value) return;
|
||||
isContactSidebarOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -80,9 +67,7 @@ const closeMobileSidebar = () => {
|
||||
>
|
||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||
<div
|
||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<Breadcrumb
|
||||
:items="breadcrumbItems"
|
||||
@click="handleBreadcrumbClick"
|
||||
@@ -100,11 +85,6 @@ const closeMobileSidebar = () => {
|
||||
:disabled="isUpdating"
|
||||
@click="toggleBlock"
|
||||
/>
|
||||
<VoiceCallButton
|
||||
:phone="selectedContact?.phoneNumber"
|
||||
:label="$t('CONTACT_PANEL.CALL')"
|
||||
size="sm"
|
||||
/>
|
||||
<ComposeConversation :contact-id="contactId">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
@@ -125,65 +105,11 @@ const closeMobileSidebar = () => {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Desktop sidebar -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
+54
-57
@@ -34,13 +34,13 @@ const emit = defineEmits([
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
>
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<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 w-full">
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2">
|
||||
<Input
|
||||
:model-value="searchValue"
|
||||
type="search"
|
||||
@@ -48,7 +48,6 @@ const emit = defineEmits([
|
||||
: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',
|
||||
]"
|
||||
class="w-full"
|
||||
@input="emit('search', $event.target.value)"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -59,66 +58,64 @@ const emit = defineEmits([
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<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>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isLabelView && !isActiveView" class="relative">
|
||||
<Button
|
||||
v-if="
|
||||
hasActiveFilters &&
|
||||
!isSegmentsView &&
|
||||
!isLabelView &&
|
||||
!isActiveView
|
||||
id="toggleContactsFilterButton"
|
||||
:icon="
|
||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||
"
|
||||
icon="i-lucide-save"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="relative w-8"
|
||||
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')"
|
||||
/>
|
||||
@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>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
<Button
|
||||
v-if="
|
||||
hasActiveFilters &&
|
||||
!isSegmentsView &&
|
||||
!isLabelView &&
|
||||
!isActiveView
|
||||
"
|
||||
icon="i-lucide-save"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('createSegment')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isSegmentsView && !isLabelView && !isActiveView"
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('deleteSegment')"
|
||||
/>
|
||||
<ContactSortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<ContactMoreActions
|
||||
@add="emit('add')"
|
||||
@import="emit('import')"
|
||||
@export="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+11
-16
@@ -62,7 +62,6 @@ const segmentsQuery = ref({});
|
||||
|
||||
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const hasActiveSegments = computed(
|
||||
() => props.activeSegment && props.segmentsId !== 0
|
||||
);
|
||||
@@ -216,7 +215,6 @@ const setParamsForEditSegmentModal = () => {
|
||||
countries,
|
||||
filterTypes: contactFilterItems,
|
||||
allCustomAttributes: useSnakeCase(contactAttributes.value),
|
||||
labels: labels.value || [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -293,20 +291,17 @@ defineExpose({
|
||||
@delete-segment="openDeleteSegmentDialog"
|
||||
>
|
||||
<template #filter>
|
||||
<div
|
||||
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
|
||||
>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
v-model="appliedFilter"
|
||||
:segment-name="activeSegmentName"
|
||||
:is-segment-view="hasActiveSegments"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</div>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
v-model="appliedFilter"
|
||||
:segment-name="activeSegmentName"
|
||||
:is-segment-view="hasActiveSegments"
|
||||
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</template>
|
||||
</ContactsHeader>
|
||||
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
@@ -96,7 +96,10 @@ const openFilter = () => {
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<footer
|
||||
v-if="showPaginationFooter"
|
||||
class="sticky bottom-0 z-10 px-4 pb-4"
|
||||
>
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@ export default [
|
||||
city: 'Los Angeles',
|
||||
country: 'United States',
|
||||
description:
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Lumora.",
|
||||
companyName: 'Lumora',
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
|
||||
companyName: 'Chatwoot',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'candice-dev',
|
||||
@@ -16,7 +16,7 @@ export default [
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice.matherson@lumora.com',
|
||||
email: 'candice.matherson@chatwoot.com',
|
||||
id: 22,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+14155552671',
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
phone: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
size: { type: String, default: 'sm' },
|
||||
tooltipLabel: { type: String, default: '' },
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
const attrs = useAttrs();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const onClick = () => {
|
||||
if (voiceInboxes.value.length > 1) {
|
||||
dialogRef.value?.open();
|
||||
return;
|
||||
}
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
};
|
||||
|
||||
const onPickInbox = () => {
|
||||
// Placeholder until actual call wiring happens
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="contents">
|
||||
<Button
|
||||
v-if="shouldRender"
|
||||
v-tooltip.top-end="tooltipLabel || null"
|
||||
v-bind="attrs"
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
:size="size"
|
||||
@click="onClick"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
v-if="shouldRender && voiceInboxes.length > 1"
|
||||
ref="dialogRef"
|
||||
:title="$t('CONTACT_PANEL.VOICE_INBOX_PICKER.TITLE')"
|
||||
show-cancel-button
|
||||
:show-confirm-button="false"
|
||||
width="md"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-for="inbox in voiceInboxes"
|
||||
:key="inbox.id"
|
||||
type="button"
|
||||
class="flex items-center justify-between w-full px-4 py-2 text-left rounded-lg hover:bg-n-alpha-2"
|
||||
@click="onPickInbox(inbox)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-ri-phone-fill text-n-slate-10" />
|
||||
<span class="text-sm text-n-slate-12">{{ inbox.name }}</span>
|
||||
</div>
|
||||
<span v-if="inbox.phone_number" class="text-xs text-n-slate-10">
|
||||
{{ inbox.phone_number }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</span>
|
||||
</template>
|
||||
-1
@@ -47,7 +47,6 @@ const unreadMessagesCount = computed(() => {
|
||||
</p>
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
v-if="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
|
||||
-1
@@ -96,7 +96,6 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
|
||||
+2
-2
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
const { phoneNumber, channelType } = inbox.value;
|
||||
return getInboxIconByType(channelType, phoneNumber);
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
|
||||
+6
-16
@@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, helpers, url } from '@vuelidate/validators';
|
||||
import { isValidSlug } from 'shared/helpers/Validators';
|
||||
import { required, minLength, helpers } from '@vuelidate/validators';
|
||||
import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@@ -51,20 +51,12 @@ const originalState = reactive({ ...state });
|
||||
|
||||
const liveChatWidgets = computed(() => {
|
||||
const inboxes = store.getters['inboxes/getInboxes'];
|
||||
const widgetOptions = inboxes
|
||||
return inboxes
|
||||
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
|
||||
.map(inbox => ({
|
||||
value: inbox.id,
|
||||
label: inbox.name,
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
value: '',
|
||||
label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'),
|
||||
},
|
||||
...widgetOptions,
|
||||
];
|
||||
});
|
||||
|
||||
const rules = {
|
||||
@@ -79,7 +71,7 @@ const rules = {
|
||||
isValidSlug
|
||||
),
|
||||
},
|
||||
homePageLink: { url },
|
||||
homePageLink: { shouldBeUrl },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
@@ -116,7 +108,7 @@ watch(
|
||||
widgetColor: newVal.color,
|
||||
homePageLink: newVal.homepage_link,
|
||||
slug: newVal.slug,
|
||||
liveChatWidgetInboxId: newVal.inbox?.id || '',
|
||||
liveChatWidgetInboxId: newVal.inbox?.id,
|
||||
});
|
||||
if (newVal.logo) {
|
||||
const {
|
||||
@@ -323,9 +315,7 @@ const handleAvatarDelete = () => {
|
||||
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||
>
|
||||
|
||||
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
|
||||
const inbox = computed(() => props.stateInbox);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
const { phoneNumber, channelType } = inbox.value;
|
||||
return getInboxIconByType(channelType, phoneNumber);
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
|
||||
@@ -15,7 +15,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['remove', 'hover']);
|
||||
|
||||
const handleRemoveLabel = () => {
|
||||
emit('remove', props.label);
|
||||
emit('remove', props.label?.id);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
@@ -45,7 +45,6 @@ const handleMouseEnter = () => {
|
||||
<Button
|
||||
class="transition-opacity duration-200 !h-7 ltr:rounded-r-md rtl:rounded-l-md ltr:rounded-l-none rtl:rounded-r-none w-6 bg-transparent"
|
||||
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
|
||||
type="button"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
processContactableInboxes,
|
||||
mergeInboxDetails,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||
|
||||
@@ -39,16 +37,9 @@ const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||
|
||||
const isSmallScreen = computed(
|
||||
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
||||
);
|
||||
|
||||
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
|
||||
|
||||
const contacts = ref([]);
|
||||
const selectedContact = ref(null);
|
||||
const targetInbox = ref(null);
|
||||
@@ -76,7 +67,7 @@ const directUploadsEnabled = computed(
|
||||
const activeContact = computed(() => contactById.value(props.contactId));
|
||||
|
||||
const composePopoverClass = computed(() => {
|
||||
if (viewInModal.value) return '';
|
||||
if (props.isModal) return '';
|
||||
|
||||
return props.alignPosition === 'right'
|
||||
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
||||
@@ -188,18 +179,14 @@ const toggle = () => {
|
||||
|
||||
watch(
|
||||
activeContact,
|
||||
(currentContact, previousContact) => {
|
||||
if (currentContact && props.contactId) {
|
||||
// Reset on contact change
|
||||
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
|
||||
|
||||
() => {
|
||||
if (activeContact.value && props.contactId) {
|
||||
const contactInboxes = activeContact.value?.contactInboxes || [];
|
||||
// First process the contactable inboxes to get the right structure
|
||||
const processedInboxes = processContactableInboxes(
|
||||
currentContact.contactInboxes || []
|
||||
);
|
||||
const processedInboxes = processContactableInboxes(contactInboxes);
|
||||
// Then Merge processedInboxes with the inboxes list
|
||||
selectedContact.value = {
|
||||
...currentContact,
|
||||
...activeContact.value,
|
||||
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
|
||||
};
|
||||
}
|
||||
@@ -215,7 +202,7 @@ const handleClickOutside = () => {
|
||||
};
|
||||
|
||||
const onModalBackdropClick = () => {
|
||||
if (!viewInModal.value) return;
|
||||
if (!props.isModal) return;
|
||||
handleClickOutside();
|
||||
};
|
||||
|
||||
@@ -244,7 +231,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
]"
|
||||
class="relative"
|
||||
:class="{
|
||||
'z-50': showComposeNewConversation && !viewInModal,
|
||||
'z-40': showComposeNewConversation,
|
||||
}"
|
||||
>
|
||||
<slot
|
||||
@@ -256,12 +243,12 @@ useKeyboardEvents(keyboardEvents);
|
||||
v-if="showComposeNewConversation"
|
||||
:class="{
|
||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
||||
viewInModal,
|
||||
isModal,
|
||||
}"
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<ComposeNewConversationForm
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
|
||||
+8
-32
@@ -11,14 +11,12 @@ import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
channelType: { type: String, default: '' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
@@ -27,14 +25,12 @@ const props = defineProps({
|
||||
hasNoInbox: { type: Boolean, default: false },
|
||||
isDropdownActive: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
inboxId: { type: Number, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'discard',
|
||||
'sendMessage',
|
||||
'sendWhatsappMessage',
|
||||
'sendTwilioMessage',
|
||||
'insertEmoji',
|
||||
'addSignature',
|
||||
'removeSignature',
|
||||
@@ -66,20 +62,6 @@ const sendWithSignature = computed(() => {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
});
|
||||
|
||||
const showTwilioContentTemplates = computed(() => {
|
||||
return props.isTwilioWhatsAppInbox && props.inboxId;
|
||||
});
|
||||
|
||||
const shouldShowEmojiButton = computed(() => {
|
||||
return (
|
||||
!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox && !props.hasNoInbox
|
||||
);
|
||||
});
|
||||
|
||||
const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const setSignature = () => {
|
||||
if (signatureToApply.value) {
|
||||
if (sendWithSignature.value) {
|
||||
@@ -142,7 +124,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('enter') &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isWhatsappInbox &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -153,7 +135,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('cmd_enter') &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isWhatsappInbox &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -168,20 +150,14 @@ useKeyboardEvents(keyboardEvents);
|
||||
<div
|
||||
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<WhatsAppOptions
|
||||
v-if="isWhatsappInbox"
|
||||
:inbox-id="inboxId"
|
||||
:message-templates="messageTemplates"
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<ContentTemplateSelector
|
||||
v-if="showTwilioContentTemplates"
|
||||
:inbox-id="inboxId"
|
||||
@send-message="emit('sendTwilioMessage', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldShowEmojiButton"
|
||||
v-if="!isWhatsappInbox && !hasNoInbox"
|
||||
v-on-click-outside="() => (isEmojiPickerOpen = false)"
|
||||
class="relative"
|
||||
>
|
||||
@@ -194,7 +170,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
|
||||
class="left-0 top-full mt-1.5"
|
||||
:on-click="onClickInsertEmoji"
|
||||
/>
|
||||
</div>
|
||||
@@ -221,7 +197,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@@ -230,7 +206,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
||||
variant="faded"
|
||||
@@ -240,7 +216,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@click="emit('discard')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isRegularMessageMode"
|
||||
v-if="!isWhatsappInbox"
|
||||
:label="sendButtonLabel"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ const removeAttachment = id => {
|
||||
variant="ghost"
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
class="absolute top-1 ltr:right-1 rtl:left-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
|
||||
class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
|
||||
@click="removeAttachment(attachment.resource.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+5
-44
@@ -74,9 +74,6 @@ const inboxTypes = computed(() => ({
|
||||
isTwilioSMS:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
|
||||
props.targetInbox?.medium === 'sms',
|
||||
isTwilioWhatsapp:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
|
||||
props.targetInbox?.medium === 'whatsapp',
|
||||
}));
|
||||
|
||||
const whatsappMessageTemplates = computed(() =>
|
||||
@@ -148,21 +145,10 @@ const isAnyDropdownActive = computed(() => {
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
const hasAlphabet = Array.from(query).some(char => {
|
||||
const lower = char.toLowerCase();
|
||||
const upper = char.toUpperCase();
|
||||
return lower !== upper;
|
||||
emit('searchContacts', {
|
||||
keys: ['email', 'phone_number', 'name'],
|
||||
query: value,
|
||||
});
|
||||
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
|
||||
|
||||
const keys = ['email', 'phone_number', 'name'].filter(key => {
|
||||
if (key === 'phone_number' && hasAlphabet) return false;
|
||||
if (key === 'name' && isEmailLike) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
emit('searchContacts', { keys, query: value });
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
@@ -275,33 +261,11 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
isFromWhatsApp: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendTwilioMessage = async ({ message, templateParams }) => {
|
||||
const twilioMessagePayload = prepareWhatsAppMessagePayload({
|
||||
targetInbox: props.targetInbox,
|
||||
selectedContact: props.selectedContact,
|
||||
message,
|
||||
templateParams,
|
||||
currentUser: props.currentUser,
|
||||
});
|
||||
await emit('createConversation', {
|
||||
payload: twilioMessagePayload,
|
||||
isFromWhatsApp: true,
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowMessageEditor = computed(() => {
|
||||
return (
|
||||
!inboxTypes.value.isWhatsapp &&
|
||||
!showNoInboxAlert.value &&
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
||||
>
|
||||
<ContactSelector
|
||||
:contacts="contacts"
|
||||
@@ -347,7 +311,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
/>
|
||||
|
||||
<MessageEditor
|
||||
v-if="shouldShowMessageEditor"
|
||||
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@@ -367,13 +331,11 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
:channel-type="inboxChannelType"
|
||||
:is-loading="isCreating"
|
||||
:disable-send-button="isCreating"
|
||||
:has-selected-inbox="!!targetInbox"
|
||||
:inbox-id="targetInbox?.id"
|
||||
:has-no-inbox="showNoInboxAlert"
|
||||
:is-dropdown-active="isAnyDropdownActive"
|
||||
:message-signature="messageSignature"
|
||||
@@ -384,7 +346,6 @@ const shouldShowMessageEditor = computed(() => {
|
||||
@discard="$emit('discard')"
|
||||
@send-message="handleSendMessage"
|
||||
@send-whatsapp-message="handleSendWhatsappMessage"
|
||||
@send-twilio-message="handleSendTwilioMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
<script setup>
|
||||
import ContentTemplateParser from 'dashboard/components-next/content-templates/ContentTemplateParser.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">
|
||||
<ContentTemplateParser
|
||||
: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('CONTENT_TEMPLATES.FORM.BACK_BUTTON')"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full font-medium"
|
||||
@click="goBack"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.FORM.SEND_MESSAGE_BUTTON')"
|
||||
class="w-full font-medium"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContentTemplateParser>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
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 Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ContentTemplateForm from './ContentTemplateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const inbox = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const contentTemplates = computed(() => {
|
||||
const inboxData = inbox.value(props.inboxId);
|
||||
return inboxData?.content_templates?.templates || [];
|
||||
});
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return contentTemplates.value.filter(
|
||||
template =>
|
||||
template.friendly_name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.value.toLowerCase()) &&
|
||||
template.status === 'approved'
|
||||
);
|
||||
});
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
searchQuery.value = '';
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = template => {
|
||||
emit('sendMessage', template);
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
icon="i-ph-whatsapp-logo"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
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)"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+1
-1
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
|
||||
<DropdownMenu
|
||||
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||
:menu-items="contactableInboxesList"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||
@action="emit('handleInboxAction', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+26
-19
@@ -1,25 +1,23 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
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 WhatsappTemplate from './WhatsappTemplate.vue';
|
||||
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
messageTemplates: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const getFilteredWhatsAppTemplates = useMapGetter(
|
||||
'inboxes/getFilteredWhatsAppTemplates'
|
||||
);
|
||||
|
||||
// TODO: Remove this when we support all formats
|
||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
@@ -27,7 +25,19 @@ const selectedTemplate = ref(null);
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const whatsAppTemplateMessages = computed(() => {
|
||||
return getFilteredWhatsAppTemplates.value(props.inboxId);
|
||||
// Add null check and ensure it's an array
|
||||
const templates = Array.isArray(props.messageTemplates)
|
||||
? props.messageTemplates
|
||||
: [];
|
||||
|
||||
// TODO: Remove the last filter when we support all formats
|
||||
return templates
|
||||
.filter(template => template?.status?.toLowerCase() === 'approved')
|
||||
.filter(template => {
|
||||
return template?.components?.every(component => {
|
||||
return !formatsToRemove.includes(component.format);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
@@ -74,13 +84,10 @@ const handleSendMessage = template => {
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
@@ -89,13 +96,13 @@ const handleSendMessage = template => {
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||
@@ -104,12 +111,12 @@ const handleSendMessage = template => {
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
<p class="w-full pt-2 text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<WhatsappTemplate
|
||||
<WhatsappTemplateParser
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user