Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c80ab26ad9 | ||
|
|
e523db12f4 | ||
|
|
8bacbd2b23 | ||
|
|
8aa37907c0 |
+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,100 +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 \
|
||||
spec/models/application_record_external_credentials_encryption_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/
|
||||
@@ -94,8 +94,3 @@ yarn-debug.log*
|
||||
.vscode
|
||||
.claude/settings.local.json
|
||||
.cursor
|
||||
CLAUDE.local.md
|
||||
|
||||
# Histoire deployment
|
||||
.netlify
|
||||
.histoire
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ Metrics/MethodLength:
|
||||
- 'enterprise/lib/captain/agent.rb'
|
||||
|
||||
RSpec/ExampleLength:
|
||||
Max: 50
|
||||
Max: 25
|
||||
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
@@ -336,4 +336,4 @@ FactoryBot/RedundantFactoryOption:
|
||||
Enabled: false
|
||||
|
||||
FactoryBot/FactoryAssociationWithStrategy:
|
||||
Enabled: false
|
||||
Enabled: false
|
||||
@@ -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'
|
||||
|
||||
@@ -187,10 +179,7 @@ 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 'ai-agents', '>= 0.2.1'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
@@ -220,8 +209,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 +218,6 @@ group :test do
|
||||
gem 'webmock'
|
||||
# test profiling
|
||||
gem 'test-prof'
|
||||
gem 'simplecov_json_formatter', require: false
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
@@ -256,7 +242,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
|
||||
|
||||
+94
-182
@@ -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
|
||||
@@ -126,7 +126,7 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.4.3)
|
||||
ai-agents (0.2.1)
|
||||
ruby_llm (~> 1.3)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
@@ -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.3)
|
||||
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,16 +714,13 @@ 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)
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.5.1)
|
||||
ruby_llm (1.3.1)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
@@ -801,7 +729,6 @@ GEM
|
||||
faraday-retry (>= 1)
|
||||
marcel (~> 1.0)
|
||||
zeitwerk (~> 2)
|
||||
ruby_llm-schema (0.1.0)
|
||||
ruby_parser (3.20.0)
|
||||
sexp_processor (~> 4.16)
|
||||
sass (3.7.4)
|
||||
@@ -821,9 +748,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 +794,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 +828,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)
|
||||
@@ -935,7 +854,7 @@ GEM
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.0.4)
|
||||
uri (1.0.3)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
@@ -962,6 +881,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)
|
||||
@@ -990,7 +910,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.4.3)
|
||||
ai-agents (>= 0.2.1)
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
@@ -1007,11 +927,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 +939,6 @@ DEPENDENCIES
|
||||
facebook-messenger
|
||||
factory_bot_rails (>= 6.4.3)
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
@@ -1061,8 +979,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
|
||||
@@ -1088,10 +1004,8 @@ DEPENDENCIES
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
ruby-openai
|
||||
ruby_llm-schema
|
||||
scout_apm
|
||||
scss_lint
|
||||
searchkick
|
||||
seed_dump
|
||||
sentry-rails (>= 5.19.0)
|
||||
sentry-ruby
|
||||
@@ -1101,9 +1015,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 +1024,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
|
||||
@@ -7,7 +7,6 @@ class Messages::MessageBuilder
|
||||
@private = params[:private] || false
|
||||
@conversation = conversation
|
||||
@user = user
|
||||
@account = conversation.account
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
@@ -21,9 +20,6 @@ class Messages::MessageBuilder
|
||||
@message = @conversation.messages.build(message_params)
|
||||
process_attachments
|
||||
process_emails
|
||||
# When the message has no quoted content, it will just be rendered as a regular message
|
||||
# The frontend is equipped to handle this case
|
||||
process_email_content if @account.feature_enabled?(:quoted_email_reply)
|
||||
@message.save!
|
||||
@message
|
||||
end
|
||||
@@ -96,14 +92,6 @@ class Messages::MessageBuilder
|
||||
@message.content_attributes[:to_emails] = to_emails
|
||||
end
|
||||
|
||||
def process_email_content
|
||||
return unless should_process_email_content?
|
||||
|
||||
@message.content_attributes ||= {}
|
||||
email_attributes = build_email_attributes
|
||||
@message.content_attributes[:email] = email_attributes
|
||||
end
|
||||
|
||||
def process_email_string(email_string)
|
||||
return [] if email_string.blank?
|
||||
|
||||
@@ -165,71 +153,4 @@ class Messages::MessageBuilder
|
||||
source_id: @params[:source_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
end
|
||||
|
||||
def email_inbox?
|
||||
@conversation.inbox&.inbox_type == 'Email'
|
||||
end
|
||||
|
||||
def should_process_email_content?
|
||||
email_inbox? && !@private && @message.content.present?
|
||||
end
|
||||
|
||||
def build_email_attributes
|
||||
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
||||
normalized_content = normalize_email_body(@message.content)
|
||||
|
||||
# Use custom HTML content if provided, otherwise generate from message content
|
||||
email_attributes[:html_content] = if custom_email_content_provided?
|
||||
build_custom_html_content
|
||||
else
|
||||
build_html_content(normalized_content)
|
||||
end
|
||||
|
||||
email_attributes[:text_content] = build_text_content(normalized_content)
|
||||
email_attributes
|
||||
end
|
||||
|
||||
def build_html_content(normalized_content)
|
||||
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
|
||||
rendered_html = render_email_html(normalized_content)
|
||||
html_content[:full] = rendered_html
|
||||
html_content[:reply] = rendered_html
|
||||
html_content
|
||||
end
|
||||
|
||||
def build_text_content(normalized_content)
|
||||
text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
|
||||
text_content[:full] = normalized_content
|
||||
text_content[:reply] = normalized_content
|
||||
text_content
|
||||
end
|
||||
|
||||
def ensure_indifferent_access(hash)
|
||||
return {} if hash.blank?
|
||||
|
||||
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
||||
end
|
||||
|
||||
def normalize_email_body(content)
|
||||
content.to_s.gsub("\r\n", "\n")
|
||||
end
|
||||
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
end
|
||||
|
||||
def custom_email_content_provided?
|
||||
@params[:email_html_content].present?
|
||||
end
|
||||
|
||||
def build_custom_html_content
|
||||
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
|
||||
|
||||
html_content[:full] = @params[:email_html_content]
|
||||
html_content[:reply] = @params[:email_html_content]
|
||||
|
||||
html_content
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,28 +10,10 @@ class V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@@ -42,6 +24,14 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def fetch_average_time(event_name)
|
||||
get_grouped_average(reporting_events.where(name: event_name))
|
||||
end
|
||||
|
||||
def fetch_resolved_count
|
||||
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
# Override this method
|
||||
end
|
||||
@@ -50,6 +40,10 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def get_grouped_average(events)
|
||||
events.group(group_by_key).average(average_value_key)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
|
||||
@@ -13,7 +13,10 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
authorize @conversation, :show?
|
||||
authorize @conversation.inbox, :show?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -160,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
|
||||
authorize @conversation, :show?
|
||||
authorize @conversation.inbox, :show?
|
||||
end
|
||||
|
||||
def 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
|
||||
@@ -4,8 +4,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
before_action :check_authorization, except: [:show]
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@@ -71,22 +70,16 @@ 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 }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -98,12 +91,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
@@ -198,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')
|
||||
|
||||
@@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
|
||||
private
|
||||
|
||||
def authorize_request
|
||||
authorize @conversation, :show?
|
||||
authorize @conversation.inbox, :show?
|
||||
end
|
||||
|
||||
def render_response(response)
|
||||
|
||||
@@ -85,8 +85,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,4 +1,5 @@
|
||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
before_action :validate_feature_enabled!
|
||||
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
||||
|
||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
@@ -64,6 +65,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
|
||||
|
||||
@@ -14,7 +14,6 @@ module AccessTokenAuthHelper
|
||||
ensure_access_token
|
||||
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
|
||||
|
||||
# NOTE: This ensures that current_user is set and available for the rest of the controller actions
|
||||
@resource = @access_token.owner
|
||||
Current.user = @resource if allowed_current_user_type?(@resource)
|
||||
end
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -19,19 +19,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
|
||||
end
|
||||
|
||||
def sign_in_user_on_mobile
|
||||
@resource.skip_confirmation! if confirmable_enabled?
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
# that will log them in
|
||||
encoded_email = ERB::Util.url_encode(@resource.email)
|
||||
params = { email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token }.to_query
|
||||
|
||||
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
|
||||
redirect_to "#{mobile_deep_link_base}://auth/saml?#{params}", allow_other_host: true
|
||||
end
|
||||
|
||||
def sign_up_user
|
||||
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
|
||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||
@@ -60,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?
|
||||
@@ -86,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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -1,36 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainCustomTools extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/custom_tools', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: { page, searchKey },
|
||||
});
|
||||
}
|
||||
|
||||
show(id) {
|
||||
return axios.get(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
create(data = {}) {
|
||||
return axios.post(this.url, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
|
||||
update(id, data = {}) {
|
||||
return axios.put(`${this.url}/${id}`, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainCustomTools();
|
||||
@@ -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,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import ApiClient from './ApiClient';
|
||||
import { CHANGELOG_API_URL } from 'shared/constants/links';
|
||||
|
||||
class ChangelogApi extends ApiClient {
|
||||
constructor() {
|
||||
super('changelog', { apiVersion: 'v1' });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
fetchFromHub() {
|
||||
return axios.get(CHANGELOG_API_URL);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChangelogApi();
|
||||
@@ -1,14 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class InboxHealthAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
@@ -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>
|
||||
@@ -86,8 +86,8 @@ const handleLabelAction = async ({ value }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLabel = label => {
|
||||
return handleLabelAction({ value: label.id });
|
||||
const handleRemoveLabel = labelId => {
|
||||
return handleLabelAction({ value: labelId });
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -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
-10
@@ -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 = {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-28
@@ -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 },
|
||||
@@ -34,7 +32,6 @@ const emit = defineEmits([
|
||||
'discard',
|
||||
'sendMessage',
|
||||
'sendWhatsappMessage',
|
||||
'sendTwilioMessage',
|
||||
'insertEmoji',
|
||||
'addSignature',
|
||||
'removeSignature',
|
||||
@@ -66,20 +63,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 +125,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('enter') &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isWhatsappInbox &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -153,7 +136,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('cmd_enter') &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isWhatsappInbox &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -175,13 +158,8 @@ useKeyboardEvents(keyboardEvents);
|
||||
: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 +172,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
|
||||
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
|
||||
:on-click="onClickInsertEmoji"
|
||||
/>
|
||||
</div>
|
||||
@@ -221,7 +199,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@@ -240,7 +218,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@click="emit('discard')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isRegularMessageMode"
|
||||
v-if="!isWhatsappInbox"
|
||||
:label="sendButtonLabel"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
|
||||
+4
-42
@@ -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,28 +261,6 @@ 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>
|
||||
@@ -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,7 +331,6 @@ 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"
|
||||
@@ -384,7 +347,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>
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'back']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const processedParams = ref({});
|
||||
|
||||
const templateName = computed(() => {
|
||||
return props.template?.name || '';
|
||||
});
|
||||
|
||||
const templateString = computed(() => {
|
||||
return props.template?.components?.find(
|
||||
component => component.type === 'BODY'
|
||||
).text;
|
||||
});
|
||||
|
||||
const processVariable = str => {
|
||||
return str.replace(/{{|}}/g, '');
|
||||
};
|
||||
|
||||
const processedString = computed(() => {
|
||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||
const variableKey = processVariable(variable);
|
||||
return processedParams.value[variableKey] || `{{${variable}}}`;
|
||||
});
|
||||
});
|
||||
|
||||
const processedStringWithVariableHighlight = computed(() => {
|
||||
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
|
||||
|
||||
return variables.reduce((result, variable) => {
|
||||
const variableKey = processVariable(variable);
|
||||
const value = processedParams.value[variableKey] || variable;
|
||||
return result.replace(
|
||||
variable,
|
||||
`<span class="break-all text-n-slate-12">${value}</span>`
|
||||
);
|
||||
}, templateString.value);
|
||||
});
|
||||
|
||||
const rules = computed(() => {
|
||||
const paramRules = {};
|
||||
Object.keys(processedParams.value).forEach(key => {
|
||||
paramRules[key] = { required: requiredIf(true) };
|
||||
});
|
||||
return {
|
||||
processedParams: paramRules,
|
||||
};
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(rules, { processedParams });
|
||||
|
||||
const getFieldErrorType = key => {
|
||||
if (!v$.value.processedParams[key]?.$error) return 'info';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const generateVariables = () => {
|
||||
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
||||
if (!matchedVariables) return;
|
||||
|
||||
const finalVars = matchedVariables.map(i => processVariable(i));
|
||||
processedParams.value = finalVars.reduce((acc, variable) => {
|
||||
acc[variable] = '';
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
const isValid = await v$.value.$validate();
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = {
|
||||
message: processedString.value,
|
||||
templateParams: {
|
||||
name: props.template.name,
|
||||
category: props.template.category,
|
||||
language: props.template.language,
|
||||
namespace: props.template.namespace,
|
||||
processed_params: processedParams.value,
|
||||
},
|
||||
};
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
generateVariables();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto 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"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
|
||||
{ templateName: templateName }
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<p
|
||||
class="mb-0 text-sm text-n-slate-11"
|
||||
v-html="processedStringWithVariableHighlight"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="Object.keys(processedParams).length"
|
||||
class="text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-for="(variable, key) in processedParams"
|
||||
:key="key"
|
||||
class="flex items-center w-full gap-2"
|
||||
>
|
||||
<span
|
||||
class="block h-8 text-sm min-w-6 text-start truncate text-n-slate-10 leading-8"
|
||||
:title="key"
|
||||
>
|
||||
{{ key }}
|
||||
</span>
|
||||
<Input
|
||||
v-model="processedParams[key]"
|
||||
custom-input-class="!h-8 w-full !bg-transparent"
|
||||
class="w-full"
|
||||
:message-type="getFieldErrorType(key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between w-full gap-3 h-14">
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
|
||||
)
|
||||
"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full font-medium"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
|
||||
)
|
||||
"
|
||||
class="w-full font-medium"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+1
-2
@@ -25,7 +25,7 @@ export const generateLabelForContactableInboxesList = ({
|
||||
channelType === INBOX_TYPES.TWILIO ||
|
||||
channelType === INBOX_TYPES.WHATSAPP
|
||||
) {
|
||||
return phoneNumber ? `${name} (${phoneNumber})` : name;
|
||||
return `${name} (${phoneNumber})`;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
@@ -53,7 +53,6 @@ const transformInbox = ({
|
||||
email,
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
...rest,
|
||||
});
|
||||
|
||||
|
||||
@@ -21,17 +21,9 @@ const onClick = (item, index) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
:aria-label="t('BREADCRUMB.ARIA_LABEL')"
|
||||
class="flex items-center h-8 min-w-0"
|
||||
>
|
||||
<ol class="flex items-center mb-0 min-w-0">
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="flex items-center"
|
||||
:class="{ 'min-w-0 flex-1': index === items.length - 1 }"
|
||||
>
|
||||
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
|
||||
<ol class="flex items-center mb-0">
|
||||
<li v-for="(item, index) in items" :key="index" class="flex items-center">
|
||||
<Icon
|
||||
v-if="index > 0"
|
||||
icon="i-lucide-chevron-right"
|
||||
@@ -48,7 +40,7 @@ const onClick = (item, index) => {
|
||||
</button>
|
||||
|
||||
<!-- The last breadcrumb item is plain text -->
|
||||
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
|
||||
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
|
||||
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<script setup>
|
||||
import Guardrails from './Guardrails.vue';
|
||||
import Scenarios from './Scenarios.vue';
|
||||
import ResponseGuidelines from './ResponseGuidelines.vue';
|
||||
import Settings from './Settings.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/AnimatingImg/AnimatingImg"
|
||||
:layout="{ type: 'grid', width: '300px' }"
|
||||
>
|
||||
<Variant title="Guardrails">
|
||||
<div class="p-4 bg-n-background w-full h-full">
|
||||
<Guardrails class="size-60" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Scenarios">
|
||||
<div class="p-4 bg-n-background w-full h-full">
|
||||
<Scenarios class="size-60" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="ResponseGuidelines">
|
||||
<div class="p-4 bg-n-background w-full h-full">
|
||||
<ResponseGuidelines class="size-60" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Settings">
|
||||
<div class="p-4 bg-n-background w-full h-full">
|
||||
<Settings class="size-60" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user