Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e28b2846e | ||
|
|
a7d14905de | ||
|
|
c08fa631a9 | ||
|
|
14b4c83dc6 | ||
|
|
df92fd12cb | ||
|
|
d84ae196d5 | ||
|
|
bdcc62f1b0 | ||
|
|
7acd239c70 | ||
|
|
9ca03c1af3 | ||
|
|
c218eff5ec | ||
|
|
4548bdcf7b | ||
|
|
7c60ad9e28 | ||
|
|
6b3f1114fd | ||
|
|
109b43aadb | ||
|
|
dfa77b88cf | ||
|
|
892d71eca5 | ||
|
|
beeb023e19 | ||
|
|
3ddab3ab26 | ||
|
|
e2dd2ccb42 | ||
|
|
9fab70aebf | ||
|
|
1a158317fb | ||
|
|
0736d69dec | ||
|
|
efe49f7da4 | ||
|
|
5aef9d2dd0 | ||
|
|
b98c614669 | ||
|
|
ba804e0f30 | ||
|
|
a44cb2c738 | ||
|
|
172ff87b5b | ||
|
|
55f6257313 | ||
|
|
76f129efaf | ||
|
|
3a1777d2fe | ||
|
|
2963ccbd47 | ||
|
|
7cec4ebaae | ||
|
|
6be95e79f8 | ||
|
|
2b85275e26 | ||
|
|
5b167b5b5b | ||
|
|
2a31ec7afd | ||
|
|
b220663785 | ||
|
|
40da358dc2 | ||
|
|
957a1b17c9 | ||
|
|
9dd13b9a2b | ||
|
|
223c04c748 | ||
|
|
7b6b2afdce | ||
|
|
2441487a76 | ||
|
|
418bd177f8 | ||
|
|
572f5b2709 | ||
|
|
15f25f019e | ||
|
|
280ca06e5b | ||
|
|
d8f4bb940e | ||
|
|
db7e02b93b | ||
|
|
dbab0fe8da | ||
|
|
26c38a90f2 | ||
|
|
f826dc2d15 | ||
|
|
6902969a09 | ||
|
|
7b2b3ac37d | ||
|
|
c9619eaed2 | ||
|
|
2ab117e8eb | ||
|
|
594333a183 | ||
|
|
a238034153 | ||
|
|
2bd1d88d50 | ||
|
|
e8152642f2 | ||
|
|
dae4f3ee13 | ||
|
|
e75e8a77f6 | ||
|
|
229f56d6e3 | ||
|
|
138840a23f | ||
|
|
c5f6844877 | ||
|
|
39243b9e71 | ||
|
|
fb2f5e1d42 | ||
|
|
cfe3061b5d | ||
|
|
aa7e3c2d38 | ||
|
|
3874383698 | ||
|
|
101eca3003 | ||
|
|
61eaa098ae | ||
|
|
9cd7c4ef89 | ||
|
|
f4538ae2c5 | ||
|
|
fd5ac2a8a3 | ||
|
|
6b7180d051 | ||
|
|
4d362da9f0 | ||
|
|
2c2f0547f7 | ||
|
|
c7193c7917 | ||
|
|
d272a64ff7 | ||
|
|
b2cb3717e5 | ||
|
|
00ed074d72 | ||
|
|
7b512bd00e | ||
|
|
8f95fafff4 | ||
|
|
0ad47d87f4 | ||
|
|
e65ea24360 | ||
|
|
b252656984 | ||
|
|
6e397c7571 | ||
|
|
4622560fac | ||
|
|
6632610e78 | ||
|
|
bd732f1fa9 | ||
|
|
67112647e8 | ||
|
|
04c456e0a3 | ||
|
|
1345f67966 | ||
|
|
0f914fa2ab | ||
|
|
59663dd558 |
@@ -94,6 +94,8 @@ yarn-debug.log*
|
|||||||
.vscode
|
.vscode
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.cursor
|
.cursor
|
||||||
|
.codex/
|
||||||
|
.claude/
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
|
|
||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
@@ -101,3 +103,4 @@ CLAUDE.local.md
|
|||||||
.histoire
|
.histoire
|
||||||
.pnpm-store/*
|
.pnpm-store/*
|
||||||
local/
|
local/
|
||||||
|
Procfile.worktree
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
|
|
||||||
- **Setup**: `bundle install && pnpm install`
|
- **Setup**: `bundle install && pnpm install`
|
||||||
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
|
- **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 JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
|
||||||
- **Lint Ruby**: `bundle exec rubocop -a`
|
- **Lint Ruby**: `bundle exec rubocop -a`
|
||||||
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
||||||
@@ -50,6 +55,13 @@
|
|||||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
- 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
|
- 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
|
## Commit Messages
|
||||||
|
|
||||||
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
||||||
@@ -86,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs
|
|||||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
- 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.
|
- 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/`.
|
- 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.
|
||||||
|
|||||||
@@ -191,12 +191,14 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.7.0'
|
gem 'ai-agents', '>= 0.9.1'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
gem 'ruby_llm', '>= 1.8.2'
|
gem 'ruby_llm', '>= 1.8.2'
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
|
gem 'cld3', '~> 3.7'
|
||||||
|
|
||||||
# OpenTelemetry for LLM observability
|
# OpenTelemetry for LLM observability
|
||||||
gem 'opentelemetry-sdk'
|
gem 'opentelemetry-sdk'
|
||||||
gem 'opentelemetry-exporter-otlp'
|
gem 'opentelemetry-exporter-otlp'
|
||||||
|
|||||||
+22
-20
@@ -126,8 +126,8 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
ai-agents (0.7.0)
|
ai-agents (0.9.1)
|
||||||
ruby_llm (~> 1.8.2)
|
ruby_llm (~> 1.9.1)
|
||||||
annotaterb (4.20.0)
|
annotaterb (4.20.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
@@ -186,6 +186,7 @@ GEM
|
|||||||
byebug (11.1.3)
|
byebug (11.1.3)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
|
cld3 (3.7.0)
|
||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
@@ -297,7 +298,7 @@ GEM
|
|||||||
railties (>= 5.0.0)
|
railties (>= 5.0.0)
|
||||||
faker (3.2.0)
|
faker (3.2.0)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.13.1)
|
faraday (2.14.1)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
@@ -308,12 +309,12 @@ GEM
|
|||||||
hashie
|
hashie
|
||||||
faraday-multipart (1.0.4)
|
faraday-multipart (1.0.4)
|
||||||
multipart-post (~> 2)
|
multipart-post (~> 2)
|
||||||
faraday-net_http (3.4.0)
|
faraday-net_http (3.4.2)
|
||||||
net-http (>= 0.5.0)
|
net-http (~> 0.5)
|
||||||
faraday-net_http_persistent (2.1.0)
|
faraday-net_http_persistent (2.1.0)
|
||||||
faraday (~> 2.5)
|
faraday (~> 2.5)
|
||||||
net-http-persistent (~> 4.0)
|
net-http-persistent (~> 4.0)
|
||||||
faraday-retry (2.2.1)
|
faraday-retry (2.4.0)
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday_middleware-aws-sigv4 (1.0.1)
|
faraday_middleware-aws-sigv4 (1.0.1)
|
||||||
aws-sigv4 (~> 1.0)
|
aws-sigv4 (~> 1.0)
|
||||||
@@ -464,7 +465,7 @@ GEM
|
|||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.13.2)
|
json (2.18.1)
|
||||||
json_refs (0.1.8)
|
json_refs (0.1.8)
|
||||||
hana
|
hana
|
||||||
json_schemer (0.2.24)
|
json_schemer (0.2.24)
|
||||||
@@ -539,7 +540,7 @@ GEM
|
|||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.1.0)
|
||||||
maxminddb (0.1.22)
|
maxminddb (0.1.22)
|
||||||
meta_request (0.8.5)
|
meta_request (0.8.5)
|
||||||
rack-contrib (>= 1.1, < 3)
|
rack-contrib (>= 1.1, < 3)
|
||||||
@@ -558,12 +559,12 @@ GEM
|
|||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.8.0)
|
multi_xml (0.8.0)
|
||||||
bigdecimal (>= 3.1, < 5)
|
bigdecimal (>= 3.1, < 5)
|
||||||
multipart-post (2.3.0)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
neighbor (0.2.3)
|
neighbor (0.2.3)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
net-http (0.6.0)
|
net-http (0.9.1)
|
||||||
uri
|
uri (>= 0.11.1)
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.20)
|
net-imap (0.4.20)
|
||||||
@@ -582,14 +583,14 @@ GEM
|
|||||||
newrelic_rpm (9.6.0)
|
newrelic_rpm (9.6.0)
|
||||||
base64
|
base64
|
||||||
nio4r (2.7.3)
|
nio4r (2.7.3)
|
||||||
nokogiri (1.18.9)
|
nokogiri (1.19.1)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.9-arm64-darwin)
|
nokogiri (1.19.1-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.9-x86_64-darwin)
|
nokogiri (1.19.1-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.9-x86_64-linux-gnu)
|
nokogiri (1.19.1-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oauth (1.1.0)
|
oauth (1.1.0)
|
||||||
oauth-tty (~> 1.0, >= 1.0.1)
|
oauth-tty (~> 1.0, >= 1.0.1)
|
||||||
@@ -676,7 +677,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.3)
|
rack (3.2.5)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-contrib (2.5.0)
|
rack-contrib (2.5.0)
|
||||||
@@ -824,7 +825,7 @@ GEM
|
|||||||
ruby2ruby (2.5.0)
|
ruby2ruby (2.5.0)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
ruby_llm (1.8.2)
|
ruby_llm (1.9.2)
|
||||||
base64
|
base64
|
||||||
event_stream_parser (~> 1)
|
event_stream_parser (~> 1)
|
||||||
faraday (>= 1.10.0)
|
faraday (>= 1.10.0)
|
||||||
@@ -968,7 +969,7 @@ GEM
|
|||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uniform_notifier (1.17.0)
|
uniform_notifier (1.17.0)
|
||||||
uri (1.0.4)
|
uri (1.1.1)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (5.2.6)
|
valid_email2 (5.2.6)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
@@ -1003,7 +1004,7 @@ GEM
|
|||||||
working_hours (1.4.1)
|
working_hours (1.4.1)
|
||||||
activesupport (>= 3.2)
|
activesupport (>= 3.2)
|
||||||
tzinfo
|
tzinfo
|
||||||
zeitwerk (2.6.17)
|
zeitwerk (2.7.4)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
arm64-darwin-20
|
arm64-darwin-20
|
||||||
@@ -1023,7 +1024,7 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
ai-agents (>= 0.7.0)
|
ai-agents (>= 0.9.1)
|
||||||
annotaterb
|
annotaterb
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
@@ -1037,6 +1038,7 @@ DEPENDENCIES
|
|||||||
bullet
|
bullet
|
||||||
bundle-audit
|
bundle-audit
|
||||||
byebug
|
byebug
|
||||||
|
cld3 (~> 3.7)
|
||||||
climate_control
|
climate_control
|
||||||
commonmarker
|
commonmarker
|
||||||
csv-safe
|
csv-safe
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.10.1
|
4.11.1
|
||||||
|
|||||||
@@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def message_params
|
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,
|
account_id: conversation.account_id,
|
||||||
inbox_id: conversation.inbox_id,
|
inbox_id: conversation.inbox_id,
|
||||||
message_type: @message_type,
|
message_type: @message_type,
|
||||||
|
status: @outgoing_echo ? :delivered : :sent,
|
||||||
content: response.content,
|
content: response.content,
|
||||||
source_id: response.identifier,
|
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
|
sender: @outgoing_echo ? nil : @contact_inbox.contact
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def meta
|
def meta
|
||||||
result = conversation_finder.perform
|
result = conversation_finder.perform_meta_only
|
||||||
@conversations_count = result[:count]
|
@conversations_count = result[:count]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
|
|
||||||
def transcript
|
def transcript
|
||||||
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
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?
|
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
|
||||||
|
|
||||||
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_inbox
|
before_action :fetch_inbox
|
||||||
before_action :validate_whatsapp_channel
|
before_action :validate_whatsapp_channel
|
||||||
|
before_action :validate_captain_enabled, only: [:analyze]
|
||||||
|
|
||||||
def show
|
def show
|
||||||
service = CsatTemplateManagementService.new(@inbox)
|
service = CsatTemplateManagementService.new(@inbox)
|
||||||
@@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
|||||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def fetch_inbox
|
def fetch_inbox
|
||||||
@@ -46,6 +64,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
|||||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||||
end
|
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)
|
def render_template_creation_result(result)
|
||||||
if result[:success]
|
if result[:success]
|
||||||
render_successful_template_creation(result)
|
render_successful_template_creation(result)
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def transcript
|
def transcript
|
||||||
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
|
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
|
send_transcript_email
|
||||||
head :ok
|
head :ok
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.from_email(params[:email])
|
@user = User.from_email(params[:email])
|
||||||
if @user
|
@user&.send_reset_password_instructions
|
||||||
@user.send_reset_password_instructions
|
build_response(I18n.t('messages.reset_password'), 200)
|
||||||
build_response(I18n.t('messages.reset_password_success'), 200)
|
|
||||||
else
|
|
||||||
build_response(I18n.t('messages.reset_password_failure'), 404)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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
|
||||||
@@ -11,6 +11,7 @@ class ConversationFinder
|
|||||||
'priority_desc' => %w[sort_on_priority desc],
|
'priority_desc' => %w[sort_on_priority desc],
|
||||||
'waiting_since_asc' => %w[sort_on_waiting_since asc],
|
'waiting_since_asc' => %w[sort_on_waiting_since asc],
|
||||||
'waiting_since_desc' => %w[sort_on_waiting_since desc],
|
'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
|
# To be removed in v3.5.0
|
||||||
'latest' => %w[sort_on_last_activity_at desc],
|
'latest' => %w[sort_on_last_activity_at desc],
|
||||||
@@ -55,6 +56,22 @@ class ConversationFinder
|
|||||||
}
|
}
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def set_up
|
def set_up
|
||||||
|
|||||||
@@ -57,39 +57,35 @@ module Api::V1::InboxesHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_smtp_connection(channel_data, smtp)
|
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],
|
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
||||||
channel_data[:smtp_authentication]&.to_sym || :login)
|
channel_data[:smtp_authentication]&.to_sym || :login)
|
||||||
smtp.finish
|
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
|
end
|
||||||
|
|
||||||
def set_smtp_encryption(channel_data, smtp)
|
def set_smtp_encryption(channel_data, smtp)
|
||||||
if channel_data[:smtp_enable_ssl_tls]
|
if channel_data[:smtp_enable_ssl_tls]
|
||||||
set_enable_tls(channel_data, smtp)
|
set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
|
||||||
elsif channel_data[:smtp_enable_starttls_auto]
|
elsif channel_data[:smtp_enable_starttls_auto]
|
||||||
set_enable_starttls_auto(channel_data, smtp)
|
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_enable_starttls_auto(channel_data, smtp)
|
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
|
||||||
return unless smtp.respond_to?(:enable_starttls_auto)
|
return unless smtp.respond_to?(method)
|
||||||
|
|
||||||
if channel_data[:smtp_openssl_verify_mode]
|
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
|
||||||
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
context ? smtp.send(method, context) : smtp.send(method)
|
||||||
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
|
end
|
||||||
|
|
||||||
def enable_openssl_mode(smtp_openssl_verify_mode)
|
def enable_openssl_mode(smtp_openssl_verify_mode)
|
||||||
|
|||||||
@@ -164,10 +164,4 @@ export default {
|
|||||||
.v-popper--theme-tooltip .v-popper__arrow-container {
|
.v-popper--theme-tooltip .v-popper__arrow-container {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiselect__input {
|
|
||||||
margin-bottom: 0px !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
getCSATTemplateStatus(inboxId) {
|
getCSATTemplateStatus(inboxId) {
|
||||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
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();
|
export default new Inboxes();
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ textarea {
|
|||||||
// Field base styles (Input, TextArea, Select)
|
// Field base styles (Input, TextArea, Select)
|
||||||
@layer components {
|
@layer components {
|
||||||
.field-base {
|
.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-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;
|
@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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-disabled {
|
.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(.multiselect__input):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(.no-margin)";
|
||||||
|
|
||||||
#{$form-input-selector} {
|
#{$form-input-selector} {
|
||||||
@apply field-base h-10;
|
@apply field-base h-10;
|
||||||
@@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='file']:not(.multiselect__input) {
|
input[type='file'] {
|
||||||
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
|
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,13 +126,6 @@ label:has(.help-text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error handling
|
|
||||||
.has-multi-select-error {
|
|
||||||
div.multiselect {
|
|
||||||
@apply mb-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormKit support
|
// FormKit support
|
||||||
.formkit-outer[data-invalid='true'] {
|
.formkit-outer[data-invalid='true'] {
|
||||||
#{$form-input-selector},
|
#{$form-input-selector},
|
||||||
@@ -150,9 +143,7 @@ label:has(.help-text) {
|
|||||||
#{$form-input-selector},
|
#{$form-input-selector},
|
||||||
input:not([type]),
|
input:not([type]),
|
||||||
textarea,
|
textarea,
|
||||||
select,
|
select {
|
||||||
.multiselect > .multiselect__tags,
|
|
||||||
.multiselect:not(.no-margin) {
|
|
||||||
@apply field-error;
|
@apply field-error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
@import 'base';
|
@import 'base';
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
@import 'plugins/multiselect';
|
|
||||||
@import 'plugins/date-picker';
|
@import 'plugins/date-picker';
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -66,4 +65,84 @@ body {
|
|||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* TYPOGRAPHY UTILITIES
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* | Class | Use Case |
|
||||||
|
* |--------------------|----------------------------------------------------|
|
||||||
|
* | .text-body-main | <p>, <span>, general body text |
|
||||||
|
* | .text-body-para | <p> for paragraphs, larger text blocks |
|
||||||
|
* | .text-heading-1 | <h1>, page titles, panel headers |
|
||||||
|
* | .text-heading-2 | <h2>, section headings, card titles |
|
||||||
|
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
|
||||||
|
* | .text-label | <label>, form labels, field names |
|
||||||
|
* | .text-label-small | <small>, footnotes, tags, badges, captions |
|
||||||
|
* | .text-button | <button>, standard button text |
|
||||||
|
* | .text-button-small | <button>, small/compact button text |
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* body-text-main: Main text style for general body text */
|
||||||
|
.text-body-main {
|
||||||
|
@apply font-inter text-sm font-420;
|
||||||
|
line-height: 21px; /* 150% */
|
||||||
|
letter-spacing: -0.28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* body-text-paragraph: For paragraphs or larger blocks of text */
|
||||||
|
.text-body-para {
|
||||||
|
@apply font-inter text-sm font-420;
|
||||||
|
line-height: 21px; /* 150% */
|
||||||
|
letter-spacing: -0.21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* heading-1: Large heading for pages and panels */
|
||||||
|
.text-heading-1 {
|
||||||
|
@apply font-inter text-lg font-520;
|
||||||
|
line-height: 24px; /* 133.333% */
|
||||||
|
letter-spacing: -0.27px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* heading-2: Secondary heading for sections */
|
||||||
|
.text-heading-2 {
|
||||||
|
@apply font-inter text-base font-medium;
|
||||||
|
line-height: 24px; /* 133.333% */
|
||||||
|
letter-spacing: -0.27px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* heading-3: For card headings, breadcrumbs, subsections */
|
||||||
|
.text-heading-3 {
|
||||||
|
@apply font-inter text-sm font-medium;
|
||||||
|
line-height: 21px; /* 150% */
|
||||||
|
letter-spacing: -0.27px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* label: Standard label text for form fields */
|
||||||
|
.text-label {
|
||||||
|
@apply font-inter text-sm font-medium;
|
||||||
|
line-height: 21px; /* 150% */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* label-small: Smallest font for labels, footnotes, tags */
|
||||||
|
.text-label-small {
|
||||||
|
@apply font-inter text-xs font-440;
|
||||||
|
line-height: 16px; /* 133.333% */
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* button-text: Text for standard size buttons */
|
||||||
|
.text-button {
|
||||||
|
@apply font-inter text-sm font-460;
|
||||||
|
line-height: 21px; /* 150% */
|
||||||
|
letter-spacing: -0.28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* button-text-small: Text for smaller buttons */
|
||||||
|
.text-button-small {
|
||||||
|
@apply font-inter text-xs font-440;
|
||||||
|
line-height: 18px; /* 150% */
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
@mixin label-multiselect-hover {
|
|
||||||
&::after {
|
|
||||||
@apply text-n-brand;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@apply bg-n-slate-3;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
@apply text-n-blue-11;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -49,7 +49,7 @@ const handleFetchUsers = () => {
|
|||||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
<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 justify-between w-full">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</h3>
|
</h3>
|
||||||
<CardPopover
|
<CardPopover
|
||||||
@@ -78,7 +78,7 @@ const handleFetchUsers = () => {
|
|||||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -20,7 +20,7 @@ const handleClick = () => {
|
|||||||
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
|
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
|
||||||
<div class="flex flex-col items-start gap-2">
|
<div class="flex flex-col items-start gap-2">
|
||||||
<div class="flex justify-between w-full items-center">
|
<div class="flex justify-between w-full items-center">
|
||||||
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
|
<h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3>
|
||||||
<Button
|
<Button
|
||||||
xs
|
xs
|
||||||
slate
|
slate
|
||||||
@@ -29,14 +29,14 @@ const handleClick = () => {
|
|||||||
@click.stop="handleClick"
|
@click.stop="handleClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
|
<p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="flex flex-col items-start gap-3 mt-3">
|
<ul class="flex flex-col items-start gap-3 mt-3">
|
||||||
<li
|
<li
|
||||||
v-for="feature in features"
|
v-for="feature in features"
|
||||||
:key="feature.id"
|
:key="feature.id"
|
||||||
class="flex items-center gap-3 text-sm"
|
class="flex items-center gap-3 text-body-para"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
:icon="feature.icon"
|
:icon="feature.icon"
|
||||||
|
|||||||
-3
@@ -39,7 +39,6 @@ const policyA = withCount({
|
|||||||
description: 'Distributes conversations evenly among available agents',
|
description: 'Distributes conversations evenly among available agents',
|
||||||
assignmentOrder: 'round_robin',
|
assignmentOrder: 'round_robin',
|
||||||
conversationPriority: 'high',
|
conversationPriority: 'high',
|
||||||
enabled: true,
|
|
||||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
@@ -50,7 +49,6 @@ const policyB = withCount({
|
|||||||
description: 'Assigns based on capacity and workload',
|
description: 'Assigns based on capacity and workload',
|
||||||
assignmentOrder: 'capacity_based',
|
assignmentOrder: 'capacity_based',
|
||||||
conversationPriority: 'medium',
|
conversationPriority: 'medium',
|
||||||
enabled: true,
|
|
||||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
@@ -61,7 +59,6 @@ const emptyPolicy = withCount({
|
|||||||
description: 'Policy with no assigned inboxes',
|
description: 'Policy with no assigned inboxes',
|
||||||
assignmentOrder: 'manual',
|
assignmentOrder: 'manual',
|
||||||
conversationPriority: 'low',
|
conversationPriority: 'low',
|
||||||
enabled: false,
|
|
||||||
inboxes: [],
|
inboxes: [],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-35
@@ -15,7 +15,6 @@ const props = defineProps({
|
|||||||
assignmentOrder: { type: String, default: '' },
|
assignmentOrder: { type: String, default: '' },
|
||||||
conversationPriority: { type: String, default: '' },
|
conversationPriority: { type: String, default: '' },
|
||||||
assignedInboxCount: { type: Number, default: 0 },
|
assignedInboxCount: { type: Number, default: 0 },
|
||||||
enabled: { type: Boolean, default: false },
|
|
||||||
inboxes: { type: Array, default: () => [] },
|
inboxes: { type: Array, default: () => [] },
|
||||||
isFetchingInboxes: { type: Boolean, default: false },
|
isFetchingInboxes: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
@@ -61,39 +60,19 @@ const handleFetchInboxes = () => {
|
|||||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
<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 justify-between w-full">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-2">
|
<CardPopover
|
||||||
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
|
:title="
|
||||||
<span
|
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER')
|
||||||
class="text-xs"
|
"
|
||||||
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
|
icon="i-lucide-inbox"
|
||||||
>
|
:count="assignedInboxCount"
|
||||||
{{
|
:items="inboxes"
|
||||||
enabled
|
:is-fetching="isFetchingInboxes"
|
||||||
? t(
|
@fetch="handleFetchInboxes"
|
||||||
'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>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -110,18 +89,18 @@ const handleFetchInboxes = () => {
|
|||||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-3 py-1.5">
|
<div class="flex items-center gap-3 py-1.5">
|
||||||
<span v-if="order" class="text-n-slate-11 text-sm">
|
<span v-if="order" class="text-n-slate-11 text-body-para">
|
||||||
{{
|
{{
|
||||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
|
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
|
||||||
}}
|
}}
|
||||||
<span class="text-n-slate-12">{{ order }}</span>
|
<span class="text-n-slate-12">{{ order }}</span>
|
||||||
</span>
|
</span>
|
||||||
<div v-if="order" class="w-px h-3 bg-n-strong" />
|
<div v-if="order" class="w-px h-3 bg-n-strong" />
|
||||||
<span v-if="priority" class="text-n-slate-11 text-sm">
|
<span v-if="priority" class="text-n-slate-11 text-body-para">
|
||||||
{{
|
{{
|
||||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
|
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['delete']);
|
const emit = defineEmits(['delete', 'navigate']);
|
||||||
|
|
||||||
const handleDelete = itemId => {
|
const handleDelete = itemId => {
|
||||||
emit('delete', itemId);
|
emit('delete', itemId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavigate = item => {
|
||||||
|
emit('navigate', item);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -47,7 +51,11 @@ const handleDelete = itemId => {
|
|||||||
:key="item.id"
|
: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"
|
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 col-span-2">
|
<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)"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="item.icon"
|
v-if="item.icon"
|
||||||
:icon="item.icon"
|
:icon="item.icon"
|
||||||
@@ -61,10 +69,16 @@ const handleDelete = itemId => {
|
|||||||
:size="20"
|
:size="20"
|
||||||
rounded-full
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-n-slate-12 truncate min-w-0">
|
<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"
|
||||||
|
>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<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 class="flex items-start gap-2 col-span-1">
|
<div class="flex items-start gap-2 col-span-1">
|
||||||
<span
|
<span
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
import { computed, ref, onMounted } from 'vue';
|
import { computed, ref, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
|
||||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||||
|
|
||||||
|
|||||||
+18
-4
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||||
@@ -15,6 +15,9 @@ 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', {
|
const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 3600,
|
default: 3600,
|
||||||
@@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
|||||||
|
|
||||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
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 detectUnit = minutes => {
|
||||||
const m = Number(minutes) || 0;
|
const m = Number(minutes) || 0;
|
||||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||||
@@ -34,7 +48,7 @@ const detectUnit = minutes => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
windowUnit.value = detectUnit(fairDistributionWindow.value);
|
windowUnit.value = detectUnit(windowInMinutes.value);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -73,9 +87,9 @@ onMounted(() => {
|
|||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
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 -->
|
<!-- allow 10 mins to 999 days (in minutes) -->
|
||||||
<DurationInput
|
<DurationInput
|
||||||
v-model:model-value="fairDistributionWindow"
|
v-model:model-value="windowInMinutes"
|
||||||
v-model:unit="windowUnit"
|
v-model:unit="windowUnit"
|
||||||
:min="10"
|
:min="10"
|
||||||
:max="1438560"
|
:max="1438560"
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isActive: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['select']);
|
|
||||||
|
|
||||||
const handleChange = () => {
|
|
||||||
if (!props.isActive) {
|
|
||||||
emit('select', props.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
|
||||||
:class="[
|
|
||||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
|
|
||||||
]"
|
|
||||||
@click="handleChange"
|
|
||||||
>
|
|
||||||
<div class="absolute top-4 right-4">
|
|
||||||
<input
|
|
||||||
:id="`${id}`"
|
|
||||||
:checked="isActive"
|
|
||||||
:value="id"
|
|
||||||
:name="id"
|
|
||||||
type="radio"
|
|
||||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
|
||||||
@change="handleChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="flex flex-col gap-3 items-start">
|
|
||||||
<h3 class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ label }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-n-slate-11">
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
-4
@@ -6,7 +6,6 @@ const policyName = ref('Round Robin Policy');
|
|||||||
const description = ref(
|
const description = ref(
|
||||||
'Distributes conversations evenly among available agents'
|
'Distributes conversations evenly among available agents'
|
||||||
);
|
);
|
||||||
const enabled = ref(true);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -19,13 +18,10 @@ const enabled = ref(true);
|
|||||||
<BaseInfo
|
<BaseInfo
|
||||||
v-model:policy-name="policyName"
|
v-model:policy-name="policyName"
|
||||||
v-model:description="description"
|
v-model:description="description"
|
||||||
v-model:enabled="enabled"
|
|
||||||
name-label="Policy Name"
|
name-label="Policy Name"
|
||||||
name-placeholder="Enter policy name"
|
name-placeholder="Enter policy name"
|
||||||
description-label="Description"
|
description-label="Description"
|
||||||
description-placeholder="Enter policy description"
|
description-placeholder="Enter policy description"
|
||||||
status-label="Status"
|
|
||||||
status-placeholder="Active"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ const handleButtonClick = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
<header class="sticky top-0 z-10 px-6">
|
||||||
<div class="w-full max-w-[60rem] mx-auto">
|
<div class="w-full max-w-5xl mx-auto">
|
||||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||||
<span class="text-xl font-medium text-n-slate-12">
|
<span class="text-heading-1 text-n-slate-12">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@@ -43,8 +43,8 @@ const handleButtonClick = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
<main class="flex-1 px-6 overflow-y-auto">
|
||||||
<div class="w-full max-w-[60rem] mx-auto py-4">
|
<div class="w-full max-w-5xl mx-auto py-4">
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const handleClick = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
|
class="flex flex-col w-full outline-1 outline outline-n-container -outline-offset-1 group/cardLayout rounded-xl bg-n-solid-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex w-full gap-3 py-5"
|
class="flex w-full gap-3 py-5"
|
||||||
|
|||||||
+3
-3
@@ -15,11 +15,11 @@ const emit = defineEmits(['search', 'update:sort']);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="sticky top-0 z-10">
|
<header class="sticky top-0 z-10 px-6">
|
||||||
<div
|
<div
|
||||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||||
>
|
>
|
||||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
<span class="text-heading-1 truncate text-n-slate-12">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center flex-row flex-shrink-0 gap-2">
|
<div class="flex items-center flex-row flex-shrink-0 gap-2">
|
||||||
|
|||||||
@@ -32,17 +32,18 @@ const updateCurrentPage = page => {
|
|||||||
@search="emit('search', $event)"
|
@search="emit('search', $event)"
|
||||||
@update:sort="emit('update:sort', $event)"
|
@update:sort="emit('update:sort', $event)"
|
||||||
/>
|
/>
|
||||||
<main class="flex-1 overflow-y-auto">
|
<main class="flex-1 px-6 overflow-y-auto">
|
||||||
<div class="w-full mx-auto max-w-[60rem]">
|
<div class="w-full mx-auto max-w-5xl py-4">
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:items-per-page="25"
|
:items-per-page="25"
|
||||||
|
class="max-w-[67rem]"
|
||||||
@update:current-page="updateCurrentPage"
|
@update:current-page="updateCurrentPage"
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { computed, watch, onMounted, ref } from 'vue';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
|
|
||||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
|
||||||
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
|
import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
contactId: {
|
contactId: {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
|
|||||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||||
INSTAGRAM: 'i-ri-instagram-line',
|
INSTAGRAM: 'i-ri-instagram-line',
|
||||||
|
TELEGRAM: 'i-ri-telegram-fill',
|
||||||
TIKTOK: 'i-ri-tiktok-fill',
|
TIKTOK: 'i-ri-tiktok-fill',
|
||||||
TWITTER: 'i-ri-twitter-x-fill',
|
TWITTER: 'i-ri-twitter-x-fill',
|
||||||
GITHUB: 'i-ri-github-fill',
|
GITHUB: 'i-ri-github-fill',
|
||||||
@@ -66,6 +67,7 @@ const defaultState = {
|
|||||||
facebook: '',
|
facebook: '',
|
||||||
github: '',
|
github: '',
|
||||||
instagram: '',
|
instagram: '',
|
||||||
|
telegram: '',
|
||||||
tiktok: '',
|
tiktok: '',
|
||||||
linkedin: '',
|
linkedin: '',
|
||||||
twitter: '',
|
twitter: '',
|
||||||
@@ -103,9 +105,13 @@ const prepareStateBasedOnProps = () => {
|
|||||||
countryCode = '',
|
countryCode = '',
|
||||||
country = '',
|
country = '',
|
||||||
city = '',
|
city = '',
|
||||||
|
socialTelegramUserName = '',
|
||||||
socialProfiles = {},
|
socialProfiles = {},
|
||||||
} = additionalAttributes || {};
|
} = additionalAttributes || {};
|
||||||
|
|
||||||
|
const telegramUsername =
|
||||||
|
socialProfiles?.telegram || socialTelegramUserName || '';
|
||||||
|
|
||||||
Object.assign(state, {
|
Object.assign(state, {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -119,7 +125,10 @@ const prepareStateBasedOnProps = () => {
|
|||||||
countryCode,
|
countryCode,
|
||||||
country,
|
country,
|
||||||
city,
|
city,
|
||||||
socialProfiles,
|
socialProfiles: {
|
||||||
|
...socialProfiles,
|
||||||
|
telegram: telegramUsername,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ const emit = defineEmits([
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="sticky top-0 z-10">
|
<header class="sticky top-0 z-10 px-6">
|
||||||
<div
|
<div
|
||||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||||
>
|
>
|
||||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
|
|||||||
+1
-2
@@ -68,8 +68,7 @@ const hasActiveSegments = computed(
|
|||||||
);
|
);
|
||||||
const activeSegmentName = computed(() => props.activeSegment?.name);
|
const activeSegmentName = computed(() => props.activeSegment?.name);
|
||||||
|
|
||||||
const openCreateNewContactDialog = async () => {
|
const openCreateNewContactDialog = () => {
|
||||||
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
|
|
||||||
createNewContactDialogRef.value?.dialogRef.open();
|
createNewContactDialogRef.value?.dialogRef.open();
|
||||||
};
|
};
|
||||||
const openContactImportDialog = () =>
|
const openContactImportDialog = () =>
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
|
|||||||
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
|
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
|
||||||
"
|
"
|
||||||
:show-clear-button="!hasActiveSegments"
|
:show-clear-button="!hasActiveSegments"
|
||||||
class="max-w-[60rem] px-6"
|
class="max-w-5xl"
|
||||||
@open-filter="emit('openFilter')"
|
@open-filter="emit('openFilter')"
|
||||||
@clear-filters="emit('clearFilters')"
|
@clear-filters="emit('clearFilters')"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ const showPagination = computed(() => {
|
|||||||
@apply-filter="emit('applyFilter', $event)"
|
@apply-filter="emit('applyFilter', $event)"
|
||||||
@clear-filters="emit('clearFilters')"
|
@clear-filters="emit('clearFilters')"
|
||||||
/>
|
/>
|
||||||
<main class="flex-1 overflow-y-auto">
|
<main class="flex-1 overflow-y-auto px-6">
|
||||||
<div class="w-full mx-auto max-w-[60rem]">
|
<div class="w-full mx-auto max-w-5xl">
|
||||||
<ContactsActiveFiltersPreview
|
<ContactsActiveFiltersPreview
|
||||||
v-if="showActiveFiltersPreview"
|
v-if="showActiveFiltersPreview"
|
||||||
:active-segment="activeSegment"
|
:active-segment="activeSegment"
|
||||||
@@ -114,11 +114,12 @@ const showPagination = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
|
<footer v-if="showPagination" class="sticky bottom-0 z-0">
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
|
class="max-w-[67rem]"
|
||||||
:items-per-page="itemsPerPage"
|
:items-per-page="itemsPerPage"
|
||||||
@update:current-page="updateCurrentPage"
|
@update:current-page="updateCurrentPage"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+49
-42
@@ -1,8 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import Label from 'dashboard/components-next/label/Label.vue';
|
||||||
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
attribute: {
|
attribute: {
|
||||||
@@ -18,7 +20,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['edit', 'delete']);
|
const emit = defineEmits(['edit', 'delete']);
|
||||||
|
|
||||||
const iconByType = {
|
const iconByType = {
|
||||||
text: 'i-lucide-align-justify',
|
text: 'i-lucide-menu',
|
||||||
checkbox: 'i-lucide-circle-check-big',
|
checkbox: 'i-lucide-circle-check-big',
|
||||||
list: 'i-lucide-list',
|
list: 'i-lucide-list',
|
||||||
date: 'i-lucide-calendar',
|
date: 'i-lucide-calendar',
|
||||||
@@ -28,61 +30,66 @@ const iconByType = {
|
|||||||
|
|
||||||
const attributeIcon = computed(() => {
|
const attributeIcon = computed(() => {
|
||||||
const typeKey = props.attribute.type?.toLowerCase();
|
const typeKey = props.attribute.type?.toLowerCase();
|
||||||
return iconByType[typeKey] || 'i-lucide-align-justify';
|
return iconByType[typeKey] || 'i-lucide-menu';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="flex flex-col py-4 min-w-0">
|
||||||
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
|
<div class="flex justify-between flex-row items-center gap-4 min-w-0">
|
||||||
>
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
<div class="flex flex-wrap gap-2 justify-between items-center">
|
<div
|
||||||
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
|
||||||
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
>
|
||||||
{{ attribute.label }}
|
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
|
||||||
</h4>
|
</div>
|
||||||
<div class="w-px h-3 bg-n-strong" />
|
<div class="flex flex-col gap-1.5 items-start min-w-0 overflow-hidden">
|
||||||
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
<h4 class="text-heading-3 truncate text-n-slate-12 min-w-0">
|
||||||
<Icon :icon="attributeIcon" class="size-4" />
|
{{ attribute.label }}
|
||||||
<span class="text-sm">{{ attribute.type }}</span>
|
</h4>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Label :label="attribute.type" compact />
|
||||||
|
<AttributeBadge
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.type"
|
||||||
|
:type="badge.type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-px h-3 bg-n-weak" />
|
<div class="grid grid-cols-[auto_1fr] items-center gap-1.5">
|
||||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
<Icon icon="i-lucide-key-round" class="size-3.5 text-n-slate-11" />
|
||||||
<Icon icon="i-lucide-key-round" class="size-4" />
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
<span class="text-body-main text-n-slate-11 truncate">
|
||||||
|
{{ attribute.value }}
|
||||||
|
</span>
|
||||||
|
<template
|
||||||
|
v-if="attribute.attribute_description || attribute.description"
|
||||||
|
>
|
||||||
|
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
|
||||||
|
<span class="text-body-main text-n-slate-11 truncate">
|
||||||
|
{{ attribute.attribute_description || attribute.description }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-3 justify-end flex-shrink-0">
|
||||||
<AttributeBadge
|
|
||||||
v-for="badge in badges"
|
|
||||||
:key="badge.type"
|
|
||||||
:type="badge.type"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="badges.length > 0"
|
|
||||||
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-pencil-line"
|
icon="i-woot-edit-pen"
|
||||||
size="sm"
|
slate
|
||||||
color="slate"
|
sm
|
||||||
ghost
|
|
||||||
@click="emit('edit', attribute)"
|
@click="emit('edit', attribute)"
|
||||||
/>
|
/>
|
||||||
<div class="w-px h-3 bg-n-strong" />
|
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-trash"
|
icon="i-woot-bin"
|
||||||
size="sm"
|
slate
|
||||||
color="slate"
|
sm
|
||||||
ghost
|
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||||
@click="emit('delete', attribute)"
|
@click="emit('delete', attribute)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-0 text-sm text-n-slate-11">
|
|
||||||
{{ attribute.attribute_description || attribute.description || '' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+5
-3
@@ -35,18 +35,20 @@ const handleDelete = () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex justify-between items-center px-4 py-3 w-full">
|
<div class="flex justify-between items-center px-4 py-3 w-full">
|
||||||
<div class="flex gap-3 items-center">
|
<div class="flex gap-3 items-center">
|
||||||
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
<h5 class="text-heading-3 text-n-slate-12 line-clamp-1">
|
||||||
{{ attribute.label }}
|
{{ attribute.label }}
|
||||||
</h5>
|
</h5>
|
||||||
<div class="w-px h-2.5 bg-n-slate-5" />
|
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||||
<div class="flex gap-1.5 items-center">
|
<div class="flex gap-1.5 items-center">
|
||||||
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
|
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
|
||||||
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
|
<span class="text-body-para text-n-slate-11">{{ attribute.type }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-px h-2.5 bg-n-slate-5" />
|
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||||
<div class="flex gap-1.5 items-center">
|
<div class="flex gap-1.5 items-center">
|
||||||
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
|
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
|
||||||
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
|
<span class="text-body-para text-n-slate-11">{{
|
||||||
|
attribute.value
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
|
|||||||
+2
-2
@@ -129,10 +129,10 @@ const handleDelete = attribute => {
|
|||||||
<div class="flex flex-col gap-2 items-start px-5 py-4">
|
<div class="flex flex-col gap-2 items-start px-5 py-4">
|
||||||
<div class="flex justify-between items-center w-full">
|
<div class="flex justify-between items-center w-full">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h3 class="text-base font-medium text-n-slate-12">
|
<h3 class="text-heading-2 text-n-slate-12">
|
||||||
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
|
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mb-0 text-sm text-n-slate-11">
|
<p class="mb-0 text-body-para text-n-slate-11">
|
||||||
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
|
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import Label from 'dashboard/components-next/label/Label.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
type: {
|
type: {
|
||||||
@@ -18,11 +19,13 @@ const attributeConfig = {
|
|||||||
colorClass: 'text-n-blue-11',
|
colorClass: 'text-n-blue-11',
|
||||||
icon: 'i-lucide-message-circle',
|
icon: 'i-lucide-message-circle',
|
||||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
||||||
|
color: 'slate',
|
||||||
},
|
},
|
||||||
resolution: {
|
resolution: {
|
||||||
colorClass: 'text-n-teal-11',
|
colorClass: 'text-n-teal-11',
|
||||||
icon: 'i-lucide-circle-check-big',
|
icon: 'i-lucide-circle-check-big',
|
||||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
||||||
|
color: 'slate',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const config = computed(
|
const config = computed(
|
||||||
@@ -31,12 +34,9 @@ const config = computed(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<Label :label="t(config.labelKey)" :color="config.color" compact>
|
||||||
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
<template #icon>
|
||||||
>
|
<Icon :icon="config.icon" class="size-3.5 text-n-slate-12" />
|
||||||
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
</template>
|
||||||
<span class="text-xs" :class="config.colorClass">{{
|
</Label>
|
||||||
t(config.labelKey)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ defineProps({
|
|||||||
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
|
class="relative w-full max-w-5xl mx-auto overflow-hidden h-full max-h-[28rem]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="showBackdrop"
|
v-if="showBackdrop"
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ const togglePortalSwitcher = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||||
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
<header class="sticky top-0 z-10 px-6 pb-3">
|
||||||
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
|
<div class="w-full max-w-5xl mx-auto">
|
||||||
<div
|
<div
|
||||||
v-if="showHeaderTitle"
|
v-if="showHeaderTitle"
|
||||||
class="flex items-center justify-start h-20 gap-2"
|
class="flex items-center justify-start h-20 gap-2"
|
||||||
@@ -95,16 +95,17 @@ const togglePortalSwitcher = () => {
|
|||||||
<slot name="header-actions" />
|
<slot name="header-actions" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
<main class="flex-1 px-6 overflow-y-auto">
|
||||||
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
|
<div class="w-full max-w-5xl mx-auto py-3">
|
||||||
<slot name="content" />
|
<slot name="content" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:items-per-page="itemsPerPage"
|
:items-per-page="itemsPerPage"
|
||||||
|
class="max-w-[67rem]"
|
||||||
@update:current-page="updateCurrentPage"
|
@update:current-page="updateCurrentPage"
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
+1
@@ -62,6 +62,7 @@ const onCreate = async () => {
|
|||||||
from: route.name,
|
from: route.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
selectedLocale.value = '';
|
||||||
dialogRef.value?.close();
|
dialogRef.value?.close();
|
||||||
useAlert(
|
useAlert(
|
||||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
|
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useWindowSize } from '@vueuse/core';
|
import { useWindowSize } from '@vueuse/core';
|
||||||
@@ -59,6 +59,23 @@ const isFetchingInboxes = ref(false);
|
|||||||
const isSearching = ref(false);
|
const isSearching = ref(false);
|
||||||
const showComposeNewConversation = ref(false);
|
const showComposeNewConversation = ref(false);
|
||||||
|
|
||||||
|
const formState = reactive({
|
||||||
|
message: '',
|
||||||
|
subject: '',
|
||||||
|
ccEmails: '',
|
||||||
|
bccEmails: '',
|
||||||
|
attachedFiles: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearFormState = () => {
|
||||||
|
Object.assign(formState, {
|
||||||
|
subject: '',
|
||||||
|
ccEmails: '',
|
||||||
|
bccEmails: '',
|
||||||
|
attachedFiles: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const contactById = useMapGetter('contacts/getContactById');
|
const contactById = useMapGetter('contacts/getContactById');
|
||||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
@@ -140,12 +157,14 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
|||||||
|
|
||||||
const handleTargetInbox = inbox => {
|
const handleTargetInbox = inbox => {
|
||||||
targetInbox.value = inbox;
|
targetInbox.value = inbox;
|
||||||
|
if (!inbox) clearFormState();
|
||||||
resetContacts();
|
resetContacts();
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
selectedContact.value = null;
|
selectedContact.value = null;
|
||||||
targetInbox.value = null;
|
targetInbox.value = null;
|
||||||
|
clearFormState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeCompose = () => {
|
const closeCompose = () => {
|
||||||
@@ -160,6 +179,12 @@ const closeCompose = () => {
|
|||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const discardCompose = () => {
|
||||||
|
clearFormState();
|
||||||
|
formState.message = '';
|
||||||
|
closeCompose();
|
||||||
|
};
|
||||||
|
|
||||||
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||||
try {
|
try {
|
||||||
const data = await store.dispatch('contactConversations/create', {
|
const data = await store.dispatch('contactConversations/create', {
|
||||||
@@ -171,7 +196,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
|
|||||||
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
|
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
|
||||||
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
|
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
|
||||||
};
|
};
|
||||||
closeCompose();
|
discardCompose();
|
||||||
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
|
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
|
||||||
return true; // Return success
|
return true; // Return success
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -193,7 +218,11 @@ watch(
|
|||||||
(currentContact, previousContact) => {
|
(currentContact, previousContact) => {
|
||||||
if (currentContact && props.contactId) {
|
if (currentContact && props.contactId) {
|
||||||
// Reset on contact change
|
// Reset on contact change
|
||||||
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
|
if (currentContact?.id !== previousContact?.id) {
|
||||||
|
clearSelectedContact();
|
||||||
|
clearFormState();
|
||||||
|
formState.message = '';
|
||||||
|
}
|
||||||
|
|
||||||
// First process the contactable inboxes to get the right structure
|
// First process the contactable inboxes to get the right structure
|
||||||
const processedInboxes = processContactableInboxes(
|
const processedInboxes = processContactableInboxes(
|
||||||
@@ -265,6 +294,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
@click.self="onModalBackdropClick"
|
@click.self="onModalBackdropClick"
|
||||||
>
|
>
|
||||||
<ComposeNewConversationForm
|
<ComposeNewConversationForm
|
||||||
|
:form-state="formState"
|
||||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
:contact-id="contactId"
|
:contact-id="contactId"
|
||||||
@@ -285,7 +315,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
@update-target-inbox="handleTargetInbox"
|
@update-target-inbox="handleTargetInbox"
|
||||||
@clear-selected-contact="clearSelectedContact"
|
@clear-selected-contact="clearSelectedContact"
|
||||||
@create-conversation="createConversation"
|
@create-conversation="createConversation"
|
||||||
@discard="closeCompose"
|
@discard="discardCompose"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
-20
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, requiredIf } from '@vuelidate/validators';
|
import { required, requiredIf } from '@vuelidate/validators';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
@@ -37,6 +37,7 @@ const props = defineProps({
|
|||||||
contactsUiFlags: { type: Object, default: null },
|
contactsUiFlags: { type: Object, default: null },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
|
formState: { type: Object, required: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -57,13 +58,13 @@ const showBccEmailsDropdown = ref(false);
|
|||||||
|
|
||||||
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
|
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
|
||||||
|
|
||||||
const state = reactive({
|
const state = props.formState || {
|
||||||
message: '',
|
message: '',
|
||||||
subject: '',
|
subject: '',
|
||||||
ccEmails: '',
|
ccEmails: '',
|
||||||
bccEmails: '',
|
bccEmails: '',
|
||||||
attachedFiles: [],
|
attachedFiles: [],
|
||||||
});
|
};
|
||||||
|
|
||||||
const inboxTypes = computed(() => ({
|
const inboxTypes = computed(() => ({
|
||||||
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
||||||
@@ -157,21 +158,7 @@ const isAnyDropdownActive = computed(() => {
|
|||||||
|
|
||||||
const handleContactSearch = value => {
|
const handleContactSearch = value => {
|
||||||
showContactsDropdown.value = true;
|
showContactsDropdown.value = true;
|
||||||
const query = typeof value === 'string' ? value.trim() : '';
|
emit('searchContacts', value);
|
||||||
const hasAlphabet = Array.from(query).some(char => {
|
|
||||||
const lower = char.toLowerCase();
|
|
||||||
const upper = char.toUpperCase();
|
|
||||||
return lower !== upper;
|
|
||||||
});
|
|
||||||
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
|
|
||||||
|
|
||||||
const keys = ['email', 'phone_number', 'name'].filter(key => {
|
|
||||||
if (key === 'phone_number' && hasAlphabet) return false;
|
|
||||||
if (key === 'name' && isEmailLike) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
emit('searchContacts', { keys, query: value });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDropdownUpdate = (type, value) => {
|
const handleDropdownUpdate = (type, value) => {
|
||||||
@@ -186,12 +173,12 @@ const handleDropdownUpdate = (type, value) => {
|
|||||||
|
|
||||||
const searchCcEmails = value => {
|
const searchCcEmails = value => {
|
||||||
showCcEmailsDropdown.value = true;
|
showCcEmailsDropdown.value = true;
|
||||||
emit('searchContacts', { keys: ['email'], query: value });
|
emit('searchContacts', value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchBccEmails = value => {
|
const searchBccEmails = value => {
|
||||||
showBccEmailsDropdown.value = true;
|
showBccEmailsDropdown.value = true;
|
||||||
emit('searchContacts', { keys: ['email'], query: value });
|
emit('searchContacts', value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelectedContact = async ({ value, action, ...rest }) => {
|
const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||||
|
|||||||
+12
-8
@@ -44,14 +44,16 @@ const bccEmailsArray = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const contactEmailsList = computed(() => {
|
const contactEmailsList = computed(() => {
|
||||||
return props.contacts?.map(({ name, id, email }) => ({
|
return props.contacts
|
||||||
id,
|
?.filter(contact => contact.email)
|
||||||
label: email,
|
.map(({ name, id, email }) => ({
|
||||||
email,
|
id,
|
||||||
thumbnail: { name: name, src: '' },
|
label: email,
|
||||||
value: id,
|
email,
|
||||||
action: 'email',
|
thumbnail: { name: name, src: '' },
|
||||||
}));
|
value: id,
|
||||||
|
action: 'email',
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle updates from TagInput and convert array back to string
|
// Handle updates from TagInput and convert array back to string
|
||||||
@@ -95,6 +97,7 @@ const inputClass = computed(() => {
|
|||||||
:show-dropdown="showCcEmailsDropdown"
|
:show-dropdown="showCcEmailsDropdown"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
type="email"
|
type="email"
|
||||||
|
allow-create
|
||||||
class="flex-1 min-h-7"
|
class="flex-1 min-h-7"
|
||||||
@focus="emit('updateDropdown', 'cc', true)"
|
@focus="emit('updateDropdown', 'cc', true)"
|
||||||
@input="emit('searchCcEmails', $event)"
|
@input="emit('searchCcEmails', $event)"
|
||||||
@@ -127,6 +130,7 @@ const inputClass = computed(() => {
|
|||||||
:show-dropdown="showBccEmailsDropdown"
|
:show-dropdown="showBccEmailsDropdown"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
type="email"
|
type="email"
|
||||||
|
allow-create
|
||||||
class="flex-1 min-h-7"
|
class="flex-1 min-h-7"
|
||||||
focus-on-mount
|
focus-on-mount
|
||||||
@focus="emit('updateDropdown', 'bcc', true)"
|
@focus="emit('updateDropdown', 'bcc', true)"
|
||||||
|
|||||||
+5
-23
@@ -176,32 +176,14 @@ export const prepareWhatsAppMessagePayload = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateContactQuery = ({ keys = ['email'], query }) => {
|
|
||||||
return {
|
|
||||||
payload: keys.map(key => {
|
|
||||||
const filterPayload = {
|
|
||||||
attribute_key: key,
|
|
||||||
filter_operator: 'contains',
|
|
||||||
values: [query],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
};
|
|
||||||
if (keys.findIndex(k => k === key) !== keys.length - 1) {
|
|
||||||
filterPayload.query_operator = 'or';
|
|
||||||
}
|
|
||||||
return filterPayload;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// API Calls
|
// API Calls
|
||||||
export const searchContacts = async ({ keys, query }) => {
|
export const searchContacts = async query => {
|
||||||
|
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { payload },
|
data: { payload },
|
||||||
} = await ContactAPI.filter(
|
} = await ContactAPI.search(trimmed);
|
||||||
undefined,
|
|
||||||
'name',
|
|
||||||
generateContactQuery({ keys, query })
|
|
||||||
);
|
|
||||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||||
// Filter contacts that have either phone_number or email
|
// Filter contacts that have either phone_number or email
|
||||||
const filteredPayload = camelCasedPayload?.filter(
|
const filteredPayload = camelCasedPayload?.filter(
|
||||||
|
|||||||
+9
-97
@@ -70,7 +70,7 @@ describe('composeConversationHelper', () => {
|
|||||||
const result = helpers.buildContactableInboxesList(inboxes);
|
const result = helpers.buildContactableInboxesList(inboxes);
|
||||||
expect(result[0]).toMatchObject({
|
expect(result[0]).toMatchObject({
|
||||||
id: 1,
|
id: 1,
|
||||||
icon: 'i-ri-mail-line',
|
icon: 'i-woot-mail',
|
||||||
label: 'Email Inbox (support@example.com)',
|
label: 'Email Inbox (support@example.com)',
|
||||||
action: 'inbox',
|
action: 'inbox',
|
||||||
value: 1,
|
value: 1,
|
||||||
@@ -336,70 +336,6 @@ describe('composeConversationHelper', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateContactQuery', () => {
|
|
||||||
it('generates correct query structure for contact search', () => {
|
|
||||||
const query = 'test@example.com';
|
|
||||||
const expected = {
|
|
||||||
payload: [
|
|
||||||
{
|
|
||||||
attribute_key: 'email',
|
|
||||||
filter_operator: 'contains',
|
|
||||||
values: [query],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
|
|
||||||
expected
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty query', () => {
|
|
||||||
const expected = {
|
|
||||||
payload: [
|
|
||||||
{
|
|
||||||
attribute_key: 'email',
|
|
||||||
filter_operator: 'contains',
|
|
||||||
values: [''],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(
|
|
||||||
helpers.generateContactQuery({ keys: ['email'], query: '' })
|
|
||||||
).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles mutliple keys', () => {
|
|
||||||
const expected = {
|
|
||||||
payload: [
|
|
||||||
{
|
|
||||||
attribute_key: 'email',
|
|
||||||
filter_operator: 'contains',
|
|
||||||
values: ['john'],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
query_operator: 'or',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_key: 'phone_number',
|
|
||||||
filter_operator: 'contains',
|
|
||||||
values: ['john'],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(
|
|
||||||
helpers.generateContactQuery({
|
|
||||||
keys: ['email', 'phone_number'],
|
|
||||||
query: 'john',
|
|
||||||
})
|
|
||||||
).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('API calls', () => {
|
||||||
describe('searchContacts', () => {
|
describe('searchContacts', () => {
|
||||||
it('searches contacts and returns camelCase results', async () => {
|
it('searches contacts and returns camelCase results', async () => {
|
||||||
@@ -413,14 +349,11 @@ describe('composeConversationHelper', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
ContactAPI.filter.mockResolvedValue({
|
ContactAPI.search.mockResolvedValue({
|
||||||
data: { payload: mockPayload },
|
data: { payload: mockPayload },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await helpers.searchContacts({
|
const result = await helpers.searchContacts('john');
|
||||||
keys: ['email'],
|
|
||||||
query: 'john',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
@@ -432,16 +365,7 @@ describe('composeConversationHelper', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||||
payload: [
|
|
||||||
{
|
|
||||||
attribute_key: 'email',
|
|
||||||
filter_operator: 'contains',
|
|
||||||
values: ['john'],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('searches contacts and returns only contacts with email or phone number', async () => {
|
it('searches contacts and returns only contacts with email or phone number', async () => {
|
||||||
@@ -469,14 +393,11 @@ describe('composeConversationHelper', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
ContactAPI.filter.mockResolvedValue({
|
ContactAPI.search.mockResolvedValue({
|
||||||
data: { payload: mockPayload },
|
data: { payload: mockPayload },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await helpers.searchContacts({
|
const result = await helpers.searchContacts('john');
|
||||||
keys: ['email'],
|
|
||||||
query: 'john',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should only return contacts with either email or phone number
|
// Should only return contacts with either email or phone number
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
@@ -496,20 +417,11 @@ describe('composeConversationHelper', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||||
payload: [
|
|
||||||
{
|
|
||||||
attribute_key: 'email',
|
|
||||||
filter_operator: 'contains',
|
|
||||||
values: ['john'],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty search results', async () => {
|
it('handles empty search results', async () => {
|
||||||
ContactAPI.filter.mockResolvedValue({
|
ContactAPI.search.mockResolvedValue({
|
||||||
data: { payload: [] },
|
data: { payload: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -536,7 +448,7 @@ describe('composeConversationHelper', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
ContactAPI.filter.mockResolvedValue({
|
ContactAPI.search.mockResolvedValue({
|
||||||
data: { payload: mockPayload },
|
data: { payload: mockPayload },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
defaultOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isOpen, toggle] = useToggle(props.defaultOpen);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-bind="$attrs"
|
||||||
|
class="flex items-center justify-between w-full cursor-pointer pb-2 pt-4 border-t border-n-weak"
|
||||||
|
@click="toggle()"
|
||||||
|
>
|
||||||
|
<span class="text-heading-2 text-n-slate-12 w-full">
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="isOpen" class="w-full space-y-4 pt-4 mb-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
helpText: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full py-2 mb-2 [interpolate-size:allow-keywords]">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 lg:grid-cols-8 gap-1.5 lg:gap-4 items-start lg:items-center"
|
||||||
|
>
|
||||||
|
<label class="text-heading-3 text-n-slate-12 col-span-1 lg:col-span-2">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<div class="col-span-1 lg:col-span-6">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="helpText" class="grid grid-cols-1 lg:grid-cols-8">
|
||||||
|
<div class="col-span-1 lg:col-span-2 invisible" />
|
||||||
|
<p
|
||||||
|
v-if="helpText"
|
||||||
|
class="mt-1.5 col-span-1 lg:col-span-6 text-label-small text-n-slate-11 ltr:ml-1 rtl:mr-1"
|
||||||
|
>
|
||||||
|
{{ helpText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<slot name="extra" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup>
|
||||||
|
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
header: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelValue = defineModel({ type: Boolean, default: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-start outline outline-1 -outline-offset-1 outline-n-weak rounded-xl [interpolate-size:allow-keywords]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1 items-start w-full px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3 w-full justify-between">
|
||||||
|
<span class="text-heading-3 text-n-slate-12">
|
||||||
|
{{ header }}
|
||||||
|
</span>
|
||||||
|
<ToggleSwitch v-model="modelValue" />
|
||||||
|
</div>
|
||||||
|
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||||
|
{{ description }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="$slots.editor"
|
||||||
|
class="w-full border-t border-n-weak"
|
||||||
|
:class="{ 'p-0': compact, 'px-4 pb-4 pt-2': !compact }"
|
||||||
|
>
|
||||||
|
<slot name="editor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -112,6 +112,19 @@ const containerStyles = computed(() => ({
|
|||||||
height: `${props.size}px`,
|
height: `${props.size}px`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const borderRadiusClass = computed(() => {
|
||||||
|
if (props.roundedFull) {
|
||||||
|
return 'rounded-full';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximates 25% of size
|
||||||
|
if (props.size <= 16) return 'rounded'; // 4px
|
||||||
|
if (props.size <= 24) return 'rounded-md'; // 6px
|
||||||
|
if (props.size <= 32) return 'rounded-lg'; // 8px
|
||||||
|
if (props.size <= 48) return 'rounded-xl'; // 12px
|
||||||
|
return 'rounded-2xl'; // 16px
|
||||||
|
});
|
||||||
|
|
||||||
const avatarStyles = computed(() => ({
|
const avatarStyles = computed(() => ({
|
||||||
...containerStyles.value,
|
...containerStyles.value,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
@@ -184,7 +197,7 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
class="relative inline-flex group/avatar z-0 flex-shrink-0"
|
class="relative inline-flex group/avatar z-0 flex-shrink-0 align-middle"
|
||||||
:style="containerStyles"
|
:style="containerStyles"
|
||||||
>
|
>
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
@@ -216,9 +229,9 @@ watch(
|
|||||||
<!-- Avatar Container -->
|
<!-- Avatar Container -->
|
||||||
<span
|
<span
|
||||||
role="img"
|
role="img"
|
||||||
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
|
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium outline outline-1 -outline-offset-1 outline-[rgb(0_0_0_/_0.03)] dark:outline-[rgb(255_255_255_/_0.04)]"
|
||||||
:class="[
|
:class="[
|
||||||
roundedFull ? 'rounded-full' : 'rounded-xl',
|
borderRadiusClass,
|
||||||
{
|
{
|
||||||
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
|
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
|
||||||
!showDefaultAvatar && (!src || !isImageValid),
|
!showDefaultAvatar && (!src || !isImageValid),
|
||||||
@@ -267,7 +280,8 @@ watch(
|
|||||||
:handle-image-upload="handleImageUpload"
|
:handle-image-upload="handleImageUpload"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||||
|
:class="borderRadiusClass"
|
||||||
@click="handleUploadAvatar"
|
@click="handleUploadAvatar"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ const handleCreateAssistant = () => {
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
<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">
|
||||||
<div class="w-full max-w-[60rem] mx-auto">
|
<div class="w-full max-w-5xl mx-auto">
|
||||||
<div
|
<div
|
||||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||||
>
|
>
|
||||||
@@ -140,7 +140,9 @@ const handleCreateAssistant = () => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-chevron-down"
|
icon="i-lucide-chevron-down"
|
||||||
variant="ghost"
|
:variant="
|
||||||
|
showAssistantSwitcherDropdown ? 'faded' : 'ghost'
|
||||||
|
"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="xs"
|
size="xs"
|
||||||
:disabled="isFetchingAssistants"
|
:disabled="isFetchingAssistants"
|
||||||
@@ -204,7 +206,7 @@ const handleCreateAssistant = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto">
|
<main class="flex-1 px-6 overflow-y-auto">
|
||||||
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
|
<div class="w-full max-w-5xl h-full mx-auto py-4">
|
||||||
<slot v-if="!showPaywall" name="controls" />
|
<slot v-if="!showPaywall" name="controls" />
|
||||||
<div
|
<div
|
||||||
v-if="isFetching"
|
v-if="isFetching"
|
||||||
@@ -222,11 +224,12 @@ const handleCreateAssistant = () => {
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:total-items="totalCount"
|
:total-items="totalCount"
|
||||||
:items-per-page="itemsPerPage"
|
:items-per-page="itemsPerPage"
|
||||||
|
class="max-w-[67rem]"
|
||||||
@update:current-page="handlePageChange"
|
@update:current-page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const openBilling = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
|
class="w-full max-w-5xl mx-auto h-full max-h-[448px] grid place-content-center"
|
||||||
>
|
>
|
||||||
<BasePaywallModal
|
<BasePaywallModal
|
||||||
class="mx-auto"
|
class="mx-auto"
|
||||||
|
|||||||
@@ -56,8 +56,13 @@ const selectedLabel = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectOption = option => {
|
const selectOption = option => {
|
||||||
selectedValue.value = option.value;
|
if (selectedValue.value === option.value) {
|
||||||
emit('update:modelValue', option.value);
|
selectedValue.value = '';
|
||||||
|
emit('update:modelValue', '');
|
||||||
|
} else {
|
||||||
|
selectedValue.value = option.value;
|
||||||
|
emit('update:modelValue', option.value);
|
||||||
|
}
|
||||||
open.value = false;
|
open.value = false;
|
||||||
search.value = '';
|
search.value = '';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ defineExpose({
|
|||||||
:aria-multiselectable="multiple"
|
:aria-multiselectable="multiple"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="option in options"
|
v-for="(option, index) in options"
|
||||||
:key="option.value"
|
:key="`${option.value}-${index}`"
|
||||||
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
|
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-n-alpha-2': isSelected(option),
|
'bg-n-alpha-2': isSelected(option),
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ const props = defineProps({
|
|||||||
default: 'lg',
|
default: 'lg',
|
||||||
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
|
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
|
||||||
},
|
},
|
||||||
|
position: {
|
||||||
|
type: String,
|
||||||
|
default: 'center',
|
||||||
|
validator: value => ['center', 'top'].includes(value),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['confirm', 'close']);
|
const emit = defineEmits(['confirm', 'close']);
|
||||||
@@ -61,6 +66,7 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const dialogRef = ref(null);
|
const dialogRef = ref(null);
|
||||||
const dialogContentRef = ref(null);
|
const dialogContentRef = ref(null);
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
const maxWidthClass = computed(() => {
|
const maxWidthClass = computed(() => {
|
||||||
const classesMap = {
|
const classesMap = {
|
||||||
@@ -75,13 +81,19 @@ const maxWidthClass = computed(() => {
|
|||||||
return classesMap[props.width] ?? 'max-w-md';
|
return classesMap[props.width] ?? 'max-w-md';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const positionClass = computed(() =>
|
||||||
|
props.position === 'top' ? 'dialog-position-top' : ''
|
||||||
|
);
|
||||||
|
|
||||||
const open = () => {
|
const open = () => {
|
||||||
|
isOpen.value = true;
|
||||||
dialogRef.value?.showModal();
|
dialogRef.value?.showModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
emit('close');
|
emit('close');
|
||||||
dialogRef.value?.close();
|
dialogRef.value?.close();
|
||||||
|
isOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
@@ -98,6 +110,7 @@ defineExpose({ open, close });
|
|||||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||||
:class="[
|
:class="[
|
||||||
maxWidthClass,
|
maxWidthClass,
|
||||||
|
positionClass,
|
||||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
||||||
]"
|
]"
|
||||||
@close="close"
|
@close="close"
|
||||||
@@ -105,7 +118,7 @@ defineExpose({ open, close });
|
|||||||
<OnClickOutside @trigger="close">
|
<OnClickOutside @trigger="close">
|
||||||
<form
|
<form
|
||||||
ref="dialogContentRef"
|
ref="dialogContentRef"
|
||||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||||
@submit.prevent="confirm"
|
@submit.prevent="confirm"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
@@ -119,7 +132,7 @@ defineExpose({ open, close });
|
|||||||
</p>
|
</p>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot v-if="isOpen" />
|
||||||
<!-- Dialog content will be injected here -->
|
<!-- Dialog content will be injected here -->
|
||||||
<slot name="footer">
|
<slot name="footer">
|
||||||
<div
|
<div
|
||||||
@@ -156,4 +169,9 @@ defineExpose({ open, close });
|
|||||||
dialog::backdrop {
|
dialog::backdrop {
|
||||||
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-position-top {
|
||||||
|
margin-top: clamp(2rem, 5vh, 5rem);
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
type: String,
|
||||||
|
default: 'max-h-96',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -15,7 +19,10 @@ defineProps({
|
|||||||
>
|
>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</div>
|
</div>
|
||||||
<ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto">
|
<ul
|
||||||
|
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
|
||||||
|
:class="height"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ const openLink = link => {
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80"
|
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80 z-20"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"
|
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ const currentFilter = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getOperator = (filter, selectedOperator) => {
|
const getOperator = (filter, selectedOperator) => {
|
||||||
const operatorFromOptions = filter.filterOperators.find(
|
const operatorFromOptions = filter?.filterOperators?.find(
|
||||||
operator => operator.value === selectedOperator
|
operator => operator.value === selectedOperator
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!operatorFromOptions) {
|
if (!operatorFromOptions) {
|
||||||
return filter.filterOperators[0];
|
return filter?.filterOperators?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return operatorFromOptions;
|
return operatorFromOptions;
|
||||||
@@ -138,7 +138,11 @@ const validate = () => {
|
|||||||
return !validationError.value;
|
return !validationError.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
defineExpose({ validate });
|
const resetValidation = () => {
|
||||||
|
showErrors.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ validate, resetValidation });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -166,18 +170,20 @@ defineExpose({ validate });
|
|||||||
<FilterSelect
|
<FilterSelect
|
||||||
v-model="filterOperator"
|
v-model="filterOperator"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:options="currentFilter.filterOperators"
|
:options="currentFilter?.filterOperators"
|
||||||
/>
|
/>
|
||||||
<template v-if="currentOperator.hasInput">
|
<template v-if="currentOperator?.hasInput">
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-if="inputType === 'multiSelect'"
|
v-if="inputType === 'multiSelect'"
|
||||||
v-model="values"
|
v-model="values"
|
||||||
:options="currentFilter.options"
|
:options="currentFilter.options"
|
||||||
|
dropdown-max-height="max-h-72"
|
||||||
/>
|
/>
|
||||||
<SingleSelect
|
<SingleSelect
|
||||||
v-else-if="inputType === 'searchSelect'"
|
v-else-if="inputType === 'searchSelect'"
|
||||||
v-model="values"
|
v-model="values"
|
||||||
:options="currentFilter.options"
|
:options="currentFilter.options"
|
||||||
|
dropdown-max-height="max-h-64"
|
||||||
/>
|
/>
|
||||||
<SingleSelect
|
<SingleSelect
|
||||||
v-else-if="inputType === 'booleanSelect'"
|
v-else-if="inputType === 'booleanSelect'"
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const { height } = useWindowSize();
|
|||||||
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
||||||
|
|
||||||
const selectedOption = computed(() => {
|
const selectedOption = computed(() => {
|
||||||
return props.options.find(o => o.value === selected.value) || {};
|
return props.options?.find(o => o.value === selected.value) || {};
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconToRender = computed(() => {
|
const iconToRender = computed(() => {
|
||||||
@@ -87,18 +87,25 @@ const updateSelected = newValue => {
|
|||||||
</template>
|
</template>
|
||||||
<DropdownBody
|
<DropdownBody
|
||||||
ref="dropdownRef"
|
ref="dropdownRef"
|
||||||
class="min-w-48 z-50"
|
class="min-w-56 z-50"
|
||||||
:class="dropdownPosition"
|
:class="dropdownPosition"
|
||||||
strong
|
strong
|
||||||
>
|
>
|
||||||
<DropdownSection class="[&>ul]:max-h-80">
|
<DropdownSection class="[&>ul]:max-h-72">
|
||||||
<DropdownItem
|
<template v-for="option in options" :key="option.value">
|
||||||
v-for="option in options"
|
<li
|
||||||
:key="option.value"
|
v-if="option.disabled"
|
||||||
:label="option.label"
|
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
|
||||||
:icon="option.icon"
|
>
|
||||||
@click="updateSelected(option.value)"
|
{{ option.label }}
|
||||||
/>
|
</li>
|
||||||
|
<DropdownItem
|
||||||
|
v-else
|
||||||
|
:label="option.label"
|
||||||
|
:icon="option.icon"
|
||||||
|
@click="updateSelected(option.value)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</DropdownSection>
|
</DropdownSection>
|
||||||
</DropdownBody>
|
</DropdownBody>
|
||||||
</DropdownContainer>
|
</DropdownContainer>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
|||||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||||
|
|
||||||
const { options, maxChips } = defineProps({
|
const { options, maxChips, dropdownMaxHeight } = defineProps({
|
||||||
options: {
|
options: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -17,6 +17,10 @@ const { options, maxChips } = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 3,
|
default: 3,
|
||||||
},
|
},
|
||||||
|
dropdownMaxHeight: {
|
||||||
|
type: String,
|
||||||
|
default: 'max-h-80',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -123,7 +127,7 @@ const toggleOption = option => {
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
||||||
<DropdownSection class="[&>ul]:max-h-80">
|
<DropdownSection :height="dropdownMaxHeight">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.id"
|
:key="option.id"
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
|||||||
const {
|
const {
|
||||||
options,
|
options,
|
||||||
disableSearch,
|
disableSearch,
|
||||||
|
disableDeselect,
|
||||||
placeholderIcon,
|
placeholderIcon,
|
||||||
placeholder,
|
placeholder,
|
||||||
placeholderTrailingIcon,
|
placeholderTrailingIcon,
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
|
dropdownMaxHeight,
|
||||||
} = defineProps({
|
} = defineProps({
|
||||||
options: {
|
options: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -41,6 +43,14 @@ const {
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
dropdownMaxHeight: {
|
||||||
|
type: String,
|
||||||
|
default: 'max-h-80',
|
||||||
|
},
|
||||||
|
disableDeselect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -63,6 +73,8 @@ const selectedItem = computed(() => {
|
|||||||
const optionToSearch = Array.isArray(selected.value)
|
const optionToSearch = Array.isArray(selected.value)
|
||||||
? selected.value[0]
|
? selected.value[0]
|
||||||
: selected.value;
|
: selected.value;
|
||||||
|
|
||||||
|
if (!optionToSearch) return null;
|
||||||
// extract the selected item from the options array
|
// extract the selected item from the options array
|
||||||
// this ensures that options like icon is also included
|
// this ensures that options like icon is also included
|
||||||
return options.find(option => option.id === optionToSearch.id);
|
return options.find(option => option.id === optionToSearch.id);
|
||||||
@@ -77,7 +89,7 @@ const toggleSelected = option => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (selected.value && selected.value.id === optionToToggle.id) {
|
if (selected.value && selected.value.id === optionToToggle.id) {
|
||||||
selected.value = null;
|
if (!disableDeselect) selected.value = null;
|
||||||
} else {
|
} else {
|
||||||
selected.value = optionToToggle;
|
selected.value = optionToToggle;
|
||||||
}
|
}
|
||||||
@@ -124,7 +136,7 @@ const toggleSelected = option => {
|
|||||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DropdownSection class="[&>ul]:max-h-80">
|
<DropdownSection :height="dropdownMaxHeight">
|
||||||
<template v-if="searchResults.length">
|
<template v-if="searchResults.length">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-for="option in searchResults"
|
v-for="option in searchResults"
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ export function useChannelIcon(inbox) {
|
|||||||
'Channel::Sms': 'i-woot-sms',
|
'Channel::Sms': 'i-woot-sms',
|
||||||
'Channel::Telegram': 'i-woot-telegram',
|
'Channel::Telegram': 'i-woot-telegram',
|
||||||
'Channel::TwilioSms': 'i-woot-sms',
|
'Channel::TwilioSms': 'i-woot-sms',
|
||||||
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
|
'Channel::TwitterProfile': 'i-woot-x',
|
||||||
'Channel::WebWidget': 'i-woot-website',
|
'Channel::WebWidget': 'i-woot-website',
|
||||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||||
'Channel::Instagram': 'i-woot-instagram',
|
'Channel::Instagram': 'i-woot-instagram',
|
||||||
'Channel::Tiktok': 'i-woot-tiktok',
|
'Channel::Tiktok': 'i-woot-tiktok',
|
||||||
'Channel::Voice': 'i-ri-phone-fill',
|
'Channel::Voice': 'i-woot-voice',
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerIconMap = {
|
const providerIconMap = {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('useChannelIcon', () => {
|
|||||||
it('returns correct icon for Voice channel', () => {
|
it('returns correct icon for Voice channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::Voice' };
|
const inbox = { channel_type: 'Channel::Voice' };
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
expect(icon).toBe('i-ri-phone-fill');
|
expect(icon).toBe('i-woot-voice');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct icon for Line channel', () => {
|
it('returns correct icon for Line channel', () => {
|
||||||
@@ -46,7 +46,7 @@ describe('useChannelIcon', () => {
|
|||||||
it('returns correct icon for Twitter channel', () => {
|
it('returns correct icon for Twitter channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::TwitterProfile' };
|
const inbox = { channel_type: 'Channel::TwitterProfile' };
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
expect(icon).toBe('i-ri-twitter-x-fill');
|
expect(icon).toBe('i-woot-x');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct icon for WebWidget channel', () => {
|
it('returns correct icon for WebWidget channel', () => {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ onMounted(() => {
|
|||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="uniqueId"
|
:for="uniqueId"
|
||||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
class="mb-0.5 text-heading-3 text-n-slate-12"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</label>
|
</label>
|
||||||
@@ -145,7 +145,7 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
v-if="message"
|
v-if="message"
|
||||||
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
|
class="min-w-0 mt-1 mb-0 text-label-small truncate transition-all duration-500 ease-in-out"
|
||||||
:class="messageClass"
|
:class="messageClass"
|
||||||
>
|
>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: [Object, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: 'slate',
|
||||||
|
validator: value =>
|
||||||
|
['slate', 'amber', 'teal', 'ruby', 'blue', 'iris'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const COLOR_CLASSES = {
|
||||||
|
slate: 'bg-n-label-color outline-n-label-border text-n-slate-12',
|
||||||
|
amber: 'bg-n-amber-2 outline-n-amber-4 text-n-amber-11',
|
||||||
|
teal: 'bg-n-teal-2 outline-n-teal-4 text-n-teal-11',
|
||||||
|
ruby: 'bg-n-ruby-2 outline-n-ruby-4 text-n-ruby-11',
|
||||||
|
blue: 'bg-n-blue-2 outline-n-blue-4 text-n-blue-11',
|
||||||
|
iris: 'bg-n-iris-2 outline-n-iris-4 text-n-iris-11',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isStringLabel = computed(() => typeof props.label === 'string');
|
||||||
|
|
||||||
|
const labelTitle = computed(() => {
|
||||||
|
return isStringLabel.value ? props.label : props.label?.title;
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelDescription = computed(() => {
|
||||||
|
return (!isStringLabel.value && props.label?.description) || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelColor = computed(() => {
|
||||||
|
return isStringLabel.value ? null : props.label.color;
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorClasses = computed(() => COLOR_CLASSES[props.color]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:title="labelDescription"
|
||||||
|
class="rounded-lg -outline-offset-1 outline outline-1 inline-flex items-center flex-shrink-0"
|
||||||
|
:class="[
|
||||||
|
colorClasses,
|
||||||
|
compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 gap-1.5 rounded-lg',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="labelColor"
|
||||||
|
class="rounded-sm flex-shrink-0"
|
||||||
|
:class="compact ? 'size-1.5' : 'size-2'"
|
||||||
|
:style="{ background: labelColor }"
|
||||||
|
/>
|
||||||
|
<slot v-else name="icon" />
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap"
|
||||||
|
:class="compact ? 'text-label-small' : 'text-label !font-420'"
|
||||||
|
>
|
||||||
|
{{ labelTitle }}
|
||||||
|
</span>
|
||||||
|
<slot name="action" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -43,6 +43,7 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
|||||||
|
|
||||||
import MessageError from './MessageError.vue';
|
import MessageError from './MessageError.vue';
|
||||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||||
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Attachment
|
* @typedef {Object} Attachment
|
||||||
@@ -128,6 +129,7 @@ const props = defineProps({
|
|||||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||||
isEmailInbox: { type: Boolean, default: false },
|
isEmailInbox: { type: Boolean, default: false },
|
||||||
private: { type: Boolean, default: false },
|
private: { type: Boolean, default: false },
|
||||||
|
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
|
||||||
sender: { type: Object, default: null },
|
sender: { type: Object, default: null },
|
||||||
senderId: { type: Number, default: null },
|
senderId: { type: Number, default: null },
|
||||||
senderType: { type: String, default: null },
|
senderType: { type: String, default: null },
|
||||||
@@ -143,6 +145,7 @@ const { t } = useI18n();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||||
|
const { replaceInstallationName } = useBranding();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the message variant based on props
|
* Computes the message variant based on props
|
||||||
@@ -170,7 +173,10 @@ const variant = computed(() => {
|
|||||||
return MESSAGE_VARIANTS.AGENT;
|
return MESSAGE_VARIANTS.AGENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
const isBot =
|
||||||
|
props.sender?.type === SENDER_TYPES.AGENT_BOT ||
|
||||||
|
props.senderType === SENDER_TYPES.AGENT_BOT ||
|
||||||
|
(!props.sender && !props.additionalAttributes?.senderName);
|
||||||
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
||||||
return MESSAGE_VARIANTS.BOT;
|
return MESSAGE_VARIANTS.BOT;
|
||||||
}
|
}
|
||||||
@@ -389,13 +395,17 @@ const shouldRenderMessage = computed(() => {
|
|||||||
const isUnsupported = props.contentAttributes?.isUnsupported;
|
const isUnsupported = props.contentAttributes?.isUnsupported;
|
||||||
const isAnIntegrationMessage =
|
const isAnIntegrationMessage =
|
||||||
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
||||||
|
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
|
||||||
|
const hasExternalError = !!props.contentAttributes?.externalError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
hasAttachments ||
|
hasAttachments ||
|
||||||
props.content ||
|
props.content ||
|
||||||
isEmailContentType ||
|
isEmailContentType ||
|
||||||
isUnsupported ||
|
isUnsupported ||
|
||||||
isAnIntegrationMessage
|
isAnIntegrationMessage ||
|
||||||
|
isFailedMessage ||
|
||||||
|
hasExternalError
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -444,12 +454,13 @@ const avatarInfo = computed(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no sender, return bot info
|
// If no sender, check for Slack (or other integration) sender info
|
||||||
if (!props.sender) {
|
if (!props.sender) {
|
||||||
return {
|
const { senderName, senderAvatarUrl } = props.additionalAttributes || {};
|
||||||
name: t('CONVERSATION.BOT'),
|
if (senderName) {
|
||||||
src: '',
|
return { name: senderName, src: senderAvatarUrl ?? '' };
|
||||||
};
|
}
|
||||||
|
return { name: t('CONVERSATION.BOT'), src: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sender } = props;
|
const { sender } = props;
|
||||||
@@ -472,7 +483,7 @@ const avatarInfo = computed(() => {
|
|||||||
|
|
||||||
const avatarTooltip = computed(() => {
|
const avatarTooltip = computed(() => {
|
||||||
if (props.contentAttributes?.externalEcho) {
|
if (props.contentAttributes?.externalEcho) {
|
||||||
return t('CONVERSATION.NATIVE_APP_ADVISORY');
|
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
|
||||||
}
|
}
|
||||||
if (avatarInfo.value.name === '') return '';
|
if (avatarInfo.value.name === '') return '';
|
||||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['retry']);
|
const emit = defineEmits(['retry']);
|
||||||
|
|
||||||
const { orientation, status, createdAt } = useMessageContext();
|
const { orientation, status, createdAt, content, attachments } =
|
||||||
|
useMessageContext();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
|
const canRetry = computed(() => {
|
||||||
|
const hasContent = content.value !== null;
|
||||||
|
const hasAttachments = attachments.value && attachments.value.length > 0;
|
||||||
|
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ const pageInfo = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
class="flex justify-between h-[3.375rem] w-full border-t border-n-weak mx-auto bg-n-surface-1 py-3 px-6 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
|
<span class="min-w-0 text-body-main line-clamp-1 text-n-slate-11">
|
||||||
{{ currentPageInformation }}
|
{{ currentPageInformation }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,11 +97,13 @@ const pageInfo = computed(() => {
|
|||||||
:disabled="isFirstPage"
|
:disabled="isFirstPage"
|
||||||
@click="changePage(currentPage - 1)"
|
@click="changePage(currentPage - 1)"
|
||||||
/>
|
/>
|
||||||
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
<div class="inline-flex items-center gap-2 text-sm">
|
||||||
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
<span
|
||||||
|
class="px-3 tabular-nums py-0.5 font-420 bg-n-input-background text-body-main text-n-slate-12 rounded-md"
|
||||||
|
>
|
||||||
{{ formatFullNumber(currentPage) }}
|
{{ formatFullNumber(currentPage) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate">
|
<span class="truncate text-body-main text-n-slate-11">
|
||||||
{{ pageInfo }}
|
{{ pageInfo }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import RadioCard from '../RadioCard.vue';
|
import RadioCard from './RadioCard.vue';
|
||||||
|
|
||||||
const selectedOption = ref('round_robin');
|
const selectedOption = ref('round_robin');
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup>
|
||||||
|
import Label from 'dashboard/components-next/label/Label.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
disabledLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
disabledMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select']);
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
if (!props.isActive && !props.disabled) {
|
||||||
|
emit('select', props.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="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="[
|
||||||
|
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||||
|
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
||||||
|
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
||||||
|
]"
|
||||||
|
@click="handleChange"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2 items-start">
|
||||||
|
<div class="flex items-center justify-between w-full gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-heading-3 text-n-slate-12">
|
||||||
|
{{ label }}
|
||||||
|
</h3>
|
||||||
|
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
:id="`${id}`"
|
||||||
|
:checked="isActive"
|
||||||
|
:value="id"
|
||||||
|
:name="id"
|
||||||
|
:disabled="disabled"
|
||||||
|
type="radio"
|
||||||
|
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
|
||||||
|
@change="handleChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-main text-n-slate-11">
|
||||||
|
{{ disabled && disabledMessage ? disabledMessage : description }}
|
||||||
|
</p>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup>
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
validator: options =>
|
||||||
|
options.every(
|
||||||
|
opt => typeof opt === 'object' && 'value' in opt && 'label' in opt
|
||||||
|
),
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
validator: groups =>
|
||||||
|
groups.every(
|
||||||
|
group =>
|
||||||
|
'label' in group &&
|
||||||
|
Array.isArray(group.options) &&
|
||||||
|
group.options.every(opt => 'value' in opt && 'label' in opt)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelValue = defineModel({
|
||||||
|
type: [String, Number, Boolean],
|
||||||
|
default: '',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-fit relative">
|
||||||
|
<select
|
||||||
|
v-model="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="appearance-none bg-none rounded-lg border-0 outline-1 outline -outline-offset-1 transition-all duration-200 bg-n-surface-1 !mb-0 py-2 px-3 pr-10 text-sm"
|
||||||
|
:class="{
|
||||||
|
'outline-n-weak hover:outline-n-slate-6 focus:outline-n-blue-9':
|
||||||
|
!error && !disabled,
|
||||||
|
'outline-n-red-9 focus:outline-n-red-9': error && !disabled,
|
||||||
|
'outline-n-weak bg-n-slate-2 cursor-not-allowed opacity-60': disabled,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<option v-if="placeholder" value="" disabled>
|
||||||
|
{{ placeholder }}
|
||||||
|
</option>
|
||||||
|
<template v-if="groups.length">
|
||||||
|
<optgroup
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.label"
|
||||||
|
:label="group.label"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in group.options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
:disabled="option.disabled"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<option
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
:disabled="option.disabled"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
class="size-4 text-n-slate-11"
|
||||||
|
:class="{ 'opacity-50': disabled }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -25,8 +25,8 @@ const reauthorizationRequired = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
|
<span class="size-4 grid place-content-center rounded-full">
|
||||||
<ChannelIcon :inbox="inbox" class="size-3" />
|
<ChannelIcon :inbox="inbox" class="size-4" />
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
@@ -50,6 +51,18 @@ const isRTL = useMapGetter('accounts/isRTL');
|
|||||||
const { width: windowWidth } = useWindowSize();
|
const { width: windowWidth } = useWindowSize();
|
||||||
const isMobile = computed(() => windowWidth.value < 768);
|
const isMobile = computed(() => windowWidth.value < 768);
|
||||||
|
|
||||||
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAdvancedAssignment = computed(() => {
|
||||||
|
return isFeatureEnabledonAccount.value(
|
||||||
|
accountId.value,
|
||||||
|
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const toggleShortcutModalFn = show => {
|
const toggleShortcutModalFn = show => {
|
||||||
if (show) {
|
if (show) {
|
||||||
emit('openKeyShortcutModal');
|
emit('openKeyShortcutModal');
|
||||||
@@ -273,7 +286,7 @@ const menuItems = computed(() => {
|
|||||||
children: sortedInboxes.value.map(inbox => ({
|
children: sortedInboxes.value.map(inbox => ({
|
||||||
name: `${inbox.name}-${inbox.id}`,
|
name: `${inbox.name}-${inbox.id}`,
|
||||||
label: inbox.name,
|
label: inbox.name,
|
||||||
icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
|
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
||||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||||
component: leafProps =>
|
component: leafProps =>
|
||||||
h(ChannelLeaf, {
|
h(ChannelLeaf, {
|
||||||
@@ -582,18 +595,49 @@ const menuItems = computed(() => {
|
|||||||
name: 'Settings Teams',
|
name: 'Settings Teams',
|
||||||
label: t('SIDEBAR.TEAMS'),
|
label: t('SIDEBAR.TEAMS'),
|
||||||
icon: 'i-lucide-users',
|
icon: 'i-lucide-users',
|
||||||
|
activeOn: [
|
||||||
|
'settings_teams_list',
|
||||||
|
'settings_teams_new',
|
||||||
|
'settings_teams_finish',
|
||||||
|
'settings_teams_add_agents',
|
||||||
|
'settings_teams_show',
|
||||||
|
'settings_teams_edit',
|
||||||
|
'settings_teams_edit_members',
|
||||||
|
'settings_teams_edit_finish',
|
||||||
|
],
|
||||||
to: accountScopedRoute('settings_teams_list'),
|
to: accountScopedRoute('settings_teams_list'),
|
||||||
},
|
},
|
||||||
{
|
...(hasAdvancedAssignment.value
|
||||||
name: 'Settings Agent Assignment',
|
? [
|
||||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
{
|
||||||
icon: 'i-lucide-user-cog',
|
name: 'Settings Agent Assignment',
|
||||||
to: accountScopedRoute('assignment_policy_index'),
|
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||||
},
|
icon: 'i-lucide-user-cog',
|
||||||
|
activeOn: [
|
||||||
|
'assignment_policy_index',
|
||||||
|
'agent_assignment_policy_index',
|
||||||
|
'agent_assignment_policy_create',
|
||||||
|
'agent_assignment_policy_edit',
|
||||||
|
'agent_capacity_policy_index',
|
||||||
|
'agent_capacity_policy_create',
|
||||||
|
'agent_capacity_policy_edit',
|
||||||
|
],
|
||||||
|
to: accountScopedRoute('assignment_policy_index'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
name: 'Settings Inboxes',
|
name: 'Settings Inboxes',
|
||||||
label: t('SIDEBAR.INBOXES'),
|
label: t('SIDEBAR.INBOXES'),
|
||||||
icon: 'i-lucide-inbox',
|
icon: 'i-lucide-inbox',
|
||||||
|
activeOn: [
|
||||||
|
'settings_inbox_list',
|
||||||
|
'settings_inbox_show',
|
||||||
|
'settings_inbox_new',
|
||||||
|
'settings_inbox_finish',
|
||||||
|
'settings_inboxes_page_channel',
|
||||||
|
'settings_inboxes_add_agents',
|
||||||
|
],
|
||||||
to: accountScopedRoute('settings_inbox_list'),
|
to: accountScopedRoute('settings_inbox_list'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -686,7 +730,7 @@ const menuItems = computed(() => {
|
|||||||
closeMobileSidebar,
|
closeMobileSidebar,
|
||||||
{ ignore: ['#mobile-sidebar-launcher'] },
|
{ ignore: ['#mobile-sidebar-launcher'] },
|
||||||
]"
|
]"
|
||||||
class="bg-n-background flex flex-col text-sm pb-0.5 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
||||||
:class="[
|
:class="[
|
||||||
{
|
{
|
||||||
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
||||||
@@ -807,7 +851,7 @@ const menuItems = computed(() => {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="p-1 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
class="px-1 py-1.5 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||||
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
|
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
|
||||||
>
|
>
|
||||||
<SidebarProfileMenu
|
<SidebarProfileMenu
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ const shouldRenderComponent = computed(() => {
|
|||||||
:active
|
:active
|
||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Icon v-if="icon" :icon="icon" class="size-4 inline-block" />
|
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
|
||||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
<Icon :icon="icon" class="size-4 inline-block" />
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
|
||||||
</template>
|
</template>
|
||||||
</component>
|
</component>
|
||||||
</Policy>
|
</Policy>
|
||||||
|
|||||||
@@ -19,20 +19,24 @@ const updateValue = () => {
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
|
class="group relative h-4 rounded-full w-7 flex-shrink-0 select-none focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 transition-colors duration-200 ease-in-out"
|
||||||
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
|
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6'"
|
||||||
role="switch"
|
role="switch"
|
||||||
:aria-checked="modelValue"
|
:aria-checked="modelValue"
|
||||||
@click="updateValue"
|
@click="updateValue"
|
||||||
>
|
>
|
||||||
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
|
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
|
||||||
<span
|
<span
|
||||||
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-out"
|
class="absolute top-1/2 ltr:left-0.5 rtl:right-0.5 -translate-y-1/2 transition-transform duration-[350ms] ease-[cubic-bezier(0.34,1.56,0.64,1)]"
|
||||||
:class="
|
:class="
|
||||||
modelValue
|
modelValue
|
||||||
? 'translate-x-3 bg-n-background'
|
? 'ltr:translate-x-3 rtl:-translate-x-3 group-active:ltr:translate-x-[6px] rtl:group-active:-translate-x-[6px]'
|
||||||
: 'translate-x-0 bg-n-background'
|
: 'ltr:translate-x-0 rtl:translate-x-0'
|
||||||
"
|
"
|
||||||
/>
|
>
|
||||||
|
<span
|
||||||
|
class="block h-3 w-3 rounded-full bg-n-background shadow-md transition-[width] duration-[180ms] ease-in-out group-active:w-[18px]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { BaseTable, BaseTableRow, BaseTableCell } from './index';
|
||||||
|
import Button from '../button/Button.vue';
|
||||||
|
import Avatar from '../avatar/Avatar.vue';
|
||||||
|
import ToggleSwitch from '../switch/Switch.vue';
|
||||||
|
|
||||||
|
const automationData = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Welcome Message',
|
||||||
|
description: 'Send welcome message to new contacts',
|
||||||
|
active: true,
|
||||||
|
createdOn: 'Apr 21, 2022',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Auto-assign to Sales',
|
||||||
|
description: 'Automatically assign sales conversations to sales team',
|
||||||
|
active: false,
|
||||||
|
createdOn: 'May 15, 2022',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Tag Premium Users',
|
||||||
|
description: 'Add premium tag to conversations from premium users',
|
||||||
|
active: true,
|
||||||
|
createdOn: 'Jun 10, 2022',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const agentData = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
role: 'Administrator',
|
||||||
|
verified: true,
|
||||||
|
avatarUrl: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Jane Smith',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
role: 'Agent',
|
||||||
|
verified: true,
|
||||||
|
avatarUrl: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const emptyData = ref([]);
|
||||||
|
|
||||||
|
const headers = ['Name', 'Active', 'Created on', 'Actions'];
|
||||||
|
const agentHeaders = ['Agent', 'Role', 'Verification', 'Actions'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story title="Components/Table" :layout="{ type: 'grid', width: '100%' }">
|
||||||
|
<!-- Basic Table -->
|
||||||
|
<Variant title="Basic Table">
|
||||||
|
<div class="p-4 bg-n-surface-1">
|
||||||
|
<BaseTable :headers="headers" :items="automationData">
|
||||||
|
<template #row="{ items }">
|
||||||
|
<BaseTableRow
|
||||||
|
v-for="automation in items"
|
||||||
|
:key="automation.id"
|
||||||
|
:item="automation"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<BaseTableCell>
|
||||||
|
<div class="flex items-center gap-2 min-w-0 max-w-full">
|
||||||
|
<span
|
||||||
|
class="text-body-main text-n-slate-12 truncate min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
{{ automation.name }}
|
||||||
|
</span>
|
||||||
|
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
|
||||||
|
<span
|
||||||
|
class="text-body-main text-n-slate-11 truncate min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
{{ automation.description }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</BaseTableCell>
|
||||||
|
|
||||||
|
<BaseTableCell>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ToggleSwitch v-model="automation.active" />
|
||||||
|
</div>
|
||||||
|
</BaseTableCell>
|
||||||
|
|
||||||
|
<BaseTableCell>
|
||||||
|
<span
|
||||||
|
class="text-body-main text-n-slate-12 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ automation.createdOn }}
|
||||||
|
</span>
|
||||||
|
</BaseTableCell>
|
||||||
|
|
||||||
|
<BaseTableCell align="end" class="w-24">
|
||||||
|
<div class="flex gap-3 justify-end flex-shrink-0">
|
||||||
|
<Button icon="i-woot-edit-pen" slate sm />
|
||||||
|
<Button icon="i-woot-bin" slate sm />
|
||||||
|
<Button icon="i-woot-clone" slate sm />
|
||||||
|
</div>
|
||||||
|
</BaseTableCell>
|
||||||
|
</template>
|
||||||
|
</BaseTableRow>
|
||||||
|
</template>
|
||||||
|
</BaseTable>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<!-- Table with Avatars -->
|
||||||
|
<Variant title="Table with Avatars">
|
||||||
|
<div class="p-4 bg-n-surface-1">
|
||||||
|
<BaseTable :headers="agentHeaders" :items="agentData">
|
||||||
|
<template #row="{ items }">
|
||||||
|
<BaseTableRow v-for="agent in items" :key="agent.id" :item="agent">
|
||||||
|
<template #default>
|
||||||
|
<BaseTableCell>
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<Avatar :user="agent" :size="40" class="flex-shrink-0" />
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<span class="text-body-main text-n-slate-12 truncate">
|
||||||
|
{{ agent.name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-body-main text-n-slate-11 truncate">
|
||||||
|
{{ agent.email }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseTableCell>
|
||||||
|
|
||||||
|
<BaseTableCell>
|
||||||
|
<span
|
||||||
|
class="text-body-main text-n-slate-12 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ agent.role }}
|
||||||
|
</span>
|
||||||
|
</BaseTableCell>
|
||||||
|
|
||||||
|
<BaseTableCell>
|
||||||
|
<span
|
||||||
|
class="text-body-main text-n-slate-12 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ agent.verified ? 'Verified' : 'Pending' }}
|
||||||
|
</span>
|
||||||
|
</BaseTableCell>
|
||||||
|
|
||||||
|
<BaseTableCell align="end" class="w-24">
|
||||||
|
<div class="flex gap-3 justify-end flex-shrink-0">
|
||||||
|
<Button icon="i-woot-edit-pen" slate sm />
|
||||||
|
<Button icon="i-woot-bin" slate sm />
|
||||||
|
</div>
|
||||||
|
</BaseTableCell>
|
||||||
|
</template>
|
||||||
|
</BaseTableRow>
|
||||||
|
</template>
|
||||||
|
</BaseTable>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<Variant title="Empty State">
|
||||||
|
<div class="p-4 bg-n-surface-1">
|
||||||
|
<BaseTable
|
||||||
|
:headers="headers"
|
||||||
|
:items="emptyData"
|
||||||
|
no-data-message="No automation rules found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
headers: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
noDataMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasHeaderSlot = computed(() => !!props.headers.length);
|
||||||
|
const showHeaders = computed(
|
||||||
|
() => hasHeaderSlot.value && props.items.length > 0
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<table class="min-w-full table-auto divide-y divide-n-weak">
|
||||||
|
<thead v-if="showHeaders" class="border-t border-n-weak">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
v-for="(header, index) in headers"
|
||||||
|
:key="index"
|
||||||
|
class="py-4 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12 capitalize"
|
||||||
|
>
|
||||||
|
<slot :name="`header-${index}`" :header="header">
|
||||||
|
{{ header }}
|
||||||
|
</slot>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-n-weak text-n-slate-11">
|
||||||
|
<template v-if="items.length">
|
||||||
|
<slot name="row" :items="items" />
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="noDataMessage && !loading">
|
||||||
|
<td
|
||||||
|
:colspan="headers.length || 1"
|
||||||
|
class="py-20 text-center text-body-main !text-base text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{ noDataMessage }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
align: {
|
||||||
|
type: String,
|
||||||
|
default: 'start',
|
||||||
|
validator: value => ['start', 'center', 'end'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<td
|
||||||
|
class="py-3 ltr:pr-4 rtl:pl-4 text-body-main"
|
||||||
|
:class="{
|
||||||
|
'text-start': align === 'start',
|
||||||
|
'text-center': align === 'center',
|
||||||
|
'text-end': align === 'end',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
item: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tr>
|
||||||
|
<slot :item="item" />
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as BaseTable } from './BaseTable.vue';
|
||||||
|
export { default as BaseTableRow } from './BaseTableRow.vue';
|
||||||
|
export { default as BaseTableCell } from './BaseTableCell.vue';
|
||||||
@@ -36,6 +36,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
focusOnMount: { type: Boolean, default: false },
|
focusOnMount: { type: Boolean, default: false },
|
||||||
allowCreate: { type: Boolean, default: false },
|
allowCreate: { type: Boolean, default: false },
|
||||||
|
// Skip label-based dedup when the consumer already filters menuItems by ID.
|
||||||
|
// Prevents removing all same-name items when one is selected (e.g. duplicate agent names).
|
||||||
|
skipLabelDedup: { type: Boolean, default: false },
|
||||||
|
// When false, the dropdown won't auto-open on mount; it opens only on click/focus.
|
||||||
|
autoOpenDropdown: { type: Boolean, default: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -56,7 +61,7 @@ const modelValue = defineModel({
|
|||||||
const tagInputRef = ref(null);
|
const tagInputRef = ref(null);
|
||||||
const tags = ref(props.modelValue);
|
const tags = ref(props.modelValue);
|
||||||
const newTag = ref('');
|
const newTag = ref('');
|
||||||
const isFocused = ref(true);
|
const isFocused = ref(props.autoOpenDropdown);
|
||||||
|
|
||||||
const rules = computed(() => getValidationRules(props.type));
|
const rules = computed(() => getValidationRules(props.type));
|
||||||
const v$ = useVuelidate(rules, { newTag });
|
const v$ = useVuelidate(rules, { newTag });
|
||||||
@@ -74,11 +79,11 @@ const showInput = computed(() =>
|
|||||||
const showDropdownMenu = computed(() =>
|
const showDropdownMenu = computed(() =>
|
||||||
props.mode === MODE.SINGLE && tags.value.length >= 1
|
props.mode === MODE.SINGLE && tags.value.length >= 1
|
||||||
? false
|
? false
|
||||||
: props.showDropdown
|
: props.showDropdown && isFocused.value
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredMenuItems = computed(() =>
|
const filteredMenuItems = computed(() => {
|
||||||
buildTagMenuItems({
|
const items = buildTagMenuItems({
|
||||||
mode: props.mode,
|
mode: props.mode,
|
||||||
tags: tags.value,
|
tags: tags.value,
|
||||||
menuItems: props.menuItems,
|
menuItems: props.menuItems,
|
||||||
@@ -86,8 +91,14 @@ const filteredMenuItems = computed(() =>
|
|||||||
isLoading: props.isLoading,
|
isLoading: props.isLoading,
|
||||||
type: props.type,
|
type: props.type,
|
||||||
isNewTagInValidType: isNewTagInValidType.value,
|
isNewTagInValidType: isNewTagInValidType.value,
|
||||||
})
|
allowCreate: props.allowCreate,
|
||||||
);
|
skipLabelDedup: props.skipLabelDedup,
|
||||||
|
});
|
||||||
|
if (props.type !== INPUT_TYPES.TEXT) return items;
|
||||||
|
const query = newTag.value?.trim()?.toLowerCase();
|
||||||
|
if (!query) return items;
|
||||||
|
return items.filter(item => item.label?.toLowerCase().includes(query));
|
||||||
|
});
|
||||||
|
|
||||||
const emitDataOnAdd = value => {
|
const emitDataOnAdd = value => {
|
||||||
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
|
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
|
||||||
@@ -112,10 +123,13 @@ const addTag = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const isValidatedType = [INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(
|
||||||
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
|
props.type
|
||||||
props.allowCreate
|
);
|
||||||
) {
|
|
||||||
|
if (!isValidatedType && !props.allowCreate && props.showDropdown) return;
|
||||||
|
|
||||||
|
if (isValidatedType || props.allowCreate) {
|
||||||
if (!(await v$.value.$validate())) return;
|
if (!(await v$.value.$validate())) return;
|
||||||
emitDataOnAdd(trimmedTag);
|
emitDataOnAdd(trimmedTag);
|
||||||
}
|
}
|
||||||
@@ -125,28 +139,31 @@ const addTag = async () => {
|
|||||||
const removeTag = index => {
|
const removeTag = index => {
|
||||||
tags.value.splice(index, 1);
|
tags.value.splice(index, 1);
|
||||||
modelValue.value = tags.value;
|
modelValue.value = tags.value;
|
||||||
emit('remove');
|
emit('remove', index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDropdownAction = async ({
|
const handleDropdownAction = async ({
|
||||||
email: emailAddress,
|
email: emailAddress,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
|
label,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
|
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
|
||||||
if (!props.showDropdown) return;
|
if (!props.showDropdown) return;
|
||||||
|
|
||||||
const isEmail = props.type === 'email';
|
const isEmail = props.type === INPUT_TYPES.EMAIL;
|
||||||
newTag.value = isEmail ? emailAddress : phoneNumber;
|
const tagValue = isEmail ? emailAddress : phoneNumber || label;
|
||||||
|
|
||||||
if (!(await v$.value.$validate())) return;
|
if (isEmail || props.type === INPUT_TYPES.TEL) {
|
||||||
|
newTag.value = tagValue;
|
||||||
|
if (!(await v$.value.$validate())) return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = isEmail
|
emit(
|
||||||
? { email: emailAddress, ...rest }
|
'add',
|
||||||
: { phoneNumber, ...rest };
|
isEmail ? { email: emailAddress, ...rest } : { phoneNumber, label, ...rest }
|
||||||
|
);
|
||||||
emit('add', payload);
|
updateValueAndFocus(tagValue);
|
||||||
updateValueAndFocus(emailAddress);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
@@ -163,7 +180,7 @@ const handleKeydown = event => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClickOutside = () => {
|
const handleClickOutside = () => {
|
||||||
if (tags.value.length) isFocused.value = false;
|
isFocused.value = false;
|
||||||
emit('onClickOutside');
|
emit('onClickOutside');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,71 @@ describe('tagInputHelper', () => {
|
|||||||
});
|
});
|
||||||
expect(result).toEqual(menuItems);
|
expect(result).toEqual(menuItems);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not create suggestion when allowCreate is false', () => {
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
type: INPUT_TYPES.EMAIL,
|
||||||
|
newTag: 'test@example.com',
|
||||||
|
allowCreate: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still returns available menu items when allowCreate is false', () => {
|
||||||
|
const menuItems = [
|
||||||
|
{ label: 'Agent 1', value: '1' },
|
||||||
|
{ label: 'Agent 2', value: '2' },
|
||||||
|
];
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
menuItems,
|
||||||
|
newTag: 'Agent',
|
||||||
|
allowCreate: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(menuItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new item when allowCreate is true (default)', () => {
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
type: INPUT_TYPES.EMAIL,
|
||||||
|
newTag: 'test@example.com',
|
||||||
|
allowCreate: true,
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
label: 'test@example.com',
|
||||||
|
action: 'create',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips label dedup when skipLabelDedup is true', () => {
|
||||||
|
const menuItems = [
|
||||||
|
{ label: 'HDMA', value: 1 },
|
||||||
|
{ label: 'HDMA', value: 2 },
|
||||||
|
];
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
tags: ['HDMA'],
|
||||||
|
menuItems,
|
||||||
|
skipLabelDedup: true,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(menuItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by label when skipLabelDedup is false (default)', () => {
|
||||||
|
const menuItems = [
|
||||||
|
{ label: 'HDMA', value: 1 },
|
||||||
|
{ label: 'HDMA', value: 2 },
|
||||||
|
];
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
tags: ['HDMA'],
|
||||||
|
menuItems,
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('canAddTag', () => {
|
describe('canAddTag', () => {
|
||||||
|
|||||||
@@ -84,25 +84,29 @@ export const buildTagMenuItems = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
type,
|
type,
|
||||||
isNewTagInValidType,
|
isNewTagInValidType,
|
||||||
|
allowCreate = true,
|
||||||
|
skipLabelDedup = false,
|
||||||
}) => {
|
}) => {
|
||||||
if (mode === MODE.SINGLE && tags.length >= 1) return [];
|
if (mode === MODE.SINGLE && tags.length >= 1) return [];
|
||||||
|
|
||||||
const availableMenuItems = menuItems.filter(
|
const availableMenuItems = skipLabelDedup
|
||||||
item => !tags.includes(item.label)
|
? menuItems
|
||||||
);
|
: menuItems.filter(item => !tags.includes(item.label));
|
||||||
|
|
||||||
// Show typed value as suggestion only if:
|
// Show typed value as suggestion only if:
|
||||||
// 1. There's a value being typed
|
// 1. There's a value being typed
|
||||||
// 2. The value isn't already in the tags
|
// 2. The value isn't already in the tags
|
||||||
// 3. Validation passes (email/phone) and There are no menu items available
|
// 3. Validation passes (email/phone) and There are no menu items available
|
||||||
|
// 4. allowCreate is enabled
|
||||||
const trimmedNewTag = newTag?.trim();
|
const trimmedNewTag = newTag?.trim();
|
||||||
const shouldShowTypedValue =
|
const shouldShowCreateSuggestion =
|
||||||
|
allowCreate &&
|
||||||
trimmedNewTag &&
|
trimmedNewTag &&
|
||||||
!tags.includes(trimmedNewTag) &&
|
!tags.includes(trimmedNewTag) &&
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
!availableMenuItems.length;
|
!availableMenuItems.length;
|
||||||
|
|
||||||
if (shouldShowTypedValue) {
|
if (shouldShowCreateSuggestion) {
|
||||||
const { isValid, formattedValue } = validateAndFormatNewTag(
|
const { isValid, formattedValue } = validateAndFormatNewTag(
|
||||||
trimmedNewTag,
|
trimmedNewTag,
|
||||||
type,
|
type,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
year: {
|
year: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
required: true,
|
default: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ defineProps({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isComingSoon"
|
v-if="isComingSoon"
|
||||||
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95 cursor-not-allowed"
|
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span class="text-n-slate-12 font-medium text-sm">
|
<span class="text-n-slate-12 font-medium text-sm">
|
||||||
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
|
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ import {
|
|||||||
useFunctionGetter,
|
useFunctionGetter,
|
||||||
} from 'dashboard/composables/store.js';
|
} from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
|
import { Virtualizer } from 'virtua/vue';
|
||||||
// from the current one. But we should consider using tanstack virtual in the future
|
|
||||||
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
|
||||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
|
||||||
import ChatListHeader from './ChatListHeader.vue';
|
import ChatListHeader from './ChatListHeader.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||||
@@ -29,9 +26,9 @@ import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
|||||||
import ConversationItem from './ConversationItem.vue';
|
import ConversationItem from './ConversationItem.vue';
|
||||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||||
import IntersectionObserver from './IntersectionObserver.vue';
|
|
||||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||||
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
@@ -46,7 +43,6 @@ import {
|
|||||||
useSnakeCase,
|
useSnakeCase,
|
||||||
} from 'dashboard/composables/useTransformKeys';
|
} from 'dashboard/composables/useTransformKeys';
|
||||||
import { useEmitter } from 'dashboard/composables/emitter';
|
import { useEmitter } from 'dashboard/composables/emitter';
|
||||||
import { useEventListener } from '@vueuse/core';
|
|
||||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||||
|
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
@@ -70,8 +66,6 @@ import { matchesFilters } from '../store/modules/conversations/helpers/filterHel
|
|||||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||||
|
|
||||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversationInbox: { type: [String, Number], default: 0 },
|
conversationInbox: { type: [String, Number], default: 0 },
|
||||||
teamId: { type: [String, Number], default: 0 },
|
teamId: { type: [String, Number], default: 0 },
|
||||||
@@ -91,9 +85,9 @@ const store = useStore();
|
|||||||
|
|
||||||
const resolveAttributesModalRef = ref(null);
|
const resolveAttributesModalRef = ref(null);
|
||||||
const conversationListRef = ref(null);
|
const conversationListRef = ref(null);
|
||||||
const conversationDynamicScroller = ref(null);
|
const virtualListRef = ref(null);
|
||||||
|
|
||||||
provide('contextMenuElementTarget', conversationDynamicScroller);
|
provide('contextMenuElementTarget', virtualListRef);
|
||||||
|
|
||||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||||
@@ -145,6 +139,7 @@ const {
|
|||||||
isConversationSelected,
|
isConversationSelected,
|
||||||
onAssignAgent,
|
onAssignAgent,
|
||||||
onAssignLabels,
|
onAssignLabels,
|
||||||
|
onRemoveLabels,
|
||||||
onAssignTeamsForBulk,
|
onAssignTeamsForBulk,
|
||||||
onUpdateConversations,
|
onUpdateConversations,
|
||||||
} = useBulkActions();
|
} = useBulkActions();
|
||||||
@@ -160,12 +155,6 @@ const {
|
|||||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||||
|
|
||||||
// computed
|
// computed
|
||||||
const intersectionObserverOptions = computed(() => {
|
|
||||||
return {
|
|
||||||
root: conversationListRef.value,
|
|
||||||
rootMargin: '100px 0px 100px 0px',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasAppliedFilters = computed(() => {
|
const hasAppliedFilters = computed(() => {
|
||||||
return appliedFilters.value.length !== 0;
|
return appliedFilters.value.length !== 0;
|
||||||
@@ -383,18 +372,6 @@ function setFiltersFromUISettings() {
|
|||||||
|
|
||||||
function emitConversationLoaded() {
|
function emitConversationLoaded() {
|
||||||
emit('conversationLoad');
|
emit('conversationLoad');
|
||||||
// [VITE] removing this since the library has changed
|
|
||||||
// nextTick(() => {
|
|
||||||
// // Addressing a known issue in the virtual list library where dynamically added items
|
|
||||||
// // might not render correctly. This workaround involves a slight manual adjustment
|
|
||||||
// // to the scroll position, triggering the list to refresh its rendering.
|
|
||||||
// const virtualList = conversationListRef.value;
|
|
||||||
// const scrollToOffset = virtualList?.scrollToOffset;
|
|
||||||
// const currentOffset = virtualList?.getOffset() || 0;
|
|
||||||
// if (scrollToOffset) {
|
|
||||||
// scrollToOffset(currentOffset + 1);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchFilteredConversations(payload) {
|
function fetchFilteredConversations(payload) {
|
||||||
@@ -606,16 +583,13 @@ function loadMoreConversations() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a method to handle scroll events
|
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
||||||
function handleScroll() {
|
// If the list doesn’t fill the viewport, loading can stall.
|
||||||
const scroller = conversationDynamicScroller.value;
|
// IntersectionObserver triggers as soon as the sentinel is visible.
|
||||||
if (scroller && scroller.hasScrollbar) {
|
const intersectionObserverOptions = computed(() => ({
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
|
root: conversationListRef.value,
|
||||||
if (scrollHeight - (scrollTop + clientHeight) < 100) {
|
rootMargin: '100px 0px 100px 0px',
|
||||||
loadMoreConversations();
|
}));
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAssigneeTab(selectedTab) {
|
function updateAssigneeTab(selectedTab) {
|
||||||
if (activeAssigneeTab.value !== selectedTab) {
|
if (activeAssigneeTab.value !== selectedTab) {
|
||||||
@@ -821,8 +795,6 @@ useEmitter('fetch_conversation_stats', () => {
|
|||||||
store.dispatch('conversationStats/get', conversationFilters.value);
|
store.dispatch('conversationStats/get', conversationFilters.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.dispatch('setChatListFilters', conversationFilters.value);
|
store.dispatch('setChatListFilters', conversationFilters.value);
|
||||||
setFiltersFromUISettings();
|
setFiltersFromUISettings();
|
||||||
@@ -859,6 +831,7 @@ provide('deSelectConversation', deSelectConversation);
|
|||||||
provide('assignAgent', onAssignAgent);
|
provide('assignAgent', onAssignAgent);
|
||||||
provide('assignTeam', onAssignTeam);
|
provide('assignTeam', onAssignTeam);
|
||||||
provide('assignLabels', onAssignLabels);
|
provide('assignLabels', onAssignLabels);
|
||||||
|
provide('removeLabels', onRemoveLabels);
|
||||||
provide('updateConversationStatus', handleResolveConversation);
|
provide('updateConversationStatus', handleResolveConversation);
|
||||||
provide('toggleContextMenu', onContextMenuToggle);
|
provide('toggleContextMenu', onContextMenuToggle);
|
||||||
provide('markAsUnread', markAsUnread);
|
provide('markAsUnread', markAsUnread);
|
||||||
@@ -975,61 +948,40 @@ watch(conversationFilters, (newVal, oldVal) => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
ref="conversationListRef"
|
ref="conversationListRef"
|
||||||
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
|
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||||
>
|
>
|
||||||
<DynamicScroller
|
<Virtualizer
|
||||||
ref="conversationDynamicScroller"
|
ref="virtualListRef"
|
||||||
:items="conversationList"
|
v-slot="{ item, index }"
|
||||||
:min-item-size="24"
|
:data="conversationList"
|
||||||
class="overflow-auto w-full h-full"
|
|
||||||
>
|
>
|
||||||
<template #default="{ item, index, active }">
|
<ConversationItem
|
||||||
<!--
|
:source="item"
|
||||||
If we encounter resizing issues, we can set the `watchData` prop to true
|
:label="label"
|
||||||
this will deeply watch the entire object instead of just size dependencies
|
:team-id="teamId"
|
||||||
But it can impact performance
|
:folders-id="foldersId"
|
||||||
-->
|
:conversation-type="conversationType"
|
||||||
<DynamicScrollerItem
|
:show-assignee="showAssigneeInConversationCard"
|
||||||
:item="item"
|
:data-index="index"
|
||||||
:active="active"
|
@select-conversation="selectConversation"
|
||||||
:data-index="index"
|
@de-select-conversation="deSelectConversation"
|
||||||
:size-dependencies="[
|
/>
|
||||||
item.messages,
|
</Virtualizer>
|
||||||
item.labels,
|
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||||
item.uuid,
|
<Spinner class="text-n-brand" />
|
||||||
item.inbox_id,
|
</div>
|
||||||
]"
|
<p
|
||||||
>
|
v-else-if="showEndOfListMessage"
|
||||||
<ConversationItem
|
class="p-4 text-center text-n-slate-11"
|
||||||
:source="item"
|
>
|
||||||
:label="label"
|
{{ $t('CHAT_LIST.EOF') }}
|
||||||
:team-id="teamId"
|
</p>
|
||||||
:folders-id="foldersId"
|
<IntersectionObserver
|
||||||
:conversation-type="conversationType"
|
v-else
|
||||||
:show-assignee="showAssigneeInConversationCard"
|
:options="intersectionObserverOptions"
|
||||||
@select-conversation="selectConversation"
|
@observed="loadMoreConversations"
|
||||||
@de-select-conversation="deSelectConversation"
|
/>
|
||||||
/>
|
|
||||||
</DynamicScrollerItem>
|
|
||||||
</template>
|
|
||||||
<template #after>
|
|
||||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
|
||||||
<Spinner class="text-n-brand" />
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-else-if="showEndOfListMessage"
|
|
||||||
class="p-4 text-center text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{ $t('CHAT_LIST.EOF') }}
|
|
||||||
</p>
|
|
||||||
<IntersectionObserver
|
|
||||||
v-else
|
|
||||||
:options="intersectionObserverOptions"
|
|
||||||
@observed="loadMoreConversations"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</DynamicScroller>
|
|
||||||
</div>
|
</div>
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="deleteConversationDialogRef"
|
ref="deleteConversationDialogRef"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default {
|
|||||||
'assignAgent',
|
'assignAgent',
|
||||||
'assignTeam',
|
'assignTeam',
|
||||||
'assignLabels',
|
'assignLabels',
|
||||||
|
'removeLabels',
|
||||||
'updateConversationStatus',
|
'updateConversationStatus',
|
||||||
'toggleContextMenu',
|
'toggleContextMenu',
|
||||||
'markAsUnread',
|
'markAsUnread',
|
||||||
@@ -49,7 +50,6 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ConversationCard
|
<ConversationCard
|
||||||
:key="source.id"
|
|
||||||
:active-label="label"
|
:active-label="label"
|
||||||
:team-id="teamId"
|
:team-id="teamId"
|
||||||
:folders-id="foldersId"
|
:folders-id="foldersId"
|
||||||
@@ -63,6 +63,7 @@ export default {
|
|||||||
@assign-agent="assignAgent"
|
@assign-agent="assignAgent"
|
||||||
@assign-team="assignTeam"
|
@assign-team="assignTeam"
|
||||||
@assign-label="assignLabels"
|
@assign-label="assignLabels"
|
||||||
|
@remove-label="removeLabels"
|
||||||
@update-conversation-status="updateConversationStatus"
|
@update-conversation-status="updateConversationStatus"
|
||||||
@context-menu-toggle="toggleContextMenu"
|
@context-menu-toggle="toggleContextMenu"
|
||||||
@mark-as-unread="markAsUnread"
|
@mark-as-unread="markAsUnread"
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col items-start w-full gap-6">
|
|
||||||
<div class="flex flex-col w-full gap-4">
|
|
||||||
<h4 v-if="title" class="text-lg font-medium text-n-slate-12">
|
|
||||||
{{ title }}
|
|
||||||
</h4>
|
|
||||||
<div class="flex flex-row items-center justify-between">
|
|
||||||
<div class="flex-grow h-px bg-n-weak" />
|
|
||||||
</div>
|
|
||||||
<p v-if="description" class="mb-0 text-sm font-normal text-n-slate-12">
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col w-full gap-6">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -24,6 +24,10 @@ export default {
|
|||||||
type: [String, Date, Number],
|
type: [String, Date, Number],
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
conversationId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -74,6 +78,15 @@ export default {
|
|||||||
createdAtTimestamp() {
|
createdAtTimestamp() {
|
||||||
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
|
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
|
||||||
},
|
},
|
||||||
|
conversationId() {
|
||||||
|
// Reset display values and timer when the row is recycled to a different conversation.
|
||||||
|
this.lastActivityAtTimeAgo = dynamicTime(this.lastActivityTimestamp);
|
||||||
|
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
|
||||||
|
if (this.isAutoRefreshEnabled) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.createTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.isAutoRefreshEnabled) {
|
if (this.isAutoRefreshEnabled) {
|
||||||
@@ -111,7 +124,6 @@ export default {
|
|||||||
v-tooltip.top="{
|
v-tooltip.top="{
|
||||||
content: tooltipText,
|
content: tooltipText,
|
||||||
delay: { show: 1000, hide: 0 },
|
delay: { show: 1000, hide: 0 },
|
||||||
hideOnClick: true,
|
|
||||||
}"
|
}"
|
||||||
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11"
|
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput
|
|||||||
import AutomationActionFileInput from './AutomationFileInput.vue';
|
import AutomationActionFileInput from './AutomationFileInput.vue';
|
||||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
|
||||||
|
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||||
|
import NextInput from 'dashboard/components-next/input/Input.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -10,6 +13,9 @@ export default {
|
|||||||
AutomationActionFileInput,
|
AutomationActionFileInput,
|
||||||
WootMessageEditor,
|
WootMessageEditor,
|
||||||
NextButton,
|
NextButton,
|
||||||
|
SingleSelect,
|
||||||
|
MultiSelect,
|
||||||
|
NextInput,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -40,6 +46,10 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
dropdownMaxHeight: {
|
||||||
|
type: String,
|
||||||
|
default: 'max-h-80',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
|
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
|
||||||
computed: {
|
computed: {
|
||||||
@@ -69,11 +79,21 @@ export default {
|
|||||||
return this.actionTypes.find(action => action.key === this.action_name)
|
return this.actionTypes.find(action => action.key === this.action_name)
|
||||||
.inputType;
|
.inputType;
|
||||||
},
|
},
|
||||||
actionInputStyles() {
|
actionNameAsSelectModel: {
|
||||||
return {
|
get() {
|
||||||
'has-error': this.errorMessage,
|
if (!this.action_name) return null;
|
||||||
'is-a-macro': this.isMacro,
|
const found = this.actionTypes.find(a => a.key === this.action_name);
|
||||||
};
|
return found ? { id: found.key, name: found.label } : null;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.action_name = value?.id || value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionTypesAsOptions() {
|
||||||
|
return this.actionTypes.map(a => ({ id: a.key, name: a.label }));
|
||||||
|
},
|
||||||
|
isVerticalLayout() {
|
||||||
|
return ['team_message', 'textarea'].includes(this.inputType);
|
||||||
},
|
},
|
||||||
castMessageVmodel: {
|
castMessageVmodel: {
|
||||||
get() {
|
get() {
|
||||||
@@ -94,203 +114,89 @@ export default {
|
|||||||
resetAction() {
|
resetAction() {
|
||||||
this.$emit('resetAction');
|
this.$emit('resetAction');
|
||||||
},
|
},
|
||||||
|
onActionNameChange(value) {
|
||||||
|
this.actionNameAsSelectModel = value;
|
||||||
|
this.resetAction();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="filter" :class="actionInputStyles">
|
<li class="list-none py-2 first:pt-0 last:pb-0">
|
||||||
<div class="filter-inputs">
|
<div
|
||||||
<select
|
class="flex flex-col gap-2"
|
||||||
v-model="action_name"
|
:class="{ 'animate-wiggle': errorMessage }"
|
||||||
class="action__question"
|
>
|
||||||
:class="{ 'full-width': !showActionInput }"
|
<div class="flex items-center gap-2">
|
||||||
@change="resetAction()"
|
<SingleSelect
|
||||||
>
|
:model-value="actionNameAsSelectModel"
|
||||||
<option
|
:options="actionTypesAsOptions"
|
||||||
v-for="attribute in actionTypes"
|
:dropdown-max-height="dropdownMaxHeight"
|
||||||
:key="attribute.key"
|
disable-deselect
|
||||||
:value="attribute.key"
|
class="flex-shrink-0"
|
||||||
>
|
@update:model-value="onActionNameChange"
|
||||||
{{ attribute.label }}
|
/>
|
||||||
</option>
|
<template v-if="showActionInput && !isVerticalLayout">
|
||||||
</select>
|
<SingleSelect
|
||||||
<div v-if="showActionInput" class="filter__answer--wrap">
|
|
||||||
<div v-if="inputType" class="w-full">
|
|
||||||
<div
|
|
||||||
v-if="inputType === 'search_select'"
|
v-if="inputType === 'search_select'"
|
||||||
class="multiselect-wrap--small"
|
v-model="action_params"
|
||||||
>
|
:options="dropdownValues"
|
||||||
<multiselect
|
:dropdown-max-height="dropdownMaxHeight"
|
||||||
v-model="action_params"
|
/>
|
||||||
track-by="id"
|
<MultiSelect
|
||||||
label="name"
|
|
||||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
|
||||||
selected-label
|
|
||||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
|
||||||
deselect-label=""
|
|
||||||
:max-height="160"
|
|
||||||
:options="dropdownValues"
|
|
||||||
:allow-empty="false"
|
|
||||||
:option-height="104"
|
|
||||||
>
|
|
||||||
<template #noOptions>
|
|
||||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
|
||||||
</template>
|
|
||||||
</multiselect>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="inputType === 'multi_select'"
|
v-else-if="inputType === 'multi_select'"
|
||||||
class="multiselect-wrap--small"
|
v-model="action_params"
|
||||||
>
|
:options="dropdownValues"
|
||||||
<multiselect
|
:dropdown-max-height="dropdownMaxHeight"
|
||||||
v-model="action_params"
|
/>
|
||||||
track-by="id"
|
<NextInput
|
||||||
label="name"
|
|
||||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
|
||||||
multiple
|
|
||||||
selected-label
|
|
||||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
|
||||||
deselect-label=""
|
|
||||||
:max-height="160"
|
|
||||||
:options="dropdownValues"
|
|
||||||
:allow-empty="false"
|
|
||||||
:option-height="104"
|
|
||||||
>
|
|
||||||
<template #noOptions>
|
|
||||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
|
||||||
</template>
|
|
||||||
</multiselect>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-else-if="inputType === 'email'"
|
v-else-if="inputType === 'email'"
|
||||||
v-model="action_params"
|
v-model="action_params"
|
||||||
type="email"
|
type="email"
|
||||||
class="answer--text-input"
|
size="sm"
|
||||||
:placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')"
|
:placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
<input
|
<NextInput
|
||||||
v-else-if="inputType === 'url'"
|
v-else-if="inputType === 'url'"
|
||||||
v-model="action_params"
|
v-model="action_params"
|
||||||
type="url"
|
type="url"
|
||||||
class="answer--text-input"
|
size="sm"
|
||||||
:placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')"
|
:placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
<AutomationActionFileInput
|
<AutomationActionFileInput
|
||||||
v-if="inputType === 'attachment'"
|
v-else-if="inputType === 'attachment'"
|
||||||
v-model="action_params"
|
v-model="action_params"
|
||||||
:initial-file-name="initialFileName"
|
:initial-file-name="initialFileName"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
<NextButton
|
||||||
|
v-if="!isMacro"
|
||||||
|
sm
|
||||||
|
solid
|
||||||
|
slate
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
@click="removeAction"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<NextButton
|
<AutomationActionTeamMessageInput
|
||||||
v-if="!isMacro"
|
v-if="inputType === 'team_message'"
|
||||||
icon="i-lucide-x"
|
v-model="action_params"
|
||||||
slate
|
:teams="dropdownValues"
|
||||||
ghost
|
:dropdown-max-height="dropdownMaxHeight"
|
||||||
class="flex-shrink-0"
|
/>
|
||||||
@click="removeAction"
|
<WootMessageEditor
|
||||||
|
v-if="inputType === 'textarea'"
|
||||||
|
v-model="castMessageVmodel"
|
||||||
|
rows="4"
|
||||||
|
enable-variables
|
||||||
|
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||||
|
class="[&_.ProseMirror-menubar]:hidden px-3 py-1 bg-n-alpha-1 rounded-lg outline outline-1 outline-n-weak dark:outline-n-strong"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AutomationActionTeamMessageInput
|
<span v-if="errorMessage" class="text-sm text-n-ruby-11">
|
||||||
v-if="inputType === 'team_message'"
|
|
||||||
v-model="action_params"
|
|
||||||
:teams="dropdownValues"
|
|
||||||
/>
|
|
||||||
<WootMessageEditor
|
|
||||||
v-if="inputType === 'textarea'"
|
|
||||||
v-model="castMessageVmodel"
|
|
||||||
rows="4"
|
|
||||||
enable-variables
|
|
||||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
|
||||||
class="action-message"
|
|
||||||
/>
|
|
||||||
<p v-if="errorMessage" class="filter-error">
|
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.filter {
|
|
||||||
@apply bg-n-background p-2 border border-solid border-n-strong dark:border-n-strong rounded-lg mb-2;
|
|
||||||
|
|
||||||
&.is-a-macro {
|
|
||||||
@apply mb-0 bg-n-background dark:bg-n-solid-1 p-0 border-0 rounded-none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-margin-bottom {
|
|
||||||
@apply mb-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter.has-error {
|
|
||||||
@apply bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5;
|
|
||||||
|
|
||||||
&.is-a-macro {
|
|
||||||
@apply bg-transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-inputs {
|
|
||||||
@apply flex gap-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-error {
|
|
||||||
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action__question,
|
|
||||||
.filter__operator {
|
|
||||||
@apply mb-0 mr-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action__question {
|
|
||||||
@apply max-w-[50%];
|
|
||||||
}
|
|
||||||
|
|
||||||
.action__question.full-width {
|
|
||||||
@apply max-w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter__answer--wrap {
|
|
||||||
@apply max-w-[50%] flex-grow mr-1 flex w-full items-center justify-start;
|
|
||||||
|
|
||||||
input {
|
|
||||||
@apply mb-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.filter__answer {
|
|
||||||
&.answer--text-input {
|
|
||||||
@apply mb-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter__join-operator-wrap {
|
|
||||||
@apply relative z-20 m-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter__join-operator {
|
|
||||||
@apply flex items-center justify-center relative my-2.5 mx-0;
|
|
||||||
|
|
||||||
.operator__line {
|
|
||||||
@apply absolute w-full border-b border-solid border-n-weak;
|
|
||||||
}
|
|
||||||
|
|
||||||
.operator__select {
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
@apply relative w-auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.multiselect {
|
|
||||||
@apply mb-0;
|
|
||||||
}
|
|
||||||
.action-message {
|
|
||||||
@apply mt-2 mx-0 mb-0;
|
|
||||||
}
|
|
||||||
// Prosemirror does not have a native way of hiding the menu bar, hence
|
|
||||||
::v-deep .ProseMirror-menubar {
|
|
||||||
@apply hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: {
|
||||||
|
MultiSelect,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
teams: { type: Array, required: true },
|
teams: { type: Array, required: true },
|
||||||
modelValue: { type: Object, required: true },
|
modelValue: { type: Object, required: true },
|
||||||
|
dropdownMaxHeight: { type: String, default: 'max-h-80' },
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
data() {
|
data() {
|
||||||
@@ -12,9 +18,9 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
const { team_ids: teamIds } = this.modelValue;
|
const { team_ids: teamIds, message } = this.modelValue || {};
|
||||||
this.selectedTeams = teamIds;
|
this.selectedTeams = teamIds || [];
|
||||||
this.message = this.modelValue.message;
|
this.message = message || '';
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateValue() {
|
updateValue() {
|
||||||
@@ -28,37 +34,19 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col gap-2">
|
||||||
<div class="multiselect-wrap--small flex flex-col gap-1 mt-1">
|
<MultiSelect
|
||||||
<multiselect
|
v-model="selectedTeams"
|
||||||
v-model="selectedTeams"
|
:options="teams"
|
||||||
track-by="id"
|
:dropdown-max-height="dropdownMaxHeight"
|
||||||
label="name"
|
@update:model-value="updateValue"
|
||||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')"
|
/>
|
||||||
multiple
|
<textarea
|
||||||
selected-label
|
v-model="message"
|
||||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
class="mb-0 !text-sm"
|
||||||
deselect-label=""
|
rows="4"
|
||||||
:max-height="160"
|
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||||
:options="teams"
|
@input="updateValue"
|
||||||
:allow-empty="false"
|
/>
|
||||||
@update:model-value="updateValue"
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
v-model="message"
|
|
||||||
rows="4"
|
|
||||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
|
||||||
@input="updateValue"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.multiselect {
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
textarea {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ input[type='file'] {
|
|||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
@apply flex h-9 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-sm border border-dashed border-n-strong;
|
@apply flex h-8 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-lg border border-dashed border-n-strong;
|
||||||
}
|
}
|
||||||
.success-icon {
|
.success-icon {
|
||||||
@apply text-n-teal-9 mr-2;
|
@apply text-n-teal-9 mr-2;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user