mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	Merge branch 'develop' into data/populate_contact_sync
This commit is contained in:
		| @@ -1,6 +1,7 @@ | ||||
| version: 2.1 | ||||
| orbs: | ||||
|   node: circleci/node@6.1.0 | ||||
|   qlty-orb: qltysh/qlty-orb@0.0 | ||||
|  | ||||
| defaults: &defaults | ||||
|   working_directory: ~/build | ||||
| @@ -89,14 +90,6 @@ 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: | ||||
| @@ -108,10 +101,11 @@ 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 | ||||
|  | ||||
|       # we remove the FRONTED_URL from the .env before running the tests | ||||
|       # Configure environment and database | ||||
|       - run: | ||||
|           name: Database Setup and Configure Environment Variables | ||||
|           command: | | ||||
| @@ -149,17 +143,11 @@ jobs: | ||||
|           command: pnpm run eslint | ||||
|  | ||||
|       - run: | ||||
|           name: Run frontend tests | ||||
|           name: Run frontend tests (with coverage) | ||||
|           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 | ||||
| @@ -167,18 +155,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 --format progress \ | ||||
|             bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \ | ||||
|                               --format RspecJunitFormatter \ | ||||
|                               --out ~/tmp/test-results/rspec.xml \ | ||||
|                               -- ${TESTFILES} | ||||
|           no_output_timeout: 30m | ||||
|  | ||||
|       - 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" | ||||
|       # Qlty coverage publish | ||||
|       - qlty-orb/coverage_publish: | ||||
|           files: | | ||||
|             coverage/coverage.json | ||||
|             coverage/lcov.info | ||||
|  | ||||
|       - run: | ||||
|           name: List coverage directory contents | ||||
| @@ -189,3 +177,7 @@ jobs: | ||||
|           root: ~/build | ||||
|           paths: | ||||
|             - coverage | ||||
|  | ||||
|       - store_artifacts: | ||||
|           path: coverage | ||||
|           destination: coverage | ||||
|   | ||||
| @@ -6,6 +6,13 @@ | ||||
| # 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 | ||||
|   | ||||
							
								
								
									
										99
									
								
								.github/workflows/run_mfa_spec.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								.github/workflows/run_mfa_spec.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| name: Run MFA Tests | ||||
| permissions: | ||||
|   contents: read | ||||
|  | ||||
| on: | ||||
|   pull_request: | ||||
|  | ||||
| # If two pushes happen within a short time in the same PR, cancel the run of the oldest push | ||||
| concurrency: | ||||
|   group: pr-${{ github.workflow }}-${{ github.head_ref }} | ||||
|   cancel-in-progress: true | ||||
|  | ||||
| jobs: | ||||
|   test: | ||||
|     runs-on: ubuntu-22.04 | ||||
|     # Only run if MFA test keys are available | ||||
|     if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]') | ||||
|  | ||||
|     services: | ||||
|       postgres: | ||||
|         image: pgvector/pgvector:pg15 | ||||
|         env: | ||||
|           POSTGRES_USER: postgres | ||||
|           POSTGRES_PASSWORD: '' | ||||
|           POSTGRES_DB: postgres | ||||
|           POSTGRES_HOST_AUTH_METHOD: trust | ||||
|         ports: | ||||
|           - 5432:5432 | ||||
|         options: >- | ||||
|           --mount type=tmpfs,destination=/var/lib/postgresql/data | ||||
|           --health-cmd pg_isready | ||||
|           --health-interval 10s | ||||
|           --health-timeout 5s | ||||
|           --health-retries 5 | ||||
|       redis: | ||||
|         image: redis | ||||
|         ports: | ||||
|           - 6379:6379 | ||||
|         options: --entrypoint redis-server | ||||
|  | ||||
|     env: | ||||
|       RAILS_ENV: test | ||||
|       POSTGRES_HOST: localhost | ||||
|       # Active Record encryption keys required for MFA - test keys only, not for production use | ||||
|       ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7' | ||||
|       ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8' | ||||
|       ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9' | ||||
|  | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|  | ||||
|       - uses: ruby/setup-ruby@v1 | ||||
|         with: | ||||
|           bundler-cache: true | ||||
|  | ||||
|       - name: Create database | ||||
|         run: bundle exec rake db:create | ||||
|  | ||||
|       - name: Install pgvector extension | ||||
|         run: | | ||||
|           PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;" | ||||
|  | ||||
|       - name: Seed database | ||||
|         run: bundle exec rake db:schema:load | ||||
|  | ||||
|       - name: Run MFA-related backend tests | ||||
|         run: | | ||||
|           bundle exec rspec \ | ||||
|             spec/services/mfa/token_service_spec.rb \ | ||||
|             spec/services/mfa/authentication_service_spec.rb \ | ||||
|             spec/requests/api/v1/profile/mfa_controller_spec.rb \ | ||||
|             spec/controllers/devise_overrides/sessions_controller_spec.rb \ | ||||
|             --profile=10 \ | ||||
|             --format documentation | ||||
|         env: | ||||
|           NODE_OPTIONS: --openssl-legacy-provider | ||||
|  | ||||
|       - name: Run MFA-related tests in user_spec | ||||
|         run: | | ||||
|           # Run specific MFA-related tests from user_spec | ||||
|           bundle exec rspec spec/models/user_spec.rb \ | ||||
|             -e "two factor" \ | ||||
|             -e "2FA" \ | ||||
|             -e "MFA" \ | ||||
|             -e "otp" \ | ||||
|             -e "backup code" \ | ||||
|             --profile=10 \ | ||||
|             --format documentation | ||||
|         env: | ||||
|           NODE_OPTIONS: --openssl-legacy-provider | ||||
|  | ||||
|       - name: Upload test logs | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         if: failure() | ||||
|         with: | ||||
|           name: mfa-test-logs | ||||
|           path: | | ||||
|             log/test.log | ||||
|             tmp/screenshots/ | ||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -94,3 +94,8 @@ yarn-debug.log* | ||||
| .vscode | ||||
| .claude/settings.local.json | ||||
| .cursor | ||||
| CLAUDE.local.md | ||||
|  | ||||
| # Histoire deployment | ||||
| .netlify | ||||
| .histoire | ||||
|   | ||||
							
								
								
									
										17
									
								
								AGENTS.md
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								AGENTS.md
									
									
									
									
									
								
							| @@ -56,3 +56,20 @@ | ||||
| ## 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. | ||||
|   | ||||
							
								
								
									
										24
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								Gemfile
									
									
									
									
									
								
							| @@ -62,6 +62,10 @@ 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' | ||||
| @@ -74,9 +78,12 @@ 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' | ||||
| @@ -89,14 +96,14 @@ gem 'wisper', '2.0.0' | ||||
| ##--- gems for channels ---## | ||||
| gem 'facebook-messenger' | ||||
| gem 'line-bot-api' | ||||
| gem 'twilio-ruby', '~> 5.66' | ||||
| gem 'twilio-ruby' | ||||
| # 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.5.2' | ||||
| gem 'slack-ruby-client', '~> 2.7.0' | ||||
| # for dialogflow integrations | ||||
| gem 'google-cloud-dialogflow-v2', '>= 0.24.0' | ||||
| gem 'grpc' | ||||
| @@ -108,7 +115,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0' | ||||
| ##-- apm and error monitoring ---# | ||||
| # loaded only when environment variables are set. | ||||
| # ref application.rb | ||||
| gem 'ddtrace', require: false | ||||
| gem 'datadog', '~> 2.0', require: false | ||||
| gem 'elastic-apm', require: false | ||||
| gem 'newrelic_rpm', require: false | ||||
| gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false | ||||
| @@ -167,6 +174,7 @@ 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' | ||||
|  | ||||
| @@ -179,7 +187,10 @@ gem 'reverse_markdown' | ||||
|  | ||||
| gem 'iso-639' | ||||
| gem 'ruby-openai' | ||||
| gem 'ai-agents', '>= 0.2.1' | ||||
| gem 'ai-agents', '>= 0.4.3' | ||||
|  | ||||
| # TODO: Move this gem as a dependency of ai-agents | ||||
| gem 'ruby_llm-schema' | ||||
|  | ||||
| gem 'shopify_api' | ||||
|  | ||||
| @@ -209,6 +220,8 @@ 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 | ||||
| @@ -218,6 +231,7 @@ group :test do | ||||
|   gem 'webmock' | ||||
|   # test profiling | ||||
|   gem 'test-prof' | ||||
|   gem 'simplecov_json_formatter', require: false | ||||
| end | ||||
|  | ||||
| group :development, :test do | ||||
| @@ -242,7 +256,7 @@ group :development, :test do | ||||
|   gem 'rubocop-factory_bot', require: false | ||||
|   gem 'seed_dump' | ||||
|   gem 'shoulda-matchers' | ||||
|   gem 'simplecov', '0.17.1', require: false | ||||
|   gem 'simplecov', '>= 0.21', require: false | ||||
|   gem 'spring' | ||||
|   gem 'spring-watcher-listen' | ||||
| end | ||||
|   | ||||
							
								
								
									
										274
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										274
									
								
								Gemfile.lock
									
									
									
									
									
								
							| @@ -25,35 +25,35 @@ GIT | ||||
