Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b18e7009 |
@@ -1,65 +0,0 @@
|
|||||||
---
|
|
||||||
:position: before
|
|
||||||
:position_in_additional_file_patterns: before
|
|
||||||
:position_in_class: before
|
|
||||||
:position_in_factory: before
|
|
||||||
:position_in_fixture: before
|
|
||||||
:position_in_routes: before
|
|
||||||
:position_in_serializer: before
|
|
||||||
:position_in_test: before
|
|
||||||
:classified_sort: true
|
|
||||||
:exclude_controllers: true
|
|
||||||
:exclude_factories: true
|
|
||||||
:exclude_fixtures: true
|
|
||||||
:exclude_helpers: true
|
|
||||||
:exclude_scaffolds: true
|
|
||||||
:exclude_serializers: true
|
|
||||||
:exclude_sti_subclasses: false
|
|
||||||
:exclude_tests: true
|
|
||||||
:force: false
|
|
||||||
:format_markdown: false
|
|
||||||
:format_rdoc: false
|
|
||||||
:format_yard: false
|
|
||||||
:frozen: false
|
|
||||||
:grouped_polymorphic: false
|
|
||||||
:ignore_model_sub_dir: false
|
|
||||||
:ignore_unknown_models: false
|
|
||||||
:include_version: false
|
|
||||||
:show_check_constraints: false
|
|
||||||
:show_complete_foreign_keys: false
|
|
||||||
:show_foreign_keys: true
|
|
||||||
:show_indexes: true
|
|
||||||
:show_indexes_include: false
|
|
||||||
:simple_indexes: false
|
|
||||||
:sort: false
|
|
||||||
:timestamp: false
|
|
||||||
:trace: false
|
|
||||||
:with_comment: true
|
|
||||||
:with_column_comments: true
|
|
||||||
:with_table_comments: true
|
|
||||||
:position_of_column_comment: :with_name
|
|
||||||
:active_admin: false
|
|
||||||
:command:
|
|
||||||
:debug: false
|
|
||||||
:hide_default_column_types: json,jsonb,hstore
|
|
||||||
:hide_limit_column_types: integer,bigint,boolean
|
|
||||||
:timestamp_columns:
|
|
||||||
- created_at
|
|
||||||
- updated_at
|
|
||||||
:ignore_columns:
|
|
||||||
:ignore_routes:
|
|
||||||
:models: true
|
|
||||||
:routes: false
|
|
||||||
:skip_on_db_migrate: false
|
|
||||||
:target_action: :do_annotations
|
|
||||||
:wrapper:
|
|
||||||
:wrapper_close:
|
|
||||||
:wrapper_open:
|
|
||||||
:classes_default_to_s: []
|
|
||||||
:additional_file_patterns: []
|
|
||||||
:model_dir:
|
|
||||||
- app/models
|
|
||||||
- enterprise/app/models
|
|
||||||
:require: []
|
|
||||||
:root_dir:
|
|
||||||
- ''
|
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
---
|
---
|
||||||
ignore:
|
ignore:
|
||||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
||||||
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
|
||||||
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
|
|
||||||
# storage setup uses local/cloud storage with optional direct uploads to the
|
|
||||||
# storage provider rather than Rails proxy mode. Revisit if we enable
|
|
||||||
# rails_storage_proxy or other app-served Active Storage proxy routes.
|
|
||||||
- CVE-2026-33658
|
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/tmp
|
mkdir -p ~/tmp
|
||||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
|
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||||
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
|
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||||
|
|
||||||
# Bundle audit
|
# Bundle audit
|
||||||
- run:
|
- run:
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ yarn-debug.log*
|
|||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.cursor
|
.cursor
|
||||||
.codex/
|
.codex/
|
||||||
.claude/
|
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
|
|
||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
|
|||||||
@@ -68,15 +68,6 @@
|
|||||||
- Example: `feat(auth): add user authentication`
|
- Example: `feat(auth): add user authentication`
|
||||||
- Don't reference Claude in commit messages
|
- Don't reference Claude in commit messages
|
||||||
|
|
||||||
## PR Description Format
|
|
||||||
|
|
||||||
- Start with a short, user-facing paragraph describing the product change.
|
|
||||||
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
|
|
||||||
- For feature PRs, add `How to test` from a product/UX standpoint.
|
|
||||||
- For bugfix PRs, use `How to reproduce` when helpful.
|
|
||||||
- Optionally add a `What changed` section for implementation highlights.
|
|
||||||
- Do not add a `How this was tested` section listing specs/commands.
|
|
||||||
|
|
||||||
## Project-Specific
|
## Project-Specific
|
||||||
|
|
||||||
- **Translations**:
|
- **Translations**:
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.9.1'
|
gem 'ai-agents'
|
||||||
|
|
||||||
# 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'
|
||||||
@@ -268,7 +268,6 @@ group :development, :test do
|
|||||||
gem 'seed_dump'
|
gem 'seed_dump'
|
||||||
gem 'shoulda-matchers'
|
gem 'shoulda-matchers'
|
||||||
gem 'simplecov', '>= 0.21', require: false
|
gem 'simplecov', '>= 0.21', require: false
|
||||||
gem 'skooma'
|
|
||||||
gem 'spring'
|
gem 'spring'
|
||||||
gem 'spring-watcher-listen'
|
gem 'spring-watcher-listen'
|
||||||
end
|
end
|
||||||
|
|||||||
+11
-22
@@ -126,7 +126,7 @@ 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.9.1)
|
ai-agents (0.9.0)
|
||||||
ruby_llm (~> 1.9.1)
|
ruby_llm (~> 1.9.1)
|
||||||
annotaterb (4.20.0)
|
annotaterb (4.20.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
@@ -166,7 +166,7 @@ GEM
|
|||||||
multi_json (~> 1)
|
multi_json (~> 1)
|
||||||
statsd-ruby (~> 1.1)
|
statsd-ruby (~> 1.1)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.22)
|
bcrypt (3.1.20)
|
||||||
benchmark (0.4.1)
|
benchmark (0.4.1)
|
||||||
bigdecimal (3.2.2)
|
bigdecimal (3.2.2)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
@@ -191,7 +191,7 @@ GEM
|
|||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.5)
|
connection_pool (2.5.3)
|
||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
@@ -465,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.19.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)
|
||||||
@@ -473,12 +473,6 @@ GEM
|
|||||||
hana (~> 1.3)
|
hana (~> 1.3)
|
||||||
regexp_parser (~> 2.0)
|
regexp_parser (~> 2.0)
|
||||||
uri_template (~> 0.7)
|
uri_template (~> 0.7)
|
||||||
json_skooma (0.2.5)
|
|
||||||
bigdecimal
|
|
||||||
hana (~> 1.3)
|
|
||||||
regexp_parser (~> 2.0)
|
|
||||||
uri-idna (~> 0.2)
|
|
||||||
zeitwerk (~> 2.6)
|
|
||||||
judoscale-rails (1.8.2)
|
judoscale-rails (1.8.2)
|
||||||
judoscale-ruby (= 1.8.2)
|
judoscale-ruby (= 1.8.2)
|
||||||
railties
|
railties
|
||||||
@@ -589,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.19.1)
|
nokogiri (1.18.9)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-arm64-darwin)
|
nokogiri (1.18.9-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-x86_64-darwin)
|
nokogiri (1.18.9-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
nokogiri (1.18.9-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)
|
||||||
@@ -683,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.5)
|
rack (3.2.3)
|
||||||
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)
|
||||||
@@ -742,7 +736,7 @@ GEM
|
|||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
redis (5.0.6)
|
redis (5.0.6)
|
||||||
redis-client (>= 0.9.0)
|
redis-client (>= 0.9.0)
|
||||||
redis-client (0.26.4)
|
redis-client (0.22.2)
|
||||||
connection_pool
|
connection_pool
|
||||||
redis-namespace (1.10.0)
|
redis-namespace (1.10.0)
|
||||||
redis (>= 4)
|
redis (>= 4)
|
||||||
@@ -916,9 +910,6 @@ GEM
|
|||||||
simplecov_json_formatter (~> 0.1)
|
simplecov_json_formatter (~> 0.1)
|
||||||
simplecov-html (0.13.2)
|
simplecov-html (0.13.2)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
skooma (0.3.7)
|
|
||||||
json_skooma (~> 0.2.5)
|
|
||||||
zeitwerk (~> 2.6)
|
|
||||||
slack-ruby-client (2.7.0)
|
slack-ruby-client (2.7.0)
|
||||||
faraday (>= 2.0.1)
|
faraday (>= 2.0.1)
|
||||||
faraday-mashify
|
faraday-mashify
|
||||||
@@ -979,7 +970,6 @@ GEM
|
|||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uniform_notifier (1.17.0)
|
uniform_notifier (1.17.0)
|
||||||
uri (1.1.1)
|
uri (1.1.1)
|
||||||
uri-idna (0.3.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)
|
||||||
@@ -1034,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.9.1)
|
ai-agents
|
||||||
annotaterb
|
annotaterb
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
@@ -1153,7 +1143,6 @@ DEPENDENCIES
|
|||||||
sidekiq_alive
|
sidekiq_alive
|
||||||
simplecov (>= 0.21)
|
simplecov (>= 0.21)
|
||||||
simplecov_json_formatter
|
simplecov_json_formatter
|
||||||
skooma
|
|
||||||
slack-ruby-client (~> 2.7.0)
|
slack-ruby-client (~> 2.7.0)
|
||||||
spring
|
spring
|
||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
|
|||||||
@@ -40,12 +40,8 @@ run:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
force_run:
|
force_run:
|
||||||
@echo "Cleaning up Overmind processes..."
|
rm -f ./.overmind.sock
|
||||||
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
|
rm -f tmp/pids/*.pid
|
||||||
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
|
|
||||||
@rm -f ./.overmind.sock
|
|
||||||
@rm -f tmp/pids/*.pid
|
|
||||||
@echo "Cleanup complete"
|
|
||||||
overmind start -f Procfile.dev
|
overmind start -f Procfile.dev
|
||||||
|
|
||||||
force_run_tunnel:
|
force_run_tunnel:
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.12.1
|
4.11.2
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class ContactIdentifyAction
|
|||||||
# blank identifier or email will throw unique index error
|
# blank identifier or email will throw unique index error
|
||||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||||
@contact.save! if @contact.changed?
|
@contact.save!
|
||||||
enqueue_avatar_job
|
enqueue_avatar_job
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -105,19 +105,15 @@ 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
|
||||||
|
|||||||
@@ -2,17 +2,12 @@ class Messages::Messenger::MessageBuilder
|
|||||||
include ::FileTypeHelper
|
include ::FileTypeHelper
|
||||||
|
|
||||||
def process_attachment(attachment)
|
def process_attachment(attachment)
|
||||||
# This check handles very rare case if there are multiple files to attach with only one unsupported file
|
# This check handles very rare case if there are multiple files to attach with only one usupported file
|
||||||
return if unsupported_file_type?(attachment['type'])
|
return if unsupported_file_type?(attachment['type'])
|
||||||
|
|
||||||
params = attachment_params(attachment)
|
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
|
||||||
attachment_obj.save!
|
attachment_obj.save!
|
||||||
if facebook_reel?(attachment)
|
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||||
update_facebook_reel_content(attachment)
|
|
||||||
elsif params[:remote_file_url]
|
|
||||||
attach_file(attachment_obj, params[:remote_file_url])
|
|
||||||
end
|
|
||||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||||
@@ -31,7 +26,7 @@ class Messages::Messenger::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def attachment_params(attachment)
|
def attachment_params(attachment)
|
||||||
file_type = normalize_file_type(attachment['type'])
|
file_type = attachment['type'].to_sym
|
||||||
params = { file_type: file_type, account_id: @message.account_id }
|
params = { file_type: file_type, account_id: @message.account_id }
|
||||||
|
|
||||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||||
@@ -105,28 +100,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Facebook may send attachment types that don't directly match our file_type enum.
|
|
||||||
# Map known aliases to their canonical enum values.
|
|
||||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
|
|
||||||
|
|
||||||
def normalize_file_type(type)
|
|
||||||
sym = type.to_sym
|
|
||||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
|
||||||
# direct video URLs. Downloading these yields HTML, not video content.
|
|
||||||
def facebook_reel?(attachment)
|
|
||||||
attachment['type'].to_sym == :reel
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_facebook_reel_content(attachment)
|
|
||||||
url = attachment.dig('payload', 'url')
|
|
||||||
return if url.blank?
|
|
||||||
|
|
||||||
@message.update!(content: url) if @message.content.blank?
|
|
||||||
end
|
|
||||||
|
|
||||||
def unsupported_file_type?(attachment_type)
|
def unsupported_file_type?(attachment_type)
|
||||||
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def reorder
|
def reorder
|
||||||
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
Article.update_positions(params[:positions_hash])
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :fetch_category, except: [:index, :create, :reorder]
|
before_action :fetch_category, except: [:index, :create]
|
||||||
before_action :set_current_page, only: [:index]
|
before_action :set_current_page, only: [:index]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@@ -32,11 +32,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
|||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def reorder
|
|
||||||
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
|
||||||
head :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fetch_category
|
def fetch_category
|
||||||
@@ -44,7 +39,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
|||||||
end
|
end
|
||||||
|
|
||||||
def portal
|
def portal
|
||||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def related_categories_records
|
def related_categories_records
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
included do
|
|
||||||
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
|
||||||
before_action :check_admin_authorization?, only: [:register_webhook]
|
|
||||||
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
|
|
||||||
end
|
|
||||||
|
|
||||||
def sync_templates
|
|
||||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
|
||||||
|
|
||||||
trigger_template_sync
|
|
||||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
|
||||||
rescue StandardError => e
|
|
||||||
render status: :internal_server_error, json: { error: e.message }
|
|
||||||
end
|
|
||||||
|
|
||||||
def health
|
|
||||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
|
||||||
render json: health_data
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
|
||||||
render json: { error: e.message }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
def register_webhook
|
|
||||||
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
|
||||||
|
|
||||||
render json: { message: 'Webhook registered successfully' }, status: :ok
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
|
|
||||||
render json: { error: e.message }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def validate_whatsapp_cloud_channel
|
|
||||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
|
||||||
|
|
||||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
|
||||||
end
|
|
||||||
|
|
||||||
def whatsapp_channel?
|
|
||||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
|
||||||
end
|
|
||||||
|
|
||||||
def trigger_template_sync
|
|
||||||
if @inbox.whatsapp?
|
|
||||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
|
||||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
|
||||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -201,9 +201,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def fetch_contact
|
def fetch_contact
|
||||||
contact_scope = Current.account.contacts
|
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
|
||||||
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
|
|
||||||
@contact = contact_scope.find(params[:id])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_avatar_from_url
|
def process_avatar_from_url
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def meta
|
def meta
|
||||||
result = conversation_finder.perform_meta_only
|
result = conversation_finder.perform
|
||||||
@conversations_count = result[:count]
|
@conversations_count = result[:count]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def toggle_typing_status
|
def toggle_typing_status
|
||||||
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params)
|
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
|
||||||
typing_status_manager.toggle_typing_status
|
typing_status_manager.toggle_typing_status
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
@@ -116,8 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
|
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
|
||||||
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
|
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
|
||||||
# Always update immediately if there are unread messages to maintain accurate read/unread state.
|
# Always update immediately if there are unread messages to maintain accurate read/unread state.
|
||||||
# Visiting a conversation should clear any unread inbox notifications for this conversation.
|
|
||||||
Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform
|
|
||||||
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
|
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
|
||||||
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
|
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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)
|
||||||
@@ -25,23 +24,6 @@ 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
|
||||||
@@ -64,12 +46,6 @@ 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)
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||||
before_action :validate_limit, only: [:create]
|
before_action :validate_limit, only: [:create]
|
||||||
# we are already handling the authorization in fetch inbox
|
# we are already handling the authorization in fetch inbox
|
||||||
before_action :check_authorization, except: [:show]
|
before_action :check_authorization, except: [:show, :health]
|
||||||
|
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||||
@@ -71,6 +70,23 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sync_templates
|
||||||
|
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||||
|
|
||||||
|
trigger_template_sync
|
||||||
|
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||||
|
rescue StandardError => e
|
||||||
|
render status: :internal_server_error, json: { error: e.message }
|
||||||
|
end
|
||||||
|
|
||||||
|
def health
|
||||||
|
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||||
|
render json: health_data
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fetch_inbox
|
def fetch_inbox
|
||||||
@@ -82,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_whatsapp_cloud_channel
|
||||||
|
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||||
|
|
||||||
|
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
def create_channel
|
def create_channel
|
||||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||||
|
|
||||||
@@ -178,6 +200,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
def get_channel_attributes(channel_type)
|
def get_channel_attributes(channel_type)
|
||||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def whatsapp_channel?
|
||||||
|
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def trigger_template_sync
|
||||||
|
if @inbox.whatsapp?
|
||||||
|
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||||
|
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||||
|
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
|||||||
return unless @hook&.access_token
|
return unless @hook&.access_token
|
||||||
|
|
||||||
begin
|
begin
|
||||||
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
|
linear_client = Linear.new(@hook.access_token)
|
||||||
linear_client.revoke_token
|
linear_client.revoke_token
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
|
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
def portal_params
|
def portal_params
|
||||||
params.require(:portal).permit(
|
params.require(:portal).permit(
|
||||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
|
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_captcha
|
def validate_captcha
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
|||||||
contact = @contact
|
contact = @contact
|
||||||
end
|
end
|
||||||
|
|
||||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
|
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||||
|
|
||||||
identify_contact(contact)
|
identify_contact(contact)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -43,15 +43,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_conversation
|
def set_conversation
|
||||||
return unless conversation.nil?
|
@conversation = create_conversation if conversation.nil?
|
||||||
|
|
||||||
@conversation = create_conversation
|
|
||||||
apply_labels if permitted_params[:labels].present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def apply_labels
|
|
||||||
valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title)
|
|
||||||
@conversation.update_labels(valid_labels) if valid_labels.present?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_finder_params
|
def message_finder_params
|
||||||
@@ -72,14 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
# timestamp parameter is used in create conversation method
|
# timestamp parameter is used in create conversation method
|
||||||
# custom_attributes and labels are applied when a new conversation is created alongside the first message
|
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
|
||||||
params.permit(
|
|
||||||
:id, :before, :after, :website_token,
|
|
||||||
contact: [:name, :email],
|
|
||||||
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
|
|
||||||
custom_attributes: {},
|
|
||||||
labels: []
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_message
|
def set_message
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_captcha
|
def validate_captcha
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module AccessTokenAuthHelper
|
module AccessTokenAuthHelper
|
||||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes],
|
||||||
'api/v1/accounts/conversations/messages' => ['create'],
|
'api/v1/accounts/conversations/messages' => ['create'],
|
||||||
'api/v1/accounts/conversations/assignments' => ['create']
|
'api/v1/accounts/conversations/assignments' => ['create']
|
||||||
}.freeze
|
}.freeze
|
||||||
@@ -28,7 +28,7 @@ module AccessTokenAuthHelper
|
|||||||
|
|
||||||
def validate_bot_access_token!
|
def validate_bot_access_token!
|
||||||
return if Current.user.is_a?(User)
|
return if Current.user.is_a?(User)
|
||||||
return if @resource.is_a?(AgentBot) && agent_bot_accessible?
|
return if agent_bot_accessible?
|
||||||
|
|
||||||
render_unauthorized('Access to this endpoint is not authorized for bots')
|
render_unauthorized('Access to this endpoint is not authorized for bots')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
private
|
private
|
||||||
|
|
||||||
def sign_in_user
|
def sign_in_user
|
||||||
# Capture before skip_confirmation! sets confirmed_at, which would
|
|
||||||
# make oauth_user_needs_password_reset? return false and skip the
|
|
||||||
# password reset for persisted unconfirmed users.
|
|
||||||
needs_password_reset = oauth_user_needs_password_reset?
|
|
||||||
@resource.skip_confirmation! if confirmable_enabled?
|
@resource.skip_confirmation! if confirmable_enabled?
|
||||||
set_random_password_if_oauth_user if needs_password_reset
|
|
||||||
|
|
||||||
# once the resource is found and verified
|
# once the resource is found and verified
|
||||||
# we can just send them to the login page again with the SSO params
|
# we can just send them to the login page again with the SSO params
|
||||||
@@ -25,10 +20,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sign_in_user_on_mobile
|
def sign_in_user_on_mobile
|
||||||
# See comment in sign_in_user for why this is captured before skip_confirmation!
|
|
||||||
needs_password_reset = oauth_user_needs_password_reset?
|
|
||||||
@resource.skip_confirmation! if confirmable_enabled?
|
@resource.skip_confirmation! if confirmable_enabled?
|
||||||
set_random_password_if_oauth_user if needs_password_reset
|
|
||||||
|
|
||||||
# once the resource is found and verified
|
# once the resource is found and verified
|
||||||
# we can just send them to the login page again with the SSO params
|
# we can just send them to the login page again with the SSO params
|
||||||
@@ -45,7 +37,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||||
|
|
||||||
create_account_for_user
|
create_account_for_user
|
||||||
set_random_password_if_oauth_user
|
|
||||||
token = @resource.send(:set_reset_password_token)
|
token = @resource.send(:set_reset_password_token)
|
||||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||||
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
|
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
|
||||||
@@ -60,7 +51,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
end
|
end
|
||||||
|
|
||||||
def account_signup_allowed?
|
def account_signup_allowed?
|
||||||
GlobalConfigService.account_signup_enabled?
|
# set it to true by default, this is the behaviour across the app
|
||||||
|
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
|
||||||
end
|
end
|
||||||
|
|
||||||
def resource_class(_mapping = nil)
|
def resource_class(_mapping = nil)
|
||||||
@@ -90,15 +82,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
||||||
end
|
end
|
||||||
|
|
||||||
def oauth_user_needs_password_reset?
|
|
||||||
@resource.present? && (@resource.new_record? || !@resource.confirmed?)
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_random_password_if_oauth_user
|
|
||||||
# Password must satisfy secure_password requirements (uppercase, lowercase, number, special char)
|
|
||||||
@resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted?
|
|
||||||
end
|
|
||||||
|
|
||||||
def default_devise_mapping
|
def default_devise_mapping
|
||||||
'user'
|
'user'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ class Linear::CallbacksController < ApplicationController
|
|||||||
include Linear::IntegrationHelper
|
include Linear::IntegrationHelper
|
||||||
|
|
||||||
def show
|
def show
|
||||||
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
|
|
||||||
|
|
||||||
@response = oauth_client.auth_code.get_token(
|
@response = oauth_client.auth_code.get_token(
|
||||||
params[:code],
|
params[:code],
|
||||||
redirect_uri: "#{base_url}/linear/callback"
|
redirect_uri: "#{base_url}/linear/callback"
|
||||||
@@ -12,7 +10,7 @@ class Linear::CallbacksController < ApplicationController
|
|||||||
handle_response
|
handle_response
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error("Linear callback error: #{e.message}")
|
Rails.logger.error("Linear callback error: #{e.message}")
|
||||||
redirect_to safe_linear_redirect_uri
|
redirect_to linear_redirect_uri
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -33,19 +31,22 @@ class Linear::CallbacksController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_response
|
def handle_response
|
||||||
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
|
hook = account.hooks.new(
|
||||||
|
|
||||||
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
|
|
||||||
hook.assign_attributes(
|
|
||||||
access_token: parsed_body['access_token'],
|
access_token: parsed_body['access_token'],
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
settings: merged_integration_settings(hook.settings)
|
app_id: 'linear',
|
||||||
|
settings: {
|
||||||
|
token_type: parsed_body['token_type'],
|
||||||
|
expires_in: parsed_body['expires_in'],
|
||||||
|
scope: parsed_body['scope']
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
|
||||||
hook.save!
|
hook.save!
|
||||||
redirect_to linear_redirect_uri
|
redirect_to linear_redirect_uri
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error("Linear callback error: #{e.message}")
|
Rails.logger.error("Linear callback error: #{e.message}")
|
||||||
redirect_to safe_linear_redirect_uri
|
redirect_to linear_redirect_uri
|
||||||
end
|
end
|
||||||
|
|
||||||
def account
|
def account
|
||||||
@@ -53,47 +54,19 @@ class Linear::CallbacksController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def account_id
|
def account_id
|
||||||
return @account_id if instance_variable_defined?(:@account_id)
|
return unless params[:state]
|
||||||
|
|
||||||
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil
|
verify_linear_token(params[:state])
|
||||||
end
|
end
|
||||||
|
|
||||||
def linear_redirect_uri
|
def linear_redirect_uri
|
||||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
|
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
|
||||||
end
|
end
|
||||||
|
|
||||||
def safe_linear_redirect_uri
|
|
||||||
return base_url if account_id.blank?
|
|
||||||
|
|
||||||
linear_redirect_uri
|
|
||||||
rescue StandardError
|
|
||||||
base_url
|
|
||||||
end
|
|
||||||
|
|
||||||
def parsed_body
|
def parsed_body
|
||||||
@parsed_body ||= @response.response.parsed
|
@parsed_body ||= @response.response.parsed
|
||||||
end
|
end
|
||||||
|
|
||||||
def integration_settings
|
|
||||||
{
|
|
||||||
token_type: parsed_body['token_type'],
|
|
||||||
expires_in: parsed_body['expires_in'],
|
|
||||||
expires_on: expires_on,
|
|
||||||
scope: parsed_body['scope'],
|
|
||||||
refresh_token: parsed_body['refresh_token']
|
|
||||||
}.compact
|
|
||||||
end
|
|
||||||
|
|
||||||
def merged_integration_settings(existing_settings)
|
|
||||||
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
|
|
||||||
end
|
|
||||||
|
|
||||||
def expires_on
|
|
||||||
return if parsed_body['expires_in'].blank?
|
|
||||||
|
|
||||||
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def base_url
|
def base_url
|
||||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
|
|
||||||
before_action :set_account
|
|
||||||
before_action :validate_account_permissible
|
|
||||||
before_action :validate_feature_flag
|
|
||||||
before_action :validate_params
|
|
||||||
|
|
||||||
def create
|
|
||||||
results = migrate_email_channels
|
|
||||||
render json: { results: results }, status: :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_account
|
|
||||||
@account = Account.find(params[:account_id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_account_permissible
|
|
||||||
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
|
|
||||||
|
|
||||||
render json: { error: 'Non permissible resource' }, status: :unauthorized
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_feature_flag
|
|
||||||
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
|
|
||||||
|
|
||||||
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_params
|
|
||||||
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
|
|
||||||
|
|
||||||
return unless migration_params.size > MAX_MIGRATIONS
|
|
||||||
|
|
||||||
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
|
|
||||||
status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
def migrate_email_channels
|
|
||||||
migration_params.map { |entry| migrate_single(entry) }
|
|
||||||
end
|
|
||||||
|
|
||||||
MAX_MIGRATIONS = 25
|
|
||||||
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
|
|
||||||
|
|
||||||
def migrate_single(entry)
|
|
||||||
validate_provider!(entry[:provider])
|
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
|
||||||
channel = create_channel(entry)
|
|
||||||
inbox = create_inbox(channel, entry)
|
|
||||||
|
|
||||||
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
|
||||||
{ email: entry[:email], status: 'error', message: e.message }
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_channel(entry)
|
|
||||||
Channel::Email.create!(
|
|
||||||
account_id: @account.id,
|
|
||||||
email: entry[:email],
|
|
||||||
provider: entry[:provider],
|
|
||||||
provider_config: entry[:provider_config]&.to_h,
|
|
||||||
imap_enabled: entry.fetch(:imap_enabled, true),
|
|
||||||
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
|
|
||||||
imap_port: entry[:imap_port] || 993,
|
|
||||||
imap_login: entry[:imap_login] || entry[:email],
|
|
||||||
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_inbox(channel, entry)
|
|
||||||
@account.inboxes.create!(
|
|
||||||
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
|
|
||||||
channel: channel
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_provider!(provider)
|
|
||||||
return if SUPPORTED_PROVIDERS.include?(provider)
|
|
||||||
|
|
||||||
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def default_imap_address(provider)
|
|
||||||
case provider
|
|
||||||
when 'google' then 'imap.gmail.com'
|
|
||||||
when 'microsoft' then 'outlook.office365.com'
|
|
||||||
else ''
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def migration_params
|
|
||||||
params.permit(migrations: [
|
|
||||||
:email, :provider, :inbox_name,
|
|
||||||
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
|
|
||||||
{ provider_config: {} }
|
|
||||||
])[:migrations]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :ensure_portal_feature_enabled
|
|
||||||
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||||
before_action :set_article, only: [:show]
|
before_action :set_article, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@search_query = list_params[:query]
|
|
||||||
@articles = @portal.articles.published.includes(:category, :author)
|
@articles = @portal.articles.published.includes(:category, :author)
|
||||||
|
|
||||||
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
|
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
|
||||||
@@ -75,9 +73,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
end
|
end
|
||||||
|
|
||||||
def list_params
|
def list_params
|
||||||
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
|
params.permit(:query, :locale, :sort, :status, :page, :per_page)
|
||||||
permitted[:query] = permitted[:query].to_s.strip.presence
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :ensure_portal_feature_enabled
|
|
||||||
before_action :set_category, only: [:show]
|
before_action :set_category, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show]
|
before_action :ensure_custom_domain_request, only: [:show]
|
||||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :ensure_portal_feature_enabled
|
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@@ -25,7 +24,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
|||||||
def redirect_to_portal_with_locale
|
def redirect_to_portal_with_locale
|
||||||
return if params[:locale].present?
|
return if params[:locale].present?
|
||||||
|
|
||||||
portal
|
|
||||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,11 +18,4 @@ class PublicController < ActionController::Base
|
|||||||
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
||||||
}, status: :unauthorized and return
|
}, status: :unauthorized and return
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_portal_feature_enabled
|
|
||||||
return unless ChatwootApp.chatwoot_cloud?
|
|
||||||
return if @portal.account.feature_enabled?('help_center')
|
|
||||||
|
|
||||||
render 'public/api/v1/portals/not_active', status: :payment_required
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -77,23 +77,13 @@ class WidgetsController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def allow_iframe_requests
|
def allow_iframe_requests
|
||||||
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
|
if @web_widget.allowed_domains.blank?
|
||||||
response.headers.delete('X-Frame-Options')
|
response.headers.delete('X-Frame-Options')
|
||||||
else
|
else
|
||||||
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
|
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
|
||||||
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
|
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
|
|
||||||
# which cannot match any domain in frame-ancestors. When the per-inbox flag
|
|
||||||
# is enabled, skip frame-ancestors for these requests.
|
|
||||||
def embedded_from_non_web_origin?
|
|
||||||
return false unless @web_widget.allow_mobile_webview?
|
|
||||||
|
|
||||||
origin = request.headers['Origin']
|
|
||||||
origin.blank? || origin == 'null' || origin&.start_with?('file://')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
WidgetsController.prepend_mod_with('WidgetsController')
|
WidgetsController.prepend_mod_with('WidgetsController')
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard
|
|||||||
current_sign_in_ip: Field::String,
|
current_sign_in_ip: Field::String,
|
||||||
last_sign_in_ip: Field::String,
|
last_sign_in_ip: Field::String,
|
||||||
confirmation_token: Field::String,
|
confirmation_token: Field::String,
|
||||||
confirmed_at: ConfirmedAtField,
|
confirmed_at: Field::DateTime,
|
||||||
confirmation_sent_at: Field::DateTime,
|
confirmation_sent_at: Field::DateTime,
|
||||||
unconfirmed_email: Field::String,
|
unconfirmed_email: Field::String,
|
||||||
name: Field::String.with_options(searchable: true),
|
name: Field::String.with_options(searchable: true),
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
require 'administrate/field/base'
|
|
||||||
|
|
||||||
class ConfirmedAtField < Administrate::Field::DateTime
|
|
||||||
end
|
|
||||||
@@ -11,7 +11,6 @@ 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],
|
||||||
@@ -56,22 +55,6 @@ 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,35 +57,39 @@ 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_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
|
set_enable_tls(channel_data, smtp)
|
||||||
elsif channel_data[:smtp_enable_starttls_auto]
|
elsif channel_data[:smtp_enable_starttls_auto]
|
||||||
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
|
set_enable_starttls_auto(channel_data, smtp)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
|
def set_enable_starttls_auto(channel_data, smtp)
|
||||||
return unless smtp.respond_to?(method)
|
return unless smtp.respond_to?(:enable_starttls_auto)
|
||||||
|
|
||||||
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
|
if channel_data[:smtp_openssl_verify_mode]
|
||||||
context ? smtp.send(method, context) : smtp.send(method)
|
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
||||||
|
smtp.enable_starttls_auto(context)
|
||||||
|
else
|
||||||
|
smtp.enable_starttls_auto
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_enable_tls(channel_data, smtp)
|
||||||
|
return unless smtp.respond_to?(:enable_tls)
|
||||||
|
|
||||||
|
if channel_data[:smtp_openssl_verify_mode]
|
||||||
|
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
||||||
|
smtp.enable_tls(context)
|
||||||
|
else
|
||||||
|
smtp.enable_tls
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def enable_openssl_mode(smtp_openssl_verify_mode)
|
def enable_openssl_mode(smtp_openssl_verify_mode)
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
module TimezoneHelper
|
module TimezoneHelper
|
||||||
def timezone_name_from_params(timezone, offset)
|
|
||||||
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
|
|
||||||
|
|
||||||
timezone_name_from_offset(offset)
|
|
||||||
end
|
|
||||||
|
|
||||||
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
||||||
# would return the timezone without considering day light savings. To get the correct timezone,
|
# would return the timezone without considering day light savings. To get the correct timezone,
|
||||||
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
||||||
|
|||||||
@@ -98,9 +98,7 @@ export default {
|
|||||||
mql.onchange = e => setColorTheme(e.matches);
|
mql.onchange = e => setColorTheme(e.matches);
|
||||||
},
|
},
|
||||||
setLocale(locale) {
|
setLocale(locale) {
|
||||||
if (locale) {
|
this.$root.$i18n.locale = locale;
|
||||||
this.$root.$i18n.locale = locale;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async initializeAccount() {
|
async initializeAccount() {
|
||||||
await this.$store.dispatch('accounts/get');
|
await this.$store.dispatch('accounts/get');
|
||||||
@@ -166,4 +164,10 @@ 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>
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ class CaptainCustomTools extends ApiClient {
|
|||||||
delete(id) {
|
delete(id) {
|
||||||
return axios.delete(`${this.url}/${id}`);
|
return axios.delete(`${this.url}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
test(data = {}) {
|
|
||||||
return axios.post(`${this.url}/test`, {
|
|
||||||
custom_tool: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CaptainCustomTools();
|
export default new CaptainCustomTools();
|
||||||
|
|||||||
@@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||||
}
|
}
|
||||||
|
|
||||||
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
|
search(search = '', page = 1, sortAttr = 'name', label = '') {
|
||||||
let requestURL = `${this.url}/search?${buildContactParams(
|
let requestURL = `${this.url}/search?${buildContactParams(
|
||||||
page,
|
page,
|
||||||
sortAttr,
|
sortAttr,
|
||||||
label,
|
label,
|
||||||
search
|
search
|
||||||
)}`;
|
)}`;
|
||||||
return axios.get(requestURL, { signal: options.signal });
|
return axios.get(requestURL);
|
||||||
}
|
}
|
||||||
|
|
||||||
active(page = 1, sortAttr = 'name') {
|
active(page = 1, sortAttr = 'name') {
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ class CategoriesAPI extends PortalsAPI {
|
|||||||
delete({ portalSlug, categoryId }) {
|
delete({ portalSlug, categoryId }) {
|
||||||
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
|
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
reorder({ portalSlug, reorderedGroup }) {
|
|
||||||
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
|
|
||||||
positions_hash: reorderedGroup,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CategoriesAPI();
|
export default new CategoriesAPI();
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ class InboxHealthAPI extends ApiClient {
|
|||||||
getHealthStatus(inboxId) {
|
getHealthStatus(inboxId) {
|
||||||
return axios.get(`${this.url}/${inboxId}/health`);
|
return axios.get(`${this.url}/${inboxId}/health`);
|
||||||
}
|
}
|
||||||
|
|
||||||
registerWebhook(inboxId) {
|
|
||||||
return axios.post(`${this.url}/${inboxId}/register_webhook`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new InboxHealthAPI();
|
export default new InboxHealthAPI();
|
||||||
|
|||||||
@@ -42,12 +42,6 @@ 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();
|
||||||
|
|||||||
@@ -68,19 +68,7 @@ describe('#ContactsAPI', () => {
|
|||||||
it('#search', () => {
|
it('#search', () => {
|
||||||
contactAPI.search('leads', 1, 'date', 'customer-support');
|
contactAPI.search('leads', 1, 'date', 'customer-support');
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
|
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
|
||||||
{ signal: undefined }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#search with signal', () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
contactAPI.search('leads', 1, 'date', 'customer-support', {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
|
|
||||||
{ signal: controller.signal }
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,5 @@ describe('#BulkActionsAPI', () => {
|
|||||||
expect(categoriesAPI).toHaveProperty('create');
|
expect(categoriesAPI).toHaveProperty('create');
|
||||||
expect(categoriesAPI).toHaveProperty('update');
|
expect(categoriesAPI).toHaveProperty('update');
|
||||||
expect(categoriesAPI).toHaveProperty('delete');
|
expect(categoriesAPI).toHaveProperty('delete');
|
||||||
expect(categoriesAPI).toHaveProperty('reorder');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
|
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-disabled {
|
.field-disabled {
|
||||||
@@ -78,7 +78,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)";
|
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
|
||||||
|
|
||||||
#{$form-input-selector} {
|
#{$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'] {
|
input[type='file']:not(.multiselect__input) {
|
||||||
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
|
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +106,6 @@ select {
|
|||||||
&[disabled] {
|
&[disabled] {
|
||||||
@apply field-disabled;
|
@apply field-disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
option:not(:disabled) {
|
|
||||||
@apply bg-n-solid-2 text-n-slate-12;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Textarea
|
// Textarea
|
||||||
@@ -130,6 +126,13 @@ 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},
|
||||||
@@ -147,7 +150,9 @@ 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,6 +13,7 @@
|
|||||||
@import 'base';
|
@import 'base';
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
|
@import 'plugins/multiselect';
|
||||||
@import 'plugins/date-picker';
|
@import 'plugins/date-picker';
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -65,84 +66,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
@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-heading-2 text-n-slate-12 line-clamp-1">
|
<h3 class="text-base font-medium 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-body-para line-clamp-1 mb-0 py-1">
|
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||||
{{ description }}
|
{{ 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-heading-2">{{ title }}</h3>
|
<h3 class="text-n-slate-12 text-base font-medium">{{ 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-body-para mb-0">{{ description }}</p>
|
<p class="text-n-slate-11 text-sm 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-body-para"
|
class="flex items-center gap-3 text-sm"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
:icon="feature.icon"
|
:icon="feature.icon"
|
||||||
|
|||||||
+18
-14
@@ -60,19 +60,23 @@ 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-heading-2 text-n-slate-12 line-clamp-1">
|
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</h3>
|
</h3>
|
||||||
<CardPopover
|
<div class="flex items-center gap-2">
|
||||||
:title="
|
<CardPopover
|
||||||
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER')
|
:title="
|
||||||
"
|
t(
|
||||||
icon="i-lucide-inbox"
|
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
|
||||||
:count="assignedInboxCount"
|
)
|
||||||
:items="inboxes"
|
"
|
||||||
:is-fetching="isFetchingInboxes"
|
icon="i-lucide-inbox"
|
||||||
@fetch="handleFetchInboxes"
|
: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
|
||||||
@@ -89,18 +93,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-body-para line-clamp-1 mb-0 py-1">
|
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||||
{{ description }}
|
{{ 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-body-para">
|
<span v-if="order" class="text-n-slate-11 text-sm">
|
||||||
{{
|
{{
|
||||||
`${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-body-para">
|
<span v-if="priority" class="text-n-slate-11 text-sm">
|
||||||
{{
|
{{
|
||||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
|
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
|
||||||
}}
|
}}
|
||||||
|
|||||||
+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';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
disabledMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
if (!props.isActive && !props.disabled) {
|
||||||
|
emit('select', props.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative 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="absolute top-4 right-4">
|
||||||
|
<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"
|
||||||
|
@change="handleChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex flex-col gap-3 items-start">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ label }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
v-if="disabled"
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ disabled && disabledMessage ? disabledMessage : description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+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');
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
@@ -22,18 +21,14 @@ 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">
|
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
||||||
<div class="w-full max-w-5xl mx-auto">
|
<div class="w-full max-w-[60rem] 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-heading-1 text-n-slate-12">
|
<span class="text-xl font-medium text-n-slate-12">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
v-on-click-outside="[
|
v-on-clickaway="() => emit('close')"
|
||||||
() => emit('close'),
|
|
||||||
// This will prevent closing the modal when the editor Create link popup is open
|
|
||||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
|
||||||
]"
|
|
||||||
class="relative group/campaign-button"
|
class="relative group/campaign-button"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -48,8 +43,8 @@ const handleButtonClick = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto">
|
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||||
<div class="w-full max-w-5xl mx-auto py-4">
|
<div class="w-full max-w-[60rem] 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 -outline-offset-1 group/cardLayout rounded-xl bg-n-solid-2"
|
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
|
||||||
>
|
>
|
||||||
<div
|
<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 px-6">
|
<header class="sticky top-0 z-10">
|
||||||
<div
|
<div
|
||||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||||
>
|
>
|
||||||
<span class="text-heading-1 truncate text-n-slate-12">
|
<span class="text-xl font-medium 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,18 +32,17 @@ 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 px-6 overflow-y-auto">
|
<main class="flex-1 overflow-y-auto">
|
||||||
<div class="w-full mx-auto max-w-5xl py-4">
|
<div class="w-full mx-auto max-w-[60rem]">
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||||
<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,7 +44,6 @@ 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',
|
||||||
@@ -67,7 +66,6 @@ const defaultState = {
|
|||||||
facebook: '',
|
facebook: '',
|
||||||
github: '',
|
github: '',
|
||||||
instagram: '',
|
instagram: '',
|
||||||
telegram: '',
|
|
||||||
tiktok: '',
|
tiktok: '',
|
||||||
linkedin: '',
|
linkedin: '',
|
||||||
twitter: '',
|
twitter: '',
|
||||||
@@ -105,13 +103,9 @@ 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,
|
||||||
@@ -125,10 +119,7 @@ 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 px-6">
|
<header class="sticky top-0 z-10">
|
||||||
<div
|
<div
|
||||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||||
>
|
>
|
||||||
<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
-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-5xl"
|
class="max-w-[60rem] px-6"
|
||||||
@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 px-6">
|
<main class="flex-1 overflow-y-auto">
|
||||||
<div class="w-full mx-auto max-w-5xl">
|
<div class="w-full mx-auto max-w-[60rem]">
|
||||||
<ContactsActiveFiltersPreview
|
<ContactsActiveFiltersPreview
|
||||||
v-if="showActiveFiltersPreview"
|
v-if="showActiveFiltersPreview"
|
||||||
:active-segment="activeSegment"
|
:active-segment="activeSegment"
|
||||||
@@ -114,12 +114,11 @@ const showPagination = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="showPagination" class="sticky bottom-0 z-0">
|
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||||
<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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+195
-51
@@ -1,63 +1,207 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
defineProps({
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
priority: {
|
priority: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
showEmpty: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const icons = {
|
|
||||||
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
|
|
||||||
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
|
|
||||||
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
|
|
||||||
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
|
|
||||||
};
|
|
||||||
|
|
||||||
const priorityLabels = {
|
|
||||||
[CONVERSATION_PRIORITY.URGENT]: 'CONVERSATION.PRIORITY.OPTIONS.URGENT',
|
|
||||||
[CONVERSATION_PRIORITY.HIGH]: 'CONVERSATION.PRIORITY.OPTIONS.HIGH',
|
|
||||||
[CONVERSATION_PRIORITY.MEDIUM]: 'CONVERSATION.PRIORITY.OPTIONS.MEDIUM',
|
|
||||||
[CONVERSATION_PRIORITY.LOW]: 'CONVERSATION.PRIORITY.OPTIONS.LOW',
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconName = computed(() => {
|
|
||||||
if (props.priority && icons[props.priority]) {
|
|
||||||
return icons[props.priority];
|
|
||||||
}
|
|
||||||
return props.showEmpty ? 'i-woot-priority-empty' : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const tooltipContent = computed(() => {
|
|
||||||
if (props.priority && priorityLabels[props.priority]) {
|
|
||||||
return t(priorityLabels[props.priority]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.showEmpty) {
|
|
||||||
return t('CONVERSATION.PRIORITY.OPTIONS.NONE');
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||||
<template>
|
<template>
|
||||||
<Icon
|
<div class="inline-flex items-center justify-center rounded-md">
|
||||||
v-tooltip.top="{
|
<!-- Low Priority -->
|
||||||
content: tooltipContent,
|
<svg
|
||||||
delay: { show: 500, hide: 0 },
|
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||||
}"
|
width="20"
|
||||||
:icon="iconName"
|
height="20"
|
||||||
class="size-4 text-n-slate-5"
|
viewBox="0 0 20 20"
|
||||||
/>
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0_2030_12879"
|
||||||
|
style="mask-type: alpha"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<rect width="20" height="20" fill="#D9D9D9" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_2030_12879)">
|
||||||
|
<rect
|
||||||
|
x="3.33301"
|
||||||
|
y="10"
|
||||||
|
width="3.33333"
|
||||||
|
height="6.66667"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="8.33301"
|
||||||
|
y="6.6665"
|
||||||
|
width="3.33333"
|
||||||
|
height="10"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-slate-6"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="13.333"
|
||||||
|
y="3.3335"
|
||||||
|
width="3.33333"
|
||||||
|
height="13.3333"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-slate-6"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Medium Priority -->
|
||||||
|
<svg
|
||||||
|
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0_2030_12879"
|
||||||
|
style="mask-type: alpha"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<rect width="20" height="20" fill="#D9D9D9" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_2030_12879)">
|
||||||
|
<rect
|
||||||
|
x="3.33301"
|
||||||
|
y="10"
|
||||||
|
width="3.33333"
|
||||||
|
height="6.66667"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="8.33301"
|
||||||
|
y="6.6665"
|
||||||
|
width="3.33333"
|
||||||
|
height="10"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="13.333"
|
||||||
|
y="3.3335"
|
||||||
|
width="3.33333"
|
||||||
|
height="13.3333"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-slate-6"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- High Priority -->
|
||||||
|
<svg
|
||||||
|
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0_2030_12879"
|
||||||
|
style="mask-type: alpha"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<rect width="20" height="20" fill="#D9D9D9" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_2030_12879)">
|
||||||
|
<rect
|
||||||
|
x="3.33301"
|
||||||
|
y="10"
|
||||||
|
width="3.33333"
|
||||||
|
height="6.66667"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="8.33301"
|
||||||
|
y="6.6665"
|
||||||
|
width="3.33333"
|
||||||
|
height="10"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="13.333"
|
||||||
|
y="3.3335"
|
||||||
|
width="3.33333"
|
||||||
|
height="13.3333"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Urgent Priority -->
|
||||||
|
<svg
|
||||||
|
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0_2030_12879"
|
||||||
|
style="mask-type: alpha"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<rect width="20" height="20" fill="#D9D9D9" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_2030_12879)">
|
||||||
|
<rect
|
||||||
|
x="3.33301"
|
||||||
|
y="10"
|
||||||
|
width="3.33333"
|
||||||
|
height="6.66667"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-ruby-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="8.33301"
|
||||||
|
y="6.6665"
|
||||||
|
width="3.33333"
|
||||||
|
height="10"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-ruby-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="13.333"
|
||||||
|
y="3.3335"
|
||||||
|
width="3.33333"
|
||||||
|
height="13.3333"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-ruby-9"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+44
-51
@@ -1,10 +1,8 @@
|
|||||||
<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: {
|
||||||
@@ -20,7 +18,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['edit', 'delete']);
|
const emit = defineEmits(['edit', 'delete']);
|
||||||
|
|
||||||
const iconByType = {
|
const iconByType = {
|
||||||
text: 'i-lucide-menu',
|
text: 'i-lucide-align-justify',
|
||||||
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',
|
||||||
@@ -30,66 +28,61 @@ 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-menu';
|
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col py-4 min-w-0">
|
<div
|
||||||
<div class="flex justify-between flex-row items-center gap-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 items-center gap-4 min-w-0">
|
>
|
||||||
<div
|
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||||
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
|
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
||||||
>
|
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
||||||
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
|
{{ attribute.label }}
|
||||||
</div>
|
</h4>
|
||||||
<div class="flex flex-col gap-1.5 items-start min-w-0 overflow-hidden">
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
||||||
<h4 class="text-heading-3 truncate text-n-slate-12 min-w-0">
|
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||||
{{ attribute.label }}
|
<Icon :icon="attributeIcon" class="size-4" />
|
||||||
</h4>
|
<span class="text-sm">{{ attribute.type }}</span>
|
||||||
<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="grid grid-cols-[auto_1fr] items-center gap-1.5">
|
<div class="w-px h-3 bg-n-weak" />
|
||||||
<Icon icon="i-lucide-key-round" class="size-3.5 text-n-slate-11" />
|
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<Icon icon="i-lucide-key-round" class="size-4" />
|
||||||
<span class="text-body-main text-n-slate-11 truncate">
|
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
||||||
{{ 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-3 justify-end flex-shrink-0">
|
<div class="flex gap-2 items-center">
|
||||||
<Button
|
<AttributeBadge
|
||||||
icon="i-woot-edit-pen"
|
v-for="badge in badges"
|
||||||
slate
|
:key="badge.type"
|
||||||
sm
|
:type="badge.type"
|
||||||
@click="emit('edit', attribute)"
|
/>
|
||||||
|
<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-woot-bin"
|
icon="i-lucide-pencil-line"
|
||||||
slate
|
size="sm"
|
||||||
sm
|
color="slate"
|
||||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
ghost
|
||||||
|
@click="emit('edit', attribute)"
|
||||||
|
/>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
@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>
|
||||||
|
|||||||
+3
-5
@@ -35,20 +35,18 @@ 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-heading-3 text-n-slate-12 line-clamp-1">
|
<h5 class="text-sm font-medium 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-body-para text-n-slate-11">{{ attribute.type }}</span>
|
<span class="text-sm 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-body-para text-n-slate-11">{{
|
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
|
||||||
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-heading-2 text-n-slate-12">
|
<h3 class="text-base font-medium text-n-slate-12">
|
||||||
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
|
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mb-0 text-body-para text-n-slate-11">
|
<p class="mb-0 text-sm text-n-slate-11">
|
||||||
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
|
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
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: {
|
||||||
@@ -19,13 +18,11 @@ 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(
|
||||||
@@ -34,9 +31,12 @@ const config = computed(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Label :label="t(config.labelKey)" :color="config.color" compact>
|
<div
|
||||||
<template #icon>
|
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"
|
||||||
<Icon :icon="config.icon" class="size-3.5 text-n-slate-12" />
|
>
|
||||||
</template>
|
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
||||||
</Label>
|
<span class="text-xs" :class="config.colorClass">{{
|
||||||
|
t(config.labelKey)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const props = defineProps({
|
|||||||
medium: { type: String, default: '' },
|
medium: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'executeCopilotAction']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
|
|
||||||
@@ -113,9 +113,6 @@ watch(
|
|||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
@execute-copilot-action="
|
|
||||||
(...args) => emit('executeCopilotAction', ...args)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="showCharacterCount || slots.actions"
|
v-if="showCharacterCount || slots.actions"
|
||||||
|
|||||||
@@ -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-5xl mx-auto overflow-hidden h-full max-h-[28rem]"
|
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="showBackdrop"
|
v-if="showBackdrop"
|
||||||
|
|||||||
-1
@@ -26,7 +26,6 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
|||||||
<EmptyStateLayout
|
<EmptyStateLayout
|
||||||
:title="$t('HELP_CENTER.TITLE')"
|
:title="$t('HELP_CENTER.TITLE')"
|
||||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||||
class="bg-n-surface-1"
|
|
||||||
>
|
>
|
||||||
<template #empty-state-item>
|
<template #empty-state-item>
|
||||||
<div class="grid grid-cols-2 gap-4 p-px">
|
<div class="grid grid-cols-2 gap-4 p-px">
|
||||||
|
|||||||
@@ -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">
|
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
||||||
<div class="w-full max-w-5xl mx-auto">
|
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
|
||||||
<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,17 +95,16 @@ const togglePortalSwitcher = () => {
|
|||||||
<slot name="header-actions" />
|
<slot name="header-actions" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto">
|
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||||
<div class="w-full max-w-5xl mx-auto py-3">
|
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
|
||||||
<slot name="content" />
|
<slot name="content" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
||||||
<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>
|
||||||
|
|||||||
+2
-18
@@ -1,22 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import LocaleCard from './LocaleCard.vue';
|
import LocaleCard from './LocaleCard.vue';
|
||||||
const locales = [
|
const locales = [
|
||||||
{
|
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
|
||||||
name: 'English',
|
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
|
||||||
code: 'en',
|
|
||||||
isDefault: true,
|
|
||||||
isDraft: false,
|
|
||||||
articleCount: 29,
|
|
||||||
categoryCount: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Spanish',
|
|
||||||
code: 'es',
|
|
||||||
isDefault: false,
|
|
||||||
isDraft: true,
|
|
||||||
articleCount: 29,
|
|
||||||
categoryCount: 5,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -33,8 +19,6 @@ const locales = [
|
|||||||
<LocaleCard
|
<LocaleCard
|
||||||
:locale="locale.name"
|
:locale="locale.name"
|
||||||
:is-default="locale.isDefault"
|
:is-default="locale.isDefault"
|
||||||
:is-draft="locale.isDraft"
|
|
||||||
:locale-code="locale.code"
|
|
||||||
:article-count="locale.articleCount"
|
:article-count="locale.articleCount"
|
||||||
:category-count="locale.categoryCount"
|
:category-count="locale.categoryCount"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useToggle } from '@vueuse/core';
|
import { useToggle } from '@vueuse/core';
|
||||||
import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper';
|
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
|
||||||
|
|
||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
@@ -17,10 +17,6 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
isDraft: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
localeCode: {
|
localeCode: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -41,28 +37,11 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const [showDropdownMenu, toggleDropdown] = useToggle();
|
const [showDropdownMenu, toggleDropdown] = useToggle();
|
||||||
|
|
||||||
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
|
|
||||||
|
|
||||||
const localeMenuLabels = computed(() => ({
|
|
||||||
'change-default': t(
|
|
||||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
|
|
||||||
),
|
|
||||||
'move-to-draft': t(
|
|
||||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
|
|
||||||
),
|
|
||||||
'publish-locale': t(
|
|
||||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
|
||||||
),
|
|
||||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const localeMenuItems = computed(() =>
|
const localeMenuItems = computed(() =>
|
||||||
buildLocaleMenuItems({
|
LOCALE_MENU_ITEMS.map(item => ({
|
||||||
isDefault: props.isDefault,
|
|
||||||
isDraft: props.isDraft,
|
|
||||||
}).map(item => ({
|
|
||||||
...item,
|
...item,
|
||||||
label: localeMenuLabels.value[item.action],
|
label: t(item.label),
|
||||||
|
disabled: props.isDefault,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,7 +56,7 @@ const handleAction = ({ action, value }) => {
|
|||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
<div class="flex items-center justify-start gap-2">
|
<div class="flex items-center justify-start gap-2">
|
||||||
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||||
{{ localeLabel }}
|
{{ locale }} ({{ localeCode }})
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isDefault"
|
v-if="isDefault"
|
||||||
@@ -85,12 +64,6 @@ const handleAction = ({ action, value }) => {
|
|||||||
>
|
>
|
||||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
v-else-if="isDraft"
|
|
||||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
|
|
||||||
>
|
|
||||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end gap-4">
|
<div class="flex items-center justify-end gap-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@@ -113,7 +86,6 @@ const handleAction = ({ action, value }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="localeMenuItems.length"
|
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
class="relative group"
|
class="relative group"
|
||||||
>
|
>
|
||||||
|
|||||||
+8
-12
@@ -58,22 +58,18 @@ const openArticle = id => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onReorder = async reorderedGroup => {
|
const onReorder = reorderedGroup => {
|
||||||
try {
|
store.dispatch('articles/reorder', {
|
||||||
await store.dispatch('articles/reorder', {
|
reorderedGroup,
|
||||||
reorderedGroup,
|
portalSlug: route.params.portalSlug,
|
||||||
portalSlug: route.params.portalSlug,
|
});
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnd = () => {
|
const onDragEnd = () => {
|
||||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
// Reuse existing positions to maintain order within the current group
|
||||||
const sortedArticlePositions = localArticles.value
|
const sortedArticlePositions = localArticles.value
|
||||||
.map((article, index) => article.position || index + 1)
|
.map(article => article.position)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
|
||||||
|
|
||||||
const orderedArticles = localArticles.value.map(article => article.id);
|
const orderedArticles = localArticles.value.map(article => article.id);
|
||||||
|
|
||||||
|
|||||||
-12
@@ -98,17 +98,6 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
|||||||
deleteCategory(categoryData);
|
deleteCategory(categoryData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const reorderCategories = async reorderedGroup => {
|
|
||||||
try {
|
|
||||||
await store.dispatch('categories/reorder', {
|
|
||||||
portalSlug: route.params.portalSlug,
|
|
||||||
reorderedGroup,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -133,7 +122,6 @@ const reorderCategories = async reorderedGroup => {
|
|||||||
:categories="categories"
|
:categories="categories"
|
||||||
@click="openCategoryArticles"
|
@click="openCategoryArticles"
|
||||||
@action="handleAction"
|
@action="handleAction"
|
||||||
@reorder="reorderCategories"
|
|
||||||
/>
|
/>
|
||||||
<CategoryEmptyState
|
<CategoryEmptyState
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
+16
-60
@@ -1,22 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import Draggable from 'vuedraggable';
|
|
||||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
categories: {
|
categories: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
const emit = defineEmits(['click', 'action']);
|
||||||
|
|
||||||
const localCategories = ref(props.categories);
|
|
||||||
|
|
||||||
const dragEnabled = computed(() => {
|
|
||||||
return localCategories.value?.length > 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClick = slug => {
|
const handleClick = slug => {
|
||||||
emit('click', slug);
|
emit('click', slug);
|
||||||
@@ -25,57 +17,21 @@ const handleClick = slug => {
|
|||||||
const handleAction = ({ action, value, id }, category) => {
|
const handleAction = ({ action, value, id }, category) => {
|
||||||
emit('action', { action, value, id, category });
|
emit('action', { action, value, id, category });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnd = () => {
|
|
||||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
|
||||||
const sortedPositions = localCategories.value
|
|
||||||
.map((category, index) => category.position || index + 1)
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
const reorderedGroup = localCategories.value.reduce(
|
|
||||||
(obj, category, index) => {
|
|
||||||
obj[category.id] = sortedPositions[index];
|
|
||||||
return obj;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
emit('reorder', reorderedGroup);
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.categories,
|
|
||||||
newCategories => {
|
|
||||||
localCategories.value = newCategories;
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Draggable
|
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
v-model="localCategories"
|
<CategoryCard
|
||||||
:disabled="!dragEnabled"
|
v-for="category in categories"
|
||||||
item-key="id"
|
:id="category.id"
|
||||||
tag="ul"
|
:key="category.id"
|
||||||
role="list"
|
:title="category.name"
|
||||||
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
|
:icon="category.icon"
|
||||||
@end="onDragEnd"
|
:description="category.description"
|
||||||
>
|
:articles-count="category.meta.articles_count || 0"
|
||||||
<template #item="{ element }">
|
:slug="category.slug"
|
||||||
<li class="list-none">
|
@click="handleClick(category.slug)"
|
||||||
<CategoryCard
|
@action="handleAction($event, category)"
|
||||||
:id="element.id"
|
/>
|
||||||
:title="element.name"
|
</ul>
|
||||||
:icon="element.icon"
|
|
||||||
:description="element.description"
|
|
||||||
:articles-count="element.meta?.articles_count || 0"
|
|
||||||
:slug="element.slug"
|
|
||||||
:class="{ 'cursor-grab': dragEnabled }"
|
|
||||||
@click="handleClick(element.slug)"
|
|
||||||
@action="handleAction($event, element)"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
</Draggable>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+1
-48
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import { useAlert, useTrack } from 'dashboard/composables';
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
@@ -24,20 +24,12 @@ const dialogRef = ref(null);
|
|||||||
const isUpdating = ref(false);
|
const isUpdating = ref(false);
|
||||||
|
|
||||||
const selectedLocale = ref('');
|
const selectedLocale = ref('');
|
||||||
const localeStatus = ref('published');
|
|
||||||
|
|
||||||
const addedLocales = computed(() => {
|
const addedLocales = computed(() => {
|
||||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||||
return allowedLocales.map(locale => locale.code);
|
return allowedLocales.map(locale => locale.code);
|
||||||
});
|
});
|
||||||
|
|
||||||
const draftedLocales = computed(() => {
|
|
||||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
|
||||||
return allowedLocales
|
|
||||||
.filter(locale => locale.draft)
|
|
||||||
.map(locale => locale.code);
|
|
||||||
});
|
|
||||||
|
|
||||||
const locales = computed(() => {
|
const locales = computed(() => {
|
||||||
return Object.keys(allLocales)
|
return Object.keys(allLocales)
|
||||||
.map(key => {
|
.map(key => {
|
||||||
@@ -49,44 +41,17 @@ const locales = computed(() => {
|
|||||||
.filter(locale => !addedLocales.value.includes(locale.value));
|
.filter(locale => !addedLocales.value.includes(locale.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusOptions = computed(() => [
|
|
||||||
{
|
|
||||||
value: 'published',
|
|
||||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'draft',
|
|
||||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
selectedLocale.value = '';
|
|
||||||
localeStatus.value = 'published';
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(localeStatus, value => {
|
|
||||||
if (!value) {
|
|
||||||
localeStatus.value = 'published';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const onCreate = async () => {
|
const onCreate = async () => {
|
||||||
if (!selectedLocale.value) return;
|
if (!selectedLocale.value) return;
|
||||||
|
|
||||||
isUpdating.value = true;
|
isUpdating.value = true;
|
||||||
const updatedLocales = [...addedLocales.value, selectedLocale.value];
|
const updatedLocales = [...addedLocales.value, selectedLocale.value];
|
||||||
const updatedDraftLocales =
|
|
||||||
localeStatus.value === 'draft'
|
|
||||||
? [...new Set([...draftedLocales.value, selectedLocale.value])]
|
|
||||||
: draftedLocales.value;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.dispatch('portals/update', {
|
await store.dispatch('portals/update', {
|
||||||
portalSlug: props.portal?.slug,
|
portalSlug: props.portal?.slug,
|
||||||
config: {
|
config: {
|
||||||
allowed_locales: updatedLocales,
|
allowed_locales: updatedLocales,
|
||||||
draft_locales: updatedDraftLocales,
|
|
||||||
default_locale: props.portal?.meta?.default_locale,
|
default_locale: props.portal?.meta?.default_locale,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -97,7 +62,6 @@ const onCreate = async () => {
|
|||||||
from: route.name,
|
from: route.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
resetForm();
|
|
||||||
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')
|
||||||
@@ -122,7 +86,6 @@ defineExpose({ dialogRef });
|
|||||||
type="edit"
|
type="edit"
|
||||||
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
|
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
|
||||||
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
|
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
|
||||||
@close="resetForm"
|
|
||||||
@confirm="onCreate"
|
@confirm="onCreate"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
@@ -134,16 +97,6 @@ defineExpose({ dialogRef });
|
|||||||
"
|
"
|
||||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<span class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
|
|
||||||
</span>
|
|
||||||
<ComboBox
|
|
||||||
v-model="localeStatus"
|
|
||||||
:options="statusOptions"
|
|
||||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const isLocaleDefault = code => {
|
|||||||
|
|
||||||
const updatePortalLocales = async ({
|
const updatePortalLocales = async ({
|
||||||
newAllowedLocales,
|
newAllowedLocales,
|
||||||
newDraftLocales,
|
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
messageKey,
|
messageKey,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -40,7 +39,6 @@ const updatePortalLocales = async ({
|
|||||||
config: {
|
config: {
|
||||||
default_locale: defaultLocale,
|
default_locale: defaultLocale,
|
||||||
allowed_locales: newAllowedLocales,
|
allowed_locales: newAllowedLocales,
|
||||||
draft_locales: newDraftLocales,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,12 +53,8 @@ const updatePortalLocales = async ({
|
|||||||
|
|
||||||
const changeDefaultLocale = ({ localeCode }) => {
|
const changeDefaultLocale = ({ localeCode }) => {
|
||||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||||
const newDraftLocales = props.locales
|
|
||||||
.filter(locale => locale.isDraft)
|
|
||||||
.map(locale => locale.code);
|
|
||||||
updatePortalLocales({
|
updatePortalLocales({
|
||||||
newAllowedLocales,
|
newAllowedLocales,
|
||||||
newDraftLocales,
|
|
||||||
defaultLocale: localeCode,
|
defaultLocale: localeCode,
|
||||||
messageKey: 'CHANGE_DEFAULT_LOCALE',
|
messageKey: 'CHANGE_DEFAULT_LOCALE',
|
||||||
});
|
});
|
||||||
@@ -87,15 +81,11 @@ const deletePortalLocale = async ({ localeCode }) => {
|
|||||||
const updatedLocales = props.locales
|
const updatedLocales = props.locales
|
||||||
.filter(locale => locale.code !== localeCode)
|
.filter(locale => locale.code !== localeCode)
|
||||||
.map(locale => locale.code);
|
.map(locale => locale.code);
|
||||||
const updatedDraftLocales = props.locales
|
|
||||||
.filter(locale => locale.code !== localeCode && locale.isDraft)
|
|
||||||
.map(locale => locale.code);
|
|
||||||
|
|
||||||
const defaultLocale = props.portal.meta.default_locale;
|
const defaultLocale = props.portal.meta.default_locale;
|
||||||
|
|
||||||
await updatePortalLocales({
|
await updatePortalLocales({
|
||||||
newAllowedLocales: updatedLocales,
|
newAllowedLocales: updatedLocales,
|
||||||
newDraftLocales: updatedDraftLocales,
|
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
messageKey: 'DELETE_LOCALE',
|
messageKey: 'DELETE_LOCALE',
|
||||||
});
|
});
|
||||||
@@ -108,46 +98,9 @@ const deletePortalLocale = async ({ localeCode }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
|
|
||||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
|
||||||
const currentDraftLocales = props.locales
|
|
||||||
.filter(locale => locale.isDraft)
|
|
||||||
.map(locale => locale.code);
|
|
||||||
const newDraftLocales = shouldDraft
|
|
||||||
? [...new Set([...currentDraftLocales, localeCode])]
|
|
||||||
: currentDraftLocales.filter(locale => locale !== localeCode);
|
|
||||||
|
|
||||||
await updatePortalLocales({
|
|
||||||
newAllowedLocales,
|
|
||||||
newDraftLocales,
|
|
||||||
defaultLocale: props.portal.meta.default_locale,
|
|
||||||
messageKey,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveLocaleToDraft = async ({ localeCode }) => {
|
|
||||||
await updateDraftLocales({
|
|
||||||
localeCode,
|
|
||||||
shouldDraft: true,
|
|
||||||
messageKey: 'DRAFT_LOCALE',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const publishLocale = async ({ localeCode }) => {
|
|
||||||
await updateDraftLocales({
|
|
||||||
localeCode,
|
|
||||||
shouldDraft: false,
|
|
||||||
messageKey: 'PUBLISH_LOCALE',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAction = ({ action }, localeCode) => {
|
const handleAction = ({ action }, localeCode) => {
|
||||||
if (action === 'change-default') {
|
if (action === 'change-default') {
|
||||||
changeDefaultLocale({ localeCode: localeCode });
|
changeDefaultLocale({ localeCode: localeCode });
|
||||||
} else if (action === 'move-to-draft') {
|
|
||||||
moveLocaleToDraft({ localeCode: localeCode });
|
|
||||||
} else if (action === 'publish-locale') {
|
|
||||||
publishLocale({ localeCode: localeCode });
|
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
deletePortalLocale({ localeCode: localeCode });
|
deletePortalLocale({ localeCode: localeCode });
|
||||||
}
|
}
|
||||||
@@ -161,7 +114,6 @@ const handleAction = ({ action }, localeCode) => {
|
|||||||
:key="index"
|
:key="index"
|
||||||
:locale="locale.name"
|
:locale="locale.name"
|
||||||
:is-default="isLocaleDefault(locale.code)"
|
:is-default="isLocaleDefault(locale.code)"
|
||||||
:is-draft="locale.isDraft"
|
|
||||||
:locale-code="locale.code"
|
:locale-code="locale.code"
|
||||||
:article-count="locale.articlesCount || 0"
|
:article-count="locale.articlesCount || 0"
|
||||||
:category-count="locale.categoriesCount || 0"
|
:category-count="locale.categoriesCount || 0"
|
||||||
|
|||||||
-12
@@ -4,49 +4,37 @@ import LocalesPage from './LocalesPage.vue';
|
|||||||
const locales = [
|
const locales = [
|
||||||
{
|
{
|
||||||
name: 'English (en-US)',
|
name: 'English (en-US)',
|
||||||
code: 'en',
|
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
isDraft: false,
|
|
||||||
articleCount: 5,
|
articleCount: 5,
|
||||||
categoryCount: 5,
|
categoryCount: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Spanish (es-ES)',
|
name: 'Spanish (es-ES)',
|
||||||
code: 'es',
|
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isDraft: true,
|
|
||||||
articleCount: 20,
|
articleCount: 20,
|
||||||
categoryCount: 10,
|
categoryCount: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'English (en-UK)',
|
name: 'English (en-UK)',
|
||||||
code: 'en_GB',
|
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isDraft: false,
|
|
||||||
articleCount: 15,
|
articleCount: 15,
|
||||||
categoryCount: 7,
|
categoryCount: 7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Malay (ms-MY)',
|
name: 'Malay (ms-MY)',
|
||||||
code: 'ms',
|
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isDraft: false,
|
|
||||||
articleCount: 15,
|
articleCount: 15,
|
||||||
categoryCount: 7,
|
categoryCount: 7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Malayalam (ml-IN)',
|
name: 'Malayalam (ml-IN)',
|
||||||
code: 'ml',
|
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isDraft: false,
|
|
||||||
articleCount: 10,
|
articleCount: 10,
|
||||||
categoryCount: 5,
|
categoryCount: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Hindi (hi-IN)',
|
name: 'Hindi (hi-IN)',
|
||||||
code: 'hi',
|
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isDraft: false,
|
|
||||||
articleCount: 15,
|
articleCount: 15,
|
||||||
categoryCount: 7,
|
categoryCount: 7,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
|||||||
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';
|
||||||
import {
|
import {
|
||||||
createContactSearcher,
|
searchContacts,
|
||||||
createNewContact,
|
createNewContact,
|
||||||
fetchContactableInboxes,
|
fetchContactableInboxes,
|
||||||
processContactableInboxes,
|
processContactableInboxes,
|
||||||
@@ -39,7 +39,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
const searchContacts = createContactSearcher();
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { width: windowWidth } = useWindowSize();
|
const { width: windowWidth } = useWindowSize();
|
||||||
@@ -108,17 +107,15 @@ const onContactSearch = debounce(
|
|||||||
isSearching.value = true;
|
isSearching.value = true;
|
||||||
contacts.value = [];
|
contacts.value = [];
|
||||||
try {
|
try {
|
||||||
const results = await searchContacts(query);
|
contacts.value = await searchContacts(query);
|
||||||
// null means the request was aborted (a newer search is in-flight),
|
|
||||||
if (results === null) return;
|
|
||||||
contacts.value = results;
|
|
||||||
isSearching.value = false;
|
isSearching.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isSearching.value = false;
|
|
||||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||||
|
} finally {
|
||||||
|
isSearching.value = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
400,
|
300,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -141,7 +138,6 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
|||||||
contact = rest;
|
contact = rest;
|
||||||
}
|
}
|
||||||
selectedContact.value = contact;
|
selectedContact.value = contact;
|
||||||
contacts.value = [];
|
|
||||||
if (contact?.id) {
|
if (contact?.id) {
|
||||||
isFetchingInboxes.value = true;
|
isFetchingInboxes.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -277,7 +273,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
handleClickOutside,
|
handleClickOutside,
|
||||||
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
||||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
||||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
{ ignore: ['div.ProseMirror-prompt'] },
|
||||||
]"
|
]"
|
||||||
class="relative"
|
class="relative"
|
||||||
:class="{
|
:class="{
|
||||||
|
|||||||
+20
-49
@@ -15,9 +15,6 @@ import {
|
|||||||
prepareWhatsAppMessagePayload,
|
prepareWhatsAppMessagePayload,
|
||||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
|
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
|
||||||
|
|
||||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
|
||||||
|
|
||||||
import ContactSelector from './ContactSelector.vue';
|
import ContactSelector from './ContactSelector.vue';
|
||||||
import InboxSelector from './InboxSelector.vue';
|
import InboxSelector from './InboxSelector.vue';
|
||||||
import EmailOptions from './EmailOptions.vue';
|
import EmailOptions from './EmailOptions.vue';
|
||||||
@@ -25,7 +22,6 @@ import MessageEditor from './MessageEditor.vue';
|
|||||||
import ActionButtons from './ActionButtons.vue';
|
import ActionButtons from './ActionButtons.vue';
|
||||||
import InboxEmptyState from './InboxEmptyState.vue';
|
import InboxEmptyState from './InboxEmptyState.vue';
|
||||||
import AttachmentPreviews from './AttachmentPreviews.vue';
|
import AttachmentPreviews from './AttachmentPreviews.vue';
|
||||||
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
contacts: { type: Array, default: () => [] },
|
contacts: { type: Array, default: () => [] },
|
||||||
@@ -46,7 +42,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'searchContacts',
|
'searchContacts',
|
||||||
'resetContactSearch',
|
|
||||||
'discard',
|
'discard',
|
||||||
'updateSelectedContact',
|
'updateSelectedContact',
|
||||||
'updateTargetInbox',
|
'updateTargetInbox',
|
||||||
@@ -56,8 +51,6 @@ const emit = defineEmits([
|
|||||||
|
|
||||||
const DEFAULT_FORMATTING = 'Context::Default';
|
const DEFAULT_FORMATTING = 'Context::Default';
|
||||||
|
|
||||||
const copilot = useCopilotReply();
|
|
||||||
|
|
||||||
const showContactsDropdown = ref(false);
|
const showContactsDropdown = ref(false);
|
||||||
const showInboxesDropdown = ref(false);
|
const showInboxesDropdown = ref(false);
|
||||||
const showCcEmailsDropdown = ref(false);
|
const showCcEmailsDropdown = ref(false);
|
||||||
@@ -164,8 +157,22 @@ const isAnyDropdownActive = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleContactSearch = value => {
|
const handleContactSearch = value => {
|
||||||
showContactsDropdown.value = value.trim().length > 1;
|
showContactsDropdown.value = true;
|
||||||
emit('searchContacts', value);
|
const query = typeof value === 'string' ? value.trim() : '';
|
||||||
|
const hasAlphabet = Array.from(query).some(char => {
|
||||||
|
const lower = char.toLowerCase();
|
||||||
|
const upper = char.toUpperCase();
|
||||||
|
return lower !== upper;
|
||||||
|
});
|
||||||
|
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) => {
|
||||||
@@ -179,17 +186,13 @@ const handleDropdownUpdate = (type, value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const searchCcEmails = value => {
|
const searchCcEmails = value => {
|
||||||
showBccEmailsDropdown.value = false;
|
showCcEmailsDropdown.value = true;
|
||||||
emit('resetContactSearch');
|
emit('searchContacts', { keys: ['email'], query: value });
|
||||||
showCcEmailsDropdown.value = value.trim().length >= 2;
|
|
||||||
emit('searchContacts', value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchBccEmails = value => {
|
const searchBccEmails = value => {
|
||||||
showCcEmailsDropdown.value = false;
|
showBccEmailsDropdown.value = true;
|
||||||
emit('resetContactSearch');
|
emit('searchContacts', { keys: ['email'], query: value });
|
||||||
showBccEmailsDropdown.value = value.trim().length >= 2;
|
|
||||||
emit('searchContacts', value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelectedContact = async ({ value, action, ...rest }) => {
|
const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||||
@@ -207,7 +210,6 @@ const stripMessageFormatting = channelType => {
|
|||||||
|
|
||||||
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
copilot.reset(false);
|
|
||||||
|
|
||||||
// Strip unsupported formatting when changing the target inbox
|
// Strip unsupported formatting when changing the target inbox
|
||||||
if (channelType) {
|
if (channelType) {
|
||||||
@@ -234,7 +236,6 @@ const removeSignatureFromMessage = () => {
|
|||||||
|
|
||||||
const removeTargetInbox = value => {
|
const removeTargetInbox = value => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
copilot.reset(false);
|
|
||||||
removeSignatureFromMessage();
|
removeSignatureFromMessage();
|
||||||
|
|
||||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||||
@@ -244,7 +245,6 @@ const removeTargetInbox = value => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
copilot.reset(false);
|
|
||||||
removeSignatureFromMessage();
|
removeSignatureFromMessage();
|
||||||
emit('clearSelectedContact');
|
emit('clearSelectedContact');
|
||||||
state.message = '';
|
state.message = '';
|
||||||
@@ -276,7 +276,6 @@ const handleAttachFile = files => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearForm = () => {
|
const clearForm = () => {
|
||||||
copilot.reset(false);
|
|
||||||
Object.assign(state, {
|
Object.assign(state, {
|
||||||
message: '',
|
message: '',
|
||||||
subject: '',
|
subject: '',
|
||||||
@@ -339,24 +338,6 @@ const shouldShowMessageEditor = computed(() => {
|
|||||||
!inboxTypes.value.isTwilioWhatsapp
|
!inboxTypes.value.isTwilioWhatsapp
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
|
|
||||||
|
|
||||||
const onSubmitCopilotReply = () => {
|
|
||||||
const acceptedMessage = copilot.accept();
|
|
||||||
state.message = acceptedMessage;
|
|
||||||
};
|
|
||||||
|
|
||||||
useKeyboardEvents({
|
|
||||||
'$mod+Enter': {
|
|
||||||
action: () => {
|
|
||||||
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
|
|
||||||
onSubmitCopilotReply();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
allowOnFocusedInput: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -387,7 +368,6 @@ useKeyboardEvents({
|
|||||||
:show-inboxes-dropdown="showInboxesDropdown"
|
:show-inboxes-dropdown="showInboxesDropdown"
|
||||||
:contactable-inboxes-list="contactableInboxesList"
|
:contactable-inboxes-list="contactableInboxesList"
|
||||||
:has-errors="validationStates.isInboxInvalid"
|
:has-errors="validationStates.isInboxInvalid"
|
||||||
:is-fetching-inboxes="isFetchingInboxes"
|
|
||||||
@update-inbox="removeTargetInbox"
|
@update-inbox="removeTargetInbox"
|
||||||
@toggle-dropdown="showInboxesDropdown = $event"
|
@toggle-dropdown="showInboxesDropdown = $event"
|
||||||
@handle-inbox-action="handleInboxAction"
|
@handle-inbox-action="handleInboxAction"
|
||||||
@@ -416,7 +396,6 @@ useKeyboardEvents({
|
|||||||
:has-errors="validationStates.isMessageInvalid"
|
:has-errors="validationStates.isMessageInvalid"
|
||||||
:channel-type="inboxChannelType"
|
:channel-type="inboxChannelType"
|
||||||
:medium="targetInbox?.medium || ''"
|
:medium="targetInbox?.medium || ''"
|
||||||
:copilot="copilot"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AttachmentPreviews
|
<AttachmentPreviews
|
||||||
@@ -426,15 +405,7 @@ useKeyboardEvents({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CopilotReplyBottomPanel
|
|
||||||
v-if="isCopilotActive"
|
|
||||||
:is-generating-content="copilot.isButtonDisabled.value"
|
|
||||||
class="h-[3.25rem] !px-4 !py-2"
|
|
||||||
@submit="onSubmitCopilotReply"
|
|
||||||
@cancel="copilot.reset"
|
|
||||||
/>
|
|
||||||
<ActionButtons
|
<ActionButtons
|
||||||
v-else
|
|
||||||
:attached-files="state.attachedFiles"
|
:attached-files="state.attachedFiles"
|
||||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||||
|
|||||||
+10
-10
@@ -44,16 +44,14 @@ const bccEmailsArray = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const contactEmailsList = computed(() => {
|
const contactEmailsList = computed(() => {
|
||||||
return props.contacts
|
return props.contacts?.map(({ name, id, email }) => ({
|
||||||
?.filter(contact => contact.email)
|
id,
|
||||||
.map(({ name, id, email }) => ({
|
label: email,
|
||||||
id,
|
email,
|
||||||
label: email,
|
thumbnail: { name: name, src: '' },
|
||||||
email,
|
value: id,
|
||||||
thumbnail: { name: name, src: '' },
|
action: 'email',
|
||||||
value: id,
|
}));
|
||||||
action: 'email',
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle updates from TagInput and convert array back to string
|
// Handle updates from TagInput and convert array back to string
|
||||||
@@ -99,6 +97,7 @@ const inputClass = computed(() => {
|
|||||||
type="email"
|
type="email"
|
||||||
allow-create
|
allow-create
|
||||||
class="flex-1 min-h-7"
|
class="flex-1 min-h-7"
|
||||||
|
@focus="emit('updateDropdown', 'cc', true)"
|
||||||
@input="emit('searchCcEmails', $event)"
|
@input="emit('searchCcEmails', $event)"
|
||||||
@on-click-outside="emit('updateDropdown', 'cc', false)"
|
@on-click-outside="emit('updateDropdown', 'cc', false)"
|
||||||
@update:model-value="handleCcUpdate"
|
@update:model-value="handleCcUpdate"
|
||||||
@@ -132,6 +131,7 @@ const inputClass = computed(() => {
|
|||||||
allow-create
|
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)"
|
||||||
@input="emit('searchBccEmails', $event)"
|
@input="emit('searchBccEmails', $event)"
|
||||||
@on-click-outside="emit('updateDropdown', 'bcc', false)"
|
@on-click-outside="emit('updateDropdown', 'bcc', false)"
|
||||||
@update:model-value="handleBccUpdate"
|
@update:model-value="handleBccUpdate"
|
||||||
|
|||||||
+1
-7
@@ -1,15 +1,9 @@
|
|||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
|
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
|
||||||
>
|
>
|
||||||
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
|
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
|
||||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
|
|||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
targetInbox: {
|
targetInbox: {
|
||||||
@@ -29,10 +28,6 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
isFetchingInboxes: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -76,9 +71,7 @@ const targetInboxLabel = computed(() => {
|
|||||||
v-on-click-outside="() => emit('toggleDropdown', false)"
|
v-on-click-outside="() => emit('toggleDropdown', false)"
|
||||||
class="relative flex items-center h-7"
|
class="relative flex items-center h-7"
|
||||||
>
|
>
|
||||||
<Spinner v-if="isFetchingInboxes" :size="16" />
|
|
||||||
<Button
|
<Button
|
||||||
v-else
|
|
||||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
|
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
+21
-61
@@ -3,7 +3,6 @@ import { computed } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
hasErrors: { type: Boolean, default: false },
|
hasErrors: { type: Boolean, default: false },
|
||||||
@@ -11,7 +10,6 @@ const props = defineProps({
|
|||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
medium: { type: String, default: '' },
|
medium: { type: String, default: '' },
|
||||||
copilot: { type: Object, default: null },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||||
@@ -22,67 +20,29 @@ const modelValue = defineModel({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
|
|
||||||
|
|
||||||
const executeCopilotAction = (action, data) => {
|
|
||||||
if (props.copilot) {
|
|
||||||
props.copilot.execute(action, data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 h-full px-4 py-4">
|
<div class="flex-1 h-full">
|
||||||
<Transition
|
<Editor
|
||||||
mode="out-in"
|
v-model="modelValue"
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
:editor-key="editorKey"
|
||||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
:placeholder="
|
||||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||||
leave-active-class="transition-all duration-200 ease-in"
|
"
|
||||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
:class="
|
||||||
>
|
hasErrors
|
||||||
<div
|
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||||
:key="copilot ? copilot.editorTransitionKey.value : 'rich'"
|
: ''
|
||||||
class="h-full"
|
"
|
||||||
>
|
enable-variables
|
||||||
<CopilotEditorSection
|
:show-character-count="false"
|
||||||
v-if="isCopilotActive"
|
:signature="messageSignature"
|
||||||
:show-copilot-editor="copilot.showEditor.value"
|
allow-signature
|
||||||
:is-generating-content="copilot.isGenerating.value"
|
:send-with-signature="sendWithSignature"
|
||||||
:generated-content="copilot.generatedContent.value"
|
:channel-type="channelType"
|
||||||
class="!mb-0"
|
:medium="medium"
|
||||||
@focus="() => {}"
|
/>
|
||||||
@blur="() => {}"
|
|
||||||
@clear-selection="() => {}"
|
|
||||||
@content-ready="copilot.setContentReady"
|
|
||||||
@send="copilot.sendFollowUp"
|
|
||||||
/>
|
|
||||||
<Editor
|
|
||||||
v-else
|
|
||||||
v-model="modelValue"
|
|
||||||
:editor-key="editorKey"
|
|
||||||
:placeholder="
|
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
|
||||||
:class="
|
|
||||||
hasErrors
|
|
||||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
enable-variables
|
|
||||||
enable-captain-tools
|
|
||||||
:show-character-count="false"
|
|
||||||
:signature="messageSignature"
|
|
||||||
allow-signature
|
|
||||||
:send-with-signature="sendWithSignature"
|
|
||||||
:channel-type="channelType"
|
|
||||||
:medium="medium"
|
|
||||||
@execute-copilot-action="executeCopilotAction"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+30
-35
@@ -176,45 +176,40 @@ export const prepareWhatsAppMessagePayload = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// API Calls
|
export const generateContactQuery = ({ keys = ['email'], query }) => {
|
||||||
const MIN_SEARCH_LENGTH = 2;
|
return {
|
||||||
|
payload: keys.map(key => {
|
||||||
export const createContactSearcher = () => {
|
const filterPayload = {
|
||||||
let controller = null;
|
attribute_key: key,
|
||||||
|
filter_operator: 'contains',
|
||||||
return async (query, { skipMinLength = false } = {}) => {
|
values: [query],
|
||||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
attribute_model: 'standard',
|
||||||
|
};
|
||||||
controller?.abort();
|
if (keys.findIndex(k => k === key) !== keys.length - 1) {
|
||||||
|
filterPayload.query_operator = 'or';
|
||||||
if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
|
|
||||||
return [];
|
|
||||||
|
|
||||||
controller = new AbortController();
|
|
||||||
const { signal } = controller;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
data: { payload },
|
|
||||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
|
||||||
|
|
||||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
|
||||||
// Filter contacts that have either phone_number or email
|
|
||||||
const filteredPayload = camelCasedPayload?.filter(
|
|
||||||
contact => contact.phoneNumber || contact.email
|
|
||||||
);
|
|
||||||
return filteredPayload || [];
|
|
||||||
} catch (error) {
|
|
||||||
// Return null for aborted requests so callers can distinguish
|
|
||||||
// "request was cancelled" from "no results found"
|
|
||||||
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
throw error;
|
return filterPayload;
|
||||||
}
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// API Calls
|
||||||
|
export const searchContacts = async ({ keys, query }) => {
|
||||||
|
const {
|
||||||
|
data: { payload },
|
||||||
|
} = await ContactAPI.filter(
|
||||||
|
undefined,
|
||||||
|
'name',
|
||||||
|
generateContactQuery({ keys, query })
|
||||||
|
);
|
||||||
|
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||||
|
// Filter contacts that have either phone_number or email
|
||||||
|
const filteredPayload = camelCasedPayload?.filter(
|
||||||
|
contact => contact.phoneNumber || contact.email
|
||||||
|
);
|
||||||
|
return filteredPayload || [];
|
||||||
|
};
|
||||||
|
|
||||||
export const createNewContact = async input => {
|
export const createNewContact = async input => {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: input.startsWith('+')
|
name: input.startsWith('+')
|
||||||
|
|||||||
+99
-101
@@ -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-woot-mail',
|
icon: 'i-ri-mail-line',
|
||||||
label: 'Email Inbox (support@example.com)',
|
label: 'Email Inbox (support@example.com)',
|
||||||
action: 'inbox',
|
action: 'inbox',
|
||||||
value: 1,
|
value: 1,
|
||||||
@@ -336,13 +336,72 @@ describe('composeConversationHelper', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('generateContactQuery', () => {
|
||||||
describe('createContactSearcher', () => {
|
it('generates correct query structure for contact search', () => {
|
||||||
let searchContacts;
|
const query = 'test@example.com';
|
||||||
beforeEach(() => {
|
const expected = {
|
||||||
searchContacts = helpers.createContactSearcher();
|
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('searchContacts', () => {
|
||||||
it('searches contacts and returns camelCase results', async () => {
|
it('searches contacts and returns camelCase results', async () => {
|
||||||
const mockPayload = [
|
const mockPayload = [
|
||||||
{
|
{
|
||||||
@@ -354,11 +413,14 @@ describe('composeConversationHelper', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
ContactAPI.search.mockResolvedValue({
|
ContactAPI.filter.mockResolvedValue({
|
||||||
data: { payload: mockPayload },
|
data: { payload: mockPayload },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await searchContacts('john');
|
const result = await helpers.searchContacts({
|
||||||
|
keys: ['email'],
|
||||||
|
query: 'john',
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
@@ -370,56 +432,16 @@ describe('composeConversationHelper', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
||||||
'john',
|
payload: [
|
||||||
1,
|
{
|
||||||
'name',
|
attribute_key: 'email',
|
||||||
'',
|
filter_operator: 'contains',
|
||||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
values: ['john'],
|
||||||
);
|
attribute_model: 'standard',
|
||||||
});
|
},
|
||||||
|
],
|
||||||
it('returns empty array for queries shorter than 2 characters', async () => {
|
|
||||||
const result = await searchContacts('j');
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty array for empty or whitespace-only queries', async () => {
|
|
||||||
expect(await searchContacts('')).toEqual([]);
|
|
||||||
expect(await searchContacts(' ')).toEqual([]);
|
|
||||||
expect(await searchContacts(null)).toEqual([]);
|
|
||||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('aborts previous in-flight request when a new search starts', async () => {
|
|
||||||
const mockPayload = [
|
|
||||||
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
let resolveFirst;
|
|
||||||
const firstCall = new Promise(resolve => {
|
|
||||||
resolveFirst = resolve;
|
|
||||||
});
|
});
|
||||||
ContactAPI.search
|
|
||||||
.mockReturnValueOnce(firstCall)
|
|
||||||
.mockResolvedValueOnce({ data: { payload: mockPayload } });
|
|
||||||
|
|
||||||
// Start first search (will hang)
|
|
||||||
const first = searchContacts('alpha');
|
|
||||||
// Start second search (aborts first)
|
|
||||||
const second = searchContacts('beta');
|
|
||||||
|
|
||||||
// Resolve the first call with CanceledError (simulating axios abort)
|
|
||||||
const canceledError = new Error('canceled');
|
|
||||||
canceledError.name = 'CanceledError';
|
|
||||||
resolveFirst(Promise.reject(canceledError));
|
|
||||||
|
|
||||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
|
||||||
expect(firstResult).toBeNull();
|
|
||||||
expect(secondResult).toEqual([
|
|
||||||
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -447,11 +469,14 @@ describe('composeConversationHelper', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
ContactAPI.search.mockResolvedValue({
|
ContactAPI.filter.mockResolvedValue({
|
||||||
data: { payload: mockPayload },
|
data: { payload: mockPayload },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await searchContacts('john');
|
const result = await helpers.searchContacts({
|
||||||
|
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([
|
||||||
@@ -471,21 +496,24 @@ describe('composeConversationHelper', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
||||||
'john',
|
payload: [
|
||||||
1,
|
{
|
||||||
'name',
|
attribute_key: 'email',
|
||||||
'',
|
filter_operator: 'contains',
|
||||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
values: ['john'],
|
||||||
);
|
attribute_model: 'standard',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty search results', async () => {
|
it('handles empty search results', async () => {
|
||||||
ContactAPI.search.mockResolvedValue({
|
ContactAPI.filter.mockResolvedValue({
|
||||||
data: { payload: [] },
|
data: { payload: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await searchContacts('nonexistent');
|
const result = await helpers.searchContacts('nonexistent');
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -508,11 +536,11 @@ describe('composeConversationHelper', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
ContactAPI.search.mockResolvedValue({
|
ContactAPI.filter.mockResolvedValue({
|
||||||
data: { payload: mockPayload },
|
data: { payload: mockPayload },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await searchContacts('test');
|
const result = await helpers.searchContacts('test');
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
@@ -534,36 +562,6 @@ describe('composeConversationHelper', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createContactSearcher isolation', () => {
|
|
||||||
it('creates isolated searcher instances that do not cancel each other', async () => {
|
|
||||||
const searcherA = helpers.createContactSearcher();
|
|
||||||
const searcherB = helpers.createContactSearcher();
|
|
||||||
|
|
||||||
const payloadA = [
|
|
||||||
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
|
|
||||||
];
|
|
||||||
const payloadB = [
|
|
||||||
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
ContactAPI.search
|
|
||||||
.mockResolvedValueOnce({ data: { payload: payloadA } })
|
|
||||||
.mockResolvedValueOnce({ data: { payload: payloadB } });
|
|
||||||
|
|
||||||
const [resultA, resultB] = await Promise.all([
|
|
||||||
searcherA('alice'),
|
|
||||||
searcherB('bob'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(resultA).toEqual([
|
|
||||||
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
|
|
||||||
]);
|
|
||||||
expect(resultB).toEqual([
|
|
||||||
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createNewContact', () => {
|
describe('createNewContact', () => {
|
||||||
it('creates new contact with capitalized name', async () => {
|
it('creates new contact with capitalized name', async () => {
|
||||||
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
|
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<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,
|
|
||||||
},
|
|
||||||
hideToggle: {
|
|
||||||
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>
|
|
||||||
<div v-if="hideToggle" class="size-2" />
|
|
||||||
<ToggleSwitch v-else 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,19 +112,6 @@ 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:
|
||||||
@@ -197,7 +184,7 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
class="relative inline-flex group/avatar z-0 flex-shrink-0 align-middle"
|
class="relative inline-flex group/avatar z-0 flex-shrink-0"
|
||||||
:style="containerStyles"
|
:style="containerStyles"
|
||||||
>
|
>
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
@@ -229,9 +216,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 outline outline-1 -outline-offset-1 outline-[rgb(0_0_0_/_0.03)] dark:outline-[rgb(255_255_255_/_0.04)]"
|
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
|
||||||
:class="[
|
:class="[
|
||||||
borderRadiusClass,
|
roundedFull ? 'rounded-full' : 'rounded-xl',
|
||||||
{
|
{
|
||||||
'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),
|
||||||
@@ -280,8 +267,7 @@ 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 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 rounded-xl 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-5xl mx-auto">
|
<div class="w-full max-w-[60rem] 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,9 +140,7 @@ const handleCreateAssistant = () => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-chevron-down"
|
icon="i-lucide-chevron-down"
|
||||||
:variant="
|
variant="ghost"
|
||||||
showAssistantSwitcherDropdown ? 'faded' : 'ghost'
|
|
||||||
"
|
|
||||||
color="slate"
|
color="slate"
|
||||||
size="xs"
|
size="xs"
|
||||||
:disabled="isFetchingAssistants"
|
:disabled="isFetchingAssistants"
|
||||||
@@ -206,7 +204,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-5xl h-full mx-auto py-4">
|
<div class="w-full max-w-[60rem] 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"
|
||||||
@@ -224,12 +222,11 @@ const handleCreateAssistant = () => {
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
||||||
<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>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user