Compare commits

..
Author SHA1 Message Date
Shivam Mishra b9d8144709 feat: specs for SLO 2025-09-10 15:24:23 +05:30
Shivam Mishra 63b7b91aad feat: add slo route 2025-09-10 15:21:19 +05:30
Shivam Mishra 690b956f1c fix: SessionManagable not included 2025-09-10 15:20:58 +05:30
Shivam MishraandGitHub 88b1dfb89d Merge branch 'feat/saml-ui' into feat/saml-logout 2025-09-10 13:55:53 +05:30
Shivam MishraandGitHub cc875bf277 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-10 13:55:48 +05:30
Shivam MishraandGitHub 9c04202871 Merge branch 'develop' into feat/saml-controllers 2025-09-10 13:55:31 +05:30
Shivam Mishra 40830f2707 chore: revert debug change 2025-09-10 13:45:53 +05:30
Shivam Mishra cc83fdcc8e feat: add SLO proc 2025-09-10 13:40:27 +05:30
Shivam MishraandGitHub af1f7cc1d2 Merge branch 'feat/saml-ui' into feat/saml-logout 2025-09-10 13:38:49 +05:30
Shivam Mishra 9ded118059 style: remove let! 2025-09-10 12:31:38 +05:30
Shivam Mishra 1a78b68625 style: Remove redundant sort 2025-09-10 12:30:53 +05:30
Shivam Mishra 43d2b37d64 feat: add specs for passwords controller 2025-09-10 12:29:54 +05:30
Shivam Mishra 5aaf331851 feat: add omniauth tests 2025-09-10 12:26:12 +05:30
Shivam Mishra da61c9855b feat: add test for sessions controller 2025-09-10 11:57:39 +05:30
Shivam MishraandGitHub 334aa09005 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-10 11:02:43 +05:30
Shivam Mishra fea2a695f1 refactor: proc name 2025-09-10 11:02:31 +05:30
Shivam MishraandGitHub 80018c58b9 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-10 11:00:25 +05:30
Shivam Mishra c31200b9e5 feat: better intializer loading 2025-09-10 10:59:59 +05:30
Shivam MishraandGitHub 86fa54dd13 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-10 10:56:22 +05:30
Shivam Mishra 7a9840717e feat: conditionally load saml 2025-09-10 10:56:06 +05:30
Shivam Mishra f1f2b3f302 Revert "feat: move saml init to enterprise"
This reverts commit e0a5a63f2e.
2025-09-10 10:55:00 +05:30
Shivam MishraandGitHub efbf837861 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-10 10:47:16 +05:30
Shivam MishraandGitHub 3a3f5c9469 Merge branch 'develop' into feat/saml-controllers 2025-09-10 10:46:50 +05:30
Shivam Mishra e0a5a63f2e feat: move saml init to enterprise 2025-09-10 10:46:00 +05:30
Shivam Mishra 4f6facb6ac refactor: move saml strategy to a separate proc 2025-09-10 10:26:29 +05:30
Shivam Mishra 2634050f1a refactor: remove redundant validation 2025-09-10 10:16:23 +05:30
Shivam Mishra 0f2b278dcd fix: validate SSO token in SAML user authentication check
Previously, SAML users could bypass password authentication restrictions
by including any sso_auth_token parameter, even invalid ones. Now the
method properly validates SSO tokens against Redis storage before
allowing the bypass, closing the security vulnerability while maintaining
legitimate SSO functionality.
2025-09-10 10:14:01 +05:30
Shivam Mishra b15f408e6a feat: add idp initated logout handler 2025-09-09 15:13:07 +05:30
Shivam Mishra 92a04e4ab4 fix: sp id not saved 2025-09-09 13:42:33 +05:30
Shivam MishraandGitHub cedacac9eb Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-09 13:17:19 +05:30
Shivam MishraandGitHub 85150dd418 Merge branch 'develop' into feat/saml-controllers 2025-09-09 13:16:43 +05:30
Shivam Mishra 56e7166ec7 feat: remove url requirement 2025-09-08 16:39:17 +05:30
Shivam Mishra 1030bf69e1 feat: add certificate validation 2025-09-02 16:52:16 +05:30
Shivam Mishra 4b12f13926 feat: add validations 2025-09-02 16:47:03 +05:30
Shivam Mishra ab64f53e67 feat: rtl fixes 2025-09-02 16:28:07 +05:30
Shivam Mishra 0d54ebf85b feat: handle idp and sp entity ids separately 2025-09-02 15:46:13 +05:30
Shivam Mishra 8859017642 chore: use existing figerprint method 2025-09-02 15:35:42 +05:30
Shivam MishraandGitHub 315d471468 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-02 15:33:11 +05:30
Shivam Mishra 2b08ac4a02 feat: use correct id 2025-09-02 15:32:23 +05:30
Shivam MishraandGitHub d8f306ef5c Merge branch 'feat/sso-models' into feat/saml-controllers 2025-09-02 15:29:41 +05:30
Shivam Mishra 66abb424e4 test: certificate fingerprint 2025-09-02 15:27:45 +05:30
Shivam Mishra ba94ba3bf4 feat: add certificate fingerprint 2025-09-02 15:27:07 +05:30
Shivam Mishra 8b93241ed6 feat: include idp_entity_id and ceriticate details 2025-09-02 15:20:08 +05:30
Shivam Mishra 7cd0863487 feat: update model 2025-09-02 15:13:43 +05:30
Shivam Mishra 468413158f feat: avoid redundant fetch 2025-09-02 14:38:30 +05:30
Shivam MishraandGitHub a5cb283f42 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-01 14:13:04 +05:30
Shivam MishraandGitHub cd75ac262a Merge branch 'feat/sso-models' into feat/saml-controllers 2025-09-01 14:13:02 +05:30
Shivam MishraandGitHub 9081da755d Merge branch 'develop' into feat/sso-models 2025-09-01 14:12:51 +05:30
Shivam Mishra 2156598dde fix: no guard against missing account 2025-09-01 14:00:21 +05:30
Shivam Mishra ac51a2b3d9 fix: info order 2025-09-01 13:34:45 +05:30
Shivam Mishra 3d9cb97bbb fix: save fingerprint from the response 2025-09-01 13:33:38 +05:30
Shivam Mishra 5ba79036ea chore: docs 2025-09-01 13:30:42 +05:30
Shivam Mishra edd78ff4bc fix: remove fingerprint on disable 2025-09-01 13:29:54 +05:30
Shivam Mishra f6b3cb6295 feat: add additional SAML information 2025-09-01 13:28:55 +05:30
Shivam Mishra 5227b4169f feat: include fingerprint and certificate 2025-09-01 13:27:39 +05:30
Shivam Mishra 61eb729a11 feat: add help route 2025-09-01 12:30:37 +05:30
Shivam Mishra 1f41e53ef5 feat: add paywall 2025-09-01 12:27:18 +05:30
Shivam Mishra 73992bef97 feat: include in feature flags 2025-09-01 12:19:58 +05:30
Shivam Mishra 2dbd43217c feat: add SAML settings UI 2025-09-01 12:10:58 +05:30
Shivam Mishra b81683a4de feat: add saml settings API 2025-09-01 12:10:37 +05:30
Shivam MishraandGitHub 71321c2548 Merge branch 'develop' into feat/sso-models 2025-08-28 19:43:05 +05:30
Shivam Mishra b22425d5fb Merge branch 'feat/sso-models' of github.com:chatwoot/chatwoot into feat/saml-controllers 2025-08-28 17:54:15 +05:30
Shivam MishraandGitHub 9b8d6bfc74 Merge branch 'develop' into feat/sso-models 2025-08-28 17:52:15 +05:30
Shivam Mishra d48a7aac64 feat: update gems 2025-08-28 17:25:24 +05:30
Shivam Mishra eca7704bda Merge branch 'feat/sso-models' of github.com:chatwoot/chatwoot into feat/saml-controllers 2025-08-28 16:58:48 +05:30
Shivam Mishra cb887f0dd9 test: fix rubocop issue 2025-08-28 16:54:21 +05:30
Shivam Mishra 3c0f053983 chore: docs 2025-08-28 16:50:51 +05:30
Shivam Mishra 35b09fd8b8 refactor: account_id is not optional 2025-08-28 16:50:40 +05:30
Shivam Mishra cbe5936b7b refactor: builder 2025-08-28 16:47:07 +05:30
Shivam Mishra 184b735846 chore: remove enabled check 2025-08-28 16:39:12 +05:30
Shivam Mishra 594ca1f526 chore: remove attribute map spec 2025-08-28 16:38:13 +05:30
Shivam Mishra 7c78fd3087 test: update specs to handle removed fields 2025-08-28 16:36:59 +05:30
Shivam Mishra f818f462ea chore: remove attribute mapping 2025-08-28 16:33:29 +05:30
Shivam Mishra f17de99d1b Merge branch 'feat/sso-models' of github.com:chatwoot/chatwoot into feat/saml-controllers 2025-08-28 16:31:30 +05:30
Shivam Mishra 94bbfff8fd chore: remove unnecessary change 2025-08-28 16:30:22 +05:30
Shivam Mishra abf07c44c2 chore: revert unrelated change 2025-08-28 16:28:39 +05:30
Shivam Mishra 0c9e4f430a feat: remove enforced_sso and attribute_mappings field 2025-08-28 16:27:44 +05:30
Shivam Mishra 846ea71e56 feat: remove certificate fingerprint field 2025-08-28 16:13:17 +05:30
Shivam Mishra 85fb20013e feat: remove enabled field 2025-08-28 16:09:07 +05:30
Shivam Mishra 04cc25d55e chore: remove debugging files 2025-08-28 16:02:46 +05:30
Shivam Mishra b999fe6439 chore: remove spec 2025-08-28 16:02:01 +05:30
Shivam Mishra 589daac302 chore: revert spec 2025-08-28 16:01:02 +05:30
Shivam Mishra 87171c5b53 feat: remove old controller 2025-08-28 16:00:29 +05:30
Shivam Mishra 40f476315b chore: remove redundant route 2025-08-28 15:58:39 +05:30
Shivam Mishra 2b533aa135 feat: remove saml methods from user 2025-08-28 15:57:35 +05:30
Shivam Mishra 3fb2506aab feat: move SAML builder to enterprise 2025-08-28 15:56:51 +05:30
Shivam Mishra 9598021f87 chore: remove redundant route 2025-08-28 15:50:35 +05:30
Shivam Mishra d7265f21ad feat: remove redundant controller 2025-08-28 15:48:56 +05:30
Shivam Mishra 9f19c41b35 chore: remove whitespace change 2025-08-28 15:47:59 +05:30
Shivam Mishra 995f56f0ad feat: move SAML code to enterprise 2025-08-28 15:44:17 +05:30
Shivam Mishra 34bc1dc4bb chore: revert redundant change 2025-08-28 15:28:35 +05:30
Shivam Mishra 44e997e3bf refactor: simplify auth hash 2025-08-28 15:28:02 +05:30
Shivam Mishra 73e6bc8ecd chore: remove allow_other_host config 2025-08-28 15:26:49 +05:30
Shivam Mishra ea8446ecfe chore: remove redundant files 2025-08-28 15:22:35 +05:30
Shivam Mishra 314ab83040 chore: remove spec 2025-08-28 15:20:54 +05:30
Shivam Mishra 90a38d1065 refactor: merge duplicate tests 2025-08-28 15:15:47 +05:30
Shivam Mishra d5b7068394 refactor: cleaner saml check 2025-08-28 15:06:15 +05:30
Shivam Mishra 379ef876eb feat: add saml enabled check 2025-08-28 15:03:54 +05:30
Shivam Mishra 5fb0c80891 feat: move saml methods to enterprise 2025-08-28 15:03:23 +05:30
Shivam Mishra 5419b949a4 style: rubocop errors 2025-08-28 14:47:55 +05:30
Shivam Mishra f6fe1b130b feat: don't allow password login for SAML user 2025-08-28 14:34:18 +05:30
Shivam Mishra ecc1857f63 feat: don't allow SAML user to reset password 2025-08-28 14:27:05 +05:30
Shivam Mishra 5f0feae688 test: user saml methods 2025-08-28 14:26:47 +05:30
Shivam Mishra 1dd8f73c90 test: saml builder 2025-08-28 14:26:35 +05:30
Shivam Mishra f2eb8c37af feat: allow converting existing users to SAML 2025-08-28 13:54:06 +05:30
Shivam Mishra 9b09ae0e14 feat: add saml error messages 2025-08-28 13:53:47 +05:30
Shivam Mishra 091586177c feat: add saml helpers 2025-08-28 13:52:54 +05:30
Shivam Mishra cac97c543b refactor: clean up callbacks 2025-08-28 13:35:20 +05:30
Shivam Mishra 03dc1b6352 refactor: split success callback 2025-08-28 13:31:03 +05:30
Shivam Mishra 42ca3d97e6 feat: add specs 2025-08-28 13:26:42 +05:30
Shivam Mishra 2b1ded8f4d feat: handle user created but not associated to the account 2025-08-28 13:26:27 +05:30
Shivam Mishra 2eadb61b8e chore: clean debug logs 2025-08-28 12:14:19 +05:30
Shivam Mishra 88115441b9 WIP 2025-08-27 18:40:13 +05:30
Shivam Mishra e17694da73 refactor: use from_email method 2025-08-27 14:28:52 +05:30
Shivam Mishra e8b0b24b36 feat: implement login 2025-08-27 14:27:19 +05:30
Shivam Mishra 4fcee3bbba feat: mock login flow 2025-08-27 14:15:21 +05:30
Shivam Mishra f9d4816db1 feat: kickstart basic controller 2025-08-27 12:27:27 +05:30
Shivam MishraandGitHub 6274f0f803 Merge branch 'develop' into feat/sso-models 2025-08-25 16:45:59 +05:30
Shivam Mishra 572df07365 style: autofix rubocop 2025-08-25 14:40:20 +05:30
Shivam Mishra 5079e0b3b9 feat: move spec to enterprise 2025-08-25 14:39:27 +05:30
Shivam Mishra 92df56f738 fix: saml settings certificate check 2025-08-25 14:29:43 +05:30
Shivam Mishra 7a33065efe fix: certificate 2025-08-25 14:29:30 +05:30
Shivam Mishra 99ab1eadb5 style: fix rubocop 2025-08-25 14:28:58 +05:30
Shivam Mishra 7549a2a1d4 feat: add fingerprint generation 2025-08-25 14:21:57 +05:30
Shivam Mishra 0562647ab1 feat: add specs 2025-08-25 13:57:06 +05:30
Shivam Mishra f5c45c35f2 fix: saml association 2025-08-25 13:56:55 +05:30
Shivam Mishra d06e2cfd98 feat: add saml settings controller 2025-08-25 13:43:43 +05:30
Shivam Mishra e9d852969f chore: update model annotation 2025-08-25 13:43:28 +05:30
Shivam Mishra 71285b368c feat: add saml policy 2025-08-25 13:43:16 +05:30
Shivam Mishra f7323b6dec feat: add jbuilders 2025-08-25 13:43:05 +05:30
Shivam Mishra 94fab7ad1c fix: schema 2025-08-25 13:28:32 +05:30
Shivam Mishra b3fef8f110 feat: feature flag for saml 2025-08-25 13:21:19 +05:30
Shivam Mishra 81cf106578 feat: update models 2025-08-25 13:21:11 +05:30
Shivam Mishra a197f64d2c feat: run migration 2025-08-25 13:09:20 +05:30
Shivam Mishra 67b8674337 feat: add models 2025-08-25 13:08:30 +05:30
Shivam Mishra 46f7390cbe feat: add saml models 2025-08-25 13:08:12 +05:30
889 changed files with 2206 additions and 43103 deletions
-7
View File
@@ -6,13 +6,6 @@
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Active Record Encryption keys (required for MFA/2FA functionality)
# Generate these keys by running: rails db:encryption:init
# IMPORTANT: Use different keys for each environment (development, staging, production)
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages
-99
View File
@@ -1,99 +0,0 @@
name: Run MFA Tests
permissions:
contents: read
on:
pull_request:
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-22.04
# Only run if MFA test keys are available
if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
services:
postgres:
image: pgvector/pgvector:pg15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
env:
RAILS_ENV: test
POSTGRES_HOST: localhost
# Active Record encryption keys required for MFA - test keys only, not for production use
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Create database
run: bundle exec rake db:create
- name: Install pgvector extension
run: |
PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
- name: Seed database
run: bundle exec rake db:schema:load
- name: Run MFA-related backend tests
run: |
bundle exec rspec \
spec/services/mfa/token_service_spec.rb \
spec/services/mfa/authentication_service_spec.rb \
spec/requests/api/v1/profile/mfa_controller_spec.rb \
spec/controllers/devise_overrides/sessions_controller_spec.rb \
--profile=10 \
--format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Run MFA-related tests in user_spec
run: |
# Run specific MFA-related tests from user_spec
bundle exec rspec spec/models/user_spec.rb \
-e "two factor" \
-e "2FA" \
-e "MFA" \
-e "otp" \
-e "backup code" \
--profile=10 \
--format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload test logs
uses: actions/upload-artifact@v4
if: failure()
with:
name: mfa-test-logs
path: |
log/test.log
tmp/screenshots/
-4
View File
@@ -95,7 +95,3 @@ yarn-debug.log*
.claude/settings.local.json
.cursor
CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
+1 -3
View File
@@ -78,8 +78,6 @@ 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'
@@ -103,7 +101,7 @@ gem 'twitty', '~> 0.1.5'
# facebook client
gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.7.0'
gem 'slack-ruby-client', '~> 2.5.2'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'grpc'
+5 -12
View File
@@ -212,11 +212,6 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.1.0)
activesupport (>= 7.0, < 8.1)
devise (~> 4.0)
railties (>= 7.0, < 8.1)
rotp (~> 6.0)
devise_token_auth (1.2.5)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
@@ -292,7 +287,7 @@ GEM
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (1.0.0)
faraday-mashify (0.1.1)
faraday (~> 2.0)
hashie
faraday-multipart (1.0.4)
@@ -727,8 +722,7 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.4.4)
rotp (6.3.0)
rexml (3.4.1)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
@@ -876,8 +870,8 @@ GEM
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)
slack-ruby-client (2.5.2)
faraday (>= 2.0)
faraday-mashify
faraday-multipart
gli
@@ -1011,7 +1005,6 @@ DEPENDENCIES
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
@@ -1103,7 +1096,7 @@ DEPENDENCIES
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
slack-ruby-client (~> 2.7.0)
slack-ruby-client (~> 2.5.2)
spring
spring-watcher-listen
squasher
+4 -3
View File
@@ -2,8 +2,9 @@
# 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
# Load Enterprise Edition rake tasks if they exist
enterprise_tasks_path = Rails.root.join('enterprise/lib/tasks.rb').to_s
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
-2
View File
@@ -52,5 +52,3 @@ class AgentBuilder
}.compact))
end
end
AgentBuilder.prepend_mod_with('AgentBuilder')
-54
View File
@@ -1,54 +0,0 @@
class Email::BaseBuilder
pattr_initialize [:inbox!]
private
def channel
@channel ||= inbox.channel
end
def account
@account ||= inbox.account
end
def conversation
@conversation ||= message.conversation
end
def custom_sender_name
message&.sender&.available_name || I18n.t('conversations.reply.email.header.notifications')
end
def sender_name(sender_email)
# Friendly: <agent_name> from <business_name>
# Professional: <business_name>
if inbox.friendly?
I18n.t(
'conversations.reply.email.header.friendly_name',
sender_name: custom_sender_name,
business_name: business_name,
from_email: sender_email
)
else
I18n.t(
'conversations.reply.email.header.professional_name',
business_name: business_name,
from_email: sender_email
)
end
end
def business_name
inbox.business_name || inbox.sanitized_name
end
def account_support_email
# Parse the email to ensure it's in the correct format, the user
# can save it in the format "Name <email@domain.com>"
parse_email(account.support_email)
end
def parse_email(email_string)
Mail::Address.new(email_string).address
end
end
-51
View File
@@ -1,51 +0,0 @@
class Email::FromBuilder < Email::BaseBuilder
pattr_initialize [:inbox!, :message!]
def build
return sender_name(account_support_email) unless inbox.email?
from_email = case email_channel_type
when :standard_imap_smtp,
:google_oauth,
:microsoft_oauth,
:forwarding_own_smtp
channel.email
when :imap_chatwoot_smtp,
:forwarding_chatwoot_smtp
channel.verified_for_sending ? channel.email : account_support_email
else
account_support_email
end
sender_name(from_email)
end
private
def email_channel_type
return :google_oauth if channel.google?
return :microsoft_oauth if channel.microsoft?
return :standard_imap_smtp if imap_and_smtp_enabled?
return :imap_chatwoot_smtp if imap_enabled_without_smtp?
return :forwarding_own_smtp if forwarding_with_own_smtp?
return :forwarding_chatwoot_smtp if forwarding_without_smtp?
:unknown
end
def imap_and_smtp_enabled?
channel.imap_enabled && channel.smtp_enabled
end
def imap_enabled_without_smtp?
channel.imap_enabled && !channel.smtp_enabled
end
def forwarding_with_own_smtp?
!channel.imap_enabled && channel.smtp_enabled
end
def forwarding_without_smtp?
!channel.imap_enabled && !channel.smtp_enabled
end
end
-21
View File
@@ -1,21 +0,0 @@
class Email::ReplyToBuilder < Email::BaseBuilder
pattr_initialize [:inbox!, :message!]
def build
reply_to = if inbox.email?
channel.email
elsif inbound_email_enabled?
"reply+#{conversation.uuid}@#{account.inbound_email_domain}"
else
account_support_email
end
sender_name(reply_to)
end
private
def inbound_email_enabled?
account.feature_enabled?('inbound_emails') && account.inbound_email_domain.present?
end
end
@@ -85,8 +85,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
return {} if permitted_params[:inbox_id].blank?
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :validate_feature_enabled!
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -64,6 +65,15 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
def validate_feature_enabled!
return if Current.account.feature_whatsapp_embedded_signup?
render json: {
success: false,
error: 'WhatsApp embedded signup is not enabled for this account'
}, status: :forbidden
end
def validate_embedded_signup_params!
missing_params = []
missing_params << 'code' if params[:code].blank?
@@ -1,68 +0,0 @@
class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
def show; end
def create
mfa_service.enable_two_factor!
end
def verify
@backup_codes = mfa_service.verify_and_activate!
end
def destroy
mfa_service.disable_two_factor!
end
def backup_codes
@backup_codes = mfa_service.generate_backup_codes!
end
private
def mfa_service
@mfa_service ||= Mfa::ManagementService.new(user: current_user)
end
def check_mfa_enabled
render_could_not_create_error(I18n.t('errors.mfa.not_enabled')) unless current_user.mfa_enabled?
end
def check_mfa_feature_available
return if Chatwoot.mfa_enabled?
render json: {
error: I18n.t('errors.mfa.feature_unavailable')
}, status: :forbidden
end
def check_mfa_disabled
render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled?
end
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code]
).authenticate
return if authenticated
render_could_not_create_error(I18n.t('errors.mfa.invalid_code'))
end
def validate_password
return if current_user.valid_password?(mfa_params[:password])
render_could_not_create_error(I18n.t('errors.mfa.invalid_credentials'))
end
def mfa_params
params.permit(:otp_code, :password)
end
end
@@ -23,29 +23,24 @@ module RequestExceptionHandler
Current.reset
end
def render_error(message, status, error = nil)
log_handled_error(error) if error
render json: { error: message }, status: status
end
def render_unauthorized(message)
render_error(message, :unauthorized)
render json: { error: message }, status: :unauthorized
end
def render_not_found_error(message)
render_error(message, :not_found)
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(message)
render_error(message, :unprocessable_entity)
render json: { error: message }, status: :unprocessable_entity
end
def render_payment_required(message)
render_error(message, :payment_required)
render json: { error: message }, status: :payment_required
end
def render_internal_server_error(message)
render_error(message, :internal_server_error)
render json: { error: message }, status: :internal_server_error
end
def render_record_invalid(exception)
@@ -62,23 +57,6 @@ module RequestExceptionHandler
end
def log_handled_error(exception)
return unless exception
logger.info("Handled error: #{exception.inspect}")
report_to_apms(exception)
end
def report_to_apms(exception)
apm_reporters = {
'NewRelic::Agent' => -> { ::NewRelic::Agent.notice_error(exception) },
'Datadog::Tracing' => -> { ::Datadog::Tracing.active_trace&.set_error(exception) },
'ElasticAPM' => -> { ::ElasticAPM.report(exception) },
'ScoutApm::Error' => -> { ::ScoutApm::Error.capture(exception) },
'Sentry' => -> { ::Sentry.capture_exception(exception) }
}
apm_reporters.each do |module_name, reporter|
reporter.call if Object.const_defined?(module_name)
end
end
end
@@ -9,14 +9,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def create
return handle_mfa_verification if mfa_verification_request?
return handle_sso_authentication if sso_authentication_request?
user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled?
# Only proceed with standard authentication if no MFA is required
super
# Authenticate user via the temporary sso auth token
if params[:sso_auth_token].present? && @resource.present?
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
else
super
end
end
def render_create_success
@@ -25,31 +25,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
def find_user_for_authentication
return nil unless params[:email].present? && params[:password].present?
normalized_email = params[:email].strip.downcase
user = User.from_email(normalized_email)
return nil unless user&.valid_password?(params[:password])
return nil unless user.active_for_authentication?
user
end
def mfa_verification_request?
params[:mfa_token].present?
end
def sso_authentication_request?
params[:sso_auth_token].present? && @resource.present?
end
def handle_sso_authentication
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
end
def login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
@@ -71,41 +46,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = User.from_email(params[:email])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
def handle_mfa_required(user)
render json: {
mfa_required: true,
mfa_token: Mfa::TokenService.new(user: user).generate_token
}, status: :partial_content
end
def handle_mfa_verification
user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token
return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user
authenticated = Mfa::AuthenticationService.new(
user: user,
otp_code: params[:otp_code],
backup_code: params[:backup_code]
).authenticate
return render_mfa_error('errors.mfa.invalid_code') unless authenticated
sign_in_mfa_user(user)
end
def sign_in_mfa_user(user)
@resource = user
@token = @resource.create_token
@resource.save!
sign_in(:user, @resource, store: false, bypass: false)
render_create_success
end
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
@@ -3,7 +3,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
def index
@conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations
@conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations
end
def show; end
@@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
redirect_to new_super_admin_user_path, notice: notice
end
end
def update
requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present?
super
end
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
+1 -6
View File
@@ -70,12 +70,7 @@ class WidgetsController < ActionController::Base
end
def allow_iframe_requests
if @web_widget.allowed_domains.blank?
response.headers.delete('X-Frame-Options')
else
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
end
response.headers.delete('X-Frame-Options')
end
end
+1 -1
View File
@@ -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
+9 -44
View File
@@ -6,54 +6,19 @@ class EmailChannelFinder
end
def perform
channel_from_primary_recipients || channel_from_bcc_recipients
end
channel = nil
private
recipient_mails.each do |email|
normalized_email = normalize_email_with_plus_addressing(email)
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
def channel_from_primary_recipients
primary_recipient_emails.each do |email|
channel = channel_from_email(email)
return channel if channel.present?
break if channel.present?
end
nil
channel
end
def channel_from_bcc_recipients
bcc_recipient_emails.each do |email|
channel = channel_from_email(email)
# Skip if BCC processing is disabled for this account
next if channel && !allow_bcc_processing?(channel.account_id)
return channel if channel.present?
end
nil
end
def primary_recipient_emails
(@email_object.to.to_a + @email_object.cc.to_a + [@email_object['X-Original-To'].try(:value)]).flatten.compact
end
def bcc_recipient_emails
@email_object.bcc.to_a.flatten.compact
end
def channel_from_email(email)
normalized_email = normalize_email_with_plus_addressing(email)
Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
end
def bcc_processing_skipped_accounts
config_value = GlobalConfigService.load('SKIP_INCOMING_BCC_PROCESSING', '')
return [] if config_value.blank?
config_value.split(',').map(&:to_i)
end
def allow_bcc_processing?(account_id)
bcc_processing_skipped_accounts.exclude?(account_id)
def recipient_mails
recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
recipient_addresses.flatten.compact
end
end
+1 -2
View File
@@ -1,5 +1,4 @@
module PortalHelper
include UrlHelper
def set_og_image_url(portal_name, title)
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
return if cdn_url.blank?
@@ -80,7 +79,7 @@ module PortalHelper
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)
query_params['utm_source'] = URI.parse(referer).host if referer.present? && referer.match?(URI::DEFAULT_PARSER.make_regexp)
url.query = query_params.to_query
url.to_s
@@ -1,43 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class AgentCapacityPolicies extends ApiClient {
constructor() {
super('agent_capacity_policies', { accountScoped: true });
}
getUsers(policyId) {
return axios.get(`${this.url}/${policyId}/users`);
}
addUser(policyId, userData) {
return axios.post(`${this.url}/${policyId}/users`, {
user_id: userData.id,
capacity: userData.capacity,
});
}
removeUser(policyId, userId) {
return axios.delete(`${this.url}/${policyId}/users/${userId}`);
}
createInboxLimit(policyId, limitData) {
return axios.post(`${this.url}/${policyId}/inbox_limits`, {
inbox_id: limitData.inboxId,
conversation_limit: limitData.conversationLimit,
});
}
updateInboxLimit(policyId, limitId, limitData) {
return axios.put(`${this.url}/${policyId}/inbox_limits/${limitId}`, {
conversation_limit: limitData.conversationLimit,
});
}
deleteInboxLimit(policyId, limitId) {
return axios.delete(`${this.url}/${policyId}/inbox_limits/${limitId}`);
}
}
export default new AgentCapacityPolicies();
@@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient {
super('captain/assistant_responses', { accountScoped: true });
}
get({ page = 1, search, assistantId, documentId, status } = {}) {
get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
return axios.get(this.url, {
params: {
page,
search,
searchKey,
assistant_id: assistantId,
document_id: documentId,
status,
-28
View File
@@ -1,28 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class MfaAPI extends ApiClient {
constructor() {
super('profile/mfa', { accountScoped: false });
}
enable() {
return axios.post(`${this.url}`);
}
verify(otpCode) {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
disable(password, otpCode) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode },
});
}
regenerateBackupCodes(otpCode) {
return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode });
}
}
export default new MfaAPI();
@@ -1,98 +0,0 @@
import agentCapacityPolicies from '../agentCapacityPolicies';
import ApiClient from '../ApiClient';
describe('#AgentCapacityPoliciesAPI', () => {
it('creates correct instance', () => {
expect(agentCapacityPolicies).toBeInstanceOf(ApiClient);
expect(agentCapacityPolicies).toHaveProperty('get');
expect(agentCapacityPolicies).toHaveProperty('show');
expect(agentCapacityPolicies).toHaveProperty('create');
expect(agentCapacityPolicies).toHaveProperty('update');
expect(agentCapacityPolicies).toHaveProperty('delete');
expect(agentCapacityPolicies).toHaveProperty('getUsers');
expect(agentCapacityPolicies).toHaveProperty('addUser');
expect(agentCapacityPolicies).toHaveProperty('removeUser');
expect(agentCapacityPolicies).toHaveProperty('createInboxLimit');
expect(agentCapacityPolicies).toHaveProperty('updateInboxLimit');
expect(agentCapacityPolicies).toHaveProperty('deleteInboxLimit');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
post: vi.fn(() => Promise.resolve()),
put: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
// Mock accountIdFromRoute
Object.defineProperty(agentCapacityPolicies, 'accountIdFromRoute', {
get: () => '1',
configurable: true,
});
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getUsers', () => {
agentCapacityPolicies.getUsers(123);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/users'
);
});
it('#addUser', () => {
const userData = { id: 456, capacity: 20 };
agentCapacityPolicies.addUser(123, userData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/users',
{
user_id: 456,
capacity: 20,
}
);
});
it('#removeUser', () => {
agentCapacityPolicies.removeUser(123, 456);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/users/456'
);
});
it('#createInboxLimit', () => {
const limitData = { inboxId: 1, conversationLimit: 10 };
agentCapacityPolicies.createInboxLimit(123, limitData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits',
{
inbox_id: 1,
conversation_limit: 10,
}
);
});
it('#updateInboxLimit', () => {
const limitData = { conversationLimit: 15 };
agentCapacityPolicies.updateInboxLimit(123, 789, limitData);
expect(axiosMock.put).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789',
{
conversation_limit: 15,
}
);
});
it('#deleteInboxLimit', () => {
agentCapacityPolicies.deleteInboxLimit(123, 789);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789'
);
});
});
});
@@ -1,116 +0,0 @@
<script setup>
import AgentCapacityPolicyCard from './AgentCapacityPolicyCard.vue';
const mockUsers = [
{
id: 1,
name: 'John Smith',
email: 'john.smith@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=1',
},
{
id: 2,
name: 'Sarah Johnson',
email: 'sarah.johnson@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=2',
},
{
id: 3,
name: 'Mike Chen',
email: 'mike.chen@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=3',
},
{
id: 4,
name: 'Emily Davis',
email: 'emily.davis@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=4',
},
{
id: 5,
name: 'Alex Rodriguez',
email: 'alex.rodriguez@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=5',
},
];
const withCount = policy => ({
...policy,
assignedAgentCount: policy.users.length,
});
const policyA = withCount({
id: 1,
name: 'High Volume Support',
description:
'Capacity-based policy for handling high conversation volumes with experienced agents',
users: [mockUsers[0], mockUsers[1], mockUsers[2]],
isFetchingUsers: false,
});
const policyB = withCount({
id: 2,
name: 'Specialized Team',
description: 'Custom capacity limits for specialized support team members',
users: [mockUsers[3], mockUsers[4]],
isFetchingUsers: false,
});
const emptyPolicy = withCount({
id: 3,
name: 'New Policy',
description: 'Recently created policy with no assigned agents yet',
users: [],
isFetchingUsers: false,
});
const loadingPolicy = withCount({
id: 4,
name: 'Loading Policy',
description: 'Policy currently loading agent information',
users: [],
isFetchingUsers: true,
});
const onEdit = id => console.log('Edit policy:', id);
const onDelete = id => console.log('Delete policy:', id);
const onFetchUsers = id => console.log('Fetch users for policy:', id);
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AgentCapacityPolicyCard"
:layout="{ type: 'grid', width: '1200px' }"
>
<Variant title="Multiple Cards (Various States)">
<div class="p-4 bg-n-background">
<div class="grid grid-cols-1 gap-4">
<AgentCapacityPolicyCard
v-bind="policyA"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
<AgentCapacityPolicyCard
v-bind="policyB"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
<AgentCapacityPolicyCard
v-bind="emptyPolicy"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
<AgentCapacityPolicyCard
v-bind="loadingPolicy"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
</div>
</div>
</Variant>
</Story>
</template>
@@ -1,86 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import CardPopover from '../components/CardPopover.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
description: { type: String, default: '' },
assignedAgentCount: { type: Number, default: 0 },
users: { type: Array, default: () => [] },
isFetchingUsers: { type: Boolean, default: false },
});
const emit = defineEmits(['edit', 'delete', 'fetchUsers']);
const { t } = useI18n();
const users = computed(() => {
return props.users.map(user => {
return {
name: user.name,
key: user.id,
email: user.email,
avatarUrl: user.avatarUrl,
};
});
});
const handleEdit = () => {
emit('edit', props.id);
};
const handleDelete = () => {
emit('delete', props.id);
};
const handleFetchUsers = () => {
if (props.users?.length > 0) return;
emit('fetchUsers', props.id);
};
</script>
<template>
<CardLayout class="[&>div]:px-5">
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
:title="
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.POPOVER')
"
icon="i-lucide-users-round"
:count="assignedAgentCount"
:items="users"
:is-fetching="isFetchingUsers"
@fetch="handleFetchUsers"
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.EDIT')
"
sm
slate
link
class="px-2"
@click="handleEdit"
/>
<div class="w-px h-2.5 bg-n-slate-5" />
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }}
</p>
</div>
</CardLayout>
</template>
@@ -1,169 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useToggle, useWindowSize, useElementBounding } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { picoSearch } from '@scmmishra/pico-search';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
label: {
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
items: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['add']);
const BUFFER_SPACE = 20;
const [showPopover, togglePopover] = useToggle();
const buttonRef = ref();
const dropdownRef = ref();
const searchValue = ref('');
const { width: windowWidth, height: windowHeight } = useWindowSize();
const {
top: buttonTop,
left: buttonLeft,
width: buttonWidth,
height: buttonHeight,
} = useElementBounding(buttonRef);
const { width: dropdownWidth, height: dropdownHeight } =
useElementBounding(dropdownRef);
const filteredItems = computed(() => {
if (!searchValue.value) return props.items;
const query = searchValue.value.toLowerCase();
return picoSearch(props.items, query, ['name']);
});
const handleAdd = item => {
emit('add', item);
togglePopover(false);
};
const shouldShowAbove = computed(() => {
if (!buttonRef.value || !dropdownRef.value) return false;
const spaceBelow =
windowHeight.value - (buttonTop.value + buttonHeight.value);
const spaceAbove = buttonTop.value;
return (
spaceBelow < dropdownHeight.value + BUFFER_SPACE && spaceAbove > spaceBelow
);
});
const shouldAlignRight = computed(() => {
if (!buttonRef.value || !dropdownRef.value) return false;
const spaceRight = windowWidth.value - buttonLeft.value;
const spaceLeft = buttonLeft.value + buttonWidth.value;
return (
spaceRight < dropdownWidth.value + BUFFER_SPACE && spaceLeft > spaceRight
);
});
const handleClickOutside = () => {
if (showPopover.value) {
togglePopover(false);
}
};
</script>
<template>
<div
v-on-click-outside="handleClickOutside"
class="relative flex items-center group"
>
<Button
ref="buttonRef"
slate
type="button"
icon="i-lucide-plus"
sm
:label="label"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
ref="dropdownRef"
class="z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
:class="[
shouldShowAbove ? 'bottom-full mb-2' : 'top-full mt-2',
shouldAlignRight ? 'right-0' : 'left-0',
]"
>
<div class="flex flex-col divide-y divide-n-slate-4 w-full">
<Input
v-model="searchValue"
:placeholder="searchPlaceholder"
custom-input-class="bg-transparent !outline-none w-full ltr:!pl-10 rtl:!pr-10 h-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
<div
v-for="item in filteredItems"
:key="item.id"
class="flex gap-3 min-w-0 w-full py-4 px-3 hover:bg-n-alpha-2 cursor-pointer"
:class="{ 'items-center': item.color, 'items-start': !item.color }"
@click="handleAdd(item)"
>
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0 mt-0.5"
/>
<span
v-else-if="item.color"
:style="{ backgroundColor: item.color }"
class="size-3 rounded-sm"
/>
<Avatar
v-else
:title="item.name"
:src="item.avatarUrl"
:name="item.name"
:size="20"
rounded-full
/>
<div class="flex flex-col items-start gap-2 min-w-0 flex-1">
<div class="flex items-center gap-1 min-w-0 w-full">
<span
:title="item.name || item.title"
class="text-sm text-n-slate-12 truncate min-w-0 flex-1"
>
{{ item.name || item.title }}
</span>
</div>
<span
v-if="item.email || item.phoneNumber"
:title="item.email || item.phoneNumber"
class="text-sm text-n-slate-11 truncate min-w-0 w-full block"
>
{{ item.email || item.phoneNumber }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,127 +0,0 @@
<script setup>
import { computed, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
defineProps({
nameLabel: {
type: String,
default: '',
},
namePlaceholder: {
type: String,
default: '',
},
descriptionLabel: {
type: String,
default: '',
},
descriptionPlaceholder: {
type: String,
default: '',
},
statusLabel: {
type: String,
default: '',
},
statusPlaceholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['validationChange']);
const policyName = defineModel('policyName', {
type: String,
default: '',
});
const description = defineModel('description', {
type: String,
default: '',
});
const enabled = defineModel('enabled', {
type: Boolean,
default: true,
});
const validationRules = {
policyName: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, { policyName, description });
const isValid = computed(() => !v$.value.$invalid);
watch(
isValid,
() => {
emit('validationChange', {
isValid: isValid.value,
section: 'baseInfo',
});
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4 pb-4">
<!-- Policy Name Field -->
<div class="flex items-center gap-6">
<WithLabel
:label="nameLabel"
name="policyName"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex-1">
<Input
v-model="policyName"
type="text"
:placeholder="namePlaceholder"
/>
</div>
</WithLabel>
</div>
<!-- Description Field -->
<div class="flex items-center gap-6">
<WithLabel
:label="descriptionLabel"
name="description"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex-1">
<Input
v-model="description"
type="text"
:placeholder="descriptionPlaceholder"
/>
</div>
</WithLabel>
</div>
<!-- Status Field -->
<div v-if="statusLabel" class="flex items-center gap-6">
<WithLabel
:label="statusLabel"
name="enabled"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex items-center gap-2">
<Switch v-model="enabled" />
<span class="text-sm text-n-slate-11">
{{ statusPlaceholder }}
</span>
</div>
</WithLabel>
</div>
</div>
</template>
@@ -1,8 +1,6 @@
<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';
@@ -55,7 +53,7 @@ const handleClickOutside = () => {
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" />
<Icon icon="i-lucide-inbox" class="size-3.5 text-n-slate-12" />
<span class="text-n-slate-12 text-sm">
{{ count }}
</span>
@@ -76,44 +74,28 @@ const handleClickOutside = () => {
<Spinner />
</div>
<div v-else class="flex flex-col gap-4 w-full">
<div v-else class="flex flex-col gap-4">
<div
v-for="item in items"
:key="item.id"
class="flex items-center justify-between gap-2 min-w-0 w-full"
class="flex items-center 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
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0"
/>
<div class="flex items-center gap-1 min-w-0 flex-1">
<span
: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>
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>
<span v-if="item.email" class="text-sm text-n-slate-11 flex-shrink-0">
{{ item.email }}
</span>
</div>
</div>
</div>
@@ -1,90 +0,0 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
items: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
emptyStateMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['delete']);
const handleDelete = itemId => {
emit('delete', itemId);
};
</script>
<template>
<div
v-if="isFetching"
class="flex items-center justify-center py-3 w-full text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="items.length === 0 && emptyStateMessage"
class="custom-dashed-border flex items-center justify-center py-6 w-full"
>
<span class="text-sm text-n-slate-11">
{{ emptyStateMessage }}
</span>
</div>
<div v-else class="flex flex-col divide-y divide-n-weak">
<div
v-for="item in items"
:key="item.id"
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
>
<div class="flex items-center gap-2 col-span-2">
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0"
/>
<Avatar
v-else
:title="item.name"
:src="item.avatarUrl"
:name="item.name"
:size="20"
rounded-full
/>
<span class="text-sm text-n-slate-12 truncate min-w-0">
{{ item.name }}
</span>
</div>
<div class="flex items-start gap-2 col-span-1">
<span
:title="item.email || item.phoneNumber"
class="text-sm text-n-slate-12 truncate min-w-0"
>
{{ item.email || item.phoneNumber }}
</span>
</div>
<div class="col-span-1 justify-end flex items-center">
<Button
icon="i-lucide-trash"
slate
ghost
sm
type="button"
@click="handleDelete(item.id)"
/>
</div>
</div>
</div>
</template>
@@ -1,149 +0,0 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const props = defineProps({
tagsList: {
type: Array,
default: () => [],
},
});
const excludedLabels = defineModel('excludedLabels', {
type: Array,
default: () => [],
});
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
type: Number,
default: 10,
});
// Duration limits: 10 minutes to 999 days (in minutes)
const MIN_DURATION_MINUTES = 10;
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
const { t } = useI18n();
const hoveredLabel = ref(null);
const windowUnit = ref(DURATION_UNITS.MINUTES);
const addedTags = computed(() =>
props.tagsList
.filter(label => excludedLabels.value.includes(label.name))
.map(label => ({ id: label.id, title: label.name, ...label }))
);
const filteredTags = computed(() =>
props.tagsList.filter(
label => !addedTags.value.some(tag => tag.id === label.id)
)
);
const detectUnit = minutes => {
const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES;
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
if (m % 60 === 0) return DURATION_UNITS.HOURS;
return DURATION_UNITS.MINUTES;
};
const onClickAddTag = tag => {
excludedLabels.value = [...excludedLabels.value, tag.name];
};
const onClickRemoveTag = tag => {
excludedLabels.value = excludedLabels.value.filter(
name => name !== tag.title
);
};
onMounted(() => {
windowUnit.value = detectUnit(excludeOlderThanMinutes.value);
});
</script>
<template>
<div class="py-4 flex-col flex gap-6">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.LABEL'
)
}}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DESCRIPTION'
)
}}
</p>
</div>
<div class="flex flex-col items-start gap-4">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.LABEL'
)
}}
</label>
<div
class="flex items-start gap-2 flex-wrap"
@mouseleave="hoveredLabel = null"
>
<LabelItem
v-for="tag in addedTags"
:key="tag.id"
:label="tag"
:is-hovered="hoveredLabel === tag.id"
class="h-8"
@remove="onClickRemoveTag"
@hover="hoveredLabel = tag.id"
/>
<AddDataDropdown
:label="
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.ADD_TAG'
)
"
:search-placeholder="
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.DROPDOWN.SEARCH_PLACEHOLDER'
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
@add="onClickAddTag"
/>
</div>
</div>
<div class="flex flex-col items-start gap-4">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DURATION.LABEL'
)
}}
</label>
<div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model:unit="windowUnit"
v-model:model-value="excludeOlderThanMinutes"
:min="MIN_DURATION_MINUTES"
:max="MAX_DURATION_MINUTES"
/>
</div>
</div>
</div>
</template>
@@ -1,86 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const { t } = useI18n();
const fairDistributionLimit = defineModel('fairDistributionLimit', {
type: Number,
default: 100,
set(value) {
return Number(value) || 0;
},
});
const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number,
default: 3600,
set(value) {
return Number(value) || 0;
},
});
const windowUnit = ref(DURATION_UNITS.MINUTES);
const detectUnit = minutes => {
const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES;
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
if (m % 60 === 0) return DURATION_UNITS.HOURS;
return DURATION_UNITS.MINUTES;
};
onMounted(() => {
windowUnit.value = detectUnit(fairDistributionWindow.value);
});
</script>
<template>
<div
class="flex items-start xl:items-center flex-col md:flex-row gap-4 lg:gap-3 bg-n-solid-1 p-4 outline outline-1 outline-n-weak rounded-xl"
>
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.INPUT_MAX'
)
}}
</label>
<div class="flex-1">
<Input
v-model="fairDistributionLimit"
type="number"
placeholder="100"
max="100000"
class="w-full"
/>
</div>
</div>
<div class="flex sm:flex-row flex-col items-start sm:items-center gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.DURATION'
)
}}
</label>
<div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model:model-value="fairDistributionWindow"
v-model:unit="windowUnit"
:min="10"
:max="1438560"
/>
</div>
</div>
</div>
</template>
@@ -1,177 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
const props = defineProps({
inboxList: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['delete', 'add', 'update']);
const inboxCapacityLimits = defineModel('inboxCapacityLimits', {
type: Array,
default: () => [],
});
const { t } = useI18n();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const DEFAULT_CONVERSATION_LIMIT = 10;
const MIN_CONVERSATION_LIMIT = 1;
const MAX_CONVERSATION_LIMIT = 100000;
const selectedInboxIds = computed(
() => new Set(inboxCapacityLimits.value.map(limit => limit.inboxId))
);
const availableInboxes = computed(() =>
props.inboxList.filter(
inbox => inbox && !selectedInboxIds.value.has(inbox.id)
)
);
const isLimitValid = limit => {
return (
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
);
};
const inboxMap = computed(
() => new Map(props.inboxList.map(inbox => [inbox.id, inbox]))
);
const handleAddInbox = inbox => {
emit('add', {
inboxId: inbox.id,
conversationLimit: DEFAULT_CONVERSATION_LIMIT,
});
};
const handleRemoveLimit = limitId => {
emit('delete', limitId);
};
const handleLimitChange = limit => {
if (isLimitValid(limit)) {
emit('update', limit);
}
};
const getInboxName = inboxId => {
return inboxMap.value.get(inboxId)?.name || '';
};
</script>
<template>
<div class="py-4 flex-col flex gap-3">
<div class="flex items-center w-full gap-8 justify-between pt-1 pb-3">
<label class="text-sm font-medium text-n-slate-12">
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.LABEL`) }}
</label>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SELECT_INBOX`)
"
:items="availableInboxes"
@add="handleAddInbox"
/>
</div>
<div
v-if="isFetching"
class="flex items-center justify-center py-3 w-full text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="!inboxCapacityLimits.length"
class="custom-dashed-border flex items-center justify-center py-6 w-full"
>
<span class="text-sm text-n-slate-11">
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.EMPTY_STATE`) }}
</span>
</div>
<div v-else class="flex-col flex gap-3">
<div
v-for="(limit, index) in inboxCapacityLimits"
:key="limit.id || `temp-${index}`"
class="flex flex-col xs:flex-row items-stretch gap-3"
>
<div
class="flex items-center rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full min-w-0"
:title="getInboxName(limit.inboxId)"
>
<span class="truncate min-w-0">
{{ getInboxName(limit.inboxId) }}
</span>
</div>
<div class="flex items-center gap-3 w-full xs:w-auto">
<div
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-1 xs:flex-shrink-0 flex items-center min-w-0"
:class="[
!isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
]"
>
<label
class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2 truncate min-w-0 flex-shrink"
:title="
t(
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
)
"
>
{{
t(
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
)
}}
</label>
<div class="h-5 w-px bg-n-weak" />
<input
v-model.number="limit.conversationLimit"
type="number"
:min="MIN_CONVERSATION_LIMIT"
:max="MAX_CONVERSATION_LIMIT"
class="reset-base bg-transparent focus:outline-none min-w-16 w-24 text-sm flex-shrink-0"
:class="[
!isLimitValid(limit)
? 'placeholder:text-n-ruby-9 !text-n-ruby-9'
: 'placeholder:text-n-slate-10 text-n-slate-12',
]"
:placeholder="
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SET_LIMIT`)
"
@blur="handleLimitChange(limit)"
/>
</div>
<Button
type="button"
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click="handleRemoveLimit(limit.id)"
/>
</div>
</div>
</div>
</div>
</template>
@@ -1,60 +0,0 @@
<script setup>
const props = defineProps({
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const handleChange = () => {
if (!props.isActive) {
emit('select', props.id);
}
};
</script>
<template>
<div
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
]"
@click="handleChange"
>
<div class="absolute top-4 right-4">
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
/>
</div>
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<p class="text-sm text-n-slate-11">
{{ description }}
</p>
</div>
</div>
</template>
@@ -1,92 +0,0 @@
<script setup>
import AddDataDropdown from '../AddDataDropdown.vue';
const mockInboxes = [
{
id: 1,
name: 'Website Support',
email: 'support@company.com',
icon: 'i-lucide-globe',
},
{
id: 2,
name: 'Email Support',
email: 'help@company.com',
icon: 'i-lucide-mail',
},
{
id: 3,
name: 'WhatsApp Business',
phoneNumber: '+1 555-0123',
icon: 'i-lucide-message-circle',
},
{
id: 4,
name: 'Facebook Messenger',
email: 'messenger@company.com',
icon: 'i-lucide-facebook',
},
{
id: 5,
name: 'Twitter DM',
email: 'twitter@company.com',
icon: 'i-lucide-twitter',
},
];
const mockTags = [
{
id: 1,
name: 'urgent',
color: '#ff4757',
},
{
id: 2,
name: 'bug',
color: '#ff6b6b',
},
{
id: 3,
name: 'feature-request',
color: '#4834d4',
},
{
id: 4,
name: 'documentation',
color: '#26de81',
},
];
const handleAdd = item => {
console.log('Add item:', item);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AddDataDropdown"
:layout="{ type: 'grid', width: '500px' }"
>
<Variant title="Basic Usage - Inboxes">
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
<AddDataDropdown
label="Add Inbox"
search-placeholder="Search inboxes..."
:items="mockInboxes"
@add="handleAdd"
/>
</div>
</Variant>
<Variant title="Basic Usage - Tags">
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
<AddDataDropdown
label="Add Tag"
search-placeholder="Search tags..."
:items="mockTags"
@add="handleAdd"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,33 +0,0 @@
<script setup>
import { ref } from 'vue';
import BaseInfo from '../BaseInfo.vue';
const policyName = ref('Round Robin Policy');
const description = ref(
'Distributes conversations evenly among available agents'
);
const enabled = ref(true);
</script>
<template>
<Story
title="Components/AgentManagementPolicy/BaseInfo"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background">
<BaseInfo
v-model:policy-name="policyName"
v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name"
name-placeholder="Enter policy name"
description-label="Description"
description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/>
</div>
</Variant>
</Story>
</template>
@@ -23,39 +23,6 @@ const mockItems = [
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>
@@ -74,16 +41,5 @@ const mockUsers = [
/>
</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>
@@ -1,87 +0,0 @@
<script setup>
import DataTable from '../DataTable.vue';
const mockItems = [
{
id: 1,
name: 'Website Support',
email: 'support@company.com',
icon: 'i-lucide-globe',
},
{
id: 2,
name: 'Email Support',
email: 'help@company.com',
icon: 'i-lucide-mail',
},
{
id: 3,
name: 'WhatsApp Business',
phoneNumber: '+1 555-0123',
icon: 'i-lucide-message-circle',
},
];
const mockAgentList = [
{
id: 1,
name: 'John Doe',
email: 'john.doe@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=1',
},
{
id: 2,
name: 'Jane Smith',
email: 'jane.smith@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=2',
},
];
const handleDelete = itemId => {
console.log('Delete item:', itemId);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/DataTable"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With Data">
<div class="p-8 bg-n-background">
<DataTable
:items="mockItems"
:is-fetching="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="With Agents">
<div class="p-8 bg-n-background">
<DataTable
:items="mockAgentList"
:is-fetching="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-8 bg-n-background">
<DataTable :items="[]" is-fetching @delete="handleDelete" />
</div>
</Variant>
<Variant title="Empty State">
<div class="p-8 bg-n-background">
<DataTable
:items="[]"
:is-fetching="false"
empty-state-message="No items found"
@delete="handleDelete"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,67 +0,0 @@
<script setup>
import ExclusionRules from '../ExclusionRules.vue';
import { ref } from 'vue';
const mockTagsList = [
{
id: 1,
name: 'urgent',
color: '#ff4757',
},
{
id: 2,
name: 'bug',
color: '#ff6b6b',
},
{
id: 3,
name: 'feature-request',
color: '#4834d4',
},
{
id: 4,
name: 'documentation',
color: '#26de81',
},
{
id: 5,
name: 'enhancement',
color: '#2ed573',
},
{
id: 6,
name: 'question',
color: '#ffa502',
},
{
id: 7,
name: 'duplicate',
color: '#747d8c',
},
{
id: 8,
name: 'wontfix',
color: '#57606f',
},
];
const excludedLabelsBasic = ref([]);
const excludeOlderThanHoursBasic = ref(10);
</script>
<template>
<Story
title="Components/AgentManagementPolicy/ExclusionRules"
:layout="{ type: 'grid', width: '1200px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background h-[600px]">
<ExclusionRules
v-model:excluded-labels="excludedLabelsBasic"
v-model:exclude-older-than-minutes="excludeOlderThanHoursBasic"
:tags-list="mockTagsList"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,25 +0,0 @@
<script setup>
import { ref } from 'vue';
import FairDistribution from '../FairDistribution.vue';
const fairDistributionLimit = ref(100);
const fairDistributionWindow = ref(3600);
const windowUnit = ref('minutes');
</script>
<template>
<Story
title="Components/AgentManagementPolicy/FairDistribution"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background">
<FairDistribution
v-model:fair-distribution-limit="fairDistributionLimit"
v-model:fair-distribution-window="fairDistributionWindow"
v-model:window-unit="windowUnit"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,108 +0,0 @@
<script setup>
import InboxCapacityLimits from '../InboxCapacityLimits.vue';
import { ref } from 'vue';
const mockInboxList = [
{
value: 1,
label: 'Website Support',
icon: 'i-lucide-globe',
},
{
value: 2,
label: 'Email Support',
icon: 'i-lucide-mail',
},
{
value: 3,
label: 'WhatsApp Business',
icon: 'i-lucide-message-circle',
},
{
value: 4,
label: 'Facebook Messenger',
icon: 'i-lucide-facebook',
},
{
value: 5,
label: 'Twitter DM',
icon: 'i-lucide-twitter',
},
{
value: 6,
label: 'Telegram',
icon: 'i-lucide-send',
},
];
const inboxCapacityLimitsEmpty = ref([]);
const inboxCapacityLimitsNew = ref([
{ id: 1, inboxId: 1, conversationLimit: 5 },
{ inboxId: null, conversationLimit: null },
]);
const handleDelete = id => {
console.log('Delete capacity limit:', id);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/InboxCapacityLimits"
:layout="{ type: 'grid', width: '900px' }"
>
<Variant title="Empty State">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
:inbox-list="mockInboxList"
:is-fetching="false"
:is-updating="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
:inbox-list="mockInboxList"
is-fetching
:is-updating="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="With New Row and existing data">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsNew"
:inbox-list="mockInboxList"
:is-fetching="false"
:is-updating="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Interactive Demo">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
:inbox-list="mockInboxList"
:is-fetching="false"
:is-updating="false"
@delete="handleDelete"
/>
<div class="mt-4 p-4 bg-n-alpha-2 rounded-lg">
<h4 class="text-sm font-medium mb-2">Current Limits:</h4>
<pre class="text-xs">{{
JSON.stringify(inboxCapacityLimitsEmpty, null, 2)
}}</pre>
</div>
</div>
</Variant>
</Story>
</template>
@@ -1,61 +0,0 @@
<script setup>
import { ref } from 'vue';
import RadioCard from '../RadioCard.vue';
const selectedOption = ref('round_robin');
const handleSelect = value => {
selectedOption.value = value;
console.log('Selected:', value);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/RadioCard"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background space-y-4">
<RadioCard
id="round_robin"
label="Round Robin"
description="Distributes conversations evenly among all available agents in a rotating manner"
:is-active="selectedOption === 'round_robin'"
@select="handleSelect"
/>
<RadioCard
id="balanced"
label="Balanced Assignment"
description="Assigns conversations based on agent workload to maintain balance"
:is-active="selectedOption === 'balanced'"
@select="handleSelect"
/>
</div>
</Variant>
<Variant title="Active State">
<div class="p-8 bg-n-background">
<RadioCard
id="active_option"
label="Active Option"
description="This option is currently selected and active"
is-active
@select="handleSelect"
/>
</div>
</Variant>
<Variant title="Inactive State">
<div class="p-8 bg-n-background">
<RadioCard
id="inactive_option"
label="Inactive Option"
description="This option is not selected and can be clicked to activate"
is-active
@select="handleSelect"
/>
</div>
</Variant>
</Story>
</template>
@@ -86,8 +86,8 @@ const handleLabelAction = async ({ value }) => {
}
};
const handleRemoveLabel = label => {
return handleLabelAction({ value: label.id });
const handleRemoveLabel = labelId => {
return handleLabelAction({ value: labelId });
};
watch(
@@ -51,20 +51,12 @@ const originalState = reactive({ ...state });
const liveChatWidgets = computed(() => {
const inboxes = store.getters['inboxes/getInboxes'];
const widgetOptions = inboxes
return inboxes
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
return [
{
value: '',
label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'),
},
...widgetOptions,
];
});
const rules = {
@@ -116,7 +108,7 @@ watch(
widgetColor: newVal.color,
homePageLink: newVal.homepage_link,
slug: newVal.slug,
liveChatWidgetInboxId: newVal.inbox?.id || '',
liveChatWidgetInboxId: newVal.inbox?.id,
});
if (newVal.logo) {
const {
@@ -15,7 +15,7 @@ const props = defineProps({
const emit = defineEmits(['remove', 'hover']);
const handleRemoveLabel = () => {
emit('remove', props.label);
emit('remove', props.label?.id);
};
const handleMouseEnter = () => {
@@ -45,7 +45,6 @@ const handleMouseEnter = () => {
<Button
class="transition-opacity duration-200 !h-7 ltr:rounded-r-md rtl:rounded-l-md ltr:rounded-l-none rtl:rounded-r-none w-6 bg-transparent"
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
type="button"
slate
xs
faded
@@ -1,34 +0,0 @@
<script setup>
import Guardrails from './Guardrails.vue';
import Scenarios from './Scenarios.vue';
import ResponseGuidelines from './ResponseGuidelines.vue';
import Settings from './Settings.vue';
</script>
<template>
<Story
title="Captain/AnimatingImg/AnimatingImg"
:layout="{ type: 'grid', width: '300px' }"
>
<Variant title="Guardrails">
<div class="p-4 bg-n-background w-full h-full">
<Guardrails class="size-60" />
</div>
</Variant>
<Variant title="Scenarios">
<div class="p-4 bg-n-background w-full h-full">
<Scenarios class="size-60" />
</div>
</Variant>
<Variant title="ResponseGuidelines">
<div class="p-4 bg-n-background w-full h-full">
<ResponseGuidelines class="size-60" />
</div>
</Variant>
<Variant title="Settings">
<div class="p-4 bg-n-background w-full h-full">
<Settings class="size-60" />
</div>
</Variant>
</Story>
</template>
File diff suppressed because it is too large Load Diff
@@ -1,990 +0,0 @@
<script setup>
import { ref } from 'vue';
const paused = ref(false);
const toggle = () => {
paused.value = !paused.value;
};
</script>
<template>
<div
class="svg-wrapper relative"
:class="{ paused }"
role="button"
:aria-pressed="paused"
tabindex="0"
@click="toggle"
>
<div class="absolute z-0 flex-shrink-0">
<svg
width="auto"
height="auto"
viewBox="0 0 200 156"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g opacity="0.5">
<g clip-path="url(#clip0_773_34322)">
<circle cx="8" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="153" r="1" class="fill-n-blue-9" />
<rect
width="200"
height="156"
fill="url(#paint0_linear_773_34322)"
/>
<rect
width="200"
height="156"
fill="url(#paint1_linear_773_34322)"
/>
</g>
</g>
<defs>
<linearGradient
id="paint0_linear_773_34322"
x1="100"
y1="0"
x2="100"
y2="156"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--gradient-start)" />
<stop
offset="0.480769"
stop-color="var(--gradient-start)"
stop-opacity="0"
/>
<stop offset="1" stop-color="var(--gradient-start)" />
</linearGradient>
<linearGradient
id="paint1_linear_773_34322"
x1="0"
y1="78"
x2="200"
y2="78"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--gradient-start)" />
<stop
offset="0.480769"
stop-color="var(--gradient-start)"
stop-opacity="0"
/>
<stop offset="1" stop-color="var(--gradient-start)" />
</linearGradient>
<clipPath id="clip0_773_34322">
<rect width="200" height="156" rx="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
<svg
viewBox="0 0 136 108"
aria-hidden="false"
focusable="false"
class="z-10 relative flex-shrink-0"
>
<rect width="136" height="108" fill="url(#paint0_radial_720_25701)" />
<path
d="M49 35.039C49 33.9129 49.9129 33 51.039 33C52.1651 33 53.0779 33.9129 53.0779 35.039V39.7513C53.0779 40.8774 52.1651 41.7902 51.039 41.7902C49.9129 41.7902 49 40.8774 49 39.7513V35.039Z"
class="fill-n-slate-10"
/>
<path
d="M55.5244 35.039C55.5244 33.9129 56.4373 33 57.5634 33C58.6895 33 59.6024 33.9129 59.6024 35.039V39.7513C59.6024 40.8774 58.6895 41.7902 57.5634 41.7902C56.4373 41.7902 55.5244 40.8774 55.5244 39.7513V35.039Z"
class="fill-n-slate-10"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M58.1862 24.173C50.5933 23.1017 44.4039 23.0594 36.6405 24.1683C34.678 24.4485 33.3796 24.638 32.3896 24.9273C31.4716 25.1956 30.9496 25.5155 30.5177 25.9981C29.6236 26.9974 29.5057 28.1276 29.3838 32.0695C29.264 35.9436 29.5013 39.4156 29.9507 43.3222C30.1901 45.4039 30.3549 46.8088 30.618 47.8769C30.8676 48.89 31.1694 49.4347 31.5889 49.8518C32.0117 50.2721 32.5536 50.5686 33.5482 50.8091C34.6002 51.0636 35.9806 51.2175 38.032 51.4413C44.6668 52.1652 49.6333 52.1615 56.2927 51.4451C58.369 51.2218 59.7719 51.0678 60.8396 50.815C61.8541 50.5749 62.3995 50.2805 62.8165 49.8722C63.2247 49.4724 63.5366 48.921 63.8071 47.8574C64.0894 46.7477 64.2815 45.2793 64.5594 43.1222C65.0553 39.2735 65.4123 35.8935 65.4262 32.1908C65.4411 28.232 65.3601 27.1056 64.4558 26.0608C64.019 25.5562 63.488 25.2244 62.5491 24.9481C61.5352 24.6497 60.2017 24.4573 58.1862 24.173ZM36.1664 20.849C44.2537 19.6939 50.7606 19.7391 58.6546 20.8529L58.7701 20.8692C60.6392 21.1328 62.2183 21.3555 63.4958 21.7315C64.8745 22.1372 66.0274 22.7532 66.991 23.8665C68.8087 25.9666 68.7969 28.4583 68.7811 31.7688C68.7804 31.9121 68.7797 32.057 68.7792 32.2034C68.7645 36.1184 68.3857 39.6634 67.8849 43.5506L67.8717 43.6535C67.6103 45.682 67.395 47.3537 67.0566 48.6839C66.7008 50.0827 66.169 51.282 65.1624 52.2678C64.1645 53.245 62.9854 53.7528 61.6119 54.0778C60.3149 54.3848 58.7011 54.5584 56.7558 54.7676L56.6513 54.7789C49.7551 55.5207 44.5465 55.5249 37.6684 54.7745L37.5622 54.7629C35.6442 54.5537 34.0471 54.3795 32.7599 54.0681C31.3915 53.7371 30.2202 53.219 29.2249 52.2296C28.2263 51.2369 27.7018 50.0571 27.3623 48.6788C27.0421 47.3788 26.856 45.7601 26.6318 43.8109L26.6197 43.7053C26.1588 39.6989 25.906 36.0546 26.0325 31.9659C26.037 31.8176 26.0414 31.6709 26.0458 31.5257C26.144 28.24 26.2178 25.7754 28.019 23.7623C28.9747 22.6942 30.1043 22.102 31.449 21.7089C32.6962 21.3444 34.2339 21.1249 36.0541 20.865C36.0914 20.8597 36.1288 20.8543 36.1664 20.849Z"
class="fill-n-slate-10"
/>
<path
d="M103.999 77.4062H108.999C110.104 77.4062 110.999 78.3017 110.999 79.4062C110.999 80.5108 110.104 81.4062 108.999 81.4062H103.999V77.4062Z"
class="fill-n-slate-9"
/>
<path
d="M63 77.4062H58C56.8954 77.4062 56 78.3017 56 79.4062C56 80.5108 56.8954 81.4062 58 81.4062H63V77.4062Z"
class="fill-n-slate-9"
/>
<path
d="M83.999 64.4063C85.4527 63.7978 86.4227 63.4566 87.9986 63.4508C89.5744 63.445 91.136 63.7496 92.5941 64.3472C94.0523 64.9449 95.3784 65.8239 96.4968 66.9341C97.6153 68.0442 98.5041 69.3638 99.1125 70.8175C99.721 72.2711 100.037 73.8304 100.043 75.4062"
class="stroke-n-blue-11 fill-n-slate-2"
stroke-width="1.6"
/>
<path
d="M67.049 75.0889C67.0432 73.5131 67.3478 71.9515 67.9454 70.4934C68.5431 69.0352 69.4221 67.7091 70.5322 66.5907C71.6424 65.4723 72.962 64.5834 74.4157 63.975C75.8693 63.3666 77.4286 63.0504 79.0044 63.0445C80.5803 63.0387 82.1419 63.3433 83.6 63.941C85.0581 64.5386 86.3843 65.4176 87.5027 66.5278C88.6211 67.638 89.5099 68.9576 90.1184 70.4112C90.7268 71.8649 91.043 73.4241 91.0488 75"
class="stroke-n-blue-11 fill-n-slate-2"
stroke-width="1.6"
/>
<path
d="M71.4659 74.9421C71.462 73.8915 71.6651 72.8505 72.0635 71.8784C72.462 70.9063 73.048 70.0222 73.7881 69.2766C74.5282 68.531 75.4079 67.9384 76.377 67.5328C77.3462 67.1272 78.3857 66.9164 79.4362 66.9125C80.4868 66.9086 81.5278 67.1117 82.4999 67.5101C83.472 67.9086 84.3561 68.4946 85.1017 69.2347C85.8473 69.9748 86.4399 70.8545 86.8455 71.8236C87.2511 72.7927 87.4619 73.8322 87.4658 74.8828"
class="stroke-n-blue-11 fill-n-slate-2"
stroke-width="1.6"
/>
<path
d="M88.4304 67.3188C89.4809 67.3149 90.522 67.5179 91.4941 67.9164C92.4662 68.3148 93.3503 68.9008 94.0959 69.6409C94.8415 70.381 95.434 71.2608 95.8397 72.2299C96.2453 73.199 96.4561 74.2385 96.46 75.2891"
class="stroke-n-blue-11 fill-n-slate-2"
stroke-width="1.6"
/>
<path
d="M75.9473 74.9203C75.9454 74.395 76.0469 73.8745 76.2461 73.3884C76.4453 72.9024 76.7383 72.4603 77.1084 72.0875C77.4785 71.7147 77.9183 71.4184 78.4029 71.2156C78.8874 71.0128 79.4072 70.9074 79.9325 70.9055C80.4578 70.9035 80.9783 71.0051 81.4643 71.2043C81.9504 71.4035 82.3924 71.6965 82.7652 72.0666C83.138 72.4366 83.4343 72.8765 83.6371 73.361C83.8399 73.8456 83.9453 74.3653 83.9473 74.8906"
class="stroke-n-blue-11 fill-n-slate-2"
stroke-width="1.6"
/>
<path
d="M90.4585 71.6066C90.9445 71.8058 91.3866 72.0988 91.7594 72.4689C92.1322 72.839 92.4284 73.2788 92.6313 73.7634C92.8341 74.2479 92.9395 74.7677 92.9414 75.293"
class="stroke-n-blue-11"
stroke-width="1.6"
/>
<path
d="M102 74.9062C103.09 74.9062 104.032 75.7999 103.973 76.957C103.853 79.29 103.335 81.588 102.439 83.751C101.939 84.9592 101.499 85.8713 100.931 86.7236C100.363 87.5763 99.6906 88.3338 98.7646 89.2598C96.6889 91.3355 93.3531 92.0527 91.0576 92.0527H76.5576C73.9588 92.0527 70.4405 91.3497 68.3506 89.2598C67.4349 88.3441 66.737 87.5905 66.1416 86.7441C65.5415 85.8911 65.0682 84.9765 64.5605 83.751C63.6646 81.588 63.1471 79.29 63.0273 76.957C62.968 75.7999 63.9098 74.9062 65 74.9062H102Z"
class="fill-n-slate-2 stroke-n-slate-9"
stroke-width="2"
/>
<g class="default-group">
<path
d="M103.85 21.1431C104.824 20.8821 105.847 21.3479 106.291 22.2542L112.209 34.3514C112.793 35.544 112.143 36.9734 110.861 37.3171L100.014 40.2235C98.7313 40.5669 97.4538 39.654 97.3628 38.3295L96.4399 24.8937C96.3708 23.8871 97.0247 22.9718 97.9993 22.7107L103.85 21.1431Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M104.528 18.8482C104.84 18.7645 105.124 18.5966 105.347 18.363C106.576 17.0748 105.336 14.9834 103.616 15.4439L95.3492 17.659C93.6293 18.1203 93.6011 20.5515 95.3101 21.0523C95.6201 21.1431 95.9494 21.1468 96.2614 21.0633L104.528 18.8482Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<rect
x="95.3496"
y="21.2852"
width="10.4348"
height="2.08696"
rx="0.695652"
transform="rotate(-15 95.3496 21.2852)"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M103.249 14.4618C103.363 14.5616 103.464 14.6686 103.55 14.7819C103.782 15.0879 103.556 15.4819 103.185 15.5813L99.4893 16.5716L95.7936 17.5618C95.4224 17.6613 95.0296 17.4332 95.0777 17.052C95.0955 16.9109 95.1292 16.7679 95.1785 16.6242C95.3106 16.2393 95.5529 15.8568 95.8916 15.4986C96.2303 15.1403 96.6587 14.8133 97.1525 14.5362C97.6462 14.2592 98.1955 14.0375 98.7691 13.8838C99.3426 13.7301 99.9292 13.6474 100.495 13.6405C101.061 13.6336 101.596 13.7026 102.068 13.8435C102.541 13.9844 102.942 14.1945 103.249 14.4618Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
</g>
<g class="frame frame-1">
<path
d="M97.8899 24.7525C97.1819 24.0337 97.0822 22.9139 97.6517 22.0811L105.253 10.9636C106.002 9.8676 107.566 9.72734 108.498 10.6731L116.378 18.6731C117.31 19.619 117.146 21.1806 116.039 21.9133L104.809 29.3463C103.967 29.9032 102.848 29.7861 102.14 29.0673L97.8899 24.7525Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M95.5608 25.2908C95.3341 25.0607 95.0482 24.8971 94.735 24.8184C93.0078 24.3847 91.8006 26.4952 93.05 27.7641L99.0562 33.8613C100.306 35.1296 102.435 33.9543 102.027 32.2207C101.953 31.9063 101.794 31.6181 101.567 31.3879L95.5608 25.2908Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<rect
x="102.207"
y="32.0742"
width="10.4348"
height="2.08696"
rx="0.695652"
transform="rotate(-134.569 102.207 32.0742)"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M92.3768 28.5691C92.4071 28.4202 92.4505 28.2799 92.5066 28.1492C92.6581 27.7962 93.1124 27.7985 93.3821 28.0722L96.0671 30.7979L98.7521 33.5236C99.0218 33.7973 99.0172 34.2515 98.6619 34.3978C98.5304 34.4519 98.3895 34.4932 98.2402 34.5212C97.8402 34.5963 97.3879 34.5743 96.9092 34.4565C96.4304 34.3387 95.9346 34.1274 95.45 33.8347C94.9654 33.542 94.5015 33.1737 94.0848 32.7506C93.668 32.3276 93.3067 31.8582 93.0213 31.3692C92.7359 30.8803 92.5321 30.3813 92.4216 29.9009C92.311 29.4204 92.2958 28.9679 92.3768 28.5691Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<circle cx="85" cy="40" r="1" class="fill-n-slate-11" />
<circle cx="91" cy="38" r="1" class="fill-n-slate-11" />
<circle cx="91" cy="44" r="1" class="fill-n-slate-11" />
<circle cx="85" cy="46" r="1" class="fill-n-slate-11" />
<circle cx="79" cy="46" r="1" class="fill-n-slate-11" />
<circle cx="88" cy="51" r="1" class="fill-n-slate-11" />
</g>
<g class="frame frame-2">
<path
d="M97.8879 24.7721C97.1799 24.0532 97.0803 22.9335 97.6497 22.1006L105.251 10.9832C106 9.88713 107.564 9.74688 108.496 10.6927L116.376 18.6926C117.308 19.6385 117.144 21.2001 116.037 21.9329L104.807 29.3658C103.965 29.9227 102.846 29.8056 102.138 29.0868L97.8879 24.7721Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M95.5579 25.3103C95.3311 25.0803 95.0453 24.9167 94.7321 24.838C93.0048 24.4042 91.7977 26.5148 93.0471 27.7836L99.0532 33.8808C100.303 35.1491 102.432 33.9738 102.024 32.2403C101.95 31.9259 101.791 31.6376 101.564 31.4075L95.5579 25.3103Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<rect
x="102.205"
y="32.0938"
width="10.4348"
height="2.08696"
rx="0.695652"
transform="rotate(-134.569 102.205 32.0938)"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M92.3758 28.5886C92.4061 28.4397 92.4495 28.2994 92.5056 28.1687C92.6572 27.8157 93.1115 27.818 93.3811 28.0917L96.0661 30.8174L98.7512 33.5431C99.0208 33.8168 99.0162 34.2711 98.661 34.4173C98.5294 34.4714 98.3885 34.5128 98.2392 34.5408C97.8392 34.6158 97.3869 34.5938 96.9082 34.476C96.4295 34.3582 95.9336 34.1469 95.449 33.8542C94.9644 33.5616 94.5005 33.1932 94.0838 32.7702C93.6671 32.3471 93.3057 31.8777 93.0203 31.3888C92.735 30.8998 92.5312 30.4009 92.4206 29.9204C92.31 29.44 92.2948 28.9874 92.3758 28.5886Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<circle cx="84" cy="42" r="1" class="fill-n-slate-11" />
<circle cx="90" cy="40" r="1" class="fill-n-slate-11" />
<circle cx="90" cy="46" r="1" class="fill-n-slate-11" />
<circle cx="84" cy="48" r="1" class="fill-n-slate-11" />
<circle cx="78" cy="48" r="1" class="fill-n-slate-11" />
<circle cx="87" cy="53" r="1" class="fill-n-slate-11" />
</g>
<g class="frame frame-3">
<path
d="M97.8879 24.7486C97.1799 24.0298 97.0803 22.91 97.6497 22.0771L105.251 10.9597C106 9.86369 107.564 9.72344 108.496 10.6692L116.376 18.6692C117.308 19.6151 117.144 21.1767 116.037 21.9094L104.807 29.3424C103.965 29.8993 102.846 29.7822 102.138 29.0634L97.8879 24.7486Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M95.5579 25.2908C95.3311 25.0607 95.0453 24.8971 94.7321 24.8184C93.0048 24.3847 91.7977 26.4952 93.0471 27.7641L99.0532 33.8613C100.303 35.1296 102.432 33.9543 102.024 32.2207C101.95 31.9063 101.791 31.6181 101.564 31.3879L95.5579 25.2908Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<rect
x="102.205"
y="32.0703"
width="10.4348"
height="2.08696"
rx="0.695652"
transform="rotate(-134.569 102.205 32.0703)"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M92.3758 28.5691C92.4061 28.4202 92.4495 28.2799 92.5056 28.1492C92.6572 27.7962 93.1115 27.7985 93.3811 28.0722L96.0661 30.7979L98.7512 33.5236C99.0208 33.7973 99.0162 34.2515 98.661 34.3978C98.5294 34.4519 98.3885 34.4932 98.2392 34.5212C97.8392 34.5963 97.3869 34.5743 96.9082 34.4565C96.4295 34.3387 95.9336 34.1274 95.449 33.8347C94.9644 33.542 94.5005 33.1737 94.0838 32.7506C93.6671 32.3276 93.3057 31.8582 93.0203 31.3692C92.735 30.8803 92.5312 30.3813 92.4206 29.9009C92.31 29.4204 92.2948 28.9679 92.3758 28.5691Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<circle cx="82" cy="44" r="1" class="fill-n-slate-11" />
<circle cx="88" cy="42" r="1" class="fill-n-slate-11" />
<circle cx="88" cy="48" r="1" class="fill-n-slate-11" />
<circle cx="82" cy="50" r="1" class="fill-n-slate-11" />
<circle cx="76" cy="50" r="1" class="fill-n-slate-11" />
<circle cx="85" cy="55" r="1" class="fill-n-slate-11" />
</g>
<defs>
<radialGradient
id="paint0_radial_720_25701"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(68 54) rotate(90) scale(54 68)"
>
<stop
offset="0.527769"
stop-color="var(--gradient-end)"
stop-opacity="0.9"
/>
<stop offset="1" stop-color="var(--gradient-end)" stop-opacity="0" />
</radialGradient>
</defs>
</svg>
</div>
</template>
<style scoped>
svg {
--gradient-start: #fcfcfd;
--gradient-end: #fcfcfd;
}
body.dark svg,
.htw-dark svg {
--gradient-start: #121213;
--gradient-end: #121213;
}
.svg-wrapper {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
outline: none;
}
.default-group {
opacity: 0;
transition: opacity 120ms linear;
pointer-events: none;
}
.frame {
opacity: 0;
animation-name: frameVisible;
animation-duration: 600ms;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
transform-origin: center;
}
.frame-1 {
animation-delay: 0ms;
}
.frame-2 {
animation-delay: 200ms;
}
.frame-3 {
animation-delay: 400ms;
}
@keyframes frameVisible {
0% {
opacity: 1;
}
33.333% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.svg-wrapper.paused .frame {
animation-play-state: paused;
opacity: 0 !important;
}
.svg-wrapper.paused .default-group {
opacity: 1;
}
.svg-wrapper:not(.paused) .default-group {
opacity: 0;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,752 +0,0 @@
<template>
<div class="svg-wrapper relative" tabindex="0">
<div class="absolute z-0 flex-shrink-0">
<svg
width="auto"
height="auto"
viewBox="0 0 200 156"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g opacity="0.5">
<g clip-path="url(#clip0_773_34322)">
<circle cx="8" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="153" r="1" class="fill-n-blue-9" />
<rect
width="200"
height="156"
fill="url(#paint0_linear_773_34322)"
/>
<rect
width="200"
height="156"
fill="url(#paint1_linear_773_34322)"
/>
</g>
</g>
<defs>
<linearGradient
id="paint0_linear_773_34322"
x1="100"
y1="0"
x2="100"
y2="156"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--gradient-start)" />
<stop
offset="0.480769"
stop-color="var(--gradient-start)"
stop-opacity="0"
/>
<stop offset="1" stop-color="var(--gradient-start)" />
</linearGradient>
<linearGradient
id="paint1_linear_773_34322"
x1="0"
y1="78"
x2="200"
y2="78"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--gradient-start)" />
<stop
offset="0.480769"
stop-color="var(--gradient-start)"
stop-opacity="0"
/>
<stop offset="1" stop-color="var(--gradient-start)" />
</linearGradient>
<clipPath id="clip0_773_34322">
<rect width="200" height="156" rx="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
<svg
viewBox="0 0 136 108"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="false"
focusable="false"
class="z-10 relative flex-shrink-0"
>
<rect width="136" height="108" fill="url(#paint0_radial_797_91519)" />
<path
d="M58 48.039C58 46.9129 58.9129 46 60.039 46C61.1651 46 62.0779 46.9129 62.0779 48.039V52.7513C62.0779 53.8774 61.1651 54.7902 60.039 54.7902C58.9129 54.7902 58 53.8774 58 52.7513V48.039Z"
class="fill-n-slate-10"
/>
<path
d="M64.5244 48.039C64.5244 46.9129 65.4373 46 66.5634 46C67.6895 46 68.6024 46.9129 68.6024 48.039V52.7513C68.6024 53.8774 67.6895 54.7902 66.5634 54.7902C65.4373 54.7902 64.5244 53.8774 64.5244 52.7513V48.039Z"
class="fill-n-slate-10"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M79.1862 40.173C71.5933 39.1017 65.4039 39.0594 57.6405 40.1683C55.678 40.4485 54.3796 40.638 53.3896 40.9273C52.4716 41.1956 51.9496 41.5155 51.5177 41.9981C50.6236 42.9974 50.5057 44.1276 50.3838 48.0695C50.264 51.9436 50.5013 55.4156 50.9507 59.3222C51.1901 61.4039 51.3549 62.8088 51.618 63.8769C51.8676 64.89 52.1694 65.4347 52.5889 65.8518C53.0117 66.2721 53.5536 66.5686 54.5482 66.8091C55.6002 67.0636 56.9806 67.2175 59.032 67.4413C65.6668 68.1652 70.6333 68.1615 77.2927 67.4451C79.369 67.2218 80.7719 67.0678 81.8396 66.815C82.8541 66.5749 83.3995 66.2805 83.8165 65.8722C84.2247 65.4724 84.5366 64.921 84.8071 63.8574C85.0894 62.7477 85.2815 61.2793 85.5594 59.1222C86.0553 55.2735 86.4123 51.8935 86.4262 48.1908C86.4411 44.232 86.3601 43.1056 85.4558 42.0608C85.019 41.5562 84.488 41.2244 83.5491 40.9481C82.5352 40.6497 81.2017 40.4573 79.1862 40.173ZM57.1664 36.849C65.2537 35.6939 71.7606 35.7391 79.6546 36.8529L79.7701 36.8692C81.6392 37.1328 83.2183 37.3555 84.4958 37.7315C85.8745 38.1372 87.0274 38.7532 87.991 39.8665C89.8087 41.9666 89.7969 44.4583 89.7811 47.7688C89.7804 47.9121 89.7797 48.057 89.7792 48.2034C89.7645 52.1184 89.3857 55.6634 88.8849 59.5506L88.8717 59.6535C88.6103 61.682 88.395 63.3537 88.0566 64.6839C87.7008 66.0827 87.169 67.282 86.1624 68.2678C85.1645 69.245 83.9854 69.7528 82.6119 70.0778C81.3149 70.3848 79.7011 70.5584 77.7558 70.7676L77.6513 70.7789C70.7551 71.5207 65.5465 71.5249 58.6684 70.7745L58.5622 70.7629C56.6442 70.5537 55.0471 70.3795 53.7599 70.0681C52.3915 69.7371 51.2202 69.219 50.2249 68.2296C49.2263 67.2369 48.7018 66.0571 48.3623 64.6788C48.0421 63.3788 47.856 61.7601 47.6318 59.8109L47.6197 59.7053C47.1588 55.6989 46.906 52.0546 47.0325 47.9659C47.037 47.8176 47.0414 47.6709 47.0458 47.5257C47.144 44.24 47.2178 41.7754 49.019 39.7623C49.9747 38.6942 51.1043 38.102 52.449 37.7089C53.6962 37.3444 55.2339 37.1249 57.0541 36.865C57.0914 36.8597 57.1288 36.8543 57.1664 36.849Z"
class="fill-n-slate-10"
/>
<defs>
<radialGradient
id="paint0_radial_797_91519"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(68 54) rotate(90) scale(54 68)"
>
<stop
offset="0.527769"
stop-color="var(--gradient-end)"
stop-opacity="0.9"
/>
<stop offset="1" stop-color="var(--gradient-end)" stop-opacity="0" />
</radialGradient>
</defs>
</svg>
</div>
</template>
<style scoped>
svg {
--gradient-start: #fcfcfd;
--gradient-end: #fcfcfd;
}
body.dark svg,
.htw-dark svg {
--gradient-start: #121213;
--gradient-end: #121213;
}
.svg-wrapper {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
outline: none;
}
</style>
@@ -45,7 +45,7 @@ const activeAssistantLabel = computed(() => {
/>
</template>
<DropdownBody class="bottom-9 min-w-64 z-50" strong>
<DropdownSection class="[&>ul]:max-h-80">
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="assistant in assistants"
:key="assistant.id"
@@ -103,12 +103,6 @@ const validationError = computed(() => {
);
});
const inputFieldType = computed(() => {
if (inputType.value === 'date') return 'date';
if (inputType.value === 'number') return 'number';
return 'text';
});
const resetModelOnAttributeKeyChange = newAttributeKey => {
/**
* Resets the filter values and operator when the attribute key changes. This ensures that
@@ -188,7 +182,7 @@ defineExpose({ validate });
<Input
v-else
v-model="values"
:type="inputFieldType"
:type="inputType === 'date' ? 'date' : 'text'"
class="[&>input]:h-8 [&>input]:py-1.5 [&>input]:outline-offset-0"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
@@ -91,7 +91,7 @@ const updateSelected = newValue => {
:class="dropdownPosition"
strong
>
<DropdownSection class="[&>ul]:max-h-80">
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
:key="option.value"
@@ -123,7 +123,7 @@ const toggleOption = option => {
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownSection class="[&>ul]:max-h-80">
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
:key="option.id"
@@ -124,7 +124,7 @@ const toggleSelected = option => {
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="[&>ul]:max-h-80">
<DropdownSection class="max-h-80 overflow-scroll">
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
@@ -164,8 +164,8 @@ export function useConversationFilterContext() {
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
inputType: 'number',
dataType: 'number',
inputType: 'plainText',
datatype: 'number',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
@@ -179,7 +179,7 @@ export function useConversationFilterContext() {
id: campaign.id,
name: campaign.title,
})),
dataType: 'number',
datatype: 'number',
filterOperators: presenceOperators.value,
attributeModel: 'standard',
},
@@ -78,8 +78,6 @@ watch(unit, () => {
<option :value="DURATION_UNITS.HOURS">
{{ t('DURATION_INPUT.HOURS') }}
</option>
<option :value="DURATION_UNITS.DAYS">
{{ t('DURATION_INPUT.DAYS') }}
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select>
</template>
@@ -7,11 +7,6 @@ const props = defineProps({
placeholder: { type: String, default: '' },
label: { type: String, default: '' },
id: { type: String, default: '' },
size: {
type: String,
default: 'md',
validator: value => ['sm', 'md'].includes(value),
},
message: { type: String, default: '' },
disabled: { type: Boolean, default: false },
messageType: {
@@ -20,7 +15,6 @@ const props = defineProps({
validator: value => ['info', 'error', 'success'].includes(value),
},
min: { type: String, default: '' },
max: { type: String, default: '' },
autofocus: { type: Boolean, default: false },
});
@@ -60,12 +54,7 @@ const inputOutlineClass = computed(() => {
});
const handleInput = event => {
let value = event.target.value;
// Convert to number if type is number and value is not empty
if (props.type === 'number' && value !== '') {
value = Number(value);
}
emit('update:modelValue', value);
emit('update:modelValue', event.target.value);
emit('input', event);
};
@@ -74,17 +63,6 @@ const handleFocus = event => {
isFocused.value = true;
};
const sizeClass = computed(() => {
switch (props.size) {
case 'sm':
return 'h-8 !px-3 !py-2';
case 'md':
return 'h-10 !px-3 !py-2.5';
default:
return 'h-10 !px-3 !py-2.5';
}
});
const handleBlur = event => {
emit('blur', event);
isFocused.value = false;
@@ -116,13 +94,11 @@ onMounted(() => {
<slot name="prefix" />
<input
:id="uniqueId"
v-bind="$attrs"
ref="inputRef"
:value="modelValue"
:class="[
customInputClass,
inputOutlineClass,
sizeClass,
{
error: messageType === 'error',
focus: isFocused,
@@ -132,12 +108,7 @@ onMounted(() => {
:placeholder="placeholder"
:disabled="disabled"
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
:max="
['date', 'datetime-local', 'time', 'number'].includes(type)
? max
: undefined
"
class="block w-full reset-base text-sm !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -131,8 +131,6 @@ const props = defineProps({
sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties
});
const emit = defineEmits(['retry']);
const contextMenuPosition = ref({});
const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false);
@@ -526,7 +524,6 @@ provideMessageContext({
class="[grid-area:meta]"
:class="flexOrientationClass"
:error="contentAttributes.externalError"
@retry="emit('retry')"
/>
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
@@ -1,22 +1,16 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from './provider.js';
import { hasOneDayPassed } from 'shared/helpers/timeHelper';
import { ORIENTATION, MESSAGE_STATUS } from './constants';
import { ORIENTATION } from './constants';
defineProps({
error: { type: String, required: true },
});
const emit = defineEmits(['retry']);
const { orientation, status, createdAt } = useMessageContext();
const { orientation } = useMessageContext();
const { t } = useI18n();
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
</script>
<template>
@@ -41,14 +35,5 @@ const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
{{ error }}
</div>
</div>
<button
v-if="canRetry"
type="button"
:disabled="status !== MESSAGE_STATUS.FAILED"
class="bg-n-alpha-2 rounded-md size-5 grid place-content-center cursor-pointer"
@click="emit('retry')"
>
<Icon icon="i-lucide-refresh-ccw" class="text-n-ruby-11 size-[14px]" />
</button>
</div>
</template>
@@ -1,10 +1,8 @@
<script setup>
import { defineProps, computed, reactive } from 'vue';
import { defineProps, computed } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useMapGetter } from 'dashboard/composables/store.js';
import MessageApi from 'dashboard/api/inbox/message.js';
/**
* Props definition for the component
@@ -39,54 +37,10 @@ const props = defineProps({
},
});
const emit = defineEmits(['retry']);
const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
});
const currentChat = useMapGetter('getSelectedChat');
// Cache for fetched reply messages to avoid duplicate API calls
const fetchedReplyMessages = reactive(new Map());
/**
* Fetches a specific message from the API by trying to get messages around it
* @param {number} messageId - The ID of the message to fetch
* @param {number} conversationId - The ID of the conversation
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
*/
const fetchReplyMessage = async (messageId, conversationId) => {
// Return cached result if already fetched
if (fetchedReplyMessages.has(messageId)) {
return fetchedReplyMessages.get(messageId);
}
try {
const response = await MessageApi.getPreviousMessages({
conversationId,
before: messageId + 100,
after: messageId - 100,
});
const messages = response.data?.payload || [];
const targetMessage = messages.find(msg => msg.id === messageId);
if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(messageId, camelCaseMessage);
return camelCaseMessage;
}
// Cache null result to avoid repeated API calls
fetchedReplyMessages.set(messageId, null);
return null;
} catch (error) {
fetchedReplyMessages.set(messageId, null);
return null;
}
};
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
@@ -134,26 +88,10 @@ const getInReplyToMessage = parentMessage => {
if (!inReplyToMessageId) return null;
// Try to find in current messages first
let replyMessage = props.messages?.find(msg => msg.id === inReplyToMessageId);
// Then try store messages
if (!replyMessage && currentChat.value?.messages) {
replyMessage = currentChat.value.messages.find(
msg => msg.id === inReplyToMessageId
);
}
// Then check fetch cache
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
}
// If still not found and we have conversation context, fetch it
if (!replyMessage && currentChat.value?.id) {
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
return null; // Let UI handle loading state
}
// Find in-reply-to message in the messages prop
const replyMessage = props.messages?.find(
message => message.id === inReplyToMessageId
);
return replyMessage ? useCamelCase(replyMessage) : null;
};
@@ -175,7 +113,6 @@ const getInReplyToMessage = parentMessage => {
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
@retry="emit('retry', message)"
/>
</template>
<slot name="after" />
@@ -1,16 +1,8 @@
<script setup>
import {
computed,
onMounted,
useTemplateRef,
ref,
getCurrentInstance,
} from 'vue';
import { computed, onMounted, useTemplateRef, ref } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
const { attachment } = defineProps({
attachment: {
@@ -35,8 +27,6 @@ const currentTime = ref(0);
const duration = ref(0);
const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
const onLoadedMetadata = () => {
duration.value = audioPlayer.value?.duration;
};
@@ -53,18 +43,6 @@ onMounted(() => {
audioPlayer.value.playbackRate = playbackSpeed.value;
});
// Listen for global audio play events and pause if it's not this audio
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
try {
audioPlayer.value.pause();
} catch {
/* ignore pause errors */
}
isPlaying.value = false;
}
});
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
@@ -92,8 +70,6 @@ const playOrPause = () => {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
// Emit event to pause all other audio
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
@@ -125,7 +101,6 @@ const downloadAudio = async () => {
ref="audioPlayer"
controls
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
@@ -76,7 +76,7 @@ function changeAvailabilityStatus(availability) {
</script>
<template>
<DropdownSection class="[&>ul]:overflow-visible">
<DropdownSection>
<div class="grid gap-0">
<DropdownItem preserve-open>
<div class="flex-grow flex items-center gap-1">
@@ -72,10 +72,6 @@ const formatType = computed(() => {
return format ? format.charAt(0) + format.slice(1).toLowerCase() : '';
});
const isDocumentTemplate = computed(() => {
return headerComponent.value?.format?.toLowerCase() === 'document';
});
const hasVariables = computed(() => {
return bodyText.value?.match(/{{([^}]+)}}/g) !== null;
});
@@ -130,11 +126,6 @@ const updateMediaUrl = value => {
processedParams.value.header.media_url = value;
};
const updateMediaName = value => {
processedParams.value.header ??= {};
processedParams.value.header.media_name = value;
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
@@ -177,12 +168,10 @@ defineExpose({
processedParams,
hasVariables,
hasMediaHeader,
isDocumentTemplate,
headerComponent,
renderedTemplate,
v$,
updateMediaUrl,
updateMediaName,
sendMessage,
resetTemplate,
goBack,
@@ -236,17 +225,6 @@ defineExpose({
@update:model-value="updateMediaUrl"
/>
</div>
<div v-if="isDocumentTemplate" class="flex items-center mb-2.5">
<Input
:model-value="processedParams.header?.media_name || ''"
type="text"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.DOCUMENT_NAME_PLACEHOLDER')
"
@update:model-value="updateMediaName"
/>
</div>
</div>
<!-- Body Variables Section -->
@@ -1,328 +0,0 @@
<script setup>
import axios from 'axios';
import { ref, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { handleOtpPaste } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { useAccount } from 'dashboard/composables/useAccount';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import FormInput from 'v3/components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
mfaToken: {
type: String,
required: true,
},
});
const emit = defineEmits(['verified', 'cancel']);
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const OTP = 'otp';
const BACKUP = 'backup';
// State
const verificationMethod = ref(OTP);
const otpDigits = ref(['', '', '', '', '', '']);
const backupCode = ref('');
const isVerifying = ref(false);
const errorMessage = ref('');
const helpModalRef = ref(null);
const otpInputRefs = ref([]);
// Computed
const otpCode = computed(() => otpDigits.value.join(''));
const canSubmit = computed(() =>
verificationMethod.value === OTP
? otpCode.value.length === 6
: backupCode.value.length === 8
);
const contactDescKey = computed(() =>
isOnChatwootCloud.value ? 'CONTACT_DESC_CLOUD' : 'CONTACT_DESC_SELF_HOSTED'
);
const focusInput = i => otpInputRefs.value[i]?.focus();
// Verification
const handleVerification = async () => {
if (!canSubmit.value || isVerifying.value) return;
isVerifying.value = true;
errorMessage.value = '';
try {
const payload = {
mfa_token: props.mfaToken,
};
if (verificationMethod.value === OTP) {
payload.otp_code = otpCode.value;
} else {
payload.backup_code = backupCode.value;
}
const response = await axios.post('/auth/sign_in', payload);
// Set auth credentials and redirect
if (response.data && response.headers) {
// Store auth credentials in cookies
const authData = {
'access-token': response.headers['access-token'],
'token-type': response.headers['token-type'],
client: response.headers.client,
expiry: response.headers.expiry,
uid: response.headers.uid,
};
// Store in cookies for auth
document.cookie = `cw_d_session_info=${encodeURIComponent(JSON.stringify(authData))}; path=/; SameSite=Lax`;
// Redirect to dashboard
window.location.href = '/app/';
} else {
emit('verified', response.data);
}
} catch (error) {
errorMessage.value =
parseAPIErrorResponse(error) || t('MFA_VERIFICATION.VERIFICATION_FAILED');
// Clear inputs on error
if (verificationMethod.value === OTP) {
otpDigits.value.fill('');
await nextTick();
focusInput(0);
} else {
backupCode.value = '';
}
} finally {
isVerifying.value = false;
}
};
// OTP Input Handling
const handleOtpInput = async i => {
const v = otpDigits.value[i];
// Only allow numbers
if (!/^\d*$/.test(v)) {
otpDigits.value[i] = '';
return;
}
// Move to next input if value entered
if (v && i < 5) {
await nextTick();
focusInput(i + 1);
}
// Auto-submit if all digits entered
if (otpCode.value.length === 6) {
handleVerification();
}
};
const handleBackspace = (e, i) => {
if (!otpDigits.value[i] && i > 0) {
e.preventDefault();
focusInput(i - 1);
otpDigits.value[i - 1] = '';
}
};
const handleOtpCodePaste = e => {
e.preventDefault();
const code = handleOtpPaste(e, 6);
if (code) {
otpDigits.value = code.split('');
handleVerification();
}
};
// Alternative Actions
const handleTryAnotherMethod = () => {
// Toggle between methods
verificationMethod.value = verificationMethod.value === OTP ? BACKUP : OTP;
otpDigits.value.fill('');
backupCode.value = '';
errorMessage.value = '';
};
</script>
<template>
<div class="w-full max-w-md mx-auto">
<div
class="bg-white shadow sm:mx-auto sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
>
<!-- Header -->
<div class="text-center mb-6">
<div
class="inline-flex items-center justify-center size-14 bg-n-solid-1 outline outline-n-weak rounded-full mb-4"
>
<Icon icon="i-lucide-lock-keyhole" class="size-6 text-n-slate-10" />
</div>
<h2 class="text-2xl font-semibold text-n-slate-12">
{{ $t('MFA_VERIFICATION.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11 mt-2">
{{ $t('MFA_VERIFICATION.DESCRIPTION') }}
</p>
</div>
<!-- Tab Selection -->
<div class="flex rounded-lg bg-n-alpha-black2 p-1 mb-6">
<button
v-for="method in [OTP, BACKUP]"
:key="method"
class="flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors"
:class="
verificationMethod === method
? 'bg-n-solid-active text-n-slate-12 shadow-sm'
: 'text-n-slate-12'
"
@click="verificationMethod = method"
>
{{
$t(
`MFA_VERIFICATION.${method === OTP ? 'AUTHENTICATOR_APP' : 'BACKUP_CODE'}`
)
}}
</button>
</div>
<!-- Verification Form -->
<form class="space-y-4" @submit.prevent="handleVerification">
<!-- OTP Code Input -->
<div v-if="verificationMethod === OTP">
<label class="block text-sm font-medium text-n-slate-12 mb-2">
{{ $t('MFA_VERIFICATION.ENTER_OTP_CODE') }}
</label>
<div class="flex justify-between gap-2">
<input
v-for="(_, i) in otpDigits"
:key="i"
ref="otpInputRefs"
v-model="otpDigits[i]"
type="text"
maxlength="1"
pattern="[0-9]"
inputmode="numeric"
class="w-12 h-12 text-center text-lg font-semibold border-2 border-n-weak hover:border-n-strong rounded-lg focus:border-n-brand bg-n-alpha-black2 text-n-slate-12 placeholder:text-n-slate-10"
@input="handleOtpInput(i)"
@keydown.left.prevent="focusInput(i - 1)"
@keydown.right.prevent="focusInput(i + 1)"
@keydown.backspace="handleBackspace($event, i)"
@paste="handleOtpCodePaste"
/>
</div>
</div>
<!-- Backup Code Input -->
<div v-if="verificationMethod === BACKUP">
<FormInput
v-model="backupCode"
name="backup_code"
type="text"
data-testid="backup_code_input"
:tabindex="1"
required
:label="$t('MFA_VERIFICATION.ENTER_BACKUP_CODE')"
:placeholder="
$t('MFA_VERIFICATION.BACKUP_CODE_PLACEHOLDER') || '000000'
"
@keyup.enter="handleVerification"
/>
</div>
<!-- Error Message -->
<div
v-if="errorMessage"
class="p-3 bg-n-ruby-3 outline outline-n-ruby-5 outline-1 rounded-lg"
>
<p class="text-sm text-n-ruby-9">{{ errorMessage }}</p>
</div>
<!-- Submit Button -->
<NextButton
lg
type="submit"
data-testid="submit_button"
class="w-full"
:tabindex="2"
:label="$t('MFA_VERIFICATION.VERIFY_BUTTON')"
:disabled="!canSubmit || isVerifying"
:is-loading="isVerifying"
/>
<!-- Alternative Actions -->
<div class="text-center flex items-center flex-col gap-2 pt-4">
<NextButton
sm
link
type="button"
class="w-full hover:!no-underline"
:tabindex="2"
:label="$t('MFA_VERIFICATION.TRY_ANOTHER_METHOD')"
@click="handleTryAnotherMethod"
/>
<NextButton
sm
slate
link
type="button"
class="w-full hover:!no-underline"
:tabindex="3"
:label="$t('MFA_VERIFICATION.CANCEL_LOGIN')"
@click="() => emit('cancel')"
/>
</div>
</form>
</div>
<!-- Help Text -->
<div class="mt-6 text-center">
<p class="text-sm text-n-slate-11">
{{ $t('MFA_VERIFICATION.HELP_TEXT') }}
</p>
<NextButton
sm
link
type="button"
class="w-full hover:!no-underline"
:tabindex="4"
:label="$t('MFA_VERIFICATION.LEARN_MORE')"
@click="helpModalRef?.open()"
/>
</div>
<!-- Help Modal -->
<Dialog
ref="helpModalRef"
:title="$t('MFA_VERIFICATION.HELP_MODAL.TITLE')"
:show-confirm-button="false"
class="[&>dialog>div]:bg-n-alpha-3 [&>dialog>div]:rounded-lg"
@confirm="helpModalRef?.close()"
>
<div class="space-y-4 text-sm text-n-slate-11">
<div v-for="section in ['AUTHENTICATOR', 'BACKUP']" :key="section">
<h4 class="font-medium text-n-slate-12 mb-2">
{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_TITLE`) }}
</h4>
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_DESC`) }}</p>
</div>
<div>
<h4 class="font-medium text-n-slate-12 mb-2">
{{ $t('MFA_VERIFICATION.HELP_MODAL.CONTACT_TITLE') }}
</h4>
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${contactDescKey}`) }}</p>
</div>
</div>
</Dialog>
</div>
</template>
@@ -4,7 +4,6 @@ import { ref, provide } from 'vue';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
import ReplyBox from './ReplyBox.vue';
@@ -438,11 +437,6 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
async handleMessageRetry(message) {
if (!message) return;
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
},
};
</script>
@@ -471,7 +465,6 @@ export default {
:is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:messages="getMessages"
@retry="handleMessageRetry"
>
<template #beforeAll>
<transition name="slide-up">
@@ -32,10 +32,7 @@ export default {
value: {
required,
isEqual(value) {
// Trim whitespace from both input and target values
const normalizedInput = (value || '').trim();
const normalizedTarget = (this.confirmValue || '').trim();
return normalizedInput === normalizedTarget;
return value === this.confirmValue;
},
},
},
@@ -104,7 +104,6 @@ export default function useAutomationValues() {
contacts: contacts.value,
customAttributes: getters['attributes/getAttributes'].value,
inboxes: inboxes.value,
labels: labels.value,
statusFilterOptions: statusFilterOptions.value,
priorityOptions: priorityOptions.value,
messageTypeOptions: messageTypeOptions.value,
@@ -35,8 +35,6 @@ export default {
HELP_CENTER_DOCS_URL:
'https://www.chatwoot.com/docs/product/others/help-center',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
SMALL_SCREEN_BREAKPOINT: 768,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
+1
View File
@@ -38,6 +38,7 @@ export const FEATURE_FLAGS = {
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',
};
@@ -145,34 +145,3 @@ export const extractFilenameFromUrl = url => {
return match ? match[1] : url;
}
};
/**
* Normalizes a comma/newline separated list of domains
* @param {string} domains - The comma/newline separated list of domains
* @returns {string} - The normalized list of domains
* - Converts newlines to commas
* - Trims whitespace
* - Lowercases entries
* - Removes empty values
* - De-duplicates while preserving original order
*/
export const sanitizeAllowedDomains = domains => {
if (!domains) return '';
const tokens = domains
.replace(/\r\n/g, '\n')
.replace(/\s*\n\s*/g, ',')
.split(',')
.map(d => d.trim().toLowerCase())
.filter(d => d.length > 0);
// De-duplicate while preserving order using Set and filter index
const seen = new Set();
const unique = tokens.filter(d => {
if (seen.has(d)) return false;
seen.add(d);
return true;
});
return unique.join(',');
};
@@ -124,7 +124,6 @@ export const getConditionOptions = ({
customAttributes,
inboxes,
languages,
labels,
statusFilterOptions,
teams,
type,
@@ -151,7 +150,6 @@ export const getConditionOptions = ({
country_code: countries,
message_type: messageTypeOptions,
priority: priorityOptions,
labels: generateConditionOptions(labels, 'title'),
};
return conditionFilterMaps[type];
@@ -8,7 +8,6 @@ import {
timeStampAppendedURL,
getHostNameFromURL,
extractFilenameFromUrl,
sanitizeAllowedDomains,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -319,32 +318,4 @@ describe('#URL Helpers', () => {
).toBe('file.doc');
});
});
describe('sanitizeAllowedDomains', () => {
it('returns empty string for falsy input', () => {
expect(sanitizeAllowedDomains('')).toBe('');
expect(sanitizeAllowedDomains(null)).toBe('');
expect(sanitizeAllowedDomains(undefined)).toBe('');
});
it('trims whitespace and converts newlines to commas', () => {
const input = ' example.com \n foo.bar\nbar.baz ';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
it('handles Windows newlines and mixed spacing', () => {
const input = ' example.com\r\n\tfoo.bar , bar.baz ';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
it('removes empty values from repeated commas', () => {
const input = ',,example.com,,foo.bar,,';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar');
});
it('lowercases entries and de-duplicates preserving order', () => {
const input = 'Example.com,FOO.bar,example.com,Bar.Baz,foo.BAR';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
});
});
@@ -218,7 +218,6 @@ describe('templateHelper', () => {
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
media_name: '',
});
expect(result.body).toEqual({
1: '',
@@ -51,11 +51,6 @@ export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
// For document templates, include media_name field for filename support
if (headerComponent.format.toLowerCase() === 'document') {
allVariables.header.media_name = '';
}
}
// Process button variables
@@ -177,8 +177,7 @@
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
"PRIORITY": "Priority",
"LABELS": "Labels"
"PRIORITY": "Priority"
}
}
}
@@ -554,12 +554,10 @@
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note",
"ADD_NOTE": "Add contact note",
"EXPAND": "Expand",
"COLLAPSE": "ሰብስብ",
"NO_NOTES": "ማስታወሻዎች የሉም፣ ከእውቂያው ዝርዝር ገፅ ላይ ማስታወሻዎችን መጨመር ይችላሉ።",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.",
"CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one."
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
}
},
"EMPTY_STATE": {
@@ -71,17 +71,6 @@
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer",
"MISSED_CALL": "Missed call",
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
@@ -741,8 +741,7 @@
"LIVE_CHAT_WIDGET": {
"LABEL": "Live chat widget",
"PLACEHOLDER": "Select live chat widget",
"HELP_TEXT": "Select a live chat widget that will appear on your help center",
"NONE_OPTION": "No widget"
"HELP_TEXT": "Select a live chat widget that will appear on your help center"
},
"BRAND_COLOR": {
"LABEL": "Brand color"
@@ -272,8 +272,8 @@
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick setup with Meta",
"DESC": "Use the WhatsApp Embedded Signup flow to quickly connect new numbers. You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
@@ -281,8 +281,9 @@
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit {link}.",
"LINK_TEXT": "this link"
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
@@ -295,9 +296,7 @@
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@@ -761,7 +761,6 @@
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"SEARCH_PLACEHOLDER": "Search FAQs...",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
@@ -22,20 +22,6 @@
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login",
"SAML": {
"LABEL": "Log in via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
"WORK_EMAIL": {
"LABEL": "Work Email",
"PLACEHOLDER": "Enter your work email"
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
}
}
"SUBMIT": "Login"
}
}
@@ -1,106 +0,0 @@
{
"MFA_SETTINGS": {
"TITLE": "Two-Factor Authentication",
"SUBTITLE": "Secure your account with TOTP-based authentication",
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
"STATUS_TITLE": "Authentication Status",
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
"ENABLED": "Enabled",
"DISABLED": "Disabled",
"STATUS_ENABLED": "Two-factor authentication is active",
"STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security",
"ENABLE_BUTTON": "Enable Two-Factor Authentication",
"ENHANCE_SECURITY": "Enhance Your Account Security",
"ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.",
"SETUP": {
"STEP_NUMBER_1": "1",
"STEP_NUMBER_2": "2",
"STEP1_TITLE": "Scan QR Code with Your Authenticator App",
"STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app",
"LOADING_QR": "Loading...",
"MANUAL_ENTRY": "Can't scan? Enter code manually",
"SECRET_KEY": "Secret Key",
"COPY": "Copy",
"ENTER_CODE": "Enter the 6-digit code from your authenticator app",
"ENTER_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify & Continue",
"CANCEL": "Cancel",
"ERROR_STARTING": "MFA not enabled. Please contact administrator.",
"INVALID_CODE": "Invalid verification code",
"SECRET_COPIED": "Secret key copied to clipboard",
"SUCCESS": "Two-factor authentication has been enabled successfully"
},
"BACKUP": {
"TITLE": "Save Your Backup Codes",
"DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator",
"IMPORTANT": "Important:",
"IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.",
"DOWNLOAD": "Download",
"COPY_ALL": "Copy All",
"CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again",
"COMPLETE_SETUP": "Complete Setup",
"CODES_COPIED": "Backup codes copied to clipboard"
},
"MANAGEMENT": {
"BACKUP_CODES": "Backup Codes",
"BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones",
"REGENERATE": "Regenerate Backup Codes",
"DISABLE_MFA": "Disable 2FA",
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
"ERROR": "Failed to disable MFA. Please check your credentials."
},
"REGENERATE": {
"TITLE": "Regenerate Backup Codes",
"DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Generate New Codes",
"CANCEL": "Cancel",
"NEW_CODES_TITLE": "New Backup Codes Generated",
"NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.",
"CODES_IMPORTANT": "Important:",
"CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.",
"DOWNLOAD_CODES": "Download Codes",
"COPY_ALL_CODES": "Copy All Codes",
"CODES_SAVED": "I've Saved My Codes",
"SUCCESS": "New backup codes have been generated",
"ERROR": "Failed to regenerate backup codes"
}
},
"MFA_VERIFICATION": {
"TITLE": "Two-Factor Authentication",
"DESCRIPTION": "Enter your verification code to continue",
"AUTHENTICATOR_APP": "Authenticator App",
"BACKUP_CODE": "Backup Code",
"ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app",
"ENTER_BACKUP_CODE": "Enter one of your backup codes",
"BACKUP_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify",
"TRY_ANOTHER_METHOD": "Try another verification method",
"CANCEL_LOGIN": "Cancel and return to login",
"HELP_TEXT": "Having trouble signing in?",
"LEARN_MORE": "Learn more about 2FA",
"HELP_MODAL": {
"TITLE": "Two-Factor Authentication Help",
"AUTHENTICATOR_TITLE": "Using an Authenticator App",
"AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.",
"BACKUP_TITLE": "Using a Backup Code",
"BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.",
"CONTACT_TITLE": "Need More Help?",
"CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.",
"CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance."
},
"VERIFICATION_FAILED": "Verification failed. Please try again."
}
}
@@ -51,13 +51,6 @@
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
},
"LANGUAGE": {
"TITLE": "Preferred Language",
"NOTE": "Choose the language you want to use.",
"UPDATE_SUCCESS": "Your Language settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the language settings, please try again",
"USE_ACCOUNT_DEFAULT": "Use account default"
}
},
"MESSAGE_SIGNATURE_SECTION": {
@@ -80,11 +73,6 @@
"NOTE": "Updating your password would reset your logins in multiple devices.",
"BTN_TEXT": "Change password"
},
"SECURITY_SECTION": {
"TITLE": "Security",
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
@@ -342,7 +330,6 @@
"REPORTS_LABEL": "Labels",
"REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team",
"AGENT_ASSIGNMENT": "Agent Assignment",
"SET_AVAILABILITY_TITLE": "Set yourself as",
"SET_YOUR_AVAILABILITY": "Set your availability",
"SLA": "SLA",
@@ -363,8 +350,7 @@
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs",
"SECURITY": "Security"
"DOCS": "Read docs"
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
@@ -396,77 +382,6 @@
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
"LINK_TEXT": "Learn more about SAML SSO",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
"ACS_URL": {
"LABEL": "ACS URL",
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
},
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"FINGERPRINT": {
"LABEL": "Fingerprint",
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
},
"COPY_SUCCESS": "Copied to clipboard",
"SP_ENTITY_ID": {
"LABEL": "SP Entity ID",
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
},
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
"SUCCESS": "SAML settings updated successfully",
"ERROR": "Failed to update SAML settings",
"ERROR_LOADING": "Failed to load SAML settings",
"DISABLED": "SAML settings disabled successfully"
},
"VALIDATION": {
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
"SSO_URL_ERROR": "Please enter a valid SSO URL",
"CERTIFICATE_ERROR": "Certificate is required",
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"PAYWALL": {
"TITLE": "Upgrade to enable SAML SSO",
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ATTRIBUTE_MAPPING": {
"TITLE": "SAML Attribute Setup",
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
},
"INFO_SECTION": {
"TITLE": "Service Provider Information",
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",
@@ -503,255 +418,5 @@
"SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
}
},
"ASSIGNMENT_POLICY": {
"INDEX": {
"HEADER": {
"TITLE": "Agent assignment",
"DESCRIPTION": "Define policies to effectively manage workload and route conversations based on the needs of inboxes and agents. Learn more here"
},
"ASSIGNMENT_POLICY": {
"TITLE": "Assignment policy",
"DESCRIPTION": "Manage how conversations get assigned in inboxes.",
"FEATURES": [
"Assign by conversations evenly or by available capacity",
"Add fair distribution rules to avoid overloading any agent",
"Add inboxes to a policy - one policy per inbox"
]
},
"AGENT_CAPACITY_POLICY": {
"TITLE": "Agent capacity policy",
"DESCRIPTION": "Manage workload for agents.",
"FEATURES": [
"Define maximum conversations per inbox",
"Create exceptions based on labels and time",
"Add agents to a policy - one policy per agent"
]
}
},
"AGENT_ASSIGNMENT_POLICY": {
"INDEX": {
"HEADER": {
"TITLE": "Assignment policy",
"CREATE_POLICY": "New policy"
},
"CARD": {
"ORDER": "Order",
"PRIORITY": "Priority",
"ACTIVE": "Active",
"INACTIVE": "Inactive",
"POPOVER": "Added inboxes",
"EDIT": "Edit"
},
"NO_RECORDS_FOUND": "No assignment policies found"
},
"CREATE": {
"HEADER": {
"TITLE": "Create assignment policy"
},
"CREATE_BUTTON": "Create policy",
"API": {
"SUCCESS_MESSAGE": "Assignment policy created successfully",
"ERROR_MESSAGE": "Failed to create assignment policy"
}
},
"EDIT": {
"HEADER": {
"TITLE": "Edit assignment policy"
},
"EDIT_BUTTON": "Update policy",
"CONFIRM_ADD_INBOX_DIALOG": {
"TITLE": "Add inbox",
"DESCRIPTION": "{inboxName} inbox is already linked to another policy. Are you sure you want to link it to this policy? It will be unlinked from the other policy.",
"CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
"ERROR_MESSAGE": "Failed to update assignment policy"
},
"INBOX_API": {
"ADD": {
"SUCCESS_MESSAGE": "Inbox added to policy successfully",
"ERROR_MESSAGE": "Failed to add inbox to policy"
},
"REMOVE": {
"SUCCESS_MESSAGE": "Inbox removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove inbox from policy"
}
}
},
"FORM": {
"NAME": {
"LABEL": "Policy name:",
"PLACEHOLDER": "Enter policy name"
},
"DESCRIPTION": {
"LABEL": "Description:",
"PLACEHOLDER": "Enter description"
},
"STATUS": {
"LABEL": "Status:",
"PLACEHOLDER": "Select status",
"ACTIVE": "Policy is active",
"INACTIVE": "Policy is inactive"
},
"ASSIGNMENT_ORDER": {
"LABEL": "Assignment order",
"ROUND_ROBIN": {
"LABEL": "Round robin",
"DESCRIPTION": "Assign conversations evenly among agents."
},
"BALANCED": {
"LABEL": "Balanced",
"DESCRIPTION": "Assign conversations based on available capacity."
}
},
"ASSIGNMENT_PRIORITY": {
"LABEL": "Assignment priority",
"EARLIEST_CREATED": {
"LABEL": "Earliest created",
"DESCRIPTION": "The conversation that was created first gets assigned first."
},
"LONGEST_WAITING": {
"LABEL": "Longest waiting",
"DESCRIPTION": "The conversation waiting the longest gets assigned first."
}
},
"FAIR_DISTRIBUTION": {
"LABEL": "Fair distribution policy",
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
"INPUT_MAX": "Assign max",
"DURATION": "Conversations per agent in every"
},
"INBOXES": {
"LABEL": "Added inboxes",
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
"ADD_BUTTON": "Add inbox",
"DROPDOWN": {
"SEARCH_PLACEHOLDER": "Search and select inboxes to add",
"ADD_BUTTON": "Add"
},
"EMPTY_STATE": "No inboxes added to this policy, add an inbox to get started",
"API": {
"SUCCESS_MESSAGE": "Inbox successfully added to policy",
"ERROR_MESSAGE": "Failed to add inbox to policy"
}
}
},
"DELETE_POLICY": {
"SUCCESS_MESSAGE": "Assignment policy deleted successfully",
"ERROR_MESSAGE": "Failed to delete assignment policy"
}
},
"AGENT_CAPACITY_POLICY": {
"INDEX": {
"HEADER": {
"TITLE": "Agent capacity",
"CREATE_POLICY": "New policy"
},
"CARD": {
"POPOVER": "Added agents",
"EDIT": "Edit"
},
"NO_RECORDS_FOUND": "No agent capacity policies found"
},
"CREATE": {
"HEADER": {
"TITLE": "Create agent capacity policy"
},
"CREATE_BUTTON": "Create policy",
"API": {
"SUCCESS_MESSAGE": "Agent capacity policy created successfully",
"ERROR_MESSAGE": "Failed to create agent capacity policy"
}
},
"EDIT": {
"HEADER": {
"TITLE": "Edit agent capacity policy"
},
"EDIT_BUTTON": "Update policy",
"CONFIRM_ADD_AGENT_DIALOG": {
"TITLE": "Add agent",
"DESCRIPTION": "{agentName} is already linked to another policy. Are you sure you want to link it to this policy? It will be unlinked from the other policy.",
"CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "Agent capacity policy updated successfully",
"ERROR_MESSAGE": "Failed to update agent capacity policy"
},
"AGENT_API": {
"ADD": {
"SUCCESS_MESSAGE": "Agent added to policy successfully",
"ERROR_MESSAGE": "Failed to add agent to policy"
},
"REMOVE": {
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove agent from policy"
}
}
},
"FORM": {
"NAME": {
"LABEL": "Policy name:",
"PLACEHOLDER": "Enter policy name"
},
"DESCRIPTION": {
"LABEL": "Description:",
"PLACEHOLDER": "Enter description"
},
"INBOX_CAPACITY_LIMIT": {
"LABEL": "Inbox capacity limits",
"ADD_BUTTON": "Add inbox",
"FIELD": {
"SELECT_INBOX": "Select inbox",
"MAX_CONVERSATIONS": "Max conversations",
"SET_LIMIT": "Set limit"
},
"EMPTY_STATE": "No inbox limit set"
},
"EXCLUSION_RULES": {
"LABEL": "Exclusion rules",
"DESCRIPTION": "Conversations that satisfy the following conditions would not count towards agent capacity",
"TAGS": {
"LABEL": "Exclude conversations tagged with specific labels",
"ADD_TAG": "add tag",
"DROPDOWN": {
"SEARCH_PLACEHOLDER": "Search and select tags to add"
},
"EMPTY_STATE": "No tags added to this policy."
},
"DURATION": {
"LABEL": "Exclude conversations older than a specified duration",
"PLACEHOLDER": "Set time"
}
},
"USERS": {
"LABEL": "Assigned agents",
"DESCRIPTION": "Add agents for which this policy will be applicable.",
"ADD_BUTTON": "Add agent",
"DROPDOWN": {
"SEARCH_PLACEHOLDER": "Search and select agents to add",
"ADD_BUTTON": "Add"
},
"EMPTY_STATE": "No agents added",
"API": {
"SUCCESS_MESSAGE": "Agent successfully added to policy",
"ERROR_MESSAGE": "Failed to add agent to policy"
}
}
},
"DELETE_POLICY": {
"SUCCESS_MESSAGE": "Agent capacity policy deleted successfully",
"ERROR_MESSAGE": "Failed to delete agent capacity policy"
}
},
"DELETE_POLICY": {
"TITLE": "Delete policy",
"DESCRIPTION": "Are you sure you want to delete this policy? This action cannot be undone.",
"CONFIRM_BUTTON_LABEL": "Delete",
"CANCEL_BUTTON_LABEL": "Cancel"
}
}
}
@@ -40,7 +40,6 @@
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"DOCUMENT_NAME_PLACEHOLDER": "Enter document filename (e.g., Invoice_2025.pdf)",
"BUTTON_PARAMETER": "Enter button parameter"
}
}
@@ -177,8 +177,7 @@
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "المكلَّف",
"TEAM_NAME": "الفريق",
"PRIORITY": "الأولوية",
"LABELS": "الوسوم"
"PRIORITY": "الأولوية"
}
}
}
@@ -554,12 +554,10 @@
"WROTE": "كتب",
"YOU": "أنت",
"SAVE": "Save note",
"ADD_NOTE": "Add contact note",
"EXPAND": "Expand",
"COLLAPSE": "Collapse",
"NO_NOTES": "No notes, you can add notes from the contact details page.",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.",
"CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one."
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
}
},
"EMPTY_STATE": {
@@ -71,17 +71,6 @@
"SHOW_LABELS": "إظهار السمات",
"HIDE_LABELS": "إخفاء السمات"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer",
"MISSED_CALL": "Missed call",
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
},
"HEADER": {
"RESOLVE_ACTION": "حل المحادثة",
"REOPEN_ACTION": "إعادة فتح",
@@ -741,8 +741,7 @@
"LIVE_CHAT_WIDGET": {
"LABEL": "Live chat widget",
"PLACEHOLDER": "Select live chat widget",
"HELP_TEXT": "Select a live chat widget that will appear on your help center",
"NONE_OPTION": "No widget"
"HELP_TEXT": "Select a live chat widget that will appear on your help center"
},
"BRAND_COLOR": {
"LABEL": "Brand color"
@@ -272,8 +272,8 @@
},
"SUBMIT_BUTTON": "إنشاء قناة واتساب",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick setup with Meta",
"DESC": "Use the WhatsApp Embedded Signup flow to quickly connect new numbers. You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
@@ -281,8 +281,9 @@
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit {link}.",
"LINK_TEXT": "this link"
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
@@ -295,9 +296,7 @@
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
},
"API": {
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة واتساب"
@@ -761,7 +761,6 @@
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"SEARCH_PLACEHOLDER": "Search FAQs...",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "حذف",
"BULK_APPROVE": {
@@ -22,20 +22,6 @@
},
"FORGOT_PASSWORD": "نسيت كلمة المرور؟",
"CREATE_NEW_ACCOUNT": "إنشاء حساب جديد",
"SUBMIT": "تسجيل الدخول",
"SAML": {
"LABEL": "Log in via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
"WORK_EMAIL": {
"LABEL": "Work Email",
"PLACEHOLDER": "Enter your work email"
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
}
}
"SUBMIT": "تسجيل الدخول"
}
}
@@ -1,106 +0,0 @@
{
"MFA_SETTINGS": {
"TITLE": "Two-Factor Authentication",
"SUBTITLE": "Secure your account with TOTP-based authentication",
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
"STATUS_TITLE": "Authentication Status",
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
"ENABLED": "مفعل",
"DISABLED": "معطّل",
"STATUS_ENABLED": "Two-factor authentication is active",
"STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security",
"ENABLE_BUTTON": "Enable Two-Factor Authentication",
"ENHANCE_SECURITY": "Enhance Your Account Security",
"ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.",
"SETUP": {
"STEP_NUMBER_1": "1",
"STEP_NUMBER_2": "2",
"STEP1_TITLE": "Scan QR Code with Your Authenticator App",
"STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app",
"LOADING_QR": "جار التحميل...",
"MANUAL_ENTRY": "Can't scan? Enter code manually",
"SECRET_KEY": "Secret Key",
"COPY": "نسخ",
"ENTER_CODE": "Enter the 6-digit code from your authenticator app",
"ENTER_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify & Continue",
"CANCEL": "إلغاء",
"ERROR_STARTING": "MFA not enabled. Please contact administrator.",
"INVALID_CODE": "Invalid verification code",
"SECRET_COPIED": "Secret key copied to clipboard",
"SUCCESS": "Two-factor authentication has been enabled successfully"
},
"BACKUP": {
"TITLE": "Save Your Backup Codes",
"DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator",
"IMPORTANT": "Important:",
"IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.",
"DOWNLOAD": "تحميل",
"COPY_ALL": "Copy All",
"CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again",
"COMPLETE_SETUP": "Complete Setup",
"CODES_COPIED": "Backup codes copied to clipboard"
},
"MANAGEMENT": {
"BACKUP_CODES": "Backup Codes",
"BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones",
"REGENERATE": "Regenerate Backup Codes",
"DISABLE_MFA": "Disable 2FA",
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"PASSWORD": "كلمة المرور",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Disable 2FA",
"CANCEL": "إلغاء",
"SUCCESS": "Two-factor authentication has been disabled",
"ERROR": "Failed to disable MFA. Please check your credentials."
},
"REGENERATE": {
"TITLE": "Regenerate Backup Codes",
"DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Generate New Codes",
"CANCEL": "إلغاء",
"NEW_CODES_TITLE": "New Backup Codes Generated",
"NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.",
"CODES_IMPORTANT": "Important:",
"CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.",
"DOWNLOAD_CODES": "Download Codes",
"COPY_ALL_CODES": "Copy All Codes",
"CODES_SAVED": "I've Saved My Codes",
"SUCCESS": "New backup codes have been generated",
"ERROR": "Failed to regenerate backup codes"
}
},
"MFA_VERIFICATION": {
"TITLE": "Two-Factor Authentication",
"DESCRIPTION": "Enter your verification code to continue",
"AUTHENTICATOR_APP": "Authenticator App",
"BACKUP_CODE": "Backup Code",
"ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app",
"ENTER_BACKUP_CODE": "Enter one of your backup codes",
"BACKUP_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify",
"TRY_ANOTHER_METHOD": "Try another verification method",
"CANCEL_LOGIN": "Cancel and return to login",
"HELP_TEXT": "Having trouble signing in?",
"LEARN_MORE": "Learn more about 2FA",
"HELP_MODAL": {
"TITLE": "Two-Factor Authentication Help",
"AUTHENTICATOR_TITLE": "Using an Authenticator App",
"AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.",
"BACKUP_TITLE": "Using a Backup Code",
"BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.",
"CONTACT_TITLE": "Need More Help?",
"CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.",
"CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance."
},
"VERIFICATION_FAILED": "Verification failed. Please try again."
}
}
@@ -51,13 +51,6 @@
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
},
"LANGUAGE": {
"TITLE": "Preferred Language",
"NOTE": "Choose the language you want to use.",
"UPDATE_SUCCESS": "Your Language settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the language settings, please try again",
"USE_ACCOUNT_DEFAULT": "Use account default"
}
},
"MESSAGE_SIGNATURE_SECTION": {
@@ -80,11 +73,6 @@
"NOTE": "تعديل كلمة المرور الخاصة بك سيعيد ضبط تسجيلات الدخول الخاصة بك في الأجهزة الأخرى.",
"BTN_TEXT": "تغيير كلمة المرور"
},
"SECURITY_SECTION": {
"TITLE": "Security",
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"ACCESS_TOKEN": {
"TITLE": "رمز المصادقة",
"NOTE": "يمكن استخدام هذا رمز المصادقة إذا كنت تبني تطبيقات API للتكامل مع Chatwoot",
@@ -342,7 +330,6 @@
"REPORTS_LABEL": "الوسوم",
"REPORTS_INBOX": "صندوق الوارد",
"REPORTS_TEAM": "الفريق",
"AGENT_ASSIGNMENT": "Agent Assignment",
"SET_AVAILABILITY_TITLE": "تعيين نفسك كـ",
"SET_YOUR_AVAILABILITY": "قم بتعيين توافرك",
"SLA": "SLA",
@@ -363,8 +350,7 @@
"INFO_TEXT": "السماح للنظام بوضع علامة غير متصل تلقائياً عند عدم استخدام التطبيق أو لوحة التحكم.",
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "قراءة المستندات",
"SECURITY": "Security"
"DOCS": "قراءة المستندات"
},
"BILLING_SETTINGS": {
"TITLE": "الفواتير",
@@ -396,77 +382,6 @@
},
"NO_BILLING_USER": "حساب الفوترة الخاص بك قيد الإعداد. الرجاء تحديث الصفحة وحاول مرة أخرى."
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
"LINK_TEXT": "Learn more about SAML SSO",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
"ACS_URL": {
"LABEL": "ACS URL",
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
},
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"FINGERPRINT": {
"LABEL": "Fingerprint",
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
},
"COPY_SUCCESS": "تم نسخ الكود إلى الحافظة بنجاح",
"SP_ENTITY_ID": {
"LABEL": "SP Entity ID",
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
},
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
"SUCCESS": "SAML settings updated successfully",
"ERROR": "Failed to update SAML settings",
"ERROR_LOADING": "Failed to load SAML settings",
"DISABLED": "SAML settings disabled successfully"
},
"VALIDATION": {
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
"SSO_URL_ERROR": "Please enter a valid SSO URL",
"CERTIFICATE_ERROR": "Certificate is required",
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"PAYWALL": {
"TITLE": "Upgrade to enable SAML SSO",
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ATTRIBUTE_MAPPING": {
"TITLE": "SAML Attribute Setup",
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
},
"INFO_SECTION": {
"TITLE": "Service Provider Information",
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "أوه! لم نتمكن من العثور على الحساب. الرجاء إنشاء حساب جديد للمتابعة.",
"NEW_ACCOUNT": "حساب جديد",
@@ -503,255 +418,5 @@
"SWITCH_TO_REPLY": "التبديل إلى الرد",
"TOGGLE_SNOOZE_DROPDOWN": "تبديل القائمة المنسدلة"
}
},
"ASSIGNMENT_POLICY": {
"INDEX": {
"HEADER": {
"TITLE": "Agent assignment",
"DESCRIPTION": "Define policies to effectively manage workload and route conversations based on the needs of inboxes and agents. Learn more here"
},
"ASSIGNMENT_POLICY": {
"TITLE": "Assignment policy",
"DESCRIPTION": "Manage how conversations get assigned in inboxes.",
"FEATURES": [
"Assign by conversations evenly or by available capacity",
"Add fair distribution rules to avoid overloading any agent",
"Add inboxes to a policy - one policy per inbox"
]
},
"AGENT_CAPACITY_POLICY": {
"TITLE": "Agent capacity policy",
"DESCRIPTION": "Manage workload for agents.",
"FEATURES": [
"Define maximum conversations per inbox",
"Create exceptions based on labels and time",
"Add agents to a policy - one policy per agent"
]
}
},
"AGENT_ASSIGNMENT_POLICY": {
"INDEX": {
"HEADER": {
"TITLE": "Assignment policy",
"CREATE_POLICY": "New policy"
},
"CARD": {
"ORDER": "Order",
"PRIORITY": "الأولوية",
"ACTIVE": "مفعل",
"INACTIVE": "Inactive",
"POPOVER": "Added inboxes",
"EDIT": "تعديل"
},
"NO_RECORDS_FOUND": "No assignment policies found"
},
"CREATE": {
"HEADER": {
"TITLE": "Create assignment policy"
},
"CREATE_BUTTON": "Create policy",
"API": {
"SUCCESS_MESSAGE": "Assignment policy created successfully",
"ERROR_MESSAGE": "Failed to create assignment policy"
}
},
"EDIT": {
"HEADER": {
"TITLE": "Edit assignment policy"
},
"EDIT_BUTTON": "Update policy",
"CONFIRM_ADD_INBOX_DIALOG": {
"TITLE": "Add inbox",
"DESCRIPTION": "{inboxName} inbox is already linked to another policy. Are you sure you want to link it to this policy? It will be unlinked from the other policy.",
"CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "إلغاء"
},
"API": {
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
"ERROR_MESSAGE": "Failed to update assignment policy"
},
"INBOX_API": {
"ADD": {
"SUCCESS_MESSAGE": "Inbox added to policy successfully",
"ERROR_MESSAGE": "Failed to add inbox to policy"
},
"REMOVE": {
"SUCCESS_MESSAGE": "Inbox removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove inbox from policy"
}
}
},
"FORM": {
"NAME": {
"LABEL": "Policy name:",
"PLACEHOLDER": "Enter policy name"
},
"DESCRIPTION": {
"LABEL": "الوصف:",
"PLACEHOLDER": "أدخل الوصف"
},
"STATUS": {
"LABEL": "الحالة:",
"PLACEHOLDER": "اختر الحالة",
"ACTIVE": "Policy is active",
"INACTIVE": "Policy is inactive"
},
"ASSIGNMENT_ORDER": {
"LABEL": "Assignment order",
"ROUND_ROBIN": {
"LABEL": "Round robin",
"DESCRIPTION": "Assign conversations evenly among agents."
},
"BALANCED": {
"LABEL": "Balanced",
"DESCRIPTION": "Assign conversations based on available capacity."
}
},
"ASSIGNMENT_PRIORITY": {
"LABEL": "Assignment priority",
"EARLIEST_CREATED": {
"LABEL": "Earliest created",
"DESCRIPTION": "The conversation that was created first gets assigned first."
},
"LONGEST_WAITING": {
"LABEL": "Longest waiting",
"DESCRIPTION": "The conversation waiting the longest gets assigned first."
}
},
"FAIR_DISTRIBUTION": {
"LABEL": "Fair distribution policy",
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
"INPUT_MAX": "Assign max",
"DURATION": "Conversations per agent in every"
},
"INBOXES": {
"LABEL": "Added inboxes",
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
"ADD_BUTTON": "Add inbox",
"DROPDOWN": {
"SEARCH_PLACEHOLDER": "Search and select inboxes to add",
"ADD_BUTTON": "إضافة"
},
"EMPTY_STATE": "No inboxes added to this policy, add an inbox to get started",
"API": {
"SUCCESS_MESSAGE": "Inbox successfully added to policy",
"ERROR_MESSAGE": "Failed to add inbox to policy"
}
}
},
"DELETE_POLICY": {
"SUCCESS_MESSAGE": "Assignment policy deleted successfully",
"ERROR_MESSAGE": "Failed to delete assignment policy"
}
},
"AGENT_CAPACITY_POLICY": {
"INDEX": {
"HEADER": {
"TITLE": "Agent capacity",
"CREATE_POLICY": "New policy"
},
"CARD": {
"POPOVER": "Added agents",
"EDIT": "تعديل"
},
"NO_RECORDS_FOUND": "No agent capacity policies found"
},
"CREATE": {
"HEADER": {
"TITLE": "Create agent capacity policy"
},
"CREATE_BUTTON": "Create policy",
"API": {
"SUCCESS_MESSAGE": "Agent capacity policy created successfully",
"ERROR_MESSAGE": "Failed to create agent capacity policy"
}
},
"EDIT": {
"HEADER": {
"TITLE": "Edit agent capacity policy"
},
"EDIT_BUTTON": "Update policy",
"CONFIRM_ADD_AGENT_DIALOG": {
"TITLE": "Add agent",
"DESCRIPTION": "{agentName} is already linked to another policy. Are you sure you want to link it to this policy? It will be unlinked from the other policy.",
"CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "إلغاء"
},
"API": {
"SUCCESS_MESSAGE": "Agent capacity policy updated successfully",
"ERROR_MESSAGE": "Failed to update agent capacity policy"
},
"AGENT_API": {
"ADD": {
"SUCCESS_MESSAGE": "Agent added to policy successfully",
"ERROR_MESSAGE": "Failed to add agent to policy"
},
"REMOVE": {
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove agent from policy"
}
}
},
"FORM": {
"NAME": {
"LABEL": "Policy name:",
"PLACEHOLDER": "Enter policy name"
},
"DESCRIPTION": {
"LABEL": "الوصف:",
"PLACEHOLDER": "أدخل الوصف"
},
"INBOX_CAPACITY_LIMIT": {
"LABEL": "Inbox capacity limits",
"ADD_BUTTON": "Add inbox",
"FIELD": {
"SELECT_INBOX": "Select inbox",
"MAX_CONVERSATIONS": "Max conversations",
"SET_LIMIT": "Set limit"
},
"EMPTY_STATE": "No inbox limit set"
},
"EXCLUSION_RULES": {
"LABEL": "Exclusion rules",
"DESCRIPTION": "Conversations that satisfy the following conditions would not count towards agent capacity",
"TAGS": {
"LABEL": "Exclude conversations tagged with specific labels",
"ADD_TAG": "add tag",
"DROPDOWN": {
"SEARCH_PLACEHOLDER": "Search and select tags to add"
},
"EMPTY_STATE": "No tags added to this policy."
},
"DURATION": {
"LABEL": "Exclude conversations older than a specified duration",
"PLACEHOLDER": "Set time"
}
},
"USERS": {
"LABEL": "Assigned agents",
"DESCRIPTION": "Add agents for which this policy will be applicable.",
"ADD_BUTTON": "Add agent",
"DROPDOWN": {
"SEARCH_PLACEHOLDER": "Search and select agents to add",
"ADD_BUTTON": "إضافة"
},
"EMPTY_STATE": "No agents added",
"API": {
"SUCCESS_MESSAGE": "Agent successfully added to policy",
"ERROR_MESSAGE": "Failed to add agent to policy"
}
}
},
"DELETE_POLICY": {
"SUCCESS_MESSAGE": "Agent capacity policy deleted successfully",
"ERROR_MESSAGE": "Failed to delete agent capacity policy"
}
},
"DELETE_POLICY": {
"TITLE": "Delete policy",
"DESCRIPTION": "Are you sure you want to delete this policy? This action cannot be undone.",
"CONFIRM_BUTTON_LABEL": "حذف",
"CANCEL_BUTTON_LABEL": "إلغاء"
}
}
}
@@ -40,7 +40,6 @@
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"DOCUMENT_NAME_PLACEHOLDER": "Enter document filename (e.g., Invoice_2025.pdf)",
"BUTTON_PARAMETER": "Enter button parameter"
}
}
@@ -177,8 +177,7 @@
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
"PRIORITY": "Priority",
"LABELS": "Labels"
"PRIORITY": "Priority"
}
}
}
@@ -554,12 +554,10 @@
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note",
"ADD_NOTE": "Add contact note",
"EXPAND": "Expand",
"COLLAPSE": "Collapse",
"NO_NOTES": "No notes, you can add notes from the contact details page.",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.",
"CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one."
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
}
},
"EMPTY_STATE": {
@@ -71,17 +71,6 @@
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer",
"MISSED_CALL": "Missed call",
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
@@ -741,8 +741,7 @@
"LIVE_CHAT_WIDGET": {
"LABEL": "Live chat widget",
"PLACEHOLDER": "Select live chat widget",
"HELP_TEXT": "Select a live chat widget that will appear on your help center",
"NONE_OPTION": "No widget"
"HELP_TEXT": "Select a live chat widget that will appear on your help center"
},
"BRAND_COLOR": {
"LABEL": "Brand color"

Some files were not shown because too many files have changed in this diff Show More