| GEM | ||||
|   remote: https://rubygems.org/ | ||||
|   specs: | ||||
|     actioncable (7.1.5.1) | ||||
|       actionpack (= 7.1.5.1) | ||||
|       activesupport (= 7.1.5.1) | ||||
|     actioncable (7.1.5.2) | ||||
|       actionpack (= 7.1.5.2) | ||||
|       activesupport (= 7.1.5.2) | ||||
|       nio4r (~> 2.0) | ||||
|       websocket-driver (>= 0.6.1) | ||||
|       zeitwerk (~> 2.6) | ||||
|     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) | ||||
|     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) | ||||
|       mail (>= 2.7.1) | ||||
|       net-imap | ||||
|       net-pop | ||||
|       net-smtp | ||||
|     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) | ||||
|     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) | ||||
|       mail (~> 2.5, >= 2.5.4) | ||||
|       net-imap | ||||
|       net-pop | ||||
|       net-smtp | ||||
|       rails-dom-testing (~> 2.2) | ||||
|     actionpack (7.1.5.1) | ||||
|       actionview (= 7.1.5.1) | ||||
|       activesupport (= 7.1.5.1) | ||||
|     actionpack (7.1.5.2) | ||||
|       actionview (= 7.1.5.2) | ||||
|       activesupport (= 7.1.5.2) | ||||
|       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.1) | ||||
|       actionpack (= 7.1.5.1) | ||||
|       activerecord (= 7.1.5.1) | ||||
|       activestorage (= 7.1.5.1) | ||||
|       activesupport (= 7.1.5.1) | ||||
|     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) | ||||
|       globalid (>= 0.6.0) | ||||
|       nokogiri (>= 1.8.5) | ||||
|     actionview (7.1.5.1) | ||||
|       activesupport (= 7.1.5.1) | ||||
|     actionview (7.1.5.2) | ||||
|       activesupport (= 7.1.5.2) | ||||
|       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.1) | ||||
|       activesupport (= 7.1.5.1) | ||||
|     activejob (7.1.5.2) | ||||
|       activesupport (= 7.1.5.2) | ||||
|       globalid (>= 0.3.6) | ||||
|     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) | ||||
|     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) | ||||
|       timeout (>= 0.4.0) | ||||
|     activerecord-import (2.1.0) | ||||
|       activerecord (>= 4.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) | ||||
|     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) | ||||
|       marcel (~> 1.0) | ||||
|     activesupport (7.1.5.1) | ||||
|     activesupport (7.1.5.2) | ||||
|       base64 | ||||
|       benchmark (>= 0.3) | ||||
|       bigdecimal | ||||
| @@ -126,7 +126,7 @@ GEM | ||||
|       jbuilder (~> 2) | ||||
|       rails (>= 4.2, < 7.2) | ||||
|       selectize-rails (~> 0.6) | ||||
|     ai-agents (0.2.1) | ||||
|     ai-agents (0.4.3) | ||||
|       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.2.0) | ||||
|     base64 (0.3.0) | ||||
|     bcrypt (3.1.20) | ||||
|     benchmark (0.4.0) | ||||
|     bigdecimal (3.1.9) | ||||
|     benchmark (0.4.1) | ||||
|     bigdecimal (3.2.2) | ||||
|     bindex (0.8.1) | ||||
|     bootsnap (1.16.0) | ||||
|       msgpack (~> 1.2) | ||||
| @@ -194,10 +194,14 @@ GEM | ||||
|       activerecord (>= 5.a) | ||||
|       database_cleaner-core (~> 2.0.0) | ||||
|     database_cleaner-core (2.0.1) | ||||
|     date (3.4.1) | ||||
|     ddtrace (0.48.0) | ||||
|       ffi (~> 1.0) | ||||
|     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) | ||||
|     debug (1.8.0) | ||||
|       irb (>= 1.5.0) | ||||
|       reline (>= 0.3.1) | ||||
| @@ -208,6 +212,11 @@ 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) | ||||
| @@ -215,7 +224,7 @@ GEM | ||||
|     diff-lcs (1.5.1) | ||||
|     digest-crc (0.6.5) | ||||
|       rake (>= 12.0.0, < 14.0.0) | ||||
|     docile (1.4.0) | ||||
|     docile (1.4.1) | ||||
|     domain_name (0.5.20190701) | ||||
|       unf (>= 0.0.5, < 1.0.0) | ||||
|     dotenv (3.1.2) | ||||
| @@ -226,6 +235,35 @@ 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) | ||||
| @@ -248,22 +286,34 @@ GEM | ||||
|       railties (>= 5.0.0) | ||||
|     faker (3.2.0) | ||||
|       i18n (>= 1.8.11, < 2) | ||||
|     faraday (2.9.0) | ||||
|       faraday-net_http (>= 2.0, < 3.2) | ||||
|     faraday (2.13.1) | ||||
|       faraday-net_http (>= 2.0, < 3.5) | ||||
|       json | ||||
|       logger | ||||
|     faraday-follow_redirects (0.3.0) | ||||
|       faraday (>= 1, < 3) | ||||
|     faraday-mashify (0.1.1) | ||||
|     faraday-mashify (1.0.0) | ||||
|       faraday (~> 2.0) | ||||
|       hashie | ||||
|     faraday-multipart (1.0.4) | ||||
|       multipart-post (~> 2) | ||||
|     faraday-net_http (3.1.0) | ||||
|       net-http | ||||
|     faraday-net_http (3.4.0) | ||||
|       net-http (>= 0.5.0) | ||||
|     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) | ||||
| @@ -402,7 +452,7 @@ GEM | ||||
|       rails-dom-testing (>= 1, < 3) | ||||
|       railties (>= 4.2.0) | ||||
|       thor (>= 0.14, < 2.0) | ||||
|     json (2.12.0) | ||||
|     json (2.13.2) | ||||
|     json_refs (0.1.8) | ||||
|       hana | ||||
|     json_schemer (0.2.24) | ||||
| @@ -417,7 +467,7 @@ GEM | ||||
|     judoscale-sidekiq (1.8.2) | ||||
|       judoscale-ruby (= 1.8.2) | ||||
|       sidekiq (>= 5.0) | ||||
|     jwt (2.8.1) | ||||
|     jwt (2.10.1) | ||||
|       base64 | ||||
|     kaminari (1.2.2) | ||||
|       activesupport (>= 4.1.0) | ||||
| @@ -444,6 +494,16 @@ 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) | ||||
| @@ -489,7 +549,7 @@ GEM | ||||
|     mutex_m (0.3.0) | ||||
|     neighbor (0.2.3) | ||||
|       activerecord (>= 5.2) | ||||
|     net-http (0.4.1) | ||||
|     net-http (0.6.0) | ||||
|       uri | ||||
|     net-http-persistent (4.0.2) | ||||
|       connection_pool (~> 2.2) | ||||
| @@ -534,8 +594,9 @@ GEM | ||||
|     oj (3.16.10) | ||||
|       bigdecimal (>= 3.0) | ||||
|       ostruct (>= 0.2) | ||||
|     omniauth (2.1.2) | ||||
|     omniauth (2.1.3) | ||||
|       hashie (>= 3.4.6) | ||||
|       logger | ||||
|       rack (>= 2.2.3) | ||||
|       rack-protection | ||||
|     omniauth-google-oauth2 (1.1.3) | ||||
| @@ -549,6 +610,12 @@ 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) | ||||
| @@ -577,7 +644,7 @@ GEM | ||||
|       activesupport (>= 3.0.0) | ||||
|     raabro (1.4.0) | ||||
|     racc (1.8.1) | ||||
|     rack (2.2.15) | ||||
|     rack (3.2.0) | ||||
|     rack-attack (6.7.0) | ||||
|       rack (>= 1.0, < 4) | ||||
|     rack-contrib (2.5.0) | ||||
| @@ -586,33 +653,34 @@ GEM | ||||
|       rack (>= 2.0.0) | ||||
|     rack-mini-profiler (3.2.0) | ||||
|       rack (>= 1.2.0) | ||||
|     rack-protection (3.2.0) | ||||
|     rack-protection (4.1.1) | ||||
|       base64 (>= 0.1.0) | ||||
|       rack (~> 2.2, >= 2.2.4) | ||||
|       logger (>= 1.6.0) | ||||
|       rack (>= 3.0.0, < 4) | ||||
|     rack-proxy (0.7.7) | ||||
|       rack | ||||
|     rack-session (1.0.2) | ||||
|       rack (< 3) | ||||
|     rack-session (2.1.1) | ||||
|       base64 (>= 0.1.0) | ||||
|       rack (>= 3.0.0) | ||||
|     rack-test (2.1.0) | ||||
|       rack (>= 1.3) | ||||
|     rack-timeout (0.6.3) | ||||
|     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) | ||||
|     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) | ||||
|       bundler (>= 1.15.0) | ||||
|       railties (= 7.1.5.1) | ||||
|       railties (= 7.1.5.2) | ||||
|     rails-dom-testing (2.2.0) | ||||
|       activesupport (>= 5.0.0) | ||||
|       minitest | ||||
| @@ -620,9 +688,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.1) | ||||
|       actionpack (= 7.1.5.1) | ||||
|       activesupport (= 7.1.5.1) | ||||
|     railties (7.1.5.2) | ||||
|       actionpack (= 7.1.5.2) | ||||
|       activesupport (= 7.1.5.2) | ||||
|       irb | ||||
|       rackup (>= 1.0.0) | ||||
|       rake (>= 12.2) | ||||
| @@ -659,7 +727,8 @@ GEM | ||||
|     retriable (3.1.2) | ||||
|     reverse_markdown (2.1.1) | ||||
|       nokogiri | ||||
|     rexml (3.4.1) | ||||
|     rexml (3.4.4) | ||||
|     rotp (6.3.0) | ||||
|     rspec-core (3.13.0) | ||||
|       rspec-support (~> 3.13.0) | ||||
|     rspec-expectations (3.13.2) | ||||
| @@ -714,13 +783,16 @@ 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.3.1) | ||||
|     ruby_llm (1.5.1) | ||||
|       base64 | ||||
|       event_stream_parser (~> 1) | ||||
|       faraday (>= 1.10.0) | ||||
| @@ -729,6 +801,7 @@ 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) | ||||
| @@ -748,6 +821,9 @@ 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) | ||||
| @@ -794,13 +870,14 @@ GEM | ||||
|       faraday (>= 0.17.5, < 3.a) | ||||
|       jwt (>= 1.5, < 3.0) | ||||
|       multi_json (~> 1.10) | ||||
|     simplecov (0.17.1) | ||||
|     simplecov (0.22.0) | ||||
|       docile (~> 1.1) | ||||
|       json (>= 1.8, < 3) | ||||
|       simplecov-html (~> 0.10.0) | ||||
|     simplecov-html (0.10.2) | ||||
|     slack-ruby-client (2.5.2) | ||||
|       faraday (>= 2.0) | ||||
|       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) | ||||
|       faraday-mashify | ||||
|       faraday-multipart | ||||
|       gli | ||||
| @@ -828,13 +905,17 @@ 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 (5.77.0) | ||||
|     twilio-ruby (7.6.0) | ||||
|       faraday (>= 0.9, < 3.0) | ||||
|       jwt (>= 1.5, < 3.0) | ||||
|       nokogiri (>= 1.6, < 2.0) | ||||
| @@ -881,7 +962,6 @@ 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) | ||||
| @@ -910,7 +990,7 @@ DEPENDENCIES | ||||
|   administrate (>= 0.20.1) | ||||
|   administrate-field-active_storage (>= 1.0.3) | ||||
|   administrate-field-belongs_to_search (>= 0.9.0) | ||||
|   ai-agents (>= 0.2.1) | ||||
|   ai-agents (>= 0.4.3) | ||||
|   annotate | ||||
|   attr_extras | ||||
|   audited (~> 5.4, >= 5.4.1) | ||||
| @@ -927,10 +1007,11 @@ DEPENDENCIES | ||||
|   commonmarker | ||||
|   csv-safe | ||||
|   database_cleaner | ||||
|   ddtrace | ||||
|   datadog (~> 2.0) | ||||
|   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 | ||||
| @@ -939,6 +1020,7 @@ DEPENDENCIES | ||||
|   facebook-messenger | ||||
|   factory_bot_rails (>= 6.4.3) | ||||
|   faker | ||||
|   faraday_middleware-aws-sigv4 | ||||
|   fcm | ||||
|   flag_shih_tzu | ||||
|   foreman | ||||
| @@ -979,6 +1061,8 @@ 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 | ||||
| @@ -1004,8 +1088,10 @@ DEPENDENCIES | ||||
|   rubocop-rails | ||||
|   rubocop-rspec | ||||
|   ruby-openai | ||||
|   ruby_llm-schema | ||||
|   scout_apm | ||||
|   scss_lint | ||||
|   searchkick | ||||
|   seed_dump | ||||
|   sentry-rails (>= 5.19.0) | ||||
|   sentry-ruby | ||||
| @@ -1015,8 +1101,9 @@ DEPENDENCIES | ||||
|   sidekiq (>= 7.3.1) | ||||
|   sidekiq-cron (>= 1.12.0) | ||||
|   sidekiq_alive | ||||
|   simplecov (= 0.17.1) | ||||
|   slack-ruby-client (~> 2.5.2) | ||||
|   simplecov (>= 0.21) | ||||
|   simplecov_json_formatter | ||||
|   slack-ruby-client (~> 2.7.0) | ||||
|   spring | ||||
|   spring-watcher-listen | ||||
|   squasher | ||||
| @@ -1024,8 +1111,9 @@ DEPENDENCIES | ||||
|   stripe | ||||
|   telephone_number | ||||
|   test-prof | ||||
|   tidewave | ||||
|   time_diff | ||||
|   twilio-ruby (~> 5.66) | ||||
|   twilio-ruby | ||||
|   twitty (~> 0.1.5) | ||||
|   tzinfo-data | ||||
|   uglifier | ||||
|   | ||||
							
								
								
									
										3
									
								
								Rakefile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								Rakefile
									
									
									
									
									
								
							| @@ -2,5 +2,8 @@ | ||||
| # 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 @@ | ||||
| 3.4.0 | ||||
| 3.4.3 | ||||
|   | ||||
| @@ -52,3 +52,5 @@ class AgentBuilder | ||||
|     }.compact)) | ||||
|   end | ||||
| end | ||||
|  | ||||
| AgentBuilder.prepend_mod_with('AgentBuilder') | ||||
|   | ||||
							
								
								
									
										54
									
								
								app/builders/email/base_builder.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								app/builders/email/base_builder.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| 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 | ||||
							
								
								
									
										51
									
								
								app/builders/email/from_builder.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								app/builders/email/from_builder.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| 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 | ||||
							
								
								
									
										21
									
								
								app/builders/email/reply_to_builder.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/builders/email/reply_to_builder.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| class Email::ReplyToBuilder < Email::BaseBuilder | ||||
