Compare commits

..
Author SHA1 Message Date
iamsivin 48ae6e5381 chore: Update display property design 2026-01-22 18:18:11 +05:30
iamsivin 077ce63c8f chore: Conflict clean up 2026-01-22 16:31:10 +05:30
iamsivin 968a280b21 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-22 16:15:19 +05:30
iamsivin 6ce3423205 chore: Update contacts page 2026-01-22 09:35:58 +05:30
Sivin VargheseandGitHub 7455838206 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-21 11:21:25 +05:30
Sivin VargheseandGitHub 5c48ebd476 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-20 07:40:30 +05:30
Sivin VargheseandGitHub 0225a195d4 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-16 14:50:34 +05:30
iamsivin ed590a0684 chore: Update feedback fixes 2026-01-16 01:21:51 +05:30
Sivin VargheseandGitHub 8e5e5692fd Merge branch 'develop' into feat/v5-ui-redesign 2026-01-15 13:42:00 +05:30
Sivin VargheseandGitHub ea592481d1 feat: Update settings pages UI (#13191) 2026-01-15 12:30:53 +05:30
Sivin VargheseandGitHub f6b2bc170e Merge branch 'develop' into feat/v5-ui-redesign 2026-01-14 15:36:50 +05:30
Sivin VargheseandGitHub c29c045a2b Merge branch 'develop' into feat/v5-ui-redesign 2026-01-14 14:34:07 +05:30
Sivin VargheseandGitHub 271e2742ec Merge branch 'develop' into feat/v5-ui-redesign 2026-01-12 20:22:45 +05:30
Sivin VargheseandGitHub db13dd9ba8 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-12 12:25:24 +05:30
Sivin VargheseandGitHub fe951d9d92 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-12 10:13:31 +05:30
Sivin VargheseandGitHub 81b54c5e4e Merge branch 'develop' into feat/v5-ui-redesign 2026-01-09 23:56:29 +05:30
Sivin VargheseandGitHub 4deb999b45 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-09 12:25:39 +05:30
Sivin VargheseandGitHub 1e0dfbf7d9 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-08 20:12:51 +05:30
iamsivin 79cb13c4ac chore: Minor fix 2026-01-08 18:09:00 +05:30
Sivin VargheseandGitHub ffb0598547 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-08 15:30:07 +05:30
iamsivin 3a65481e19 fix: Rebase issue 2026-01-07 16:01:56 +05:30
Sivin VargheseandGitHub 3f29bf78e2 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-07 15:58:29 +05:30
iamsivin e42f2deab5 chore: Update typography in contacts page 2026-01-07 13:31:21 +05:30
iamsivin 3813a25eae fix: Button shadow issue 2026-01-07 12:02:21 +05:30
iamsivin 017bf17e06 chore: Update typography in Help Center 2026-01-07 11:48:35 +05:30
Sivin VargheseandGitHub d1fb816243 feat: Update locales page UI (#13053) 2026-01-07 10:59:00 +05:30
Sivin VargheseandGitHub dd426d69eb feat: Update categories page UI (#13052) 2026-01-07 10:57:46 +05:30
Sivin VargheseandGitHub b7be546934 feat: Update articles page UI (#13049) 2026-01-07 10:54:24 +05:30
Sivin VargheseandGitHub d6d7aba465 feat: Update Captain pages UI (#13184) 2026-01-07 00:44:07 +05:30
Sivin VargheseandGitHub 68fc2e36aa Merge branch 'develop' into feat/v5-ui-redesign 2026-01-06 21:00:40 +05:30
Sivin VargheseandGitHub 2387ebf443 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-06 13:23:01 +05:30
iamsivin d5ba0a946b chore: Update slate button icon color 2026-01-05 11:01:40 +05:30
Sivin VargheseandGitHub 15584c0bb4 Merge branch 'develop' into feat/v5-ui-redesign 2026-01-05 10:30:55 +05:30
Sivin VargheseandGitHub d3ed788abb Merge branch 'develop' into feat/v5-ui-redesign 2025-12-22 21:50:09 +05:30
iamsivin dcd41cdae0 chore: Feedback fixes 2025-12-22 17:22:42 +05:30
iamsivin 8191d65033 chore: Update messages section 2025-12-22 15:51:46 +05:30
Sivin VargheseandGitHub c54c125f3b Merge branch 'develop' into feat/v5-ui-redesign 2025-12-20 09:04:17 +05:30
Sivin VargheseandGitHub 3bdfeff5f2 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-19 09:58:52 +05:30
Sivin VargheseandGitHub e8b57bdeec Merge branch 'develop' into feat/v5-ui-redesign 2025-12-18 07:08:19 +05:30
iamsivin 08d3fb5b12 chore: Fix spec 2025-12-17 23:17:53 +05:30
iamsivin 6deceb651c chore: Fix Tiktok icon 2025-12-17 23:14:24 +05:30
Sivin VargheseandGitHub ceb0b773a4 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-17 22:54:42 +05:30
iamsivin 67766faa07 chore: Update typography in sidebar, chatlist, conversation card 2025-12-17 22:43:38 +05:30
Sivin VargheseandGitHub 657a89992a Merge branch 'develop' into feat/v5-ui-redesign 2025-12-17 12:18:35 +05:30
iamsivin fe37bbaad7 chore: Minor fix 2025-12-16 20:46:22 +05:30
Sivin VargheseandGitHub 169ef09606 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-16 14:47:46 +05:30
Sivin VargheseandGitHub f2ebccb89d feat: Add typography utility classes (#13075) 2025-12-16 12:33:19 +05:30
Sivin VargheseandGitHub e98257b5a1 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-16 10:06:51 +05:30
Sivin VargheseandGitHub abb2963882 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-15 17:28:39 +05:30
Sivin VargheseandGitHub 9ede713f66 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-11 20:32:39 +05:30
Sivin VargheseandGitHub 9b2b52bff8 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-11 12:25:53 +05:30
Sivin VargheseandGitHub 417f28ce4a feat: Update contact details page UI (#13041) 2025-12-10 20:14:24 +05:30
Sivin VargheseandGitHub 8d191a70b1 feat: Update contact list page (#13020) 2025-12-10 19:47:20 +05:30
iamsivin ee82a63b35 chore: Update button radius 2025-12-10 16:31:44 +05:30
Sivin VargheseandGitHub 446e164f2d Merge branch 'develop' into feat/v5-ui-redesign 2025-12-10 15:48:49 +05:30
iamsivin 0c1e7e1cd3 chore: Minor fix 2025-12-10 15:48:35 +05:30
iamsivin 3508228967 chore: Minor fix 2025-12-10 15:45:25 +05:30
Sivin VargheseandGitHub b1a782a8f3 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-09 19:56:49 +05:30
iamsivin e88db02ba2 chore: Update surface dark mode colors 2025-12-09 19:56:22 +05:30
Sivin VargheseandGitHub 99d9c8c41d Merge branch 'develop' into feat/v5-ui-redesign 2025-12-09 10:14:17 +05:30
Sivin VargheseandGitHub 9fe937094a Merge branch 'develop' into feat/v5-ui-redesign 2025-12-09 00:25:10 +05:30
Sivin VargheseandGitHub 998fed4a35 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-08 21:27:44 +05:30
iamsivin bd036c1889 chore: Update conversation header when side panel is open 2025-12-04 14:56:26 +05:30
Sivin VargheseandGitHub c1534e788d feat: Update conversation filter UI (#13000) 2025-12-04 13:57:34 +05:30
Sivin VargheseandGitHub f5608bc28b feat: Update dashboard empty state UI (#12990) 2025-12-04 13:56:45 +05:30
Sivin VargheseandGitHub b92b533afb Merge branch 'develop' into feat/v5-ui-redesign 2025-12-04 13:56:20 +05:30
Sivin VargheseandGitHub 604d4a9c30 feat: Update conversation side panel layout and styling (#12983) 2025-12-04 13:55:07 +05:30
Sivin VargheseandGitHub 832d61c004 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-04 10:39:39 +05:30
Sivin VargheseandGitHub 3308616223 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-03 15:48:19 +05:30
Sivin VargheseandGitHub 8ad1bf3d2b feat: Update conversation messages screen UI (#12975) 2025-12-03 15:48:04 +05:30
Sivin VargheseandGitHub 99f17891d0 feat: Updated conversation list UI (#12971) 2025-12-03 15:46:16 +05:30
Sivin VargheseandGitHub eccf3dd8f6 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-01 15:02:29 +05:30
Sivin VargheseandGitHub 4cf5975ab8 Merge branch 'develop' into feat/v5-ui-redesign 2025-12-01 10:24:41 +05:30
Sivin VargheseandGitHub f6852c33cb feat: Redesign conversation card component UI (#12957) 2025-12-01 10:18:14 +05:30
Sivin VargheseandGitHub a0edf2e6e3 chore: Update sidebar and app layout UI (#12933) 2025-11-28 18:39:51 +05:30
Sivin VargheseandGitHub 8cc6bde24f Merge branch 'develop' into feat/v5-ui-redesign 2025-11-28 16:46:15 +05:30
Sivin VargheseandGitHub 4c724a4887 Merge branch 'develop' into feat/v5-ui-redesign 2025-11-28 15:36:07 +05:30
Sivin VargheseandGitHub 556a49a25a Merge branch 'develop' into feat/v5-ui-redesign 2025-11-27 11:04:10 +05:30
Sivin VargheseandGitHub 727ffea9f7 Merge branch 'develop' into feat/v5-ui-redesign 2025-11-26 17:21:48 +05:30
Sivin VargheseandGitHub 08794c69ee Merge branch 'develop' into feat/v5-ui-redesign 2025-11-26 10:11:34 +05:30
Sivin VargheseandGitHub 0829fbccd1 Merge branch 'develop' into feat/v5-ui-redesign 2025-11-25 09:03:35 +05:30
Sivin VargheseandGitHub c94d76191b chore: Update theme colors and add new Inter variable fonts (#12932) 2025-11-24 20:57:34 +05:30
2560 changed files with 28798 additions and 83987 deletions
+9 -9
View File
@@ -93,8 +93,8 @@ jobs:
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# Bundle audit
- run:
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 18
parallelism: 16
steps:
- checkout
- node/install:
@@ -350,12 +350,12 @@ jobs:
destination: coverage
build:
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
workflows:
version: 2
-1
View File
@@ -276,4 +276,3 @@ AZURE_APP_SECRET=
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
-3
View File
@@ -94,8 +94,6 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
.codex/
.claude/
CLAUDE.local.md
# Histoire deployment
@@ -103,4 +101,3 @@ CLAUDE.local.md
.histoire
.pnpm-store/*
local/
Procfile.worktree
-25
View File
@@ -4,11 +4,6 @@
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
@@ -55,28 +50,12 @@
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
## Codex Worktree Workflow
- Use a separate git worktree + branch per task to keep changes isolated.
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
- Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages
## PR Description Format
- Start with a short, user-facing paragraph describing the product change.
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
- For feature PRs, add `How to test` from a product/UX standpoint.
- For bugfix PRs, use `How to reproduce` when helpful.
- Optionally add a `What changed` section for implementation highlights.
- Do not add a `How this was tested` section listing specs/commands.
## Project-Specific
- **Translations**:
@@ -107,7 +86,3 @@ Practical checklist for any change impacting core logic or public APIs
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
## Branding / White-labeling note
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
+1 -4
View File
@@ -191,14 +191,12 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.9.1'
gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
@@ -268,7 +266,6 @@ group :development, :test do
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false
gem 'skooma'
gem 'spring'
gem 'spring-watcher-listen'
end
+24 -37
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -186,12 +186,11 @@ GEM
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
connection_pool (2.5.3)
crack (1.0.0)
bigdecimal
rexml
@@ -298,7 +297,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.1)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -309,12 +308,12 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.4.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
@@ -465,7 +464,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.18.1)
json (2.13.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -473,12 +472,6 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
json_skooma (0.2.5)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
uri-idna (~> 0.2)
zeitwerk (~> 2.6)
judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2)
railties
@@ -546,11 +539,11 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.1.0)
marcel (1.0.4)
maxminddb (0.1.22)
meta_request (0.8.5)
meta_request (0.8.3)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 9)
railties (>= 3.0.0, < 8)
method_source (1.1.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
@@ -565,12 +558,12 @@ GEM
multi_json (1.15.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.9.1)
uri (>= 0.11.1)
net-http (0.6.0)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
@@ -589,14 +582,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.19.1)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
nokogiri (1.18.9-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin)
nokogiri (1.18.9-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -683,7 +676,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.5)
rack (3.2.3)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -742,7 +735,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.26.4)
redis-client (0.22.2)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -831,7 +824,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.9.2)
ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -916,9 +909,6 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
skooma (0.3.7)
json_skooma (~> 0.2.5)
zeitwerk (~> 2.6)
slack-ruby-client (2.7.0)
faraday (>= 2.0.1)
faraday-mashify
@@ -978,8 +968,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.1.1)
uri-idna (0.3.1)
uri (1.0.4)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1014,7 +1003,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.4)
zeitwerk (2.6.17)
PLATFORMS
arm64-darwin-20
@@ -1034,7 +1023,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.9.1)
ai-agents (>= 0.7.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1048,7 +1037,6 @@ DEPENDENCIES
bullet
bundle-audit
byebug
cld3 (~> 3.7)
climate_control
commonmarker
csv-safe
@@ -1153,7 +1141,6 @@ DEPENDENCIES
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0)
spring
spring-watcher-listen
+2 -6
View File
@@ -40,12 +40,8 @@ run:
fi
force_run:
@echo "Cleaning up Overmind processes..."
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
@rm -f ./.overmind.sock
@rm -f tmp/pids/*.pid
@echo "Cleanup complete"
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev
force_run_tunnel:
+1 -1
View File
@@ -1 +1 @@
4.12.0
4.10.1
+1 -1
View File
@@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! if @contact.changed?
@contact.save!
enqueue_avatar_job
end
@@ -105,19 +105,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content,
source_id: response.identifier,
content_attributes: content_attributes,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end
@@ -112,25 +112,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
create_story_reply_attachment(story_reply_attributes['url'])
end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end
def build_conversation
@@ -158,7 +139,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
@@ -167,7 +147,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
}
}
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
@@ -2,17 +2,12 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one unsupported file
# This check handles very rare case if there are multiple files to attach with only one usupported file
return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment)
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
if facebook_reel?(attachment)
update_facebook_reel_content(attachment)
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
@@ -31,7 +26,7 @@ class Messages::Messenger::MessageBuilder
end
def attachment_params(attachment)
file_type = normalize_file_type(attachment['type'])
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
@@ -105,28 +100,6 @@ class Messages::Messenger::MessageBuilder
private
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
attachment['type'].to_sym == :reel
end
def update_facebook_reel_content(attachment)
url = attachment.dig('payload', 'url')
return if url.blank?
@message.update!(content: url) if @message.content.blank?
end
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end
@@ -1,68 +0,0 @@
class V2::Reports::FirstResponseTimeDistributionBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
build_distribution
end
private
def build_distribution
results = fetch_aggregated_counts
map_to_channel_types(results)
end
def fetch_aggregated_counts
ReportingEvent
.where(account_id: account.id, name: 'first_response')
.where(range_condition)
.group(:inbox_id)
.select(
:inbox_id,
bucket_case_statements
)
end
def bucket_case_statements
<<~SQL.squish
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
SQL
end
def range_condition
range.present? ? { created_at: range } : {}
end
def inbox_channel_types
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
end
def map_to_channel_types(results)
results.each_with_object({}) do |row, hash|
channel_type = inbox_channel_types[row.inbox_id]
next unless channel_type
hash[channel_type] ||= empty_buckets
hash[channel_type]['0-1h'] += row.bucket_0_1h
hash[channel_type]['1-4h'] += row.bucket_1_4h
hash[channel_type]['4-8h'] += row.bucket_4_8h
hash[channel_type]['8-24h'] += row.bucket_8_24h
hash[channel_type]['24h+'] += row.bucket_24h_plus
end
end
def empty_buckets
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
end
end
@@ -1,65 +0,0 @@
class V2::Reports::InboxLabelMatrixBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
{
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
matrix: build_matrix
}
end
private
def filtered_inboxes
@filtered_inboxes ||= begin
inboxes = account.inboxes
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
inboxes.order(:name).to_a
end
end
def filtered_labels
@filtered_labels ||= begin
labels = account.labels
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
labels.order(:title).to_a
end
end
def conversation_filter
filter = { account_id: account.id }
filter[:created_at] = range if range.present?
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
filter
end
def fetch_grouped_counts
label_names = filtered_labels.map(&:title)
return {} if label_names.empty?
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
.where(tags: { name: label_names })
.group('conversations.inbox_id', 'tags.name')
.count
end
def build_matrix
counts = fetch_grouped_counts
filtered_inboxes.map do |inbox|
filtered_labels.map do |label|
counts[[inbox.id, label.title]] || 0
end
end
end
end
@@ -1,79 +0,0 @@
class V2::Reports::OutgoingMessagesCountBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
send("build_by_#{params[:group_by]}")
end
private
def base_messages
account.messages.outgoing.unscope(:order).where(created_at: range)
end
def build_by_agent
counts = base_messages
.where(sender_type: 'User')
.where.not(sender_id: nil)
.group(:sender_id)
.count
user_names = account.users.where(id: counts.keys).index_by(&:id)
counts.map do |user_id, count|
user = user_names[user_id]
{ id: user_id, name: user&.name, outgoing_messages_count: count }
end
end
def build_by_team
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.where.not(conversations: { team_id: nil })
.group('conversations.team_id')
.count
team_names = account.teams.where(id: counts.keys).index_by(&:id)
counts.map do |team_id, count|
team = team_names[team_id]
{ id: team_id, name: team&.name, outgoing_messages_count: count }
end
end
def build_by_inbox
counts = base_messages
.group(:inbox_id)
.count
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
counts.map do |inbox_id, count|
inbox = inbox_names[inbox_id]
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
end
end
def build_by_label
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
.group('tags.name')
.count
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
counts.map do |label_name, count|
label = label_ids[label_name]
{ id: label&.id, name: label_name, outgoing_messages_count: count }
end
end
end
@@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
Article.update_positions(params[:positions_hash])
head :ok
end
@@ -1,7 +1,7 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_category, except: [:index, :create, :reorder]
before_action :fetch_category, except: [:index, :create]
before_action :set_current_page, only: [:index]
def index
@@ -32,11 +32,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
head :ok
end
def reorder
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private
def fetch_category
@@ -44,7 +39,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
end
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end
def related_categories_records
@@ -1,55 +0,0 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
@@ -24,11 +24,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = Current.account.contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts = fetch_contacts_with_has_more(contacts)
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end
def import
@@ -141,24 +143,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
.per(RESULTS_PER_PAGE)
end
def fetch_contacts_with_has_more(contacts)
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
# Calculate offset manually to fetch one extra record for has_more check
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
results = filtrate(contacts)
.includes(includes_hash)
.offset(offset)
.limit(RESULTS_PER_PAGE + 1)
.to_a
@has_more = results.size > RESULTS_PER_PAGE
results = results.first(RESULTS_PER_PAGE) if @has_more
@contacts_count = results.size
results
end
def build_contact_inbox
return if params[:inbox_id].blank?
@@ -201,9 +185,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contact
contact_scope = Current.account.contacts
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
@contact = contact_scope.find(params[:id])
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end
def process_avatar_from_url
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def meta
result = conversation_finder.perform_meta_only
result = conversation_finder.perform
@conversations_count = result[:count]
end
@@ -70,11 +70,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@conversation.account.increment_email_sent_count
head :ok
end
@@ -107,21 +104,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def toggle_typing_status
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params)
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
typing_status_manager.toggle_typing_status
head :ok
end
def update_last_seen
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state.
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
# No unread messages - apply throttling to limit DB writes
return unless should_update_last_seen?
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end
@@ -154,25 +142,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen_on_conversation(last_seen_at, update_assignee)
updates = { agent_last_seen_at: last_seen_at }
updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates)
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
# rubocop:enable Rails/SkipsModelValidations
end
def should_update_last_seen?
# Update if at least one relevant timestamp is older than 1 hour or not set
# This prevents redundant DB writes when agents repeatedly view the same conversation
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
return agent_needs_update unless assignee?
# For assignees, check both timestamps - update if either is old
assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago
agent_needs_update || assignee_needs_update
end
def set_conversation_status
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
@@ -1,7 +1,6 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :validate_whatsapp_channel
before_action :validate_captain_enabled, only: [:analyze]
def show
service = CsatTemplateManagementService.new(@inbox)
@@ -25,23 +24,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def analyze
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
result = CsatTemplateUtilityAnalysisService.new(
account: Current.account,
inbox: @inbox,
message: template_params[:message],
button_text: template_params[:button_text],
language: template_params[:language]
).perform
render json: result
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -64,12 +46,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def validate_captain_enabled
return if Current.account.feature_enabled?('captain_integration')
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
@@ -4,9 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -71,6 +70,23 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -82,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@@ -178,6 +200,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
return unless @hook&.access_token
begin
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
linear_client = Linear.new(@hook.access_token)
linear_client.revoke_token
rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
@@ -79,7 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
@@ -92,15 +92,12 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(*permitted_settings_attributes)
end
def permitted_settings_attributes
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
conversation_required_attributes: [])
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end
def validate_captcha
@@ -115,5 +112,3 @@ class Api::V1::AccountsController < Api::BaseController
}
end
end
Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact
end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
identify_contact(contact)
end
@@ -35,11 +35,12 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
return head :too_many_requests if conversation.blank?
return head :payment_required unless conversation.account.email_transcript_enabled?
return head :too_many_requests unless conversation.account.within_email_rate_limit?
send_transcript_email
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
end
head :ok
end
@@ -76,16 +77,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
private
def send_transcript_email
return if conversation.contact&.email.blank?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
conversation.account.increment_email_sent_count
end
def trigger_typing_event(event)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end
@@ -62,31 +62,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
def inbox_label_matrix
builder = V2::Reports::InboxLabelMatrixBuilder.new(
account: Current.account,
params: inbox_label_matrix_params
)
render json: builder.build
end
def first_response_time_distribution
builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
account: Current.account,
params: first_response_time_distribution_params
)
render json: builder.build
end
OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
def outgoing_messages_count
return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
render json: builder.build
end
private
def generate_csv(filename, template)
@@ -164,28 +139,4 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
def inbox_label_matrix_params
{
since: params[:since],
until: params[:until],
inbox_ids: params[:inbox_ids],
label_ids: params[:label_ids]
}
end
def first_response_time_distribution_params
{
since: params[:since],
until: params[:until]
}
end
def outgoing_messages_count_params
{
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
end
end
@@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end
def validate_captcha
@@ -1,6 +1,6 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create']
}.freeze
@@ -28,7 +28,7 @@ module AccessTokenAuthHelper
def validate_bot_access_token!
return if Current.user.is_a?(User)
return if @resource.is_a?(AgentBot) && agent_bot_accessible?
return if agent_bot_accessible?
render_unauthorized('Access to this endpoint is not authorized for bots')
end
@@ -51,7 +51,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def account_signup_allowed?
GlobalConfigService.account_signup_enabled?
# set it to true by default, this is the behaviour across the app
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
end
def resource_class(_mapping = nil)
@@ -6,8 +6,12 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
def create
@user = User.from_email(params[:email])
@user&.send_reset_password_instructions
build_response(I18n.t('messages.reset_password'), 200)
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
end
def update
-7
View File
@@ -1,7 +0,0 @@
# Inherits from ActionController::Base to skip all middleware,
# authentication, and callbacks. Used for health checks
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
def show
render json: { status: 'woot' }
end
end
+12 -39
View File
@@ -2,8 +2,6 @@ class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper
def show
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/linear/callback"
@@ -12,7 +10,7 @@ class Linear::CallbacksController < ApplicationController
handle_response
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to safe_linear_redirect_uri
redirect_to linear_redirect_uri
end
private
@@ -33,19 +31,22 @@ class Linear::CallbacksController < ApplicationController
end
def handle_response
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
hook.assign_attributes(
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
settings: merged_integration_settings(hook.settings)
app_id: 'linear',
settings: {
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
scope: parsed_body['scope']
}
)
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
hook.save!
redirect_to linear_redirect_uri
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to safe_linear_redirect_uri
redirect_to linear_redirect_uri
end
def account
@@ -53,47 +54,19 @@ class Linear::CallbacksController < ApplicationController
end
def account_id
return @account_id if instance_variable_defined?(:@account_id)
return unless params[:state]
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil
verify_linear_token(params[:state])
end
def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end
def safe_linear_redirect_uri
return base_url if account_id.blank?
linear_redirect_uri
rescue StandardError
base_url
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def integration_settings
{
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
expires_on: expires_on,
scope: parsed_body['scope'],
refresh_token: parsed_body['refresh_token']
}.compact
end
def merged_integration_settings(existing_settings)
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
end
def expires_on
return if parsed_body['expires_in'].blank?
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
@@ -6,7 +6,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
layout 'portal'
def index
@search_query = list_params[:query]
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@@ -74,9 +73,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
permitted[:query] = permitted[:query].to_s.strip.presence
end
params.permit(:query, :locale, :sort, :status, :page, :per_page)
end
def permitted_params
@@ -42,11 +42,11 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
@@ -55,7 +55,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = mapping.fetch(
@config,
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
)
end
end
@@ -1,35 +0,0 @@
class Webhooks::ShopifyController < ActionController::API
before_action :verify_hmac!
def events
case request.headers['X-Shopify-Topic']
when 'shop/redact'
handle_shop_redact
end
head :ok
end
private
def verify_hmac!
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
return head :unauthorized if secret.blank?
data = request.body.read
request.body.rewind
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
return head :unauthorized if hmac_header.blank?
computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
end
def handle_shop_redact
shop_domain = params[:shop_domain]
return if shop_domain.blank?
Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
end
end
+1 -11
View File
@@ -77,23 +77,13 @@ class WidgetsController < ActionController::Base
end
def allow_iframe_requests
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
if @web_widget.allowed_domains.blank?
response.headers.delete('X-Frame-Options')
else
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
end
end
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
# which cannot match any domain in frame-ancestors. When the per-inbox flag
# is enabled, skip frame-ancestors for these requests.
def embedded_from_non_web_origin?
return false unless @web_widget.allow_mobile_webview?
origin = request.headers['Origin']
origin.blank? || origin == 'null' || origin&.start_with?('file://')
end
end
WidgetsController.prepend_mod_with('WidgetsController')
+1 -1
View File
@@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard
current_sign_in_ip: Field::String,
last_sign_in_ip: Field::String,
confirmation_token: Field::String,
confirmed_at: ConfirmedAtField,
confirmed_at: Field::DateTime,
confirmation_sent_at: Field::DateTime,
unconfirmed_email: Field::String,
name: Field::String.with_options(searchable: true),
-4
View File
@@ -1,4 +0,0 @@
require 'administrate/field/base'
class ConfirmedAtField < Administrate::Field::DateTime
end
-17
View File
@@ -11,7 +11,6 @@ class ConversationFinder
'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
@@ -56,22 +55,6 @@ class ConversationFinder
}
end
def perform_meta_only
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
{
count: {
mine_count: mine_count,
assigned_count: assigned_count,
unassigned_count: unassigned_count,
all_count: all_count
}
}
end
private
def set_up
+21 -17
View File
@@ -57,35 +57,39 @@ module Api::V1::InboxesHelper
end
def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish
rescue Net::SMTPAuthenticationError
raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
rescue OpenSSL::SSL::SSLError
raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
rescue StandardError => e
raise StandardError, e.message
end
def set_smtp_encryption(channel_data, smtp)
if channel_data[:smtp_enable_ssl_tls]
set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
set_enable_tls(channel_data, smtp)
elsif channel_data[:smtp_enable_starttls_auto]
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
set_enable_starttls_auto(channel_data, smtp)
end
end
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
return unless smtp.respond_to?(method)
def set_enable_starttls_auto(channel_data, smtp)
return unless smtp.respond_to?(:enable_starttls_auto)
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
context ? smtp.send(method, context) : smtp.send(method)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_starttls_auto(context)
else
smtp.enable_starttls_auto
end
end
def set_enable_tls(channel_data, smtp)
return unless smtp.respond_to?(:enable_tls)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_tls(context)
else
smtp.enable_tls
end
end
def enable_openssl_mode(smtp_openssl_verify_mode)
+5 -9
View File
@@ -47,15 +47,11 @@ module Filters::FilterHelper
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive'
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
query_hash[:attribute_key]]
)
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
else
ActiveRecord::Base.sanitize_sql_array(
["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
query_hash[:attribute_key]]
)
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
"#{filter_operator_value} #{query_hash[:query_operator]} "
end
end
@@ -74,7 +70,7 @@ module Filters::FilterHelper
def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
end
def text_case_insensitive_filter(query_hash, filter_operator_value)
+6
View File
@@ -164,4 +164,10 @@ export default {
.v-popper--theme-tooltip .v-popper__arrow-container {
display: none;
}
.multiselect__input {
margin-bottom: 0px !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
+2 -2
View File
@@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
search(search = '', page = 1, sortAttr = 'name', label = '') {
let requestURL = `${this.url}/search?${buildContactParams(
page,
sortAttr,
label,
search
)}`;
return axios.get(requestURL, { signal: options.signal });
return axios.get(requestURL);
}
active(page = 1, sortAttr = 'name') {
@@ -25,12 +25,6 @@ class CategoriesAPI extends PortalsAPI {
delete({ portalSlug, categoryId }) {
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
}
reorder({ portalSlug, reorderedGroup }) {
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
positions_hash: reorderedGroup,
});
}
}
export default new CategoriesAPI();
@@ -9,10 +9,6 @@ class InboxHealthAPI extends ApiClient {
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
registerWebhook(inboxId) {
return axios.post(`${this.url}/${inboxId}/register_webhook`);
}
}
export default new InboxHealthAPI();
-6
View File
@@ -42,12 +42,6 @@ class Inboxes extends CacheEnabledApiClient {
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
analyzeCSATTemplateUtility(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
template,
});
}
}
export default new Inboxes();
@@ -68,19 +68,7 @@ describe('#ContactsAPI', () => {
it('#search', () => {
contactAPI.search('leads', 1, 'date', 'customer-support');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: undefined }
);
});
it('#search with signal', () => {
const controller = new AbortController();
contactAPI.search('leads', 1, 'date', 'customer-support', {
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: controller.signal }
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
);
});
@@ -8,6 +8,5 @@ describe('#BulkActionsAPI', () => {
expect(categoriesAPI).toHaveProperty('create');
expect(categoriesAPI).toHaveProperty('update');
expect(categoriesAPI).toHaveProperty('delete');
expect(categoriesAPI).toHaveProperty('reorder');
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

@@ -1,34 +1,11 @@
<svg width="495" height="384" viewBox="0 0 495 384" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="No Chat History">
<path id="Fill 1" fill-rule="evenodd" clip-rule="evenodd" d="M266.903 0C221.687 0 180.128 15.7196 147.313 42.0198H94.2771C87.3866 42.0198 81.749 47.6925 81.749 54.6257C81.749 61.559 87.3866 67.2317 94.2771 67.2317H121.268H129.178C136.068 67.2317 141.706 72.9043 141.706 79.8376C141.706 83.3 140.294 86.4599 138.023 88.7374C135.759 91.0233 132.619 92.4435 129.178 92.4435H112.303H102.986H57.5281C50.6376 92.4435 45 98.1162 45 105.049C45 111.983 50.6376 117.655 57.5281 117.655H90.071C80.2473 140.886 74.8059 166.446 74.8059 193.291C74.8059 203.031 75.5242 212.603 76.9023 221.957C77.4786 225.865 78.1718 229.739 78.982 233.571C102.618 229.932 127.691 227.008 153.866 224.873L155.837 226.68L157.692 224.571C189.137 222.108 222.144 220.797 256.187 220.797C328.516 220.797 396.201 226.714 454.072 237C456.302 227.268 457.806 217.259 458.516 207.023C458.841 202.485 459 197.905 459 193.291C459 86.5356 372.999 0 266.903 0Z" fill="#43484C"/>
<path id="Fill 3" fill-rule="evenodd" clip-rule="evenodd" d="M20.2465 91.4282H12.6678C5.7005 91.4282 0 97.1882 0 104.228C0 111.268 5.7005 117.028 12.6678 117.028H20.2465C27.2137 117.028 32.9142 111.268 32.9142 104.228C32.9142 97.1882 27.2137 91.4282 20.2465 91.4282Z" fill="#43484C"/>
<g id="Group 7">
<mask id="mask0_201_141" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="78" y="219" width="417" height="165">
<path id="Clip 6" fill-rule="evenodd" clip-rule="evenodd" d="M495 219V384H286.5H78V219H495Z" fill="white"/>
</mask>
<g mask="url(#mask0_201_141)">
<path id="Fill 5" fill-rule="evenodd" clip-rule="evenodd" d="M495 279.775C495 286.675 489.336 292.321 482.413 292.321H402.016C398.559 292.321 395.405 293.734 393.131 296.009C390.849 298.276 389.43 301.421 389.43 304.867C389.43 311.768 395.094 317.413 402.016 317.413H416.954C420.41 317.413 423.566 318.827 425.84 321.102C428.122 323.369 429.54 326.514 429.54 329.96C429.54 336.86 423.876 342.506 416.954 342.506H382.608C350.344 366.662 310.251 380.981 266.802 380.981C174.004 380.981 96.5026 315.699 78 228.695C101.747 225.073 126.937 222.162 153.235 220.038L155.215 221.836L157.079 219.736C188.671 217.286 221.833 215.981 256.036 215.981C328.703 215.981 396.705 221.869 454.847 232.107C452.02 244.302 448.026 256.045 442.991 267.228H482.413C485.879 267.228 489.017 268.642 491.3 270.917C493.582 273.184 495 276.328 495 279.775Z" fill="#34393D"/>
</g>
</g>
<path id="Fill 8" fill-rule="evenodd" clip-rule="evenodd" d="M459 206.12C458.285 216.665 456.771 226.975 454.525 237C396.235 226.404 328.06 220.31 255.208 220.31C220.919 220.31 187.673 221.66 156 224.196L177.965 198.615L212.134 221.028L258.388 208.19L337.959 220.31L372.503 208.19L381.252 220.31L402.126 204.736L421.632 222.707L452.396 198L459 206.12Z" fill="#55575E"/>
<path id="Fill 10" fill-rule="evenodd" clip-rule="evenodd" d="M344.905 216.502C353.565 203.915 358.526 188.987 358.397 173.012C358.043 128.578 318.46 92.8734 269.986 93.2602C221.512 93.6478 182.505 129.983 182.86 174.416C183.214 218.849 222.798 254.555 271.271 254.168C288.59 254.03 304.7 249.3 318.238 241.253L354.454 249.78L344.905 216.502Z" fill="#686B73"/>
<path id="Fill 12" fill-rule="evenodd" clip-rule="evenodd" d="M245.029 171.813C245.069 176.862 241.008 180.988 235.959 181.028C230.909 181.069 226.783 177.008 226.743 171.959C226.703 166.909 230.763 162.784 235.813 162.743C240.861 162.703 244.987 166.763 245.029 171.813Z" fill="#BEC0D6"/>
<path id="Fill 14" fill-rule="evenodd" clip-rule="evenodd" d="M277.942 171.812C277.982 176.862 273.922 180.988 268.872 181.028C263.822 181.068 259.696 177.008 259.657 171.959C259.616 166.909 263.677 162.783 268.726 162.743C273.775 162.703 277.901 166.763 277.942 171.812Z" fill="#BEC0D6"/>
<path id="Fill 16" fill-rule="evenodd" clip-rule="evenodd" d="M310.856 171.812C310.897 176.861 306.836 180.987 301.786 181.028C296.737 181.068 292.611 177.008 292.571 171.958C292.53 166.908 296.591 162.783 301.641 162.743C306.689 162.702 310.815 166.763 310.856 171.812Z" fill="#BEC0D6"/>
<path id="Fill 20" fill-rule="evenodd" clip-rule="evenodd" d="M173.714 235.887C173.714 241.693 175.571 247.099 178.763 251.649L175.376 263.789L188.564 260.581C193.523 263.465 199.402 265.143 205.714 265.143C223.388 265.143 237.714 252.044 237.714 235.887C237.714 219.728 223.388 206.629 205.714 206.629C188.041 206.629 173.714 219.728 173.714 235.887Z" fill="url(#paint0_linear_201_141)"/>
<g id="Frame 148">
<path id="Fill 26" fill-rule="evenodd" clip-rule="evenodd" d="M189 234.658C189 236.677 190.637 238.314 192.657 238.314C194.676 238.314 196.314 236.677 196.314 234.658C196.314 232.638 194.676 231 192.657 231C190.637 231 189 232.638 189 234.658Z" fill="#0D0D0D"/>
<path id="Fill 24" fill-rule="evenodd" clip-rule="evenodd" d="M202.314 234.658C202.314 236.677 203.951 238.314 205.971 238.314C207.99 238.314 209.628 236.677 209.628 234.658C209.628 232.638 207.99 231 205.971 231C203.951 231 202.314 232.638 202.314 234.658Z" fill="#0D0D0D"/>
<path id="Fill 22" fill-rule="evenodd" clip-rule="evenodd" d="M215.629 234.658C215.629 236.677 217.265 238.314 219.285 238.314C221.305 238.314 222.943 236.677 222.943 234.658C222.943 232.638 221.305 231 219.285 231C217.265 231 215.629 232.638 215.629 234.658Z" fill="#0D0D0D"/>
</g>
<path id="Fill 28" fill-rule="evenodd" clip-rule="evenodd" d="M153.6 223.557C127.481 225.685 102.461 228.601 78.8755 232.228C78.0671 228.408 77.3754 224.546 76.8003 220.65L117.229 190.171L153.6 223.557Z" fill="#55575E"/>
<path id="Fill 30" fill-rule="evenodd" clip-rule="evenodd" d="M336.457 286.171C336.457 289.706 304.529 292.571 265.143 292.571C225.757 292.571 193.829 289.706 193.829 286.171C193.829 282.638 225.757 279.771 265.143 279.771C304.529 279.771 336.457 282.638 336.457 286.171Z" fill="#686B73"/>
<path id="Fill 32" fill-rule="evenodd" clip-rule="evenodd" d="M453.486 345.599C446.446 345.599 440.686 339.839 440.686 332.799C440.686 325.76 446.446 320 453.486 320C460.526 320 466.286 325.76 466.286 332.799C466.286 339.839 460.526 345.599 453.486 345.599Z" fill="#34393D"/>
</g>
<defs>
<linearGradient id="paint0_linear_201_141" x1="205.714" y1="177.372" x2="147.434" y2="241.115" gradientUnits="userSpaceOnUse">
<stop stop-color="#9DC9FB"/>
<stop offset="1" stop-color="#9DC9FB"/>
</linearGradient>
</defs>
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42.4834 68.6832C42.7774 69.425 42.8429 70.2378 42.6714 71.0172L40.5414 77.5971C40.4728 77.9308 40.4905 78.2765 40.5929 78.6014C40.6954 78.9263 40.8791 79.2197 41.1267 79.4536C41.3743 79.6876 41.6776 79.8545 42.0078 79.9384C42.3379 80.0223 42.6841 80.0205 43.0134 79.9331L49.8393 77.9371C50.5747 77.7912 51.3364 77.855 52.0373 78.1211C56.308 80.1155 61.146 80.5375 65.6975 79.3125C70.249 78.0876 74.2217 75.2944 76.9145 71.4259C79.6073 67.5573 80.8473 62.862 80.4157 58.1683C79.984 53.4747 77.9085 49.0843 74.5553 45.7717C71.202 42.4592 66.7866 40.4375 62.088 40.0632C57.3894 39.6889 52.7096 40.9861 48.8742 43.726C45.0388 46.4659 42.2944 50.4724 41.1252 55.0385C39.9559 59.6047 40.4369 64.4371 42.4834 68.6832Z" stroke="#5A6165" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M53.8503 25.6371C58.2429 24.787 62.7578 24.787 67.1503 25.6371" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M67.1503 94.3623C62.7578 95.2124 58.2429 95.2124 53.8503 94.3623" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M80.1311 31.02C83.8462 33.5373 87.0426 36.7456 89.5461 40.47" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26.1371 66.6506C25.287 62.258 25.287 57.7432 26.1371 53.3506" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M89.4798 79.6348C86.9625 83.3499 83.7542 86.5463 80.0298 89.0498" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M94.8621 53.3506C95.7121 57.7432 95.7121 62.258 94.8621 66.6506" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.5249 40.3696C34.0422 36.6544 37.2504 33.4581 40.9749 30.9546" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M40.0717 91.9083L29.9007 94.8833C29.3241 95.0365 28.718 95.0397 28.1398 94.8927C27.5616 94.7457 27.0306 94.4534 26.5972 94.0434C26.1638 93.6335 25.8423 93.1196 25.6634 92.5505C25.4844 91.9814 25.4539 91.376 25.5747 90.7918L28.9522 80.3618" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -1,33 +1,11 @@
<svg width="495" height="387" viewBox="0 0 495 387" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="No Chat History">
<path id="Fill 1" fill-rule="evenodd" clip-rule="evenodd" d="M267.927 1.5C222.792 1.5 181.307 17.1457 148.551 43.3222H95.611C88.7329 43.3222 83.1054 48.9682 83.1054 55.8688C83.1054 62.7695 88.7329 68.4155 95.611 68.4155H122.554H130.449C137.327 68.4155 142.955 74.0615 142.955 80.9621C142.955 84.4083 141.546 87.5533 139.278 89.8201C137.019 92.0952 133.884 93.5088 130.449 93.5088H113.605H104.304H58.9279C52.0499 93.5088 46.4224 99.1548 46.4224 106.055C46.4224 112.956 52.0499 118.602 58.9279 118.602H91.4124C81.6064 141.723 76.1748 167.164 76.1748 193.882C76.1748 203.576 76.8918 213.103 78.2674 222.413C78.8426 226.303 79.5346 230.159 80.3433 233.973C103.937 230.351 128.965 227.44 155.093 225.316L157.061 227.114L158.912 225.014C190.301 222.564 223.249 221.259 257.23 221.259C329.429 221.259 396.993 227.147 454.76 237.385C456.986 227.699 458.487 217.737 459.196 207.549C459.521 203.033 459.679 198.474 459.679 193.882C459.679 87.6286 373.832 1.5 267.927 1.5Z" fill="#F1F2F7"/>
<path id="Fill 3" fill-rule="evenodd" clip-rule="evenodd" d="M20.954 92.9282H13.3753C6.40802 92.9282 0.70752 98.6882 0.70752 105.728C0.70752 112.768 6.40802 118.528 13.3753 118.528H20.954C27.9213 118.528 33.6218 112.768 33.6218 105.728C33.6218 98.6882 27.9213 92.9282 20.954 92.9282Z" fill="#F1F2F7"/>
<g id="Group 7">
<mask id="mask0_156_62" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="80" y="221" width="415" height="165">
<path id="Clip 6" fill-rule="evenodd" clip-rule="evenodd" d="M494.293 221.536V385.5H287.188H80.083V221.536H494.293Z" fill="white"/>
</mask>
<g mask="url(#mask0_156_62)">
<path id="Fill 5" fill-rule="evenodd" clip-rule="evenodd" d="M494.293 284.929C494.293 291.786 488.667 297.396 481.79 297.396H401.932C398.498 297.396 395.364 298.801 393.106 301.062C390.838 303.314 389.429 306.439 389.429 309.864C389.429 316.721 395.055 322.331 401.932 322.331H416.769C420.202 322.331 423.337 323.736 425.596 325.997C427.863 328.249 429.271 331.374 429.271 334.799C429.271 341.656 423.645 347.266 416.769 347.266H382.653C350.604 371.271 310.78 385.5 267.622 385.5C175.444 385.5 98.4618 320.627 80.083 234.169C103.671 230.57 128.693 227.678 154.815 225.567L156.781 227.354L158.632 225.268C190.013 222.832 222.954 221.536 256.928 221.536C329.108 221.536 396.656 227.387 454.409 237.561C451.601 249.679 447.633 261.349 442.632 272.461H481.79C485.233 272.461 488.35 273.866 490.617 276.127C492.884 278.379 494.293 281.504 494.293 284.929Z" fill="#E8EBF2"/>
</g>
</g>
<path id="Fill 8" fill-rule="evenodd" clip-rule="evenodd" d="M457.85 206.981C457.142 217.363 455.643 227.515 453.42 237.386C395.73 226.953 328.256 220.953 256.153 220.953C222.216 220.953 189.312 222.282 157.964 224.779L179.704 199.591L213.521 221.659L259.3 209.019L338.053 220.953L372.242 209.019L380.901 220.953L401.56 205.618L420.866 223.313L451.314 198.986L457.85 206.981Z" fill="#E0E2EE"/>
<path id="Fill 10" fill-rule="evenodd" clip-rule="evenodd" d="M345.614 218.002C354.273 205.415 359.234 190.487 359.106 174.512C358.752 130.078 319.168 94.3734 270.694 94.7602C222.22 95.1478 183.213 131.483 183.568 175.916C183.923 220.349 223.506 256.055 271.979 255.668C289.298 255.53 305.408 250.8 318.946 242.753L355.162 251.28L345.614 218.002Z" fill="#D8DBEA"/>
<path id="Fill 12" fill-rule="evenodd" clip-rule="evenodd" d="M245.736 173.313C245.776 178.362 241.716 182.488 236.667 182.528C231.616 182.569 227.49 178.508 227.451 173.459C227.411 168.409 231.471 164.284 236.52 164.243C241.569 164.203 245.695 168.263 245.736 173.313Z" fill="#BEC0D6"/>
<path id="Fill 14" fill-rule="evenodd" clip-rule="evenodd" d="M278.65 173.312C278.69 178.362 274.629 182.488 269.58 182.528C264.53 182.568 260.404 178.508 260.365 173.459C260.323 168.409 264.384 164.283 269.434 164.243C274.483 164.203 278.609 168.263 278.65 173.312Z" fill="#BEC0D6"/>
<path id="Fill 16" fill-rule="evenodd" clip-rule="evenodd" d="M311.565 173.312C311.605 178.361 307.544 182.487 302.495 182.528C297.445 182.568 293.319 178.508 293.28 173.458C293.239 168.408 297.299 164.283 302.349 164.243C307.398 164.202 311.524 168.263 311.565 173.312Z" fill="#BEC0D6"/>
<path id="Fill 18" fill-rule="evenodd" clip-rule="evenodd" d="M157.965 224.585L156.192 226.414L154.308 224.848C155.521 224.753 156.744 224.666 157.965 224.585Z" fill="#F1F2F7"/>
<path id="Fill 20" fill-rule="evenodd" clip-rule="evenodd" d="M174.422 237.387C174.422 243.193 176.279 248.599 179.47 253.149L176.083 265.289L189.272 262.081C194.23 264.965 200.11 266.643 206.422 266.643C224.095 266.643 238.422 253.544 238.422 237.387C238.422 221.228 224.095 208.129 206.422 208.129C188.748 208.129 174.422 221.228 174.422 237.387Z" fill="url(#paint0_linear_156_62)"/>
<path id="Fill 22" fill-rule="evenodd" clip-rule="evenodd" d="M214.651 237.386C214.651 239.405 216.287 241.043 218.308 241.043C220.327 241.043 221.965 239.405 221.965 237.386C221.965 235.366 220.327 233.728 218.308 233.728C216.287 233.728 214.651 235.366 214.651 237.386Z" fill="white"/>
<path id="Fill 24" fill-rule="evenodd" clip-rule="evenodd" d="M203.679 237.386C203.679 239.405 205.316 241.043 207.336 241.043C209.355 241.043 210.993 239.405 210.993 237.386C210.993 235.366 209.355 233.728 207.336 233.728C205.316 233.728 203.679 235.366 203.679 237.386Z" fill="white"/>
<path id="Fill 26" fill-rule="evenodd" clip-rule="evenodd" d="M190.879 237.386C190.879 239.405 192.516 241.043 194.537 241.043C196.556 241.043 198.194 239.405 198.194 237.386C198.194 235.366 196.556 233.728 194.537 233.728C192.516 233.728 190.879 235.366 190.879 237.386Z" fill="white"/>
<path id="Fill 28" fill-rule="evenodd" clip-rule="evenodd" d="M154.308 225.057C128.188 227.185 103.169 230.101 79.583 233.728C78.7746 229.908 78.0829 226.046 77.5078 222.15L117.936 191.671L154.308 225.057Z" fill="#E0E2EE"/>
<path id="Fill 30" fill-rule="evenodd" clip-rule="evenodd" d="M337.164 287.671C337.164 291.206 305.235 294.071 265.85 294.071C226.464 294.071 194.536 291.206 194.536 287.671C194.536 284.138 226.464 281.271 265.85 281.271C305.235 281.271 337.164 284.138 337.164 287.671Z" fill="#D8DBEA"/>
<path id="Fill 32" fill-rule="evenodd" clip-rule="evenodd" d="M454.193 347.099C447.153 347.099 441.393 341.339 441.393 334.299C441.393 327.26 447.153 321.5 454.193 321.5C461.233 321.5 466.993 327.26 466.993 334.299C466.993 341.339 461.233 347.099 454.193 347.099Z" fill="#E1E3EF"/>
</g>
<defs>
<linearGradient id="paint0_linear_156_62" x1="206.422" y1="178.872" x2="148.142" y2="242.615" gradientUnits="userSpaceOnUse">
<stop stop-color="#9DC9FB"/>
<stop offset="1" stop-color="#9DC9FB"/>
</linearGradient>
</defs>
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42.4834 68.6832C42.7774 69.425 42.8429 70.2378 42.6714 71.0172L40.5414 77.5971C40.4728 77.9308 40.4905 78.2765 40.5929 78.6014C40.6954 78.9263 40.8791 79.2197 41.1267 79.4536C41.3743 79.6876 41.6776 79.8545 42.0078 79.9384C42.3379 80.0223 42.6841 80.0205 43.0134 79.9331L49.8393 77.9371C50.5747 77.7912 51.3364 77.855 52.0373 78.1211C56.308 80.1155 61.146 80.5375 65.6975 79.3125C70.249 78.0876 74.2217 75.2944 76.9145 71.4259C79.6073 67.5573 80.8473 62.862 80.4157 58.1683C79.984 53.4747 77.9085 49.0843 74.5553 45.7717C71.202 42.4592 66.7866 40.4375 62.088 40.0632C57.3894 39.6889 52.7096 40.9861 48.8742 43.726C45.0388 46.4659 42.2944 50.4724 41.1252 55.0385C39.9559 59.6047 40.4369 64.4371 42.4834 68.6832Z" stroke="#B9BBC6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M53.8503 25.6371C58.2429 24.787 62.7578 24.787 67.1503 25.6371" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M67.1503 94.3621C62.7578 95.2121 58.2429 95.2121 53.8503 94.3621" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M80.1311 31.02C83.8462 33.5373 87.0426 36.7456 89.5461 40.47" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26.1371 66.6503C25.287 62.2578 25.287 57.7429 26.1371 53.3503" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M89.4798 79.635C86.9625 83.3502 83.7542 86.5465 80.0298 89.05" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M94.8621 53.3503C95.7121 57.7429 95.7121 62.2578 94.8621 66.6503" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.5249 40.3696C34.0422 36.6544 37.2504 33.4581 40.9749 30.9546" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M40.0717 91.9083L29.9007 94.8833C29.3241 95.0365 28.718 95.0397 28.1398 94.8927C27.5616 94.7457 27.0306 94.4534 26.5972 94.0434C26.1638 93.6335 25.8423 93.1196 25.6634 92.5505C25.4844 91.9814 25.4539 91.376 25.5747 90.7918L28.9522 80.3618" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -66,7 +66,7 @@ textarea {
// Field base styles (Input, TextArea, Select)
@layer components {
.field-base {
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
}
.field-disabled {
@@ -78,7 +78,7 @@ textarea {
}
}
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)";
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
#{$form-input-selector} {
@apply field-base h-10;
@@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not
}
}
input[type='file'] {
input[type='file']:not(.multiselect__input) {
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
}
@@ -126,6 +126,13 @@ label:has(.help-text) {
}
}
// Error handling
.has-multi-select-error {
div.multiselect {
@apply mb-1;
}
}
// FormKit support
.formkit-outer[data-invalid='true'] {
#{$form-input-selector},
@@ -143,7 +150,9 @@ label:has(.help-text) {
#{$form-input-selector},
input:not([type]),
textarea,
select {
select,
.multiselect > .multiselect__tags,
.multiselect:not(.no-margin) {
@apply field-error;
}
@@ -13,6 +13,7 @@
@import 'base';
// Plugins
@import 'plugins/multiselect';
@import 'plugins/date-picker';
html,
@@ -45,14 +46,14 @@ body {
}
.custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='1' y='1' width='calc(100%25 - 2px)' height='calc(100%25 - 2px)' fill='none' rx='12' ry='12' stroke='%23E2E3E7' stroke-width='1.5' stroke-dasharray='6 4' stroke-linecap='butt'/%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.dark .custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='1' y='1' width='calc(100%25 - 2px)' height='calc(100%25 - 2px)' fill='none' rx='12' ry='12' stroke='%23343434' stroke-width='1.5' stroke-dasharray='6 4' stroke-linecap='butt'/%3E%3C/svg%3E");
}
@layer utilities {
@@ -0,0 +1,273 @@
@mixin label-multiselect-hover {
&::after {
@apply text-n-brand;
}
&:hover {
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-text;
}
}
}
.multiselect {
&:not(.no-margin) {
@apply mb-4;
}
&.invalid .multiselect__tags {
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
&.multiselect--disabled {
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
.multiselect__select {
@apply cursor-not-allowed bg-transparent rounded-lg;
}
}
.multiselect--active {
> .multiselect__tags {
@apply outline-n-blue-border;
}
}
.multiselect__select {
@apply min-h-[2.875rem] p-0 right-0 top-0;
&::before {
@apply right-0;
}
}
.multiselect__content-wrapper {
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
}
.multiselect__content {
@apply max-w-full;
.multiselect__option {
@apply text-sm font-normal flex justify-between items-center;
span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
}
p {
@apply mb-0;
}
&::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
}
&.multiselect__option--highlight {
@apply bg-n-alpha-black2 text-n-slate-12;
}
&.multiselect__option--highlight:hover {
@apply bg-n-brand/10 text-n-slate-12;
&::after {
@apply bg-transparent text-center text-n-slate-12;
}
}
&.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12;
}
&.multiselect__option--selected {
@apply bg-n-brand/20 text-n-slate-12;
&::after {
@apply bg-transparent;
}
&.multiselect__option--highlight:hover {
@apply bg-transparent;
&::after:hover {
@apply text-n-slate-12 bg-transparent;
}
}
}
}
}
.multiselect__tags {
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
input {
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
}
}
.multiselect__spinner {
background-color: transparent;
}
.multiselect__tags-wrap {
@apply inline-block leading-none mt-1;
}
.multiselect__placeholder {
@apply text-n-slate-10 font-normal pt-3;
}
.multiselect__tag {
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
}
.multiselect__tag-icon {
@include label-multiselect-hover;
}
.multiselect__input {
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
}
.multiselect__single {
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
}
}
.sidebar-labels-wrap {
&.has-edited,
&:hover {
.multiselect {
@apply cursor-pointer;
}
}
.multiselect {
> .multiselect__select {
@apply invisible;
}
> .multiselect__tags {
@apply outline-transparent;
}
&.multiselect--active > .multiselect__tags {
@apply outline-n-blue-border;
}
}
}
.multiselect-wrap--small {
// To be removed one SLA reports date picker is created
&.tiny {
.multiselect.no-margin {
@apply min-h-[32px];
}
.multiselect__select {
@apply min-h-[32px] h-8;
&::before {
@apply top-[60%];
}
}
.multiselect__tags {
@apply min-h-[32px] max-h-[32px];
.multiselect__single {
@apply pt-1 pb-1;
}
}
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
}
.multiselect__input {
@apply h-[2.375rem] min-h-[2.375rem];
}
.multiselect__single {
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
}
.multiselect__placeholder {
@apply m-0 py-2 px-0.5;
}
.multiselect__tag {
@apply py-[6px] my-[1px];
}
.multiselect__select {
@apply min-h-[2.5rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
}
.multiselect--disabled {
background-color: rgba(var(--black-alpha-2)) !important;
.multiselect__tags {
@apply hover:outline-n-weak;
}
}
.multiselect--active {
.multiselect__select::before {
@apply top-[62%];
}
}
.multiselect__select::before {
top: 60% !important;
}
.multiselect-wrap--medium {
.multiselect__tags,
.multiselect__input {
@apply items-center flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
}
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0 !important;
}
.multiselect__single {
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
}
.multiselect__placeholder {
@apply m-0 py-1 px-0.5;
}
.multiselect__select {
@apply min-h-[3rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
.multiselect__tags-wrap {
@apply flex-shrink-0;
}
}
@@ -49,7 +49,7 @@ const handleFetchUsers = () => {
<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-heading-2 text-n-slate-12 line-clamp-1">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
@@ -78,7 +78,7 @@ const handleFetchUsers = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }}
</p>
</div>
@@ -20,7 +20,7 @@ const handleClick = () => {
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between w-full items-center">
<h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3>
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
<Button
xs
slate
@@ -29,14 +29,14 @@ const handleClick = () => {
@click.stop="handleClick"
/>
</div>
<p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p>
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
</div>
<ul class="flex flex-col items-start gap-3 mt-3">
<li
v-for="feature in features"
:key="feature.id"
class="flex items-center gap-3 text-body-para"
class="flex items-center gap-3 text-sm"
>
<Icon
:icon="feature.icon"
@@ -39,6 +39,7 @@ const policyA = withCount({
description: 'Distributes conversations evenly among available agents',
assignmentOrder: 'round_robin',
conversationPriority: 'high',
enabled: true,
inboxes: [mockInboxes[0], mockInboxes[1]],
isFetchingInboxes: false,
});
@@ -49,6 +50,7 @@ const policyB = withCount({
description: 'Assigns based on capacity and workload',
assignmentOrder: 'capacity_based',
conversationPriority: 'medium',
enabled: true,
inboxes: [mockInboxes[2], mockInboxes[3]],
isFetchingInboxes: false,
});
@@ -59,6 +61,7 @@ const emptyPolicy = withCount({
description: 'Policy with no assigned inboxes',
assignmentOrder: 'manual',
conversationPriority: 'low',
enabled: false,
inboxes: [],
isFetchingInboxes: false,
});
@@ -15,6 +15,7 @@ const props = defineProps({
assignmentOrder: { type: String, default: '' },
conversationPriority: { type: String, default: '' },
assignedInboxCount: { type: Number, default: 0 },
enabled: { type: Boolean, default: false },
inboxes: { type: Array, default: () => [] },
isFetchingInboxes: { type: Boolean, default: false },
});
@@ -60,19 +61,39 @@ const handleFetchInboxes = () => {
<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-heading-2 text-n-slate-12 line-clamp-1">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
:title="
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER')
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
<div class="flex items-center gap-2">
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
<span
class="text-xs"
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
>
{{
enabled
? t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
)
: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
)
}}
</span>
</div>
<CardPopover
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
)
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
</div>
</div>
<div class="flex items-center gap-2">
<Button
@@ -89,18 +110,18 @@ const handleFetchInboxes = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }}
</p>
<div class="flex items-center gap-3 py-1.5">
<span v-if="order" class="text-n-slate-11 text-body-para">
<span v-if="order" class="text-n-slate-11 text-sm">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
}}
<span class="text-n-slate-12">{{ order }}</span>
</span>
<div v-if="order" class="w-px h-3 bg-n-strong" />
<span v-if="priority" class="text-n-slate-11 text-body-para">
<span v-if="priority" class="text-n-slate-11 text-sm">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
}}
@@ -19,15 +19,11 @@ defineProps({
},
});
const emit = defineEmits(['delete', 'navigate']);
const emit = defineEmits(['delete']);
const handleDelete = itemId => {
emit('delete', itemId);
};
const handleNavigate = item => {
emit('navigate', item);
};
</script>
<template>
@@ -51,11 +47,7 @@ const handleNavigate = item => {
: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"
>
<button
type="button"
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
@click="handleNavigate(item)"
>
<div class="flex items-center gap-2 col-span-2">
<Icon
v-if="item.icon"
:icon="item.icon"
@@ -69,16 +61,10 @@ const handleNavigate = item => {
:size="20"
rounded-full
/>
<span
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
>
<span class="text-sm text-n-slate-12 truncate min-w-0">
{{ item.name }}
</span>
<Icon
icon="i-lucide-external-link"
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</button>
</div>
<div class="flex items-start gap-2 col-span-1">
<span
@@ -103,7 +103,7 @@ onMounted(() => {
:key="tag.id"
:label="tag"
:is-hovered="hoveredLabel === tag.id"
class="h-8"
class="h-8 ltr:!pr-1 rtl:!pl-1"
@remove="onClickRemoveTag"
@hover="hoveredLabel = tag.id"
/>
@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
@add="onClickAddTag"
/>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
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';
@@ -15,9 +15,6 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', {
},
});
// The model value is in seconds (for the backend/DB)
// DurationInput works in minutes internally
// We need to convert between seconds and minutes
const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number,
default: 3600,
@@ -28,17 +25,6 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
const windowUnit = ref(DURATION_UNITS.MINUTES);
// Convert seconds to minutes for DurationInput
const windowInMinutes = computed({
get() {
return Math.floor((fairDistributionWindow.value || 0) / 60);
},
set(minutes) {
fairDistributionWindow.value = minutes * 60;
},
});
// Detect unit based on minutes (converted from seconds)
const detectUnit = minutes => {
const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES;
@@ -48,7 +34,7 @@ const detectUnit = minutes => {
};
onMounted(() => {
windowUnit.value = detectUnit(windowInMinutes.value);
windowUnit.value = detectUnit(fairDistributionWindow.value);
});
</script>
@@ -87,9 +73,9 @@ onMounted(() => {
<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 (in minutes) -->
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model:model-value="windowInMinutes"
v-model:model-value="fairDistributionWindow"
v-model:unit="windowUnit"
:min="10"
:max="1438560"
@@ -0,0 +1,60 @@
<script setup>
const props = defineProps({
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const handleChange = () => {
if (!props.isActive) {
emit('select', props.id);
}
};
</script>
<template>
<div
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
]"
@click="handleChange"
>
<div class="absolute top-4 right-4">
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
/>
</div>
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<p class="text-sm text-n-slate-11">
{{ description }}
</p>
</div>
</div>
</template>
@@ -6,6 +6,7 @@ const policyName = ref('Round Robin Policy');
const description = ref(
'Distributes conversations evenly among available agents'
);
const enabled = ref(true);
</script>
<template>
@@ -18,10 +19,13 @@ const description = ref(
<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>
@@ -1,6 +1,6 @@
<script setup>
import { ref } from 'vue';
import RadioCard from './RadioCard.vue';
import RadioCard from '../RadioCard.vue';
const selectedOption = ref('round_robin');
@@ -77,7 +77,7 @@ const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium);
return getInboxIconByType(type, medium, 'line');
});
</script>
@@ -47,8 +47,8 @@ const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.FROM') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
<div class="flex items-center gap-1 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-4" />
<span class="text-sm font-medium text-n-slate-12">
{{ inboxName }}
</span>
@@ -1,5 +1,4 @@
<script setup>
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
@@ -22,18 +21,16 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6">
<header
class="sticky top-0 z-10 px-6 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
>
<div class="w-full max-w-5xl mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-heading-1 text-n-slate-12">
{{ headerTitle }}
</span>
<div
v-on-click-outside="[
() => emit('close'),
// This will prevent closing the modal when the editor Create link popup is open
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
v-on-clickaway="() => emit('close')"
class="relative group/campaign-button"
>
<Button
@@ -49,7 +46,7 @@ const handleButtonClick = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl mx-auto py-4">
<div class="w-full mx-auto max-w-5xl py-4">
<slot name="default" />
</div>
</main>
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -174,7 +174,7 @@ const handleSubmit = async () => {
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -251,7 +251,7 @@ watch(
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -19,13 +19,13 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full outline-1 outline outline-n-container -outline-offset-1 group/cardLayout rounded-xl bg-n-solid-2"
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"
class="flex w-full gap-2 py-4"
:class="[
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
selectable ? 'px-10 py-6' : 'px-6',
selectable ? 'px-10 py-6' : 'px-4',
]"
@click="handleClick"
>
@@ -0,0 +1,170 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { debounce } from '@chatwoot/utils';
import { useCompaniesStore } from 'dashboard/stores/companies';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
placeholder: {
type: String,
default: '',
},
selectedCompanyName: {
type: String,
default: '',
},
});
const emit = defineEmits(['change']);
const companyId = defineModel({
type: [Number, String],
default: null,
});
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const [showCompanyDropdown, toggleCompanyDropdown] = useToggle();
const searchQuery = ref('');
const isSearching = ref(false);
const companies = computed(() => companiesStore.getCompaniesList || []);
const isLoading = computed(() => companiesStore.uiFlags.fetchingList);
const clearOption = computed(() => ({
label: t('DROPDOWN_MENU.CLEAR_SELECTION'),
value: 'clear',
action: 'clear',
}));
const companyOptions = computed(() =>
companies.value.map(company => ({
label: company.name,
value: company.id,
action: 'select',
isSelected: companyId.value === company.id,
}))
);
const menuItems = computed(() => {
const items = [];
// Show clear option if a company is selected (either by ID or by name prop)
if (companyId.value || props.selectedCompanyName) {
items.push(clearOption.value);
}
// Add company options after clear option
items.push(...companyOptions.value);
return items;
});
const selectedCompany = computed(() => {
return companies.value.find(company => company.id === companyId.value);
});
const buttonLabel = computed(() => {
if (selectedCompany.value) {
return selectedCompany.value.name;
}
if (props.selectedCompanyName) {
return props.selectedCompanyName;
}
return props.placeholder;
});
const fetchCompanies = async (search = '') => {
if (!search) return;
isSearching.value = true;
try {
await companiesStore.search({
search,
page: 1,
sort: 'name',
});
} finally {
isSearching.value = false;
}
};
const debouncedSearch = debounce(query => {
searchQuery.value = query;
fetchCompanies(query);
}, 300);
const handleAction = ({ value, action }) => {
if (action === 'clear') {
companyId.value = null;
emit('change', null);
toggleCompanyDropdown(false);
return;
}
if (companyId.value === value) {
companyId.value = null;
emit('change', null);
} else {
companyId.value = value;
const company = companies.value.find(c => c.id === value);
emit('change', company);
}
toggleCompanyDropdown(false);
};
const handleSearch = query => {
debouncedSearch(query);
};
const handleClickOutside = () => {
toggleCompanyDropdown(false);
};
watch(showCompanyDropdown, isOpen => {
if (!isOpen) {
searchQuery.value = '';
companiesStore.clearRecords();
}
});
</script>
<template>
<div v-on-click-outside="handleClickOutside" class="relative w-full min-w-0">
<Button
slate
sm
:label="buttonLabel"
no-animation
type="button"
:variant="showCompanyDropdown ? 'faded' : 'solid'"
icon="i-lucide-briefcase-business"
justify="start"
class="w-full -outline-offset-1"
@click="toggleCompanyDropdown()"
/>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<DropdownMenu
v-if="showCompanyDropdown"
:menu-items="menuItems"
show-search
disable-local-filtering
:search-placeholder="t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')"
:empty-state-message="t('COMPANIES.DROPDOWN_MENU.EMPTY_STATE')"
:is-loading="isLoading"
:is-searching="isSearching"
class="max-h-60 overflow-y-auto absolute z-50 w-full mt-1 top-full"
@action="handleAction"
@search="handleSearch"
/>
</Transition>
</div>
</template>
@@ -15,7 +15,9 @@ const emit = defineEmits(['search', 'update:sort']);
</script>
<template>
<header class="sticky top-0 z-10 px-6">
<header
class="sticky top-0 z-10 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none px-6"
>
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
@@ -91,7 +91,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute z-20 top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -1,12 +1,15 @@
<script setup>
import { computed, watch, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { computed, ref, watch } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
const props = defineProps({
wrapperRef: {
type: Object,
default: null,
},
contactId: {
type: [String, Number],
default: null,
@@ -14,7 +17,6 @@ const props = defineProps({
});
const store = useStore();
const route = useRoute();
const showDropdown = ref(false);
@@ -24,12 +26,14 @@ const showDropdown = ref(false);
const hoveredLabel = ref(null);
const allLabels = useMapGetter('labels/getLabels');
const contactLabels = useMapGetter('contactLabels/getContactLabels');
const getContactById = useMapGetter('contacts/getContactById');
const getContactLabels = useMapGetter('contactLabels/getContactLabels');
const savedLabels = computed(() => {
const availableContactLabels = contactLabels.value(props.contactId);
const contactLabelsList = getContactLabels.value(props.contactId) || [];
return allLabels.value.filter(({ title }) =>
availableContactLabels.includes(title)
contactLabelsList.includes(title)
);
});
@@ -47,13 +51,6 @@ const labelMenuItems = computed(() => {
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
});
const fetchLabels = async contactId => {
if (!contactId) {
return;
}
store.dispatch('contactLabels/get', contactId);
};
const handleLabelAction = async ({ value }) => {
try {
// Get current label titles
@@ -80,6 +77,11 @@ const handleLabelAction = async ({ value }) => {
labels: updatedLabels,
});
store.dispatch('contacts/updateContactLabels', {
contactId: props.contactId,
labels: updatedLabels,
});
showDropdown.value = false;
} catch (error) {
// error
@@ -90,19 +92,19 @@ const handleRemoveLabel = label => {
return handleLabelAction({ value: label.id });
};
// Sync contact labels from contact object to contactLabels store when contact loads
watch(
() => props.contactId,
(newVal, oldVal) => {
if (newVal !== oldVal) {
fetchLabels(newVal);
() => getContactById.value(props.contactId),
contact => {
if (contact?.labels) {
store.dispatch('contactLabels/setContactLabel', {
id: props.contactId,
data: contact.labels,
});
}
}
},
{ immediate: true }
);
onMounted(() => {
if (route.params.contactId) {
fetchLabels(route.params.contactId);
}
});
const handleMouseLeave = () => {
// Reset hover state when mouse leaves the container
@@ -125,11 +127,13 @@ const handleLabelHover = labelId => {
:key="label.id"
:label="label"
:is-hovered="hoveredLabel === label.id"
class="ltr:!pr-1 rtl:!pl-1"
@remove="handleRemoveLabel"
@hover="handleLabelHover(label.id)"
/>
<AddLabel
:label-menu-items="labelMenuItems"
:wrapper-ref="wrapperRef"
@update-label="handleLabelAction"
/>
</div>
@@ -0,0 +1,67 @@
<script setup>
import { reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
contactId: {
type: Number,
default: null,
},
});
const { t } = useI18n();
const store = useStore();
const state = reactive({
message: '',
});
const onAdd = async content => {
if (!content) return;
try {
await store.dispatch('contactNotes/create', {
content,
contactId: props.contactId,
});
state.message = '';
useAlert(t('CONTACTS_LAYOUT.CARD.ADD_NOTE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.CARD.ADD_NOTE.API.ERROR_MESSAGE'));
}
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(state.message),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col items-start gap-4 py-3">
<span
class="py-1 text-heading-3 text-n-slate-12 z-10 h-6 bg-n-surface-1 inline-flex items-center gap-2"
>
<Icon
icon="i-lucide-file-plus-corner"
class="size-4 text-n-slate-11 hidden lg:block"
/>
{{ t('CONTACTS_LAYOUT.CARD.ADD_NOTE.TITLE') }}
</span>
<div class="flex flex-col gap-6 lg:px-6 max-w-lg w-full">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.CARD.ADD_NOTE.PLACEHOLDER')"
class="[&>div]:!border-transparent [&>div]:!px-3 [&>div]:!pb-4 [&_.ProseMirror-woot-style]:min-h-6 [&_.ProseMirror-menubar]:!relative ltr:[&_.ProseMirror-menubar]:!-left-[3px] ltr:[&_.ProseMirror-menubar]:!right-[unset] rtl:[&_.ProseMirror-menubar]:!-right-[3px] rtl:[&_.ProseMirror-menubar]:!left-[unset] [&_.ProseMirror-menubar]:!w-[unset] [&_.ProseMirror-menubar]:!top-[unset] [&_.ProseMirror-menubar-spacer]:!hidden"
/>
</div>
</div>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
customAttributes: { type: Object, required: true },
});
const { t } = useI18n();
const { uiSettings } = useUISettings();
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const displayAttributes = computed(() => {
const selectedKeys = uiSettings.value.contact_list_display_properties || [];
if (!selectedKeys.length) return [];
return contactAttributes.value
.filter(
attr =>
selectedKeys.includes(attr.attributeKey) &&
attr.attributeKey in props.customAttributes
)
.map(attribute => ({
attribute,
value: props.customAttributes[attribute.attributeKey],
}));
});
const formatValue = (value, displayType) => {
if (value === null || value === undefined || value === '') return null;
switch (displayType) {
case 'checkbox':
return value
? t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.YES')
: t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.NO');
case 'list':
return Array.isArray(value) ? value.join(', ') : value;
case 'date':
return new Date(value).toLocaleDateString();
default:
return String(value);
}
};
</script>
<template>
<template
v-for="(item, index) in displayAttributes"
:key="item.attribute.attributeKey"
>
<div
v-if="index === 0"
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
/>
<div
v-tooltip.top="{
content: `${item.attribute.attributeDisplayName}: ${formatValue(item.value, item.attribute.attributeDisplayType)}`,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1 truncate"
>
<span class="text-body-main text-n-slate-12 truncate">
{{ item.attribute.attributeDisplayName }}{{ ':' }}
</span>
<span class="text-body-main text-n-slate-11 truncate">
{{ formatValue(item.value, item.attribute.attributeDisplayType) }}
</span>
</div>
<div
v-if="index < displayAttributes.length - 1"
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
/>
</template>
</template>
@@ -0,0 +1,370 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
import countries from 'shared/constants/countries';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardContent from 'dashboard/components-next/Conversation/ConversationCard/CardContent.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import CardStatusIcon from 'dashboard/components-next/Conversation/ConversationCard/CardStatusIcon.vue';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
const props = defineProps({
contactData: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const router = useRouter();
const contactNotes = useMapGetter('contactNotes/getAllNotesByContactId');
const contactConversations = useMapGetter(
'contactConversations/getAllConversationsByContactId'
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const currentUser = useMapGetter('getCurrentUser');
const agentsList = useMapGetter('agents/getAgents');
const inboxGetter = useMapGetter('inboxes/getInbox');
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
});
const countryDetails = computed(() => {
const attributes = props.contactData?.additionalAttributes || {};
const { country, countryCode, city } = attributes;
if (!country && !countryCode) return null;
const activeCountry =
countriesMap.value[country] || countriesMap.value[countryCode];
if (!activeCountry) return null;
return {
countryCode: activeCountry.id,
city: city ? `${city},` : null,
name: activeCountry.name,
};
});
const formattedLocation = computed(() => {
if (!countryDetails.value) return '';
return [countryDetails.value.city, countryDetails.value.name]
.filter(Boolean)
.join(' ');
});
const companyName = computed(() => {
const attributes = props.contactData?.additionalAttributes || {};
return attributes.companyName || '';
});
const contactLabels = computed(() => {
return props.contactData?.labels || [];
});
const latestNote = computed(() => {
if (!props.contactData?.id) return null;
const notes = contactNotes.value(props.contactData.id);
return notes && notes.length > 0 ? notes[0] : null;
});
const latestConversation = computed(() => {
if (!props.contactData?.id) return null;
const conversations = contactConversations.value(props.contactData.id);
return conversations && conversations.length > 0 ? conversations[0] : null;
});
const lastMessageInConversation = computed(() => {
if (!latestConversation.value) return null;
return getLastMessage(latestConversation.value);
});
const inbox = computed(() => {
const inboxId = latestConversation.value?.inboxId;
return inboxId ? inboxGetter.value(inboxId) : {};
});
const assignee = computed(() => {
if (!latestConversation.value?.meta?.assignee?.id) return null;
return agentsList.value?.find(
agent => agent.id === latestConversation.value.meta.assignee.id
);
});
const getWrittenBy = ({ user } = {}) => {
const currentUserId = currentUser.value?.id;
return user?.id === currentUserId
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: user?.name || t('CONVERSATION.BOT');
};
const formatTimestamp = timestamp => {
if (!timestamp) return '';
return dynamicTime(timestamp);
};
const getConversationUrl = conversationId => {
if (!conversationId || !currentAccountId.value) return '#';
const url = `/app/accounts/${currentAccountId.value}/conversations/${conversationId}`;
return router.push(url);
};
</script>
<template>
<div
class="flex flex-col gap-2 ltr:pl-2 rtl:pr-2 lg:ltr:pl-9 lg:rtl:pr-9 lg:before:content-[''] lg:before:absolute lg:before:ltr:left-[2.688rem] lg:before:rtl:right-[2.688rem] lg:before:top-0 lg:before:w-px lg:before:h-4 lg:before:bg-n-weak"
>
<div class="flex items-center gap-2 pb-2">
<Icon icon="i-woot-overview" class="size-4 text-n-slate-11" />
<h3 class="text-n-slate-11 text-body-main">
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.TITLE') }}
</h3>
</div>
<!-- Section 1: Basic Information -->
<div
class="hidden lg:flex rounded-xl bg-n-card w-auto outline outline-1 outline-n-container -outline-offset-1 p-3"
>
<div class="flex flex-wrap items-center gap-2 w-full">
<div class="flex items-center gap-1">
<Icon icon="i-lucide-circle-user" class="size-4 text-n-slate-11" />
<span class="text-label-small text-n-slate-11 m-0">
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.BASIC_INFO') }}
</span>
</div>
<div
v-if="contactData?.email"
class="h-3 w-px bg-n-strong rounded-lg"
/>
<!-- Email -->
<div v-if="contactData?.email" class="flex items-center gap-1">
<Icon
icon="i-woot-mail"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ contactData.email }}
</span>
</div>
<div
v-if="contactData?.phoneNumber"
class="h-3 w-px bg-n-strong rounded-lg"
/>
<!-- Phone Number -->
<div v-if="contactData?.phoneNumber" class="flex items-center gap-1">
<Icon
icon="i-lucide-phone"
class="size-3.5 text-n-slate-11 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ contactData.phoneNumber }}
</span>
</div>
<div v-if="companyName" class="h-3 w-px bg-n-strong rounded-lg" />
<!-- Company Name -->
<div v-if="companyName" class="flex items-center gap-1">
<Icon
icon="i-lucide-briefcase-business"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ companyName }}
</span>
</div>
<div v-if="countryDetails" class="h-3 w-px bg-n-strong rounded-lg" />
<!-- Location (Country & City) -->
<div v-if="countryDetails" class="flex items-center gap-1">
<Flag
:country="countryDetails.countryCode"
class="size-4 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ formattedLocation }}
</span>
</div>
<div
v-if="contactLabels.length > 0"
class="h-3 w-px bg-n-strong rounded-lg"
/>
<!-- Labels -->
<ContactLabels
v-if="contactLabels.length > 0"
:labels="contactLabels"
disable-toggle
class="my-0 flex-1"
/>
</div>
</div>
<!-- Section 2: Latest Contact Note -->
<div
class="flex flex-col rounded-xl bg-n-card outline outline-1 outline-n-container -outline-offset-1 p-3 gap-1.6 w-full gap-1.5"
>
<div class="flex items-center gap-2 py-1">
<div class="flex items-center gap-1.5">
<Icon icon="i-lucide-notebook-pen" class="size-3.5 text-n-slate-11" />
<span class="text-label-small text-n-slate-11 m-0">
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.LATEST_NOTE') }}
</span>
</div>
<template v-if="latestNote">
<div class="h-3 w-px bg-n-strong rounded-lg" />
<span class="text-label-small text-n-slate-11">
{{ formatTimestamp(latestNote.createdAt) }}
</span>
</template>
</div>
<div v-if="latestNote" class="flex items-center gap-1.5">
<Avatar
v-tooltip.left="{
content: getWrittenBy(latestNote),
delay: { show: 500, hide: 0 },
}"
:name="latestNote?.user?.name || 'Bot'"
:src="
latestNote?.user?.name
? latestNote?.user?.thumbnail
: '/assets/images/chatwoot_bot.png'
"
:size="14"
rounded-full
/>
<p class="text-body-main text-n-slate-12 m-0 line-clamp-1">
{{ latestNote.content }}
</p>
</div>
<p v-else class="text-body-para text-n-slate-10 m-0 italic">
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.NO_NOTES') }}
</p>
</div>
<!-- Section 3: Latest Previous Conversation -->
<div
v-if="latestConversation"
class="flex flex-col rounded-xl bg-n-card outline outline-1 outline-n-container -outline-offset-1 p-3 gap-1.6 w-full gap-1.5 cursor-pointer"
@click="getConversationUrl(latestConversation.id)"
>
<div
class="flex justify-between gap-2 w-full min-w-0"
:class="
latestConversation?.labels?.length > 0
? 'sm:flex-row flex-col items-start sm:items-center '
: 'flex-row items-center'
"
>
<div class="flex items-center gap-2 py-1 min-w-0 flex-shrink">
<div class="flex items-center gap-1.5 min-w-0">
<Icon
icon="i-lucide-message-circle"
class="size-3.5 text-n-slate-11"
/>
<span class="text-label-small text-n-slate-11 m-0 truncate">
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.LATEST_CONVERSATION') }}
</span>
</div>
<div v-if="inbox?.id" class="h-3 w-px bg-n-strong rounded-lg" />
<div class="flex items-center gap-1 min-w-0">
<InboxName
v-if="inbox?.id"
:inbox="inbox"
class="gap-1 [&>span]:text-n-slate-11"
/>
<div
v-if="latestConversation?.id"
class="flex items-center gap-1 min-w-0"
>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-label-small truncate text-n-slate-11">
{{ latestConversation.id }}
</span>
</div>
</div>
<div
v-if="latestConversation?.createdAt"
class="h-3 w-px bg-n-strong rounded-lg"
/>
<span
v-if="latestConversation?.updatedAt"
class="text-label-small text-n-slate-11 truncate min-w-0"
>
{{ formatTimestamp(latestConversation.updatedAt) }}
</span>
</div>
<ContactLabels
v-if="latestConversation?.labels?.length > 0"
:labels="latestConversation.labels"
class="flex-1 min-w-0 sm:justify-end w-full sm:w-auto"
disable-toggle
>
<template #before>
<div data-before-slot class="flex items-center gap-1.5">
<CardPriorityIcon
v-if="latestConversation.priority"
:priority="latestConversation.priority"
class="flex-shrink-0"
/>
<Avatar
v-if="assignee?.name"
v-tooltip.top="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availabilityStatus"
hide-offline-status
rounded-full
/>
<CardStatusIcon
v-if="latestConversation.status"
:status="latestConversation.status"
/>
<div class="h-3 w-px bg-n-weak rounded-lg mx-1" />
</div>
</template>
</ContactLabels>
<div v-else class="flex items-center justify-end gap-1.5 flex-1">
<CardPriorityIcon
v-if="latestConversation.priority"
:priority="latestConversation.priority"
class="flex-shrink-0"
/>
<Avatar
v-if="assignee?.name"
v-tooltip.top="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
rounded-full
/>
<CardStatusIcon
v-if="latestConversation.status"
:status="latestConversation.status"
/>
</div>
</div>
<CardContent
v-if="lastMessageInConversation"
:last-message="lastMessageInConversation"
:unread-count="lastMessageInConversation?.conversation?.unreadCount"
/>
</div>
</div>
</template>
@@ -1,68 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const [showDeleteSection, toggleDeleteSection] = useToggle();
const confirmDeleteContactDialogRef = ref(null);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
</script>
<template>
<Policy :permissions="['administrator']">
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
</div>
</div>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</Policy>
</template>
@@ -1,41 +1,46 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ContactCardOverview from 'dashboard/components-next/Contacts/ContactsCard/ContactCardOverview.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ContactLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
import ContactAttributeDisplay from './ContactAttributeDisplay.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
labels: { type: Array, default: () => [] },
additionalAttributes: { type: Object, default: () => ({}) },
customAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
isSelected: { type: Boolean, default: false },
});
const emit = defineEmits([
'toggle',
'updateContact',
'showContact',
'select',
'avatarHover',
'sendMessage',
'deleteContact',
]);
const { t } = useI18n();
const store = useStore();
const contactsFormRef = ref(null);
const isPrefetching = ref(false);
const hasPrefetched = ref(false);
const getInitialContactData = () => ({
id: props.id,
@@ -43,12 +48,11 @@ const getInitialContactData = () => ({
email: props.email,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
labels: props.labels,
});
const contactData = ref(getInitialContactData());
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
@@ -75,6 +79,11 @@ const countryDetails = computed(() => {
};
});
const companyName = computed(() => {
const attributes = props.additionalAttributes || {};
return attributes.companyName || '';
});
const formattedLocation = computed(() => {
if (!countryDetails.value) return '';
@@ -83,14 +92,6 @@ const formattedLocation = computed(() => {
.join(' ');
});
const handleFormUpdate = updatedData => {
Object.assign(contactData.value, updatedData);
};
const handleUpdateContact = () => {
emit('updateContact', contactData.value);
};
const onClickExpand = () => {
emit('toggle');
contactData.value = getInitialContactData();
@@ -98,147 +99,275 @@ const onClickExpand = () => {
const onClickViewDetails = () => emit('showContact', props.id);
const onClickOpenSendMessage = () => emit('sendMessage', props.id);
const onClickDeleteContact = () => {
emit('deleteContact', props.id);
};
const toggleSelect = checked => {
emit('select', checked);
};
const handleAvatarHover = isHovered => {
emit('avatarHover', isHovered);
const prefetchContactData = async () => {
if (hasPrefetched.value || isPrefetching.value || !props.id) return;
isPrefetching.value = true;
try {
await Promise.all([
store.dispatch('contactNotes/get', { contactId: props.id }),
store.dispatch('contactConversations/get', props.id),
]);
hasPrefetched.value = true;
} catch (error) {
// error
} finally {
isPrefetching.value = false;
}
};
const handleExpandHover = isHovered => {
if (isHovered && !props.isExpanded) {
prefetchContactData();
}
};
</script>
<template>
<div class="relative">
<CardLayout
:key="id"
layout="row"
:class="{
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
}"
<div
class="flex flex-col gap-2 p-2 lg:grid lg:gap-4 lg:items-center lg:rounded-lg lg:transition-all lg:duration-200 lg:grid-cols-[minmax(30%,1fr)_minmax(50%,1.5fr)_1fr]"
:class="{ 'border-b border-n-weak lg:border-none': isExpanded }"
>
<div class="flex items-center justify-start flex-1 gap-4">
<div
class="flex items-center gap-3 lg:gap-2"
:class="{ 'lg:col-span-3': isExpanded }"
>
<div class="flex-shrink-0 size-5 flex items-center justify-center">
<Checkbox
:model-value="isSelected"
@change="event => toggleSelect(event.target.checked)"
/>
</div>
<div
class="relative"
@mouseenter="handleAvatarHover(true)"
@mouseleave="handleAvatarHover(false)"
class="relative hidden lg:block size-4 rounded-md hover:bg-n-alpha-2 flex-shrink-0"
@mouseenter.passive="handleExpandHover(true)"
@mouseleave.passive="handleExpandHover(false)"
>
<Button
icon="i-lucide-chevron-down"
link
slate
sm
no-animation
class="flex-shrink-0 !size-8 absolute -inset-2"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
</div>
<div class="flex-shrink-0 flex items-center">
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:size="24"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<template v-if="selectable" #overlay="{ size }">
<label
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<Checkbox
:model-value="isSelected"
@change="event => toggleSelect(event.target.checked)"
/>
</label>
</template>
</Avatar>
/>
</div>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<h4
class="text-heading-3 my-0 capitalize hover:cursor-pointer truncate text-n-slate-12 lg:max-w-40"
@click="onClickViewDetails"
>
{{ name }}
</h4>
<div
v-if="isExpanded"
class="hidden lg:flex items-center gap-2 flex-shrink-0"
>
<div
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
/>
<Button
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.SEND_MESSAGE')"
icon="i-lucide-message-circle"
link
sm
class="hover:!no-underline"
@click="onClickOpenSendMessage"
/>
<div
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
/>
<Button
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.VIEW_DETAILS')"
icon="i-lucide-info"
link
sm
class="hover:!no-underline"
@click="onClickViewDetails"
/>
<Policy
:permissions="['administrator']"
class="flex items-center gap-2"
>
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
<div
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
/>
</div>
<Button
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.DELETE_CONTACT')"
icon="i-lucide-trash-2"
link
sm
ruby
class="hover:!no-underline"
@click="onClickDeleteContact"
/>
</Policy>
</div>
<div
v-if="companyName"
class="my-0 capitalize h-6 px-1 inline-flex items-center gap-1 rounded-md text-n-slate-12 max-w-40 min-w-0 bg-n-label-color outline outline-1 outline-n-label-border -outline-offset-1"
:class="{ 'lg:hidden': isExpanded }"
>
<Icon
icon="i-lucide-briefcase-business"
class="size-3.5 flex-shrink-0 text-n-slate-11"
/>
<span class="truncate text-label-small">{{ companyName }}</span>
</div>
<div
class="relative lg:hidden size-5 rounded-md hover:bg-n-alpha-2 ltr:ml-auto rtl:mr-auto flex-shrink-0"
@mouseenter.passive="handleExpandHover(true)"
@mouseleave.passive="handleExpandHover(false)"
>
<Button
icon="i-lucide-chevron-down"
link
slate
sm
no-animation
class="flex-shrink-0 !size-8 absolute -inset-1.5"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
</div>
</div>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
<template #after>
<div
class="flex items-center gap-2 lg:gap-2 lg:min-w-0"
:class="{ 'lg:hidden': isExpanded }"
>
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
v-if="email"
v-tooltip.top="{
content: email,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1 truncate lg:max-w-72"
>
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
<Icon
icon="i-woot-mail"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ email }}
</span>
</div>
</template>
</CardLayout>
<div
v-if="email && phoneNumber"
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
/>
<div
v-if="phoneNumber"
v-tooltip.top="{
content: phoneNumber,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1 truncate lg:max-w-72"
>
<Icon
icon="i-lucide-phone"
class="size-3 flex-shrink-0 text-n-slate-11"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ phoneNumber }}
</span>
</div>
<div
v-if="countryDetails"
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
/>
<div
v-if="countryDetails"
v-tooltip.top="{
content: formattedLocation,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-2 min-w-0 text-n-slate-11"
>
<Flag
:country="countryDetails.countryCode"
class="size-3.5 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ formattedLocation }}
</span>
</div>
<ContactAttributeDisplay :custom-attributes="customAttributes" />
</div>
<div class="flex flex-col gap-2 lg:hidden">
<div v-if="labels.length > 0" class="min-h-[1rem]">
<ContactLabels :labels="labels" disable-toggle class="my-0" />
</div>
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
link
xs
class="w-fit hover:!no-underline"
slate
@click="onClickViewDetails"
/>
</div>
<div
class="hidden lg:flex items-center justify-end gap-3 flex-shrink-0"
:class="{ 'lg:hidden': isExpanded }"
>
<ContactLabels
:labels="labels"
disable-toggle
class="my-0 flex-1 justify-end"
/>
</div>
</div>
<div
class="grid [transition:grid-template-rows_300ms_ease-out]"
:class="[
isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
isExpanded ? '' : 'overflow-hidden',
]"
>
<div class="min-h-0">
<div
class="relative flex flex-col pt-3 pb-4 lg:pt-[1.125rem] lg:pb-5 overflow-visible transition-opacity duration-[600ms] ease-out"
:class="isExpanded ? 'opacity-100 delay-200' : 'opacity-0'"
>
<ContactCardOverview :contact-data="contactData" />
</div>
</div>
</div>
</div>
</template>
@@ -76,12 +76,12 @@ const closeMobileSidebar = () => {
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
class="flex flex-col w-full h-full transition-all duration-300 ltr:3xl:ml-56 rtl:3xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div class="w-full mx-auto max-w-4xl">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-4 gap-2"
>
<Breadcrumb
:items="breadcrumbItems"
@@ -94,23 +94,20 @@ const closeMobileSidebar = () => {
? $t('CONTACTS_LAYOUT.HEADER.BLOCK_CONTACT')
: $t('CONTACTS_LAYOUT.HEADER.UNBLOCK_CONTACT')
"
size="sm"
slate
:is-loading="isUpdating"
:disabled="isUpdating"
@click="toggleBlock"
/>
<VoiceCallButton
:phone="selectedContact?.phoneNumber"
:contact-id="contactId"
size="md"
:label="$t('CONTACT_PANEL.CALL')"
size="sm"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
size="sm"
@click="toggle"
/>
</template>
@@ -120,7 +117,7 @@ const closeMobileSidebar = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<div class="w-full pt-2.5 pb-4 mx-auto max-w-4xl">
<slot name="default" />
</div>
</main>
@@ -129,7 +126,7 @@ const closeMobileSidebar = () => {
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-5 max-w-md ltr:border-l rtl:border-r border-n-weak bg-n-surface-2"
>
<slot name="sidebar" />
</div>
@@ -73,7 +73,7 @@ defineExpose({ dialogRef });
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
class="text-n-blue-11"
class="text-n-blue-text"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
@@ -1,7 +1,8 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { useI18n } from 'vue-i18n';
defineProps({
selectedContact: {
@@ -37,13 +38,13 @@ const { t } = useI18n();
<template>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between h-6 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
class="flex items-center justify-center px-1.5 h-6 font-420 text-xs rounded-md text-n-teal-11 outline outline-1 outline-n-weak"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
</span>
@@ -69,7 +70,7 @@ const { t } = useI18n();
@search="query => emit('search', query)"
/>
</div>
<div class="relative flex justify-center gap-2 top-4">
<div class="relative flex justify-center gap-2 mt-4 mb-3">
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
<div
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
@@ -79,13 +80,13 @@ const { t } = useI18n();
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between h-6 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
class="flex items-center justify-center px-1.5 h-6 font-420 text-xs rounded-md text-n-ruby-11 outline outline-1 outline-n-weak"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
</span>
@@ -4,11 +4,11 @@ import { useI18n } from 'vue-i18n';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CountryDropdown from 'dashboard/components-next/Countries/CountryDropdown.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
import CompaniesDropdown from 'dashboard/components-next/Companies/CompaniesDropdown.vue';
const props = defineProps({
contactData: {
@@ -44,7 +44,6 @@ const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TELEGRAM: 'i-ri-telegram-fill',
TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
@@ -67,7 +66,6 @@ const defaultState = {
facebook: '',
github: '',
instagram: '',
telegram: '',
tiktok: '',
linkedin: '',
twitter: '',
@@ -105,13 +103,9 @@ const prepareStateBasedOnProps = () => {
countryCode = '',
country = '',
city = '',
socialTelegramUserName = '',
socialProfiles = {},
} = additionalAttributes || {};
const telegramUsername =
socialProfiles?.telegram || socialTelegramUserName || '';
Object.assign(state, {
id,
name,
@@ -125,18 +119,11 @@ const prepareStateBasedOnProps = () => {
countryCode,
country,
city,
socialProfiles: {
...socialProfiles,
telegram: telegramUsername,
},
socialProfiles,
},
});
};
const countryOptions = computed(() =>
countries.map(({ name, id }) => ({ label: name, value: id }))
);
const editDetailsForm = computed(() =>
Object.keys(FORM_CONFIG).map(key => ({
key,
@@ -215,9 +202,14 @@ const getMessageType = key => {
: 'info';
};
const handleCountrySelection = value => {
const selectedCountry = countries.find(option => option.id === value);
state.additionalAttributes.country = selectedCountry?.name || '';
const handleCountryChange = country => {
state.additionalAttributes.countryCode = country?.id || '';
state.additionalAttributes.country = country?.name || '';
emit('update', state);
};
const handleCompanyChange = company => {
state.additionalAttributes.companyName = company?.name || '';
emit('update', state);
};
@@ -247,41 +239,49 @@ defineExpose({
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
<div class="flex flex-col gap-6 w-full">
<div class="flex flex-col items-start gap-5 w-full">
<span class="text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
</span>
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
<CountryDropdown
v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.countryCode"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
'[&>div>button]:h-8': !isDetailsView,
'[&_button]:!h-10 [&_button]:!rounded-[10px]': isDetailsView,
}"
@update:model-value="handleCountrySelection"
@change="handleCountryChange"
/>
<PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'"
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:show-border="isDetailsView"
:compact="!isDetailsView"
/>
<CompaniesDropdown
v-else-if="item.key === 'COMPANY_NAME'"
:placeholder="item.placeholder"
:selected-company-name="state.additionalAttributes.companyName"
:class="{
'[&>div>button]:h-8': !isDetailsView,
'[&_button]:!h-10 [&_button]:!rounded-[10px]': isDetailsView,
}"
@change="handleCompanyChange"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
:custom-input-class="`!pt-1 !pb-1 ${
!isDetailsView
? '[&:not(.error,.focus)]:!outline-transparent'
: ''
? '[&:not(.error,.focus)]:!outline-transparent h-8'
: 'h-10 !rounded-[10px]'
}`"
class="w-full"
@input="
@@ -296,18 +296,19 @@ defineExpose({
</template>
</div>
</div>
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
<div class="flex flex-col items-start gap-5">
<span class="text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
</span>
<div class="flex flex-wrap gap-2">
<div
v-for="item in socialProfilesForm"
:key="item.key"
class="flex items-center h-8 gap-2 px-2 rounded-lg"
class="flex items-center h-8 gap-2 px-2 bg-n-alpha-2 dark:bg-n-solid-2"
:class="{
'bg-n-alpha-2 dark:bg-n-solid-2': isDetailsView,
'bg-n-alpha-2 dark:bg-n-solid-3': !isDetailsView,
'rounded-[10px] outline-1 outline outline-n-weak -outline-offset-1 hover:outline-n-slate-6':
isDetailsView,
'rounded-lg': !isDetailsView,
}"
>
<Icon
@@ -1,10 +1,13 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ContactSortMenu from './components/ContactSortMenu.vue';
import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import DisplayPropertiesMenu from './components/DisplayPropertiesMenu.vue';
defineProps({
showSearch: { type: Boolean, default: true },
@@ -29,17 +32,25 @@ const emit = defineEmits([
'createSegment',
'deleteSegment',
]);
const [showDisplayProperties, toggleDisplayProperties] = useToggle(false);
const closeDisplayProperties = () => {
if (showDisplayProperties.value) {
toggleDisplayProperties(false);
}
};
</script>
<template>
<header class="sticky top-0 z-10 px-6">
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
class="flex items-start sm:items-center justify-between w-full py-4 px-6 gap-2 mx-auto after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
>
<span class="text-xl font-medium truncate text-n-slate-12">
<span class="text-heading-1 truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-3">
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
@@ -59,7 +70,23 @@ const emit = defineEmits([
</template>
</Input>
</div>
<div class="flex items-center flex-shrink-0 gap-4">
<div class="flex items-center flex-shrink-0 gap-2">
<div class="w-px h-3 rounded-lg bg-n-strong" />
<div v-on-click-outside="closeDisplayProperties" class="relative">
<Button
icon="i-lucide-settings-2"
color="slate"
:label="$t('CONTACTS_LAYOUT.HEADER.DISPLAY_PROPERTIES')"
size="sm"
:variant="!showDisplayProperties ? 'ghost' : 'faded'"
@click="toggleDisplayProperties()"
/>
<DisplayPropertiesMenu
v-if="showDisplayProperties"
class="absolute top-full mt-1 max-md:ltr:left-0 max-md:rtl:right-0 md:ltr:right-0 md:rtl:left-0"
/>
</div>
<div class="w-px h-3 rounded-lg bg-n-strong mx-1" />
<div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative">
<Button
@@ -112,7 +139,9 @@ const emit = defineEmits([
@export="emit('export')"
/>
</div>
<div class="w-px h-4 bg-n-strong" />
<div
class="w-px h-3 rounded-lg bg-n-strong ltr:ml-1 rtl:mr-2 ltr:mr-1 rtl:ml-2"
/>
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
@@ -68,7 +68,8 @@ const hasActiveSegments = computed(
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = () => {
const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
createNewContactDialogRef.value?.dialogRef.open();
};
const openContactImportDialog = () =>
@@ -293,7 +294,7 @@ defineExpose({
>
<template #filter>
<div
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
class="absolute z-20 mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
>
<ContactsFilter
v-if="showFiltersModal"
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute z-20 top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-5xl"
class="max-w-[105rem] px-6 relative z-20"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
@@ -0,0 +1,162 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const MAX_SELECTED = 3;
const selectedAttributeKeys = computed({
get: () => uiSettings.value?.contact_list_display_properties || [],
set: value => {
updateUISettings({
contact_list_display_properties: value,
});
},
});
const selectedAttributes = computed(() => {
return (
contactAttributes.value?.filter(attr =>
selectedAttributeKeys.value.includes(attr.attributeKey)
) || []
);
});
const otherAttributes = computed(() => {
return (
contactAttributes.value?.filter(
attr => !selectedAttributeKeys.value.includes(attr.attributeKey)
) || []
);
});
const canSelectMore = computed(() => {
return selectedAttributeKeys.value.length < MAX_SELECTED;
});
const toggleAttribute = attributeKey => {
const currentKeys = [...selectedAttributeKeys.value];
const index = currentKeys.indexOf(attributeKey);
if (index > -1) {
currentKeys.splice(index, 1);
} else if (currentKeys.length < MAX_SELECTED) {
currentKeys.push(attributeKey);
}
selectedAttributeKeys.value = currentKeys;
};
const removeAttribute = attributeKey => {
const currentKeys = [...selectedAttributeKeys.value];
const index = currentKeys.indexOf(attributeKey);
if (index > -1) {
currentKeys.splice(index, 1);
selectedAttributeKeys.value = currentKeys;
}
};
</script>
<template>
<div
class="flex flex-col gap-2 px-3 pt-3 pb-4 bg-n-alpha-3 rounded-xl outline outline-1 outline-n-weak -outline-offset-1 w-80 z-20 backdrop-blur-md"
>
<div class="flex items-start gap-1 flex-col">
<h3 class="text-heading-3 text-n-slate-12 m-0">
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.TITLE') }}
</h3>
<p class="text-body-main text-n-slate-11 m-0">
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.DESCRIPTION') }}
</p>
</div>
<div v-if="selectedAttributes.length > 0" class="flex flex-col gap-2">
<span class="pt-2 text-label-small text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.SELECTED_ATTRIBUTES') }}
</span>
<div class="flex flex-wrap gap-2">
<div
v-for="attribute in selectedAttributes"
:key="attribute.attributeKey"
class="inline-flex items-center gap-1 ltr:pl-1 rtl:pr-1 ltr:pr-2 rtl:pl-2 rounded-lg bg-n-button-color shadow-[0_1px_2px_0_rgba(27,28,29,0.04)] outline outline-1 -outline-offset-1 outline-n-container h-8"
>
<div class="size-5 flex items-center justify-center">
<Checkbox
model-value
@change="removeAttribute(attribute.attributeKey)"
/>
</div>
<span class="text-body-main text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
<Button
icon="i-lucide-x"
slate
xs
ghost
class="!size-4 !rounded-md"
@click="removeAttribute(attribute.attributeKey)"
/>
</div>
</div>
</div>
<div v-if="otherAttributes.length > 0" class="flex flex-col gap-2">
<span class="pt-2 text-label-small text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.OTHER_ATTRIBUTES') }}
</span>
<div class="flex flex-wrap gap-2">
<div
v-for="attribute in otherAttributes"
:key="attribute.attributeKey"
class="inline-flex items-center gap-1 ltr:pl-1 rtl:pr-1 ltr:pr-2 rtl:pl-2 rounded-lg shadow-[0_1px_2px_0_rgba(27,28,29,0.04)] outline outline-1 -outline-offset-1 outline-n-container h-8 bg-n-button-color"
:class="[
canSelectMore ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed',
]"
@click="canSelectMore && toggleAttribute(attribute.attributeKey)"
>
<div class="size-5 flex items-center justify-center">
<Checkbox
:model-value="false"
:disabled="!canSelectMore"
@change="toggleAttribute(attribute.attributeKey)"
/>
</div>
<span class="text-body-main text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
<Button
v-if="attribute.attributeDescription"
v-tooltip.top="{
content: attribute.attributeDescription,
delay: { show: 500, hide: 0 },
}"
icon="i-lucide-info"
slate
xs
ghost
:disabled="!canSelectMore"
class="!size-4 !rounded-md"
/>
</div>
</div>
</div>
<div
v-if="contactAttributes.length === 0"
class="flex flex-col items-center justify-center gap-3 py-8"
>
<p class="text-body-main text-center text-n-slate-11 m-0">
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.NO_ATTRIBUTES') }}
</p>
</div>
</div>
</template>
@@ -5,7 +5,6 @@ import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
const props = defineProps({
searchValue: { type: String, default: '' },
@@ -20,9 +19,6 @@ const props = defineProps({
segmentsId: { type: [String, Number], default: 0 },
hasAppliedFilters: { type: Boolean, default: false },
isFetchingList: { type: Boolean, default: false },
useInfiniteScroll: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
isLoadingMore: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -31,7 +27,6 @@ const emit = defineEmits([
'search',
'applyFilter',
'clearFilters',
'loadMore',
]);
const route = useRoute();
@@ -66,19 +61,11 @@ const updateCurrentPage = page => {
const openFilter = () => {
contactListHeaderWrapper.value?.onToggleFilters();
};
const showLoadMore = computed(() => {
return props.useInfiniteScroll && props.hasMore;
});
const showPagination = computed(() => {
return !props.useInfiniteScroll && props.showPaginationFooter;
});
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
@@ -98,8 +85,8 @@ const showPagination = computed(() => {
@apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto px-6">
<div class="w-full mx-auto max-w-5xl">
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto">
<ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
@@ -107,19 +94,13 @@ const showPagination = computed(() => {
@open-filter="openFilter"
/>
<slot name="default" />
<ContactsLoadMore
v-if="showLoadMore"
:is-loading="isLoadingMore"
@load-more="emit('loadMore')"
/>
</div>
</main>
<footer v-if="showPagination" class="sticky bottom-0 z-0">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
class="max-w-[67rem]"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
@@ -1,28 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
</script>
<template>
<div class="flex justify-center py-4">
<Button
:label="t('CONTACTS_LAYOUT.LOAD_MORE')"
:is-loading="isLoading"
variant="faded"
color="slate"
size="sm"
@click="emit('loadMore')"
/>
</div>
</template>
@@ -9,6 +9,7 @@ import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttrib
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
const props = defineProps({
attribute: {
@@ -75,11 +76,15 @@ const CurrentAttributeComponent = computed(() => {
<template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
class="grid grid-cols-[auto,auto] group/attribute items-center w-full gap-4"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium truncate text-n-slate-12">
<div class="flex items-center gap-1.5 min-w-0">
<HelperTextPopup
v-if="attribute.attributeDescription"
:message="attribute.attributeDescription"
/>
<span class="text-body-main truncate text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="hasContactAttributes" class="flex flex-col gap-4 px-5 pt-4 pb-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
@@ -117,16 +117,16 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-2">
<div class="flex-1 h-[1px] bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">{{
<span class="text-xs font-420 text-n-slate-10">{{
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}</span>
<div class="flex-1 h-[1px] bg-n-slate-5" />
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-4">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
@@ -135,7 +135,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
:placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 ltr:pl-10 ltr:pr-2 rtl:pr-10 rtl:pl-2 mb-0 reset-base outline-n-weak outline-1 outline hover:outline-n-slate-6 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12 outline-offset-0"
/>
</div>
<div
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
@@ -13,11 +14,6 @@ const route = useRoute();
const conversations = useMapGetter(
'contactConversations/getAllConversationsByContactId'
);
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const isFetching = computed(() => uiFlags.value.isFetching);
@@ -36,16 +32,17 @@ const contactConversations = computed(() =>
</div>
<div
v-else-if="contactConversations.length > 0"
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
class="px-6 pt-4 pb-6 divide-y divide-n-weak"
>
<ConversationCard
v-for="conversation in contactConversations"
:key="conversation.id"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
:chat="useSnakeCase(conversation, { deep: true })"
enable-context-menu
compact
show-assignee
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
:enable-selection="false"
/>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
@@ -103,12 +103,12 @@ const onMergeContacts = async () => {
</script>
<template>
<div class="flex flex-col gap-8 px-6 py-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
<div class="flex flex-col gap-6 px-5 pb-6 pt-4">
<div class="flex flex-col gap-2 mt-4">
<h4 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
</h4>
<p class="text-sm text-n-slate-11">
<p class="text-sm font-420 text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
</p>
</div>
@@ -126,13 +126,6 @@ const onMergeContacts = async () => {
@search="onContactSearch"
/>
<div class="flex items-center justify-between gap-3">
<Button
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
class="w-full"
@@ -140,6 +133,12 @@ const onMergeContacts = async () => {
:disabled="isMergingContact"
@click="onMergeContacts"
/>
<Button
slate
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full"
@click="resetState"
/>
</div>
</div>
</template>
@@ -55,12 +55,12 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6 py-6">
<div class="flex flex-col gap-4 pt-4 pb-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
focus-on-mount
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
class="[&>div]:px-4 [&>div]:py-4 px-5"
>
<template #actions>
<div class="flex items-center gap-3">
@@ -87,7 +87,7 @@ useKeyboardEvents(keyboardEvents);
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="mx-6 py-4"
class="mx-5 py-4"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
@@ -48,10 +48,26 @@ onMounted(() => {
</script>
<template>
<div class="flex flex-col gap-2 border-b border-n-strong group/note">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5 min-w-0">
<div class="flex flex-col gap-1.5 border-b border-n-strong group/note">
<div class="flex items-center justify-between w-full gap-1.5 min-w-0 h-9">
<div class="min-w-0 truncate text-n-slate-11 text-xs font-440">
{{ dynamicTime(note.createdAt) }}
</div>
<div class="flex items-center gap-1">
<Button
v-if="allowDelete"
ghost
ruby
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
<Avatar
v-tooltip.left="{
content: writtenBy,
delay: { show: 500, hide: 0 },
}"
:name="note?.user?.name || 'Bot'"
:src="
note?.user?.name
@@ -61,30 +77,12 @@ onMounted(() => {
:size="16"
rounded-full
/>
<div class="min-w-0 truncate">
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span class="font-medium text-n-slate-12">{{ writtenBy }}</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium text-n-slate-12">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
<Button
v-if="allowDelete"
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
</div>
<p
ref="noteContentRef"
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
class="mb-0 text-body-main prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
:class="{
'line-clamp-4': collapsible && !isExpanded && needsCollapse,
}"
@@ -11,6 +11,7 @@ import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/Cont
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
selectedContact: {
@@ -24,6 +25,8 @@ const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const wrapperRef = ref(null);
const confirmDeleteContactDialogRef = ref(null);
const avatarFile = ref(null);
@@ -121,17 +124,17 @@ const handleAvatarDelete = async () => {
</script>
<template>
<div class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<div class="flex flex-col items-start gap-6 pb-6 w-full">
<div ref="wrapperRef" class="flex flex-col items-start gap-3 w-full">
<Avatar
:src="avatarSrc || ''"
:name="selectedContact?.name || ''"
:size="72"
:size="64"
allow-upload
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium text-n-slate-12">
{{ selectedContact?.name }}
</h3>
@@ -140,27 +143,44 @@ const handleAvatarDelete = async () => {
v-if="selectedContact?.identifier"
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
>
<span class="i-ph-user-gear text-n-slate-10 size-4" />
<Icon
icon="i-ph-user-gear"
class="text-n-slate-10 size-4 flex-shrink-0"
/>
{{ selectedContact?.identifier }}
</span>
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span
<span class="inline-flex items-start gap-1 text-sm text-n-slate-11">
<Icon
v-if="selectedContact?.identifier"
class="i-ph-activity text-n-slate-10 size-4"
icon="i-ph-activity"
class="text-n-slate-10 size-4 flex-shrink-0 mt-0.5"
/>
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
<div
class="flex flex-col sm:flex-row sm:inline-flex items-start sm:items-center gap-1 sm:gap-3"
>
<span>
{{
$t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt })
}}
</span>
<div class="w-px h-3 rounded-xl bg-n-weak hidden sm:block" />
<span>
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
</span>
</div>
</div>
<ContactLabels :contact-id="selectedContact?.id" />
<ContactLabels
:wrapper-ref="wrapperRef"
:contact-id="selectedContact?.id"
/>
</div>
<div class="flex flex-col items-start gap-6">
<div class="flex flex-col items-start gap-6 w-full">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@@ -175,21 +195,23 @@ const handleAvatarDelete = async () => {
@click="updateContact"
/>
</div>
<Policy :permissions="['administrator']">
<Policy :permissions="['administrator']" class="w-full">
<div class="w-full h-px bg-n-slate-3 mt-1 mb-7" />
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
class="flex items-end justify-between w-full gap-4 outline outline-1 outline-n-weak rounded-2xl p-4"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
<span class="text-sm font-420 text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
ruby
class="flex-shrink-0"
@click="openConfirmDeleteContactDialog"
/>
</div>

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