|   pattr_initialize [:inbox!, :message!] | ||||
|  | ||||
|   def build | ||||
|     reply_to = if inbox.email? | ||||
|                  channel.email | ||||
|                elsif inbound_email_enabled? | ||||
|                  "reply+#{conversation.uuid}@#{account.inbound_email_domain}" | ||||
|                else | ||||
|                  account_support_email | ||||
|                end | ||||
|  | ||||
|     sender_name(reply_to) | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def inbound_email_enabled? | ||||
|     account.feature_enabled?('inbound_emails') && account.inbound_email_domain.present? | ||||
|   end | ||||
| end | ||||
| @@ -28,7 +28,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder | ||||
|  | ||||
|     { | ||||
|       conversation_counts: fetch_conversation_counts(conversation_filter), | ||||
|       resolved_counts: fetch_resolved_counts(conversation_filter), | ||||
|       resolved_counts: fetch_resolved_counts, | ||||
|       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,10 +62,21 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder | ||||
|     fetch_counts(conversation_filter) | ||||
|   end | ||||
|  | ||||
|   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])) | ||||
|   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 | ||||
|   end | ||||
|  | ||||
|   def fetch_counts(conversation_filter) | ||||
| @@ -84,9 +95,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder | ||||
|  | ||||
|   def fetch_metrics(conversation_filter, event_name, use_business_hours) | ||||
|     ReportingEvent | ||||
|       .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') | ||||
|       .joins(conversation: { taggings: :tag }) | ||||
|       .where( | ||||
|         conversations: conversation_filter, | ||||
|         name: event_name, | ||||
|   | ||||
| @@ -38,27 +38,34 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas | ||||
|   end | ||||
|  | ||||
|   def scope_for_resolutions_count | ||||
|     scope.reporting_events.joins(:conversation).select(:conversation_id).where( | ||||
|     scope.reporting_events.where( | ||||
|       name: :conversation_resolved, | ||||
|       conversations: { status: :resolved }, created_at: range | ||||
|     ).distinct | ||||
|       account_id: account.id, | ||||
|       created_at: range | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def scope_for_bot_resolutions_count | ||||
|     scope.reporting_events.joins(:conversation).select(:conversation_id).where( | ||||
|     scope.reporting_events.where( | ||||
|       name: :conversation_bot_resolved, | ||||
|       conversations: { status: :resolved }, created_at: range | ||||
|     ).distinct | ||||
|       account_id: account.id, | ||||
|       created_at: range | ||||
|     ) | ||||
|   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, | ||||
|   | ||||
| @@ -0,0 +1,20 @@ | ||||
| class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController | ||||
|   before_action :fetch_assignment_policy | ||||
|   before_action -> { check_authorization(AssignmentPolicy) } | ||||
|  | ||||
|   def index | ||||
|     @inboxes = @assignment_policy.inboxes | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def fetch_assignment_policy | ||||
|     @assignment_policy = Current.account.assignment_policies.find( | ||||
|       params[:assignment_policy_id] | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def permitted_params | ||||
|     params.permit(:assignment_policy_id) | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,36 @@ | ||||
| class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController | ||||
|   before_action :fetch_assignment_policy, only: [:show, :update, :destroy] | ||||
|   before_action :check_authorization | ||||
|  | ||||
|   def index | ||||
|     @assignment_policies = Current.account.assignment_policies | ||||
|   end | ||||
|  | ||||
|   def show; end | ||||
|  | ||||
|   def create | ||||
|     @assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params) | ||||
|   end | ||||
|  | ||||
|   def update | ||||
|     @assignment_policy.update!(assignment_policy_params) | ||||
|   end | ||||
|  | ||||
|   def destroy | ||||
|     @assignment_policy.destroy! | ||||
|     head :ok | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def fetch_assignment_policy | ||||
|     @assignment_policy = Current.account.assignment_policies.find(params[:id]) | ||||
|   end | ||||
|  | ||||
|   def assignment_policy_params | ||||
|     params.require(:assignment_policy).permit( | ||||
|       :name, :description, :assignment_order, :conversation_priority, | ||||
|       :fair_distribution_limit, :fair_distribution_window, :enabled | ||||
|     ) | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,46 @@ | ||||
| class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController | ||||
|   before_action :fetch_inbox | ||||
|   before_action :fetch_assignment_policy, only: [:create] | ||||
|   before_action -> { check_authorization(AssignmentPolicy) } | ||||
|   before_action :validate_assignment_policy, only: [:show, :destroy] | ||||
|  | ||||
|   def show | ||||
|     @assignment_policy = @inbox.assignment_policy | ||||
|   end | ||||
|  | ||||
|   def create | ||||
|     # There should be only one assignment policy for an inbox. | ||||
|     # If there is a new request to add an assignment policy, we will | ||||
|     # delete the old one and attach the new policy | ||||
|     remove_inbox_assignment_policy | ||||
|     @inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy) | ||||
|     @assignment_policy = @inbox.assignment_policy | ||||
|   end | ||||
|  | ||||
|   def destroy | ||||
|     remove_inbox_assignment_policy | ||||
|     head :ok | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def remove_inbox_assignment_policy | ||||
|     @inbox.inbox_assignment_policy&.destroy | ||||
|   end | ||||
|  | ||||
|   def fetch_inbox | ||||
|     @inbox = Current.account.inboxes.find(permitted_params[:inbox_id]) | ||||
|   end | ||||
|  | ||||
|   def fetch_assignment_policy | ||||
|     @assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id]) | ||||
|   end | ||||
|  | ||||
|   def permitted_params | ||||
|     params.permit(:assignment_policy_id, :inbox_id) | ||||
|   end | ||||
|  | ||||
|   def validate_assignment_policy | ||||
|     return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy | ||||
|   end | ||||
| end | ||||
| @@ -70,11 +70,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController | ||||
|   end | ||||
|  | ||||
|   def sync_templates | ||||
|     unless @inbox.channel.is_a?(Channel::Whatsapp) | ||||
|       return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } | ||||
|     end | ||||
|     return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? | ||||
|  | ||||
|     Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) | ||||
|     trigger_template_sync | ||||
|     render status: :ok, json: { message: 'Template sync initiated successfully' } | ||||
|   rescue StandardError => e | ||||
|     render status: :internal_server_error, json: { error: e.message } | ||||
| @@ -185,6 +183,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController | ||||
|       [] | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def whatsapp_channel? | ||||
|     @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?) | ||||
|   end | ||||
|  | ||||
|   def trigger_template_sync | ||||
|     if @inbox.whatsapp? | ||||
|       Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) | ||||
|     elsif @inbox.twilio? && @inbox.channel.whatsapp? | ||||
|       Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | ||||
| Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') | ||||
|   | ||||
| @@ -26,9 +26,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | ||||
|       @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? | ||||
|       # @portal.custom_domain = parsed_custom_domain | ||||
|       process_attached_logo if params[:blob_id].present? | ||||
|     rescue StandardError => e | ||||
|       Rails.logger.error e | ||||
|       render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity | ||||
|     rescue ActiveRecord::RecordInvalid => e | ||||
|       render_record_invalid(e) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -47,6 +46,20 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | ||||
|     head :ok | ||||
|   end | ||||
|  | ||||
|   def send_instructions | ||||
|     email = permitted_params[:email] | ||||
|     return render_could_not_create_error(I18n.t('portals.send_instructions.email_required')) if email.blank? | ||||
|     return render_could_not_create_error(I18n.t('portals.send_instructions.invalid_email_format')) unless valid_email?(email) | ||||
|     return render_could_not_create_error(I18n.t('portals.send_instructions.custom_domain_not_configured')) if @portal.custom_domain.blank? | ||||
|  | ||||
|     PortalInstructionsMailer.send_cname_instructions( | ||||
|       portal: @portal, | ||||
|       recipient_email: email | ||||
|     ).deliver_later | ||||
|  | ||||
|     render json: { message: I18n.t('portals.send_instructions.instructions_sent_successfully') }, status: :ok | ||||
|   end | ||||
|  | ||||
|   def process_attached_logo | ||||
|     blob_id = params[:blob_id] | ||||
|     blob = ActiveStorage::Blob.find_by(id: blob_id) | ||||
| @@ -60,19 +73,20 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | ||||
|   end | ||||
|  | ||||
|   def permitted_params | ||||
|     params.permit(:id) | ||||
|     params.permit(:id, :email) | ||||
|   end | ||||
|  | ||||
|   def portal_params | ||||
|     params.require(:portal).permit( | ||||
|       :account_id, :color, :custom_domain, :header_text, :homepage_link, | ||||
|       :id, :account_id, :color, :custom_domain, :header_text, :homepage_link, | ||||
|       :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def live_chat_widget_params | ||||
|     permitted_params = params.permit(:inbox_id) | ||||
|     return {} if permitted_params[:inbox_id].blank? | ||||
|     return {} unless permitted_params.key?(:inbox_id) | ||||
|     return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank? | ||||
|  | ||||
|     inbox = Inbox.find(permitted_params[:inbox_id]) | ||||
|     return {} unless inbox.web_widget? | ||||
| @@ -88,4 +102,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | ||||
|     domain = URI.parse(@portal.custom_domain) | ||||
|     domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain | ||||
|   end | ||||
|  | ||||
|   def valid_email?(email) | ||||
|     ValidEmail2::Address.new(email).valid? | ||||
|   end | ||||
| end | ||||
|  | ||||
| Api::V1::Accounts::PortalsController.prepend_mod_with('Api::V1::Accounts::PortalsController') | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController | ||||
|   before_action :validate_feature_enabled! | ||||
|   before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } | ||||
|  | ||||
|   # POST /api/v1/accounts/:account_id/whatsapp/authorization | ||||
|   # Handles the embedded signup callback data from the Facebook SDK | ||||
|   # Handles both initial authorization and reauthorization | ||||
|   # If inbox_id is present in params, it performs reauthorization | ||||
|   def create | ||||
|     validate_embedded_signup_params! | ||||
|     channel = process_embedded_signup | ||||
| @@ -16,21 +17,42 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: | ||||
|   def process_embedded_signup | ||||
|     service = Whatsapp::EmbeddedSignupService.new( | ||||
|       account: Current.account, | ||||
|       code: params[:code], | ||||
|       business_id: params[:business_id], | ||||
|       waba_id: params[:waba_id], | ||||
|       phone_number_id: params[:phone_number_id] | ||||
|       params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys, | ||||
|       inbox_id: params[:inbox_id] | ||||
|     ) | ||||
|     service.perform | ||||
|   end | ||||
|  | ||||
|   def render_success_response(inbox) | ||||
|   def fetch_and_validate_inbox | ||||
|     @inbox = Current.account.inboxes.find(params[:inbox_id]) | ||||
|     validate_reauthorization_required | ||||
|   end | ||||
|  | ||||
|   def validate_reauthorization_required | ||||
|     return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup? | ||||
|  | ||||
|     render json: { | ||||
|       success: false, | ||||
|       message: I18n.t('inbox.reauthorization.not_required') | ||||
|     }, status: :unprocessable_entity | ||||
|   end | ||||
|  | ||||
|   def can_upgrade_to_embedded_signup? | ||||
|     channel = @inbox.channel | ||||
|     return false unless channel.provider == 'whatsapp_cloud' | ||||
|  | ||||
|     true | ||||
|   end | ||||
|  | ||||
|   def render_success_response(inbox) | ||||
|     response = { | ||||
|       success: true, | ||||
|       id: inbox.id, | ||||
|       name: inbox.name, | ||||
|       channel_type: 'whatsapp' | ||||
|     } | ||||
|     response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present? | ||||
|     render json: response | ||||
|   end | ||||
|  | ||||
|   def render_error_response(error) | ||||
| @@ -42,15 +64,6 @@ 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? | ||||
|   | ||||
							
								
								
									
										68
									
								
								app/controllers/api/v1/profile/mfa_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								app/controllers/api/v1/profile/mfa_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| 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') | ||||
|     @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME') | ||||
|   end | ||||
|  | ||||
|   def set_contact | ||||
|   | ||||
| @@ -4,17 +4,28 @@ 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(&) | ||||
|     locale = locale_from_account(@current_account) | ||||
|     # 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) | ||||
|  | ||||
|     set_locale(locale, &) | ||||
|   end | ||||
|  | ||||
| @@ -32,6 +43,12 @@ 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', 'v17.0'), | ||||
|       FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'), | ||||
|       WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), | ||||
|       WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), | ||||
|       IS_ENTERPRISE: ChatwootApp.enterprise?, | ||||
|   | ||||
| @@ -47,10 +47,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa | ||||
|   end | ||||
|  | ||||
|   def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName | ||||
|     # find the user with their email instead of UID and token | ||||
|     @resource = resource_class.where( | ||||
|       email: auth_hash['info']['email'] | ||||
|     ).first | ||||
|     email = auth_hash.dig('info', 'email') | ||||
|     @resource = resource_class.from_email(email) | ||||
|   end | ||||
|  | ||||
|   def validate_signup_email_is_business_domain? | ||||
| @@ -75,3 +73,5 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa | ||||
|     'user' | ||||
|   end | ||||
| end | ||||
|  | ||||
| DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController') | ||||
|   | ||||
| @@ -44,3 +44,5 @@ 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 | ||||
|     # 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 | ||||
|     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 | ||||
|   end | ||||
|  | ||||
|   def render_create_success | ||||
| @@ -25,6 +25,31 @@ 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) | ||||
|  | ||||
| @@ -46,6 +71,41 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController | ||||
|     user = User.from_email(params[:email]) | ||||
|     @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) | ||||
|   end | ||||
|  | ||||
|   def handle_mfa_required(user) | ||||
|     render json: { | ||||
|       mfa_required: true, | ||||
|       mfa_token: Mfa::TokenService.new(user: user).generate_token | ||||
|     }, status: :partial_content | ||||
|   end | ||||
|  | ||||
|   def handle_mfa_verification | ||||
|     user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token | ||||
|     return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user | ||||
|  | ||||
|     authenticated = Mfa::AuthenticationService.new( | ||||
|       user: user, | ||||
|       otp_code: params[:otp_code], | ||||
|       backup_code: params[:backup_code] | ||||
|     ).authenticate | ||||
|  | ||||
|     return render_mfa_error('errors.mfa.invalid_code') unless authenticated | ||||
|  | ||||
|     sign_in_mfa_user(user) | ||||
|   end | ||||
|  | ||||
|   def sign_in_mfa_user(user) | ||||
|     @resource = user | ||||
|     @token = @resource.create_token | ||||
|     @resource.save! | ||||
|  | ||||
|     sign_in(:user, @resource, store: false, bypass: false) | ||||
|     render_create_success | ||||
|   end | ||||
|  | ||||
|   def render_mfa_error(message_key, status = :bad_request) | ||||
|     render json: { error: I18n.t(message_key) }, status: status | ||||
|   end | ||||
| end | ||||
|  | ||||
| DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController') | ||||
|   | ||||
| @@ -1,4 +1,11 @@ | ||||
| class Platform::Api::V1::AccountsController < PlatformController | ||||
|   def index | ||||
|     @resources = @platform_app.platform_app_permissibles | ||||
|                               .where(permissible_type: 'Account') | ||||
|                               .includes(:permissible) | ||||
|                               .map(&:permissible) | ||||
|   end | ||||
|  | ||||
|   def show; end | ||||
|  | ||||
|   def create | ||||
|   | ||||
| @@ -3,7 +3,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox | ||||
|   before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status] | ||||
|  | ||||
|   def index | ||||
|     @conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations | ||||
|     @conversations = @contact_inbox.hmac_verified? ? @contact_inbox.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') | ||||
|     @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL', 'INSTALLATION_NAME') | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -17,7 +17,12 @@ class SlackUploadsController < ApplicationController | ||||
|   end | ||||
|  | ||||
|   def blob_url | ||||
|     url_for(@blob.representation(resize_to_fill: [250, nil])) | ||||
|     # Only generate representations for images | ||||
|     if @blob.content_type.start_with?('image/') | ||||
|       url_for(@blob.representation(resize_to_fill: [250, nil])) | ||||
|     else | ||||
|       url_for(@blob) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def avatar_url | ||||
|   | ||||
| @@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController | ||||
|       redirect_to new_super_admin_user_path, notice: notice | ||||
|     end | ||||
|   end | ||||
|   # | ||||
|   # def update | ||||
|   #   super | ||||
|   #   send_foo_updated_email(requested_resource) | ||||
|   # end | ||||
|  | ||||
|   def update | ||||
|     requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present? | ||||
|     super | ||||
|   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') | ||||
|     @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME') | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -30,7 +30,8 @@ class Twilio::CallbackController < ApplicationController | ||||
|       :NumMedia, | ||||
|       :Latitude, | ||||
|       :Longitude, | ||||
|       :MessageType | ||||
|       :MessageType, | ||||
|       :ProfileName | ||||
|     ) | ||||
|   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') | ||||
|     @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME') | ||||
|   end | ||||
|  | ||||
|   def set_web_widget | ||||
| @@ -70,7 +70,12 @@ class WidgetsController < ActionController::Base | ||||
|   end | ||||
|  | ||||
|   def allow_iframe_requests | ||||
|     response.headers.delete('X-Frame-Options') | ||||
|     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 | ||||
|   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,19 +6,54 @@ class EmailChannelFinder | ||||
|   end | ||||
|  | ||||
|   def perform | ||||
|     channel = nil | ||||
|  | ||||
|     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) | ||||
|  | ||||
|       break if channel.present? | ||||
|     end | ||||
|     channel | ||||
|     channel_from_primary_recipients || channel_from_bcc_recipients | ||||
|   end | ||||
|  | ||||
|   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 | ||||
|   private | ||||
|  | ||||
|   def channel_from_primary_recipients | ||||
|     primary_recipient_emails.each do |email| | ||||
|       channel = channel_from_email(email) | ||||
|       return channel if channel.present? | ||||
|     end | ||||
|  | ||||
|     nil | ||||
|   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) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -15,7 +15,13 @@ class NotificationFinder | ||||
|   end | ||||
|  | ||||
|   def unread_count | ||||
|     @notifications.where(read_at: nil).count | ||||
|     if type_included?('read') | ||||
|       # If we're including read notifications, filter to unread | ||||
|       @notifications.where(read_at: nil).count | ||||
|     else | ||||
|       # Already filtered to unread notifications, just count | ||||
|       @notifications.count | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def count | ||||
| @@ -27,7 +33,7 @@ class NotificationFinder | ||||
|   def set_up | ||||
|     find_all_notifications | ||||
|     filter_snoozed_notifications | ||||
|     fitler_read_notifications | ||||
|     filter_read_notifications | ||||
|   end | ||||
|  | ||||
|   def find_all_notifications | ||||
| @@ -38,7 +44,7 @@ class NotificationFinder | ||||
|     @notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed') | ||||
|   end | ||||
|  | ||||
|   def fitler_read_notifications | ||||
|   def filter_read_notifications | ||||
|     @notifications = @notifications.where(read_at: nil) unless type_included?('read') | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| 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? | ||||
| @@ -74,6 +75,17 @@ 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.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved, | ||||
|                                                                                conversations: { status: :resolved }, created_at: range).distinct | ||||
|     scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, | ||||
|                                  created_at: range) | ||||
|   end | ||||
|  | ||||
|   def bot_resolutions | ||||
|     scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved, | ||||
|                                                                                conversations: { status: :resolved }, created_at: range).distinct | ||||
|     scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, | ||||
|                                  created_at: range) | ||||
|   end | ||||
|  | ||||
|   def bot_handoffs | ||||
|   | ||||
| @@ -18,12 +18,25 @@ module ReportingEventHelper | ||||
|   end | ||||
|  | ||||
|   def last_non_human_activity(conversation) | ||||
|     # check if a handoff event already exists | ||||
|     handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last | ||||
|     # Try to get either a handoff or reopened event first | ||||
|     # These will always take precedence over any other activity | ||||
|     # Also, any of these events can happen at any time in the course of a conversation lifecycle. | ||||
|     # So we pick the latest event | ||||
|     event = ReportingEvent.where( | ||||
|       conversation_id: conversation.id, | ||||
|       name: %w[conversation_bot_handoff conversation_opened] | ||||
|     ).order(event_end_time: :desc).first | ||||
|  | ||||
|     # if a handoff exists, last non human activity is when the handoff ended, | ||||
|     # otherwise it's when the conversation was created | ||||
|     handoff_event&.event_end_time || conversation.created_at | ||||
|     return event.event_end_time if event&.event_end_time | ||||
|  | ||||
|     # Fallback to bot resolved event | ||||
|     # Because this will be closest to the most accurate activity instead of conversation.created_at | ||||
|     bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last | ||||
|  | ||||
|     return bot_event.event_end_time if bot_event&.event_end_time | ||||
|  | ||||
|     # If no events found, return conversation creation time | ||||
|     conversation.created_at | ||||
|   end | ||||
|  | ||||
|   private | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import { | ||||
|   verifyServiceWorkerExistence, | ||||
| } from './helper/pushHelper'; | ||||
| import ReconnectService from 'dashboard/helper/ReconnectService'; | ||||
| import { useUISettings } from 'dashboard/composables/useUISettings'; | ||||
|  | ||||
| export default { | ||||
|   name: 'App', | ||||
| @@ -38,12 +39,14 @@ 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() { | ||||
| @@ -88,7 +91,10 @@ export default { | ||||
|   mounted() { | ||||
|     this.initializeColorTheme(); | ||||
|     this.listenToThemeChanges(); | ||||
|     this.setLocale(window.chatwootConfig.selectedLocale); | ||||
|     // If user locale is set, use it; otherwise use account locale | ||||
|     this.setLocale( | ||||
|       this.uiSettings?.locale || window.chatwootConfig.selectedLocale | ||||
|     ); | ||||
|   }, | ||||
|   unmounted() { | ||||
|     if (this.reconnectService) { | ||||
| @@ -114,7 +120,8 @@ export default { | ||||
|       const { locale, latest_chatwoot_version: latestChatwootVersion } = | ||||
|         this.getAccount(this.currentAccountId); | ||||
|       const { pubsub_token: pubsubToken } = this.currentUser || {}; | ||||
|       this.setLocale(locale); | ||||
|       // If user locale is set, use it; otherwise use account locale | ||||
|       this.setLocale(this.uiSettings?.locale || locale); | ||||
|       this.latestChatwootVersion = latestChatwootVersion; | ||||
|       vueActionCable.init(this.store, pubsubToken); | ||||
|       this.reconnectService = new ReconnectService(this.store, this.router); | ||||
| @@ -136,8 +143,7 @@ export default { | ||||
|   <div | ||||
|     v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem" | ||||
|     id="app" | ||||
|     class="flex-grow-0 w-full h-full min-h-0 app-wrapper" | ||||
|     :class="{ 'app-rtl--wrapper': isRTL }" | ||||
|     class="flex flex-col w-full h-screen min-h-0" | ||||
|     :dir="isRTL ? 'rtl' : 'ltr'" | ||||
|   > | ||||
|     <UpdateBanner :latest-chatwoot-version="latestChatwootVersion" /> | ||||
|   | ||||
							
								
								
									
										43
									
								
								app/javascript/dashboard/api/agentCapacityPolicies.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								app/javascript/dashboard/api/agentCapacityPolicies.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| /* 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(); | ||||
							
								
								
									
										36
									
								
								app/javascript/dashboard/api/assignmentPolicies.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								app/javascript/dashboard/api/assignmentPolicies.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| /* global axios */ | ||||
|  | ||||
| import ApiClient from './ApiClient'; | ||||
|  | ||||
| class AssignmentPolicies extends ApiClient { | ||||
|   constructor() { | ||||
|     super('assignment_policies', { accountScoped: true }); | ||||
|   } | ||||
|  | ||||
|   getInboxes(policyId) { | ||||
|     return axios.get(`${this.url}/${policyId}/inboxes`); | ||||
|   } | ||||
|  | ||||
|   setInboxPolicy(inboxId, policyId) { | ||||
|     return axios.post( | ||||
|       `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`, | ||||
|       { | ||||
|         assignment_policy_id: policyId, | ||||
|       } | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   getInboxPolicy(inboxId) { | ||||
|     return axios.get( | ||||
|       `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   removeInboxPolicy(inboxId) { | ||||
|     return axios.delete( | ||||
|       `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default new AssignmentPolicies(); | ||||
| @@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient { | ||||
|     super('captain/assistant_responses', { accountScoped: true }); | ||||
|   } | ||||
|  | ||||
|   get({ page = 1, searchKey, assistantId, documentId, status } = {}) { | ||||
|   get({ page = 1, search, assistantId, documentId, status } = {}) { | ||||
|     return axios.get(this.url, { | ||||
|       params: { | ||||
|         page, | ||||
|         searchKey, | ||||
|         search, | ||||
|         assistant_id: assistantId, | ||||
|         document_id: documentId, | ||||
|         status, | ||||
|   | ||||
							
								
								
									
										36
									
								
								app/javascript/dashboard/api/captain/scenarios.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								app/javascript/dashboard/api/captain/scenarios.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| /* global axios */ | ||||
| import ApiClient from '../ApiClient'; | ||||
|  | ||||
| class CaptainScenarios extends ApiClient { | ||||
|   constructor() { | ||||
|     super('captain/assistants', { accountScoped: true }); | ||||
|   } | ||||
|  | ||||
|   get({ assistantId, page = 1, searchKey } = {}) { | ||||
|     return axios.get(`${this.url}/${assistantId}/scenarios`, { | ||||
|       params: { page, searchKey }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   show({ assistantId, id }) { | ||||
|     return axios.get(`${this.url}/${assistantId}/scenarios/${id}`); | ||||
|   } | ||||
|  | ||||
|   create({ assistantId, ...data } = {}) { | ||||
|     return axios.post(`${this.url}/${assistantId}/scenarios`, { | ||||
|       scenario: data, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   update({ assistantId, id }, data = {}) { | ||||
|     return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, { | ||||
|       scenario: data, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   delete({ assistantId, id }) { | ||||
|     return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default new CaptainScenarios(); | ||||
							
								
								
									
										16
									
								
								app/javascript/dashboard/api/captain/tools.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/javascript/dashboard/api/captain/tools.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| /* global axios */ | ||||
| import ApiClient from '../ApiClient'; | ||||
|  | ||||
| class CaptainTools extends ApiClient { | ||||
|   constructor() { | ||||
|     super('captain/assistants/tools', { accountScoped: true }); | ||||
|   } | ||||
|  | ||||
|   get(params = {}) { | ||||
|     return axios.get(this.url, { | ||||
|       params, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default new CaptainTools(); | ||||
| @@ -9,6 +9,13 @@ class WhatsappChannel extends ApiClient { | ||||
|   createEmbeddedSignup(params) { | ||||
|     return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params); | ||||
|   } | ||||
|  | ||||
|   reauthorizeWhatsApp({ inboxId, ...params }) { | ||||
|     return axios.post(`${this.baseUrl()}/whatsapp/authorization`, { | ||||
|       ...params, | ||||
|       inbox_id: inboxId, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default new WhatsappChannel(); | ||||
|   | ||||
| @@ -21,6 +21,14 @@ class PortalsAPI extends ApiClient { | ||||
|   deleteLogo(portalSlug) { | ||||
|     return axios.delete(`${this.url}/${portalSlug}/logo`); | ||||
|   } | ||||
|  | ||||
|   sendCnameInstructions(portalSlug, email) { | ||||
|     return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email }); | ||||
|   } | ||||
|  | ||||
|   sslStatus(portalSlug) { | ||||
|     return axios.get(`${this.url}/${portalSlug}/ssl_status`); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default PortalsAPI; | ||||
|   | ||||
							
								
								
									
										28
									
								
								app/javascript/dashboard/api/mfa.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/javascript/dashboard/api/mfa.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| /* 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(); | ||||
							
								
								
									
										26
									
								
								app/javascript/dashboard/api/samlSettings.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/javascript/dashboard/api/samlSettings.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| /* 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(); | ||||
| @@ -0,0 +1,98 @@ | ||||
| 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' | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -0,0 +1,70 @@ | ||||
| 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' | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -37,30 +37,6 @@ body { | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .app-wrapper { | ||||
|   @apply h-screen flex-grow-0 min-h-0 w-full; | ||||
|  | ||||
|   .button--fixed-top { | ||||
|     @apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .banner + .app-wrapper { | ||||
|   // Reduce the height of the dashboard to make room for the banner. | ||||
|   // And causing the top right green-action button to be pushed down when scrolling. | ||||
|   @apply h-[calc(100%-48px)]; | ||||
|  | ||||
|   .button--fixed-top { | ||||
|     @apply top-14; | ||||
|   } | ||||
|  | ||||
|   .off-canvas-content { | ||||
|     .button--fixed-top { | ||||
|       @apply top-2; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .tooltip { | ||||
|   @apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96; | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,116 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,86 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,63 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,49 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,104 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,133 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,169 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,127 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,121 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,90 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,149 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,86 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,177 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,60 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,92 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,33 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,89 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,87 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,67 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,25 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,108 @@ | ||||
| <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> | ||||
| @@ -0,0 +1,61 @@ | ||||
| <script setup> | ||||
| import { ref } from 'vue'; | ||||
| import RadioCard from '../RadioCard.vue'; | ||||
|  | ||||
| const selectedOption = ref('round_robin'); | ||||
|  | ||||
| const handleSelect = value => { | ||||
|   selectedOption.value = value; | ||||
|   console.log('Selected:', value); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <Story | ||||
|     title="Components/AgentManagementPolicy/RadioCard" | ||||
|     :layout="{ type: 'grid', width: '600px' }" | ||||
|   > | ||||
|     <Variant title="Basic Usage"> | ||||
|       <div class="p-8 bg-n-background space-y-4"> | ||||
|         <RadioCard | ||||
|           id="round_robin" | ||||
|           label="Round Robin" | ||||
|           description="Distributes conversations evenly among all available agents in a rotating manner" | ||||
|           :is-active="selectedOption === 'round_robin'" | ||||
|           @select="handleSelect" | ||||
|         /> | ||||
|         <RadioCard | ||||
|           id="balanced" | ||||
|           label="Balanced Assignment" | ||||
|           description="Assigns conversations based on agent workload to maintain balance" | ||||
|           :is-active="selectedOption === 'balanced'" | ||||
|           @select="handleSelect" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="Active State"> | ||||
|       <div class="p-8 bg-n-background"> | ||||
|         <RadioCard | ||||
|           id="active_option" | ||||
|           label="Active Option" | ||||
|           description="This option is currently selected and active" | ||||
|           is-active | ||||
|           @select="handleSelect" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="Inactive State"> | ||||
|       <div class="p-8 bg-n-background"> | ||||
|         <RadioCard | ||||
|           id="inactive_option" | ||||
|           label="Inactive Option" | ||||
|           description="This option is not selected and can be clicked to activate" | ||||
|           is-active | ||||
|           @select="handleSelect" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|   </Story> | ||||
| </template> | ||||
| @@ -76,8 +76,8 @@ const campaignStatus = computed(() => { | ||||
| const inboxName = computed(() => props.inbox?.name || ''); | ||||
|  | ||||
| const inboxIcon = computed(() => { | ||||
|   const { phone_number: phoneNumber, channel_type: type } = props.inbox; | ||||
|   return getInboxIconByType(type, phoneNumber); | ||||
|   const { medium, channel_type: type } = props.inbox; | ||||
|   return getInboxIconByType(type, medium); | ||||
| }); | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -38,11 +38,13 @@ const handleClose = () => emit('close'); | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6" | ||||
|     class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto" | ||||
|   > | ||||
|     <h3 class="text-base font-medium text-n-slate-12"> | ||||
|       {{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }} | ||||
|     </h3> | ||||
|     <WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" /> | ||||
|     <div class="p-6 flex flex-col gap-6"> | ||||
|       <h3 class="text-base font-medium text-n-slate-12 flex-shrink-0"> | ||||
|         {{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }} | ||||
|       </h3> | ||||
|       <WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import Input from 'dashboard/components-next/input/Input.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; | ||||
| import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue'; | ||||
| import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue'; | ||||
|  | ||||
| const emit = defineEmits(['submit', 'cancel']); | ||||
|  | ||||
| @@ -18,7 +19,9 @@ const formState = { | ||||
|   uiFlags: useMapGetter('campaigns/getUIFlags'), | ||||
|   labels: useMapGetter('labels/getLabels'), | ||||
|   inboxes: useMapGetter('inboxes/getWhatsAppInboxes'), | ||||
|   getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'), | ||||
|   getFilteredWhatsAppTemplates: useMapGetter( | ||||
|     'inboxes/getFilteredWhatsAppTemplates' | ||||
|   ), | ||||
| }; | ||||
|  | ||||
| const initialState = { | ||||
| @@ -30,7 +33,7 @@ const initialState = { | ||||
| }; | ||||
|  | ||||
| const state = reactive({ ...initialState }); | ||||
| const processedParams = ref({}); | ||||
| const templateParserRef = ref(null); | ||||
|  | ||||
| const rules = { | ||||
|   title: { required, minLength: minLength(1) }, | ||||
| @@ -67,7 +70,7 @@ const inboxOptions = computed(() => | ||||
|  | ||||
| const templateOptions = computed(() => { | ||||
|   if (!state.inboxId) return []; | ||||
|   const templates = formState.getWhatsAppTemplates.value(state.inboxId); | ||||
|   const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId); | ||||
|   return templates.map(template => { | ||||
|     // Create a more user-friendly label from template name | ||||
|     const friendlyName = template.name | ||||
| @@ -88,26 +91,6 @@ const selectedTemplate = computed(() => { | ||||
|     ?.template; | ||||
| }); | ||||
|  | ||||
| const templateString = computed(() => { | ||||
|   if (!selectedTemplate.value) return ''; | ||||
|   try { | ||||
|     return ( | ||||
|       selectedTemplate.value.components?.find( | ||||
|         component => component.type === 'BODY' | ||||
|       )?.text || '' | ||||
|     ); | ||||
|   } catch (error) { | ||||
|     return ''; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const processedString = computed(() => { | ||||
|   if (!templateString.value) return ''; | ||||
|   return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => { | ||||
|     return processedParams.value[variable] || `{{${variable}}}`; | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| const getErrorMessage = (field, errorKey) => { | ||||
|   const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM'; | ||||
|   return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : ''; | ||||
| @@ -122,8 +105,7 @@ const formErrors = computed(() => ({ | ||||
| })); | ||||
|  | ||||
| const hasRequiredTemplateParams = computed(() => { | ||||
|   const params = Object.values(processedParams.value); | ||||
|   return params.length === 0 || params.every(param => param.trim() !== ''); | ||||
|   return templateParserRef.value?.v$?.$invalid === false || true; | ||||
| }); | ||||
|  | ||||
| const isSubmitDisabled = computed( | ||||
| @@ -135,32 +117,18 @@ const formatToUTCString = localDateTime => | ||||
|  | ||||
| const resetState = () => { | ||||
|   Object.assign(state, initialState); | ||||
|   processedParams.value = {}; | ||||
|   v$.value.$reset(); | ||||
| }; | ||||
|  | ||||
| const handleCancel = () => emit('cancel'); | ||||
|  | ||||
| const generateVariables = () => { | ||||
|   const matchedVariables = templateString.value.match(/{{([^}]+)}}/g); | ||||
|   if (!matchedVariables) { | ||||
|     processedParams.value = {}; | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, '')); | ||||
|   processedParams.value = finalVars.reduce((acc, variable) => { | ||||
|     acc[variable] = processedParams.value[variable] || ''; | ||||
|     return acc; | ||||
|   }, {}); | ||||
| }; | ||||
|  | ||||
| const prepareCampaignDetails = () => { | ||||
|   // Find the selected template to get its content | ||||
|   const currentTemplate = selectedTemplate.value; | ||||
|   const parserData = templateParserRef.value; | ||||
|  | ||||
|   // Extract template content - this should be the template message body | ||||
|   const templateContent = templateString.value; | ||||
|   const templateContent = parserData?.renderedTemplate || ''; | ||||
|  | ||||
|   // Prepare template_params object with the same structure as used in contacts | ||||
|   const templateParams = { | ||||
| @@ -168,7 +136,7 @@ const prepareCampaignDetails = () => { | ||||
|     namespace: currentTemplate?.namespace || '', | ||||
|     category: currentTemplate?.category || 'UTILITY', | ||||
|     language: currentTemplate?.language || 'en_US', | ||||
|     processed_params: processedParams.value, | ||||
|     processed_params: parserData?.processedParams || {}, | ||||
|   }; | ||||
|  | ||||
|   return { | ||||
| @@ -198,15 +166,6 @@ watch( | ||||
|   () => state.inboxId, | ||||
|   () => { | ||||
|     state.templateId = null; | ||||
|     processedParams.value = {}; | ||||
|   } | ||||
| ); | ||||
|  | ||||
| // Generate variables when template changes | ||||
| watch( | ||||
|   () => state.templateId, | ||||
|   () => { | ||||
|     generateVariables(); | ||||
|   } | ||||
| ); | ||||
| </script> | ||||
| @@ -254,62 +213,12 @@ watch( | ||||
|       </p> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Template Preview --> | ||||
|     <div | ||||
|     <!-- Template Parser --> | ||||
|     <WhatsAppTemplateParser | ||||
|       v-if="selectedTemplate" | ||||
|       class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2" | ||||
|     > | ||||
|       <div class="flex justify-between items-center"> | ||||
|         <h3 class="text-sm font-medium text-n-slate-12"> | ||||
|           {{ selectedTemplate.name }} | ||||
|         </h3> | ||||
|         <span class="text-xs text-n-slate-11"> | ||||
|           {{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}: | ||||
|           {{ selectedTemplate.language || 'en' }} | ||||
|         </span> | ||||
|       </div> | ||||
|  | ||||
|       <div class="flex flex-col gap-2"> | ||||
|         <div class="rounded-md bg-n-alpha-black3"> | ||||
|           <div class="text-sm whitespace-pre-wrap text-n-slate-12"> | ||||
|             {{ processedString }} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div class="text-xs text-n-slate-11"> | ||||
|         {{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}: | ||||
|         {{ selectedTemplate.category || 'UTILITY' }} | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Template Variables --> | ||||
|     <div | ||||
|       v-if="Object.keys(processedParams).length > 0" | ||||
|       class="flex flex-col gap-3" | ||||
|     > | ||||
|       <label class="text-sm font-medium text-n-slate-12"> | ||||
|         {{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }} | ||||
|       </label> | ||||
|       <div class="flex flex-col gap-2"> | ||||
|         <div | ||||
|           v-for="(value, key) in processedParams" | ||||
|           :key="key" | ||||
|           class="flex gap-2 items-center" | ||||
|         > | ||||
|           <Input | ||||
|             v-model="processedParams[key]" | ||||
|             type="text" | ||||
|             class="flex-1" | ||||
|             :placeholder=" | ||||
|               t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', { | ||||
|                 variable: key, | ||||
|               }) | ||||
|             " | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|       ref="templateParserRef" | ||||
|       :template="selectedTemplate" | ||||
|     /> | ||||
|  | ||||
|     <div class="flex flex-col gap-1"> | ||||
|       <label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12"> | ||||
|   | ||||
| @@ -86,8 +86,8 @@ const handleLabelAction = async ({ value }) => { | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const handleRemoveLabel = labelId => { | ||||
|   return handleLabelAction({ value: labelId }); | ||||
| const handleRemoveLabel = label => { | ||||
|   return handleLabelAction({ value: label.id }); | ||||
| }; | ||||
|  | ||||
| watch( | ||||
|   | ||||
| @@ -98,6 +98,7 @@ const onClickViewDetails = () => emit('showContact', props.id); | ||||
|         :src="thumbnail" | ||||
|         :size="48" | ||||
|         :status="availabilityStatus" | ||||
|         hide-offline-status | ||||
|         rounded-full | ||||
|       /> | ||||
|       <div class="flex flex-col gap-0.5 flex-1"> | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| <script setup> | ||||
| import { computed, useSlots } from 'vue'; | ||||
| import { computed, useSlots, ref } 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: { | ||||
| @@ -24,6 +26,8 @@ const { t } = useI18n(); | ||||
| const slots = useSlots(); | ||||
| const route = useRoute(); | ||||
|  | ||||
| const isContactSidebarOpen = ref(false); | ||||
|  | ||||
| const contactId = computed(() => route.params.contactId); | ||||
|  | ||||
| const selectedContactName = computed(() => { | ||||
| @@ -56,6 +60,15 @@ 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> | ||||
| @@ -67,7 +80,9 @@ const toggleBlock = () => { | ||||
|     > | ||||
|       <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 items-center justify-between w-full h-20 gap-2"> | ||||
|           <div | ||||
|             class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2" | ||||
|           > | ||||
|             <Breadcrumb | ||||
|               :items="breadcrumbItems" | ||||
|               @click="handleBreadcrumbClick" | ||||
| @@ -85,6 +100,11 @@ const toggleBlock = () => { | ||||
|                 :disabled="isUpdating" | ||||
|                 @click="toggleBlock" | ||||
|               /> | ||||
|               <VoiceCallButton | ||||
|                 :phone="selectedContact?.phoneNumber" | ||||
|                 :label="$t('CONTACT_PANEL.CALL')" | ||||
|                 size="sm" | ||||
|               /> | ||||
|               <ComposeConversation :contact-id="contactId"> | ||||
|                 <template #trigger="{ toggle }"> | ||||
|                   <Button | ||||
| @@ -105,11 +125,65 @@ const toggleBlock = () => { | ||||
|       </main> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Desktop sidebar --> | ||||
|     <div | ||||
|       v-if="slots.sidebar" | ||||
|       class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2" | ||||
|       class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2" | ||||
|     > | ||||
|       <slot name="sidebar" /> | ||||
|     </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> | ||||
|   | ||||
| @@ -34,13 +34,13 @@ const emit = defineEmits([ | ||||
| <template> | ||||
|   <header class="sticky top-0 z-10"> | ||||
|     <div | ||||
|       class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]" | ||||
|       class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]" | ||||
|     > | ||||
|       <span class="text-xl font-medium truncate text-n-slate-12"> | ||||
|         {{ headerTitle }} | ||||
|       </span> | ||||
|       <div class="flex items-center flex-shrink-0 gap-4"> | ||||
|         <div v-if="showSearch" class="flex items-center gap-2"> | ||||
|       <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"> | ||||
|           <Input | ||||
|             :model-value="searchValue" | ||||
|             type="search" | ||||
| @@ -48,6 +48,7 @@ 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> | ||||
| @@ -58,64 +59,66 @@ const emit = defineEmits([ | ||||
|             </template> | ||||
|           </Input> | ||||
|         </div> | ||||
|         <div class="flex items-center gap-2"> | ||||
|           <div v-if="!isLabelView && !isActiveView" class="relative"> | ||||
|         <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> | ||||
|             <Button | ||||
|               id="toggleContactsFilterButton" | ||||
|               :icon=" | ||||
|                 isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter' | ||||
|               v-if=" | ||||
|                 hasActiveFilters && | ||||
|                 !isSegmentsView && | ||||
|                 !isLabelView && | ||||
|                 !isActiveView | ||||
|               " | ||||
|               icon="i-lucide-save" | ||||
|               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" /> | ||||
|               @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> | ||||
|           <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 class="w-px h-4 bg-n-strong" /> | ||||
|           <ComposeConversation> | ||||
|             <template #trigger="{ toggle }"> | ||||
|               <Button :label="buttonLabel" size="sm" @click="toggle" /> | ||||
|             </template> | ||||
|           </ComposeConversation> | ||||
|         </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> | ||||
|   | ||||
| @@ -62,6 +62,7 @@ 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 | ||||
| ); | ||||
| @@ -215,6 +216,7 @@ const setParamsForEditSegmentModal = () => { | ||||
|     countries, | ||||
|     filterTypes: contactFilterItems, | ||||
|     allCustomAttributes: useSnakeCase(contactAttributes.value), | ||||
|     labels: labels.value || [], | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| @@ -291,17 +293,20 @@ defineExpose({ | ||||
|     @delete-segment="openDeleteSegmentDialog" | ||||
|   > | ||||
|     <template #filter> | ||||
|       <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" | ||||
|       /> | ||||
|       <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> | ||||
|     </template> | ||||
|   </ContactsHeader> | ||||
|  | ||||
|   | ||||
| @@ -105,7 +105,7 @@ const handleOrderChange = value => { | ||||
|     <div | ||||
|       v-if="isMenuOpen" | ||||
|       v-on-clickaway="() => (isMenuOpen = false)" | ||||
|       class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4" | ||||
|       class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4" | ||||
|     > | ||||
|       <div class="flex items-center justify-between gap-2"> | ||||
|         <span class="text-sm text-n-slate-12"> | ||||
|   | ||||
| @@ -96,10 +96,7 @@ const openFilter = () => { | ||||
|           <slot name="default" /> | ||||
|         </div> | ||||
|       </main> | ||||
|       <footer | ||||
|         v-if="showPaginationFooter" | ||||
|         class="sticky bottom-0 z-10 px-4 pb-4" | ||||
|       > | ||||
|       <footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4"> | ||||
|         <PaginationFooter | ||||
|           current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING" | ||||
|           :current-page="currentPage" | ||||
|   | ||||
| @@ -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 Chatwoot.", | ||||
|       companyName: 'Chatwoot', | ||||
|         "I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Lumora.", | ||||
|       companyName: 'Lumora', | ||||
|       countryCode: 'US', | ||||
|       socialProfiles: { | ||||
|         github: 'candice-dev', | ||||
| @@ -16,7 +16,7 @@ export default [ | ||||
|       }, | ||||
|     }, | ||||
|     availabilityStatus: 'offline', | ||||
|     email: 'candice.matherson@chatwoot.com', | ||||
|     email: 'candice.matherson@lumora.com', | ||||
|     id: 22, | ||||
|     name: 'Candice Matherson', | ||||
|     phoneNumber: '+14155552671', | ||||
|   | ||||
| @@ -0,0 +1,91 @@ | ||||
| <script setup> | ||||
| import { computed, ref, useAttrs } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useMapGetter } from 'dashboard/composables/store'; | ||||
| import { INBOX_TYPES } from 'dashboard/helper/inbox'; | ||||
| import { useAlert } from 'dashboard/composables'; | ||||
|  | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   phone: { type: String, default: '' }, | ||||
|   label: { type: String, default: '' }, | ||||
|   icon: { type: [String, Object, Function], default: '' }, | ||||
|   size: { type: String, default: 'sm' }, | ||||
|   tooltipLabel: { type: String, default: '' }, | ||||
| }); | ||||
|  | ||||
| defineOptions({ inheritAttrs: false }); | ||||
| const attrs = useAttrs(); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const inboxesList = useMapGetter('inboxes/getInboxes'); | ||||
| const voiceInboxes = computed(() => | ||||
|   (inboxesList.value || []).filter( | ||||
|     inbox => inbox.channel_type === INBOX_TYPES.VOICE | ||||
|   ) | ||||
| ); | ||||
| const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0); | ||||
|  | ||||
| // Unified behavior: hide when no phone | ||||
| const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone); | ||||
|  | ||||
| const dialogRef = ref(null); | ||||
|  | ||||
| const onClick = () => { | ||||
|   if (voiceInboxes.value.length > 1) { | ||||
|     dialogRef.value?.open(); | ||||
|     return; | ||||
|   } | ||||
|   useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT')); | ||||
| }; | ||||
|  | ||||
| const onPickInbox = () => { | ||||
|   // Placeholder until actual call wiring happens | ||||
|   useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT')); | ||||
|   dialogRef.value?.close(); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <span class="contents"> | ||||
|     <Button | ||||
|       v-if="shouldRender" | ||||
|       v-tooltip.top-end="tooltipLabel || null" | ||||
|       v-bind="attrs" | ||||
|       :label="label" | ||||
|       :icon="icon" | ||||
|       :size="size" | ||||
|       @click="onClick" | ||||
|     /> | ||||
|  | ||||
|     <Dialog | ||||
|       v-if="shouldRender && voiceInboxes.length > 1" | ||||
|       ref="dialogRef" | ||||
|       :title="$t('CONTACT_PANEL.VOICE_INBOX_PICKER.TITLE')" | ||||
|       show-cancel-button | ||||
|       :show-confirm-button="false" | ||||
|       width="md" | ||||
|     > | ||||
|       <div class="flex flex-col gap-2"> | ||||
|         <button | ||||
|           v-for="inbox in voiceInboxes" | ||||
|           :key="inbox.id" | ||||
|           type="button" | ||||
|           class="flex items-center justify-between w-full px-4 py-2 text-left rounded-lg hover:bg-n-alpha-2" | ||||
|           @click="onPickInbox(inbox)" | ||||
|         > | ||||
|           <div class="flex items-center gap-2"> | ||||
|             <span class="i-ri-phone-fill text-n-slate-10" /> | ||||
|             <span class="text-sm text-n-slate-12">{{ inbox.name }}</span> | ||||
|           </div> | ||||
|           <span v-if="inbox.phone_number" class="text-xs text-n-slate-10"> | ||||
|             {{ inbox.phone_number }} | ||||
|           </span> | ||||
|         </button> | ||||
|       </div> | ||||
|     </Dialog> | ||||
|   </span> | ||||
| </template> | ||||
| @@ -47,6 +47,7 @@ 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" | ||||
|   | ||||
| @@ -96,6 +96,7 @@ defineExpose({ | ||||
|         /> | ||||
|       </div> | ||||
|       <Avatar | ||||
|         v-if="assignee.name" | ||||
|         :name="assignee.name" | ||||
|         :src="assignee.thumbnail" | ||||
|         :size="20" | ||||
|   | ||||
| @@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox); | ||||
| const inboxName = computed(() => inbox.value?.name); | ||||
|  | ||||
| const inboxIcon = computed(() => { | ||||
|   const { phoneNumber, channelType } = inbox.value; | ||||
|   return getInboxIconByType(channelType, phoneNumber); | ||||
|   const { channelType, medium } = inbox.value; | ||||
|   return getInboxIconByType(channelType, medium); | ||||
| }); | ||||
|  | ||||
| const lastActivityAt = computed(() => { | ||||
|   | ||||
| @@ -20,6 +20,7 @@ const props = defineProps({ | ||||
|   enableVariables: { type: Boolean, default: false }, | ||||
|   enableCannedResponses: { type: Boolean, default: true }, | ||||
|   enabledMenuOptions: { type: Array, default: () => [] }, | ||||
|   enableCaptainTools: { type: Boolean, default: false }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['update:modelValue']); | ||||
| @@ -98,6 +99,7 @@ watch( | ||||
|         :enable-variables="enableVariables" | ||||
|         :enable-canned-responses="enableCannedResponses" | ||||
|         :enabled-menu-options="enabledMenuOptions" | ||||
|         :enable-captain-tools="enableCaptainTools" | ||||
|         @input="handleInput" | ||||
|         @focus="handleFocus" | ||||
|         @blur="handleBlur" | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| <script setup> | ||||
| import { ref, reactive, watch } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useVuelidate } from '@vuelidate/core'; | ||||
| import { helpers } from '@vuelidate/validators'; | ||||
| import { isValidDomain } from '@chatwoot/utils'; | ||||
|  | ||||
| import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||
| import Input from 'dashboard/components-next/input/Input.vue'; | ||||
| @@ -26,6 +29,20 @@ const formState = reactive({ | ||||
|   customDomain: props.customDomain, | ||||
| }); | ||||
|  | ||||
| const rules = { | ||||
|   customDomain: { | ||||
|     isValidDomain: helpers.withMessage( | ||||
|       () => | ||||
|         t( | ||||
|           'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.FORMAT_ERROR' | ||||
|         ), | ||||
|       isValidDomain | ||||
|     ), | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| const v$ = useVuelidate(rules, formState); | ||||
|  | ||||
| watch( | ||||
|   () => props.customDomain, | ||||
|   newVal => { | ||||
| @@ -33,7 +50,10 @@ watch( | ||||
|   } | ||||
| ); | ||||
|  | ||||
| const handleDialogConfirm = () => { | ||||
| const handleDialogConfirm = async () => { | ||||
|   const isFormCorrect = await v$.value.$validate(); | ||||
|   if (!isFormCorrect) return; | ||||
|  | ||||
|   emit('addCustomDomain', formState.customDomain); | ||||
| }; | ||||
|  | ||||
| @@ -67,6 +87,11 @@ defineExpose({ dialogRef }); | ||||
|           'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER' | ||||
|         ) | ||||
|       " | ||||
|       :message=" | ||||
|         v$.customDomain.$error ? v$.customDomain.$errors[0].$message : '' | ||||
|       " | ||||
|       :message-type="v$.customDomain.$error ? 'error' : 'info'" | ||||
|       @blur="v$.customDomain.$touch()" | ||||
|     /> | ||||
|   </Dialog> | ||||
| </template> | ||||
|   | ||||
| @@ -1,9 +1,15 @@ | ||||
| <script setup> | ||||
| import { ref, computed } from 'vue'; | ||||
| import { reactive, computed, ref } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useAlert } from 'dashboard/composables'; | ||||
| import { copyTextToClipboard } from 'shared/helpers/clipboard'; | ||||
| import { getHostNameFromURL } from 'dashboard/helper/URLHelper'; | ||||
| import { email, required } from '@vuelidate/validators'; | ||||
| import { useVuelidate } from '@vuelidate/core'; | ||||
|  | ||||
| import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||
| import Input from 'dashboard/components-next/input/Input.vue'; | ||||
| import NextButton from 'dashboard/components-next/button/Button.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   customDomain: { | ||||
| @@ -12,10 +18,20 @@ const props = defineProps({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['confirm']); | ||||
| const emit = defineEmits(['send', 'close']); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const state = reactive({ | ||||
|   email: '', | ||||
| }); | ||||
|  | ||||
| const validationRules = { | ||||
|   email: { email, required }, | ||||
| }; | ||||
|  | ||||
| const v$ = useVuelidate(validationRules, state); | ||||
|  | ||||
| const domain = computed(() => { | ||||
|   const { hostURL, helpCenterURL } = window?.chatwootConfig || {}; | ||||
|   return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || ''; | ||||
| @@ -25,10 +41,34 @@ const subdomainCNAME = computed( | ||||
|   () => `${props.customDomain} CNAME ${domain.value}` | ||||
| ); | ||||
|  | ||||
| const handleCopy = async e => { | ||||
|   e.stopPropagation(); | ||||
|   await copyTextToClipboard(subdomainCNAME.value); | ||||
|   useAlert( | ||||
|     t( | ||||
|       'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.COPY' | ||||
|     ) | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const dialogRef = ref(null); | ||||
|  | ||||
| const handleDialogConfirm = () => { | ||||
|   emit('confirm'); | ||||
| const resetForm = () => { | ||||
|   v$.value.$reset(); | ||||
|   state.email = ''; | ||||
| }; | ||||
|  | ||||
| const onClose = () => { | ||||
|   resetForm(); | ||||
|   emit('close'); | ||||
| }; | ||||
|  | ||||
| const handleSend = async () => { | ||||
|   const isFormCorrect = await v$.value.$validate(); | ||||
|   if (!isFormCorrect) return; | ||||
|  | ||||
|   emit('send', state.email); | ||||
|   onClose(); | ||||
| }; | ||||
|  | ||||
| defineExpose({ dialogRef }); | ||||
| @@ -37,42 +77,103 @@ defineExpose({ dialogRef }); | ||||
| <template> | ||||
|   <Dialog | ||||
|     ref="dialogRef" | ||||
|     :title=" | ||||
|       t( | ||||
|         'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER' | ||||
|       ) | ||||
|     " | ||||
|     :confirm-button-label=" | ||||
|       t( | ||||
|         'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL' | ||||
|       ) | ||||
|     " | ||||
|     :show-cancel-button="false" | ||||
|     @confirm="handleDialogConfirm" | ||||
|     :show-confirm-button="false" | ||||
|     @close="resetForm" | ||||
|   > | ||||
|     <template #description> | ||||
|       <p class="mb-0 text-sm text-n-slate-12"> | ||||
|         {{ | ||||
|           t( | ||||
|             'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION' | ||||
|           ) | ||||
|         }} | ||||
|       </p> | ||||
|     </template> | ||||
|     <NextButton | ||||
|       icon="i-lucide-x" | ||||
|       sm | ||||
|       ghost | ||||
|       slate | ||||
|       class="flex-shrink-0 absolute top-2 ltr:right-2 rtl:left-2" | ||||
|       @click="onClose" | ||||
|     /> | ||||
|     <div class="flex flex-col gap-6 divide-y divide-n-strong"> | ||||
|       <div class="flex flex-col gap-6"> | ||||
|         <div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10"> | ||||
|           <h3 class="text-base font-medium leading-6 text-n-slate-12"> | ||||
|             {{ | ||||
|               t( | ||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER' | ||||
|               ) | ||||
|             }} | ||||
|           </h3> | ||||
|           <p class="mb-0 text-sm text-n-slate-12"> | ||||
|             {{ | ||||
|               t( | ||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION' | ||||
|               ) | ||||
|             }} | ||||
|           </p> | ||||
|         </div> | ||||
|         <div class="flex items-center gap-3 w-full"> | ||||
|           <span | ||||
|             class="min-h-10 px-3 py-2.5 inline-flex items-center w-full text-sm bg-transparent border rounded-lg text-n-slate-11 border-n-strong" | ||||
|           > | ||||
|             {{ subdomainCNAME }} | ||||
|           </span> | ||||
|           <NextButton | ||||
|             faded | ||||
|             slate | ||||
|             type="button" | ||||
|             icon="i-lucide-copy" | ||||
|             class="flex-shrink-0" | ||||
|             @click="handleCopy" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|     <div class="flex flex-col gap-6"> | ||||
|       <span | ||||
|         class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong" | ||||
|       > | ||||
|         {{ subdomainCNAME }} | ||||
|       </span> | ||||
|       <p class="text-sm text-n-slate-12"> | ||||
|         {{ | ||||
|           t( | ||||
|             'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT' | ||||
|           ) | ||||
|         }} | ||||
|       </p> | ||||
|       <div class="flex flex-col gap-6 pt-6"> | ||||
|         <div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10"> | ||||
|           <h3 class="text-base font-medium leading-6 text-n-slate-12"> | ||||
|             {{ | ||||
|               t( | ||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.HEADER' | ||||
|               ) | ||||
|             }} | ||||
|           </h3> | ||||
|           <p class="mb-0 text-sm text-n-slate-12"> | ||||
|             {{ | ||||
|               t( | ||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.DESCRIPTION' | ||||
|               ) | ||||
|             }} | ||||
|           </p> | ||||
|         </div> | ||||
|         <form | ||||
|           class="flex items-start gap-3 w-full" | ||||
|           @submit.prevent="handleSend" | ||||
|         > | ||||
|           <Input | ||||
|             v-model="state.email" | ||||
|             :placeholder=" | ||||
|               t( | ||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.PLACEHOLDER' | ||||
|               ) | ||||
|             " | ||||
|             :message=" | ||||
|               v$.email.$error | ||||
|                 ? t( | ||||
|                     'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.ERROR' | ||||
|                   ) | ||||
|                 : '' | ||||
|             " | ||||
|             :message-type="v$.email.$error ? 'error' : 'info'" | ||||
|             class="w-full" | ||||
|             @blur="v$.email.$touch()" | ||||
|           /> | ||||
|           <NextButton | ||||
|             :label=" | ||||
|               t( | ||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.SEND_BUTTON' | ||||
|               ) | ||||
|             " | ||||
|             type="submit" | ||||
|             class="flex-shrink-0" | ||||
|           /> | ||||
|         </form> | ||||
|       </div> | ||||
|     </div> | ||||
|   </Dialog> | ||||
| </template> | ||||
|   | ||||
| @@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store'; | ||||
| import { uploadFile } from 'dashboard/helper/uploadHelper'; | ||||
| import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; | ||||
| import { useVuelidate } from '@vuelidate/core'; | ||||
| import { required, minLength, helpers } from '@vuelidate/validators'; | ||||
| import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators'; | ||||
| import { required, minLength, helpers, url } from '@vuelidate/validators'; | ||||
| import { isValidSlug } from 'shared/helpers/Validators'; | ||||
|  | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import Input from 'dashboard/components-next/input/Input.vue'; | ||||
| @@ -51,12 +51,20 @@ const originalState = reactive({ ...state }); | ||||
|  | ||||
| const liveChatWidgets = computed(() => { | ||||
|   const inboxes = store.getters['inboxes/getInboxes']; | ||||
|   return inboxes | ||||
|   const widgetOptions = 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 = { | ||||
| @@ -71,7 +79,7 @@ const rules = { | ||||
|       isValidSlug | ||||
|     ), | ||||
|   }, | ||||
|   homePageLink: { shouldBeUrl }, | ||||
|   homePageLink: { url }, | ||||
| }; | ||||
|  | ||||
| const v$ = useVuelidate(rules, state); | ||||
| @@ -108,7 +116,7 @@ watch( | ||||
|         widgetColor: newVal.color, | ||||
|         homePageLink: newVal.homepage_link, | ||||
|         slug: newVal.slug, | ||||
|         liveChatWidgetInboxId: newVal.inbox?.id, | ||||
|         liveChatWidgetInboxId: newVal.inbox?.id || '', | ||||
|       }); | ||||
|       if (newVal.logo) { | ||||
|         const { | ||||
| @@ -315,7 +323,9 @@ const handleAvatarDelete = () => { | ||||
|           class="[&>div>button:not(.focused)]:!outline-n-weak" | ||||
|         /> | ||||
|       </div> | ||||
|       <div class="flex items-start justify-between w-full gap-2"> | ||||
|       <div | ||||
|         class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]" | ||||
|       > | ||||
|         <label | ||||
|           class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12" | ||||
|         > | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| <script setup> | ||||
| import { computed, ref } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useAccount } from 'dashboard/composables/useAccount'; | ||||
|  | ||||
| import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue'; | ||||
| import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue'; | ||||
| @@ -11,11 +12,52 @@ const props = defineProps({ | ||||
|     type: Object, | ||||
|     required: true, | ||||
|   }, | ||||
|   isFetchingStatus: { | ||||
|     type: Boolean, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['updatePortalConfiguration']); | ||||
| const emit = defineEmits([ | ||||
|   'updatePortalConfiguration', | ||||
|   'refreshStatus', | ||||
|   'sendCnameInstructions', | ||||
| ]); | ||||
|  | ||||
| const SSL_STATUS = { | ||||
|   LIVE: ['active', 'staging_active'], | ||||
|   PENDING: [ | ||||
|     'provisioned', | ||||
|     'pending', | ||||
|     'initializing', | ||||
|     'pending_validation', | ||||
|     'pending_deployment', | ||||
|     'pending_issuance', | ||||
|     'holding_deployment', | ||||
|     'holding_validation', | ||||
|     'pending_expiration', | ||||
|     'pending_cleanup', | ||||
|     'pending_deletion', | ||||
|     'staging_deployment', | ||||
|     'backup_issued', | ||||
|   ], | ||||
|   ERROR: [ | ||||
|     'blocked', | ||||
|     'inactive', | ||||
|     'moved', | ||||
|     'expired', | ||||
|     'deleted', | ||||
|     'timed_out_initializing', | ||||
|     'timed_out_validation', | ||||
|     'timed_out_issuance', | ||||
|     'timed_out_deployment', | ||||
|     'timed_out_deletion', | ||||
|     'deactivating', | ||||
|   ], | ||||
| }; | ||||
|  | ||||
| const { t } = useI18n(); | ||||
| const { isOnChatwootCloud } = useAccount(); | ||||
|  | ||||
| const addCustomDomainDialogRef = ref(null); | ||||
| const dnsConfigurationDialogRef = ref(null); | ||||
| @@ -25,6 +67,45 @@ const customDomainAddress = computed( | ||||
|   () => props.activePortal?.custom_domain || '' | ||||
| ); | ||||
|  | ||||
| const sslSettings = computed(() => props.activePortal?.ssl_settings || {}); | ||||
| const verificationErrors = computed( | ||||
|   () => sslSettings.value.verification_errors || '' | ||||
| ); | ||||
|  | ||||
| const isLive = computed(() => | ||||
|   SSL_STATUS.LIVE.includes(sslSettings.value.status) | ||||
| ); | ||||
| const isPending = computed(() => | ||||
|   SSL_STATUS.PENDING.includes(sslSettings.value.status) | ||||
| ); | ||||
| const isError = computed(() => | ||||
|   SSL_STATUS.ERROR.includes(sslSettings.value.status) | ||||
| ); | ||||
|  | ||||
| const statusText = computed(() => { | ||||
|   if (isLive.value) | ||||
|     return t( | ||||
|       'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.LIVE' | ||||
|     ); | ||||
|   if (isPending.value) | ||||
|     return t( | ||||
|       'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.PENDING' | ||||
|     ); | ||||
|   if (isError.value) | ||||
|     return t( | ||||
|       'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.ERROR' | ||||
|     ); | ||||
|   return ''; | ||||
| }); | ||||
|  | ||||
| const statusColors = computed(() => { | ||||
|   if (isLive.value) | ||||
|     return { text: 'text-n-teal-11', bubble: 'outline-n-teal-6 bg-n-teal-9' }; | ||||
|   if (isError.value) | ||||
|     return { text: 'text-n-ruby-11', bubble: 'outline-n-ruby-6 bg-n-ruby-9' }; | ||||
|   return { text: 'text-n-amber-11', bubble: 'outline-n-amber-6 bg-n-amber-9' }; | ||||
| }); | ||||
|  | ||||
| const updatePortalConfiguration = customDomain => { | ||||
|   const portal = { | ||||
|     id: props.activePortal?.id, | ||||
| @@ -42,6 +123,17 @@ const closeDNSConfigurationDialog = () => { | ||||
|   updatedDomainAddress.value = ''; | ||||
|   dnsConfigurationDialogRef.value.dialogRef.close(); | ||||
| }; | ||||
|  | ||||
| const onClickRefreshSSLStatus = () => { | ||||
|   emit('refreshStatus'); | ||||
| }; | ||||
|  | ||||
| const onClickSend = email => { | ||||
|   emit('sendCnameInstructions', { | ||||
|     portalSlug: props.activePortal?.slug, | ||||
|     email, | ||||
|   }); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -63,33 +155,76 @@ const closeDNSConfigurationDialog = () => { | ||||
|       </span> | ||||
|     </div> | ||||
|     <div class="flex flex-col w-full gap-4"> | ||||
|       <div class="flex justify-between w-full gap-2"> | ||||
|         <div | ||||
|           v-if="customDomainAddress" | ||||
|           class="flex items-center w-full h-8 gap-4" | ||||
|         > | ||||
|           <label class="text-sm font-medium text-n-slate-12"> | ||||
|       <div class="flex items-center justify-between w-full gap-2"> | ||||
|         <div v-if="customDomainAddress" class="flex flex-col gap-1"> | ||||
|           <div class="flex items-center w-full h-8 gap-4"> | ||||
|             <label class="text-sm font-medium text-n-slate-12"> | ||||
|               {{ | ||||
|                 t( | ||||
|                   'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL' | ||||
|                 ) | ||||
|               }} | ||||
|             </label> | ||||
|             <span class="text-sm text-n-slate-12"> | ||||
|               {{ customDomainAddress }} | ||||
|             </span> | ||||
|           </div> | ||||
|           <span | ||||
|             v-if="!isLive && isOnChatwootCloud" | ||||
|             class="text-sm text-n-slate-11" | ||||
|           > | ||||
|             {{ | ||||
|               t( | ||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL' | ||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION' | ||||
|               ) | ||||
|             }} | ||||
|           </label> | ||||
|           <span class="text-sm text-n-slate-12"> | ||||
|             {{ customDomainAddress }} | ||||
|           </span> | ||||
|         </div> | ||||
|         <div class="flex items-center justify-end w-full"> | ||||
|           <Button | ||||
|             v-if="customDomainAddress" | ||||
|             color="slate" | ||||
|             :label=" | ||||
|               t( | ||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON' | ||||
|               ) | ||||
|             " | ||||
|             @click="addCustomDomainDialogRef.dialogRef.open()" | ||||
|           /> | ||||
|         <div class="flex items-center"> | ||||
|           <div v-if="customDomainAddress" class="flex items-center gap-3"> | ||||
|             <div | ||||
|               v-if="statusText && isOnChatwootCloud" | ||||
|               v-tooltip="verificationErrors" | ||||
|               class="flex items-center gap-3 flex-shrink-0" | ||||
|             > | ||||
|               <span | ||||
|                 class="size-1.5 rounded-full outline outline-2 block flex-shrink-0" | ||||
|                 :class="statusColors.bubble" | ||||
|               /> | ||||
|               <span | ||||
|                 :class="statusColors.text" | ||||
|                 class="text-sm leading-[16px] font-medium" | ||||
|               > | ||||
|                 {{ statusText }} | ||||
|               </span> | ||||
|             </div> | ||||
|             <div | ||||
|               v-if="statusText && isOnChatwootCloud" | ||||
|               class="w-px h-3 bg-n-weak" | ||||
|             /> | ||||
|             <Button | ||||
|               slate | ||||
|               sm | ||||
|               link | ||||
|               :label=" | ||||
|                 t( | ||||
|                   'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON' | ||||
|                 ) | ||||
|               " | ||||
|               class="hover:!no-underline flex-shrink-0" | ||||
|               @click="addCustomDomainDialogRef.dialogRef.open()" | ||||
|             /> | ||||
|             <div v-if="isOnChatwootCloud" class="w-px h-3 bg-n-weak" /> | ||||
|             <Button | ||||
|               v-if="isOnChatwootCloud" | ||||
|               slate | ||||
|               sm | ||||
|               link | ||||
|               icon="i-lucide-refresh-ccw" | ||||
|               :class="isFetchingStatus && 'animate-spin'" | ||||
|               @click="onClickRefreshSSLStatus" | ||||
|             /> | ||||
|           </div> | ||||
|           <Button | ||||
|             v-else | ||||
|             :label=" | ||||
| @@ -112,7 +247,8 @@ const closeDNSConfigurationDialog = () => { | ||||
|     <DNSConfigurationDialog | ||||
|       ref="dnsConfigurationDialogRef" | ||||
|       :custom-domain="updatedDomainAddress || customDomainAddress" | ||||
|       @confirm="closeDNSConfigurationDialog" | ||||
|       @close="closeDNSConfigurationDialog" | ||||
|       @send="onClickSend" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|   | ||||
| @@ -26,6 +26,8 @@ const emit = defineEmits([ | ||||
|   'updatePortal', | ||||
|   'updatePortalConfiguration', | ||||
|   'deletePortal', | ||||
|   'refreshStatus', | ||||
|   'sendCnameInstructions', | ||||
| ]); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
| @@ -36,6 +38,7 @@ const confirmDeletePortalDialogRef = ref(null); | ||||
| const currentPortalSlug = computed(() => route.params.portalSlug); | ||||
|  | ||||
| const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal'); | ||||
| const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus'); | ||||
|  | ||||
| const activePortal = computed(() => { | ||||
|   return props.portals?.find(portal => portal.slug === currentPortalSlug.value); | ||||
| @@ -53,6 +56,14 @@ const handleUpdatePortalConfiguration = portal => { | ||||
|   emit('updatePortalConfiguration', portal); | ||||
| }; | ||||
|  | ||||
| const fetchSSLStatus = () => { | ||||
|   emit('refreshStatus'); | ||||
| }; | ||||
|  | ||||
| const handleSendCnameInstructions = payload => { | ||||
|   emit('sendCnameInstructions', payload); | ||||
| }; | ||||
|  | ||||
| const openConfirmDeletePortalDialog = () => { | ||||
|   confirmDeletePortalDialogRef.value.dialogRef.open(); | ||||
| }; | ||||
| @@ -85,7 +96,10 @@ const handleDeletePortal = () => { | ||||
|         <PortalConfigurationSettings | ||||
|           :active-portal="activePortal" | ||||
|           :is-fetching="isFetching" | ||||
|           :is-fetching-status="isFetchingSSLStatus" | ||||
|           @update-portal-configuration="handleUpdatePortalConfiguration" | ||||
|           @refresh-status="fetchSSLStatus" | ||||
|           @send-cname-instructions="handleSendCnameInstructions" | ||||
|         /> | ||||
|         <div class="w-full h-px bg-n-weak" /> | ||||
|         <div class="flex items-end justify-between w-full gap-4"> | ||||
|   | ||||
| @@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt); | ||||
| const inbox = computed(() => props.stateInbox); | ||||
|  | ||||
| const inboxIcon = computed(() => { | ||||
|   const { phoneNumber, channelType } = inbox.value; | ||||
|   return getInboxIconByType(channelType, phoneNumber); | ||||
|   const { channelType, medium } = inbox.value; | ||||
|   return getInboxIconByType(channelType, medium); | ||||
| }); | ||||
|  | ||||
| const hasSlaThreshold = computed(() => { | ||||
| @@ -63,11 +63,12 @@ const lastActivityAt = computed(() => { | ||||
| }); | ||||
|  | ||||
| const menuItems = computed(() => [ | ||||
|   { key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') }, | ||||
|   { | ||||
|     key: isUnread.value ? 'mark_as_read' : 'mark_as_unread', | ||||
|     icon: isUnread.value ? 'mail' : 'mail-unread', | ||||
|     label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`), | ||||
|   }, | ||||
|   { key: 'delete', icon: 'delete', label: t('INBOX.MENU_ITEM.DELETE') }, | ||||
| ]); | ||||
|  | ||||
| const messageClasses = computed(() => ({ | ||||
| @@ -153,7 +154,7 @@ onBeforeMount(contextMenuActions.close); | ||||
| <template> | ||||
|   <div | ||||
|     role="button" | ||||
|     class="flex flex-col w-full gap-2 p-3 transition-all duration-300 ease-in-out cursor-pointer" | ||||
|     class="flex flex-col w-full gap-1 p-3 transition-all duration-300 ease-in-out cursor-pointer" | ||||
|     @contextmenu="contextMenuActions.open($event)" | ||||
|     @click="emit('click')" | ||||
|   > | ||||
| @@ -232,7 +233,7 @@ onBeforeMount(contextMenuActions.close); | ||||
|             class="flex-shrink-0 text-n-slate-11 size-2.5" | ||||
|           /> | ||||
|         </div> | ||||
|         <span class="text-sm text-n-slate-10"> | ||||
|         <span class="text-xs text-n-slate-10"> | ||||
|           {{ lastActivityAt }} | ||||
|         </span> | ||||
|       </div> | ||||
|   | ||||
| @@ -15,7 +15,7 @@ const props = defineProps({ | ||||
| const emit = defineEmits(['remove', 'hover']); | ||||
|  | ||||
| const handleRemoveLabel = () => { | ||||
|   emit('remove', props.label?.id); | ||||
|   emit('remove', props.label); | ||||
| }; | ||||
|  | ||||
| const handleMouseEnter = () => { | ||||
| @@ -45,6 +45,7 @@ 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 | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	 Muhsin Keloth
					Muhsin Keloth