Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d15a1bd70 | ||
|
|
5a3c15e9ba | ||
|
|
0cf4ee1e2a | ||
|
|
952d5924ab | ||
|
|
2211598b14 | ||
|
|
8f6e3e2012 | ||
|
|
4bbc2b7abe | ||
|
|
9589050ea8 | ||
|
|
9c76cde654 | ||
|
|
95eb9d2eb1 | ||
|
|
39ae46add2 | ||
|
|
3e81b4d8e0 | ||
|
|
f6ff6a72ff | ||
|
|
39210b6701 | ||
|
|
bf7943fcc5 | ||
|
|
89c7feeda7 | ||
|
|
9527059b54 | ||
|
|
79ff1b58eb | ||
|
|
6d65f2dcf0 | ||
|
|
61f01ebc2e | ||
|
|
a65861ada6 | ||
|
|
5b02b8e7c8 | ||
|
|
2f81fcee8c | ||
|
|
ffc621b6e6 | ||
|
|
c86db87161 | ||
|
|
505d1daadc | ||
|
|
c7bfc64480 | ||
|
|
1a95a99710 | ||
|
|
d84cdca041 | ||
|
|
d11a0b3180 | ||
|
|
54b9cf4b9a | ||
|
|
150b62df3e | ||
|
|
b4bd5634a1 | ||
|
|
db7c7f5edf | ||
|
|
5a38a63312 | ||
|
|
44754a98c5 | ||
|
|
04831aec00 | ||
|
|
56ab3fc6b8 | ||
|
|
eaa34e7430 | ||
|
|
4888d789bf | ||
|
|
dd39b3952c | ||
|
|
b3700c0670 | ||
|
|
75c92807cf | ||
|
|
8b9ce74845 | ||
|
|
540a1d2244 | ||
|
|
d464284394 | ||
|
|
8b3f28753a | ||
|
|
50b45170e7 | ||
|
|
e8d1abf541 | ||
|
|
d5fccf084a | ||
|
|
c8a6ef12f3 | ||
|
|
fb91dd3ab3 | ||
|
|
52062eba20 | ||
|
|
8716d38f22 | ||
|
|
1f1c700e1e | ||
|
|
ba3e647ebe | ||
|
|
6fc0c5d777 | ||
|
|
fbc0ff7509 | ||
|
|
907eb5c4bb | ||
|
|
0fe2bf8647 | ||
|
|
b67dbf3761 | ||
|
|
a49521c9d6 | ||
|
|
3fce56c98f | ||
|
|
26b4a24f11 | ||
|
|
a8ed074bf0 | ||
|
|
96fe3e146d | ||
|
|
696564863c | ||
|
|
df4c8cf58b | ||
|
|
9c2a889ebb | ||
|
|
2bd8e76886 | ||
|
|
1de8d3e56d | ||
|
|
c6e282eeac | ||
|
|
f2054e703a | ||
|
|
89d02e2c92 | ||
|
|
0d8e249fe4 | ||
|
|
20fa5eeaa5 | ||
|
|
f2eaa845dc | ||
|
|
119ac7812c | ||
|
|
863c033699 | ||
|
|
141dfc3321 | ||
|
|
aa21c15d0e | ||
|
|
3051da1e44 | ||
|
|
399c91adaa | ||
|
|
eb759255d8 | ||
|
|
cc86b8c7f1 | ||
|
|
a971ff00f8 | ||
|
|
67dc21ea5f | ||
|
|
efc3b5e7d4 | ||
|
|
eed2eaceb0 | ||
|
|
0a17976913 | ||
|
|
57904a56a0 | ||
|
|
728a5a6710 | ||
|
|
87fe1e9ad7 | ||
|
|
5c3b85334b | ||
|
|
e6a7e836a0 | ||
|
|
b269cca0bf | ||
|
|
1df5fd513a | ||
|
|
4627aad56d | ||
|
|
f23d95e004 | ||
|
|
1ef945de7b | ||
|
|
92fc286ecb | ||
|
|
dedb0426fb | ||
|
|
eb0410af53 | ||
|
|
3ded13c8d5 | ||
|
|
f1079574e3 | ||
|
|
73ad27e94c | ||
|
|
6a83cad69d | ||
|
|
7e0507e3b5 | ||
|
|
94a9e4e067 | ||
|
|
7b20ecd27b | ||
|
|
e635122ff6 | ||
|
|
fa07b9158a | ||
|
|
46eb6f39f3 | ||
|
|
f0538b25ed | ||
|
|
389b864020 | ||
|
|
26778156dc | ||
|
|
48627da0f9 | ||
|
|
6a712b7592 | ||
|
|
e9c60aec04 | ||
|
|
a8e9acfae9 | ||
|
|
f5908b0f8a |
@@ -220,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||
# DD_TRACE_AGENT_URL=
|
||||
|
||||
|
||||
# MaxMindDB API key to download GeoLite2 City database
|
||||
# IP_LOOKUP_API_KEY=
|
||||
|
||||
|
||||
@@ -99,3 +99,5 @@ CLAUDE.local.md
|
||||
# Histoire deployment
|
||||
.netlify
|
||||
.histoire
|
||||
.pnpm-store/*
|
||||
local/
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ exclude_patterns = [
|
||||
"**/target/**",
|
||||
"**/templates/**",
|
||||
"**/testdata/**",
|
||||
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||
]
|
||||
|
||||
test_patterns = [
|
||||
|
||||
+12
-1
@@ -7,6 +7,8 @@ plugins:
|
||||
require:
|
||||
- ./rubocop/use_from_email.rb
|
||||
- ./rubocop/custom_cop_location.rb
|
||||
- ./rubocop/attachment_download.rb
|
||||
- ./rubocop/one_class_per_file.rb
|
||||
|
||||
Layout/LineLength:
|
||||
Max: 150
|
||||
@@ -40,6 +42,12 @@ Style/SymbolArray:
|
||||
Style/OpenStructUse:
|
||||
Enabled: false
|
||||
|
||||
Chatwoot/AttachmentDownload:
|
||||
Enabled: true
|
||||
Exclude:
|
||||
- 'spec/**/*'
|
||||
- 'test/**/*'
|
||||
|
||||
Style/OptionalBooleanParameter:
|
||||
Exclude:
|
||||
- 'app/services/email_templates/db_resolver_service.rb'
|
||||
@@ -87,7 +95,7 @@ Metrics/ModuleLength:
|
||||
Rails/HelperInstanceVariable:
|
||||
Exclude:
|
||||
- enterprise/app/helpers/captain/chat_helper.rb
|
||||
|
||||
- enterprise/app/helpers/captain/chat_response_helper.rb
|
||||
Rails/ApplicationController:
|
||||
Exclude:
|
||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||
@@ -205,6 +213,9 @@ UseFromEmail:
|
||||
CustomCopLocation:
|
||||
Enabled: true
|
||||
|
||||
Style/OneClassPerFile:
|
||||
Enabled: true
|
||||
|
||||
AllCops:
|
||||
NewCops: enable
|
||||
Exclude:
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
||||
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
||||
- **Run Project**: `overmind start -f Procfile.dev`
|
||||
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
|
||||
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
|
||||
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
|
||||
|
||||
## Code Style
|
||||
|
||||
@@ -37,6 +40,8 @@
|
||||
|
||||
- MVP focus: Least code change, happy-path only
|
||||
- No unnecessary defensive programming
|
||||
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
||||
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||
- Break down complex tasks into small, testable units
|
||||
- Iterate after confirmation
|
||||
- Avoid writing specs unless explicitly asked
|
||||
|
||||
@@ -162,7 +162,7 @@ gem 'working_hours'
|
||||
gem 'pg_search'
|
||||
|
||||
# Subscriptions, Billing
|
||||
gem 'stripe'
|
||||
gem 'stripe', '~> 18.0'
|
||||
|
||||
## - helper gems --##
|
||||
## to populate db with sample data
|
||||
@@ -191,11 +191,16 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.4.3'
|
||||
gem 'ai-agents', '>= 0.7.0'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
gem 'ruby_llm-schema'
|
||||
|
||||
# OpenTelemetry for LLM observability
|
||||
gem 'opentelemetry-sdk'
|
||||
gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
@@ -210,7 +215,7 @@ group :production do
|
||||
end
|
||||
|
||||
group :development do
|
||||
gem 'annotate'
|
||||
gem 'annotaterb'
|
||||
gem 'bullet'
|
||||
gem 'letter_opener'
|
||||
gem 'scss_lint', require: false
|
||||
|
||||
+34
-11
@@ -126,11 +126,11 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.4.3)
|
||||
ruby_llm (~> 1.3)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
ai-agents (0.7.0)
|
||||
ruby_llm (~> 1.8.2)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
attr_extras (7.1.0)
|
||||
audited (5.4.1)
|
||||
@@ -625,6 +625,25 @@ GEM
|
||||
faraday (>= 1.0, < 3)
|
||||
multi_json (>= 1.0)
|
||||
openssl (3.2.0)
|
||||
opentelemetry-api (1.7.0)
|
||||
opentelemetry-common (0.23.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-exporter-otlp (0.31.1)
|
||||
google-protobuf (>= 3.18)
|
||||
googleapis-common-protos-types (~> 1.3)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-sdk (~> 1.10)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-registry (0.4.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-sdk (1.10.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-registry (~> 0.2)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-semantic_conventions (1.36.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.1)
|
||||
@@ -800,7 +819,7 @@ GEM
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.5.1)
|
||||
ruby_llm (1.8.2)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
@@ -808,8 +827,9 @@ GEM
|
||||
faraday-net_http (>= 1)
|
||||
faraday-retry (>= 1)
|
||||
marcel (~> 1.0)
|
||||
ruby_llm-schema (~> 0.2.1)
|
||||
zeitwerk (~> 2)
|
||||
ruby_llm-schema (0.1.0)
|
||||
ruby_llm-schema (0.2.5)
|
||||
ruby_parser (3.20.0)
|
||||
sexp_processor (~> 4.16)
|
||||
sass (3.7.4)
|
||||
@@ -909,7 +929,7 @@ GEM
|
||||
squasher (0.7.2)
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (8.5.0)
|
||||
stripe (18.0.1)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.4.0)
|
||||
@@ -998,8 +1018,8 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.4.3)
|
||||
annotate
|
||||
ai-agents (>= 0.7.0)
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
aws-actionmailbox-ses (~> 0)
|
||||
@@ -1073,6 +1093,8 @@ DEPENDENCIES
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
omniauth-saml
|
||||
opensearch-ruby
|
||||
opentelemetry-exporter-otlp
|
||||
opentelemetry-sdk
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
@@ -1098,6 +1120,7 @@ DEPENDENCIES
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
ruby-openai
|
||||
ruby_llm (>= 1.8.2)
|
||||
ruby_llm-schema
|
||||
scout_apm
|
||||
scss_lint
|
||||
@@ -1118,7 +1141,7 @@ DEPENDENCIES
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
stackprof
|
||||
stripe
|
||||
stripe (~> 18.0)
|
||||
telephone_number
|
||||
test-prof
|
||||
tidewave
|
||||
|
||||
@@ -103,3 +103,5 @@ class ContactInboxBuilder
|
||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||
end
|
||||
end
|
||||
|
||||
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
|
||||
|
||||
@@ -138,6 +138,7 @@ class Messages::MessageBuilder
|
||||
private: @private,
|
||||
sender: sender,
|
||||
content_type: @params[:content_type],
|
||||
content_attributes: content_attributes.presence,
|
||||
items: @items,
|
||||
in_reply_to: @in_reply_to,
|
||||
echo_id: @params[:echo_id],
|
||||
@@ -222,3 +223,5 @@ class Messages::MessageBuilder
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
||||
|
||||
@@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
@@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder
|
||||
file_type = attachment['type'].to_sym
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
|
||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||
params.merge!(file_type_params(attachment))
|
||||
elsif file_type == :location
|
||||
params.merge!(location_params(attachment))
|
||||
@@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def file_type_params(attachment)
|
||||
# Handle different URL field names for different attachment types
|
||||
url = case attachment['type'].to_sym
|
||||
when :ig_story
|
||||
attachment['payload']['story_media_url']
|
||||
else
|
||||
attachment['payload']['url']
|
||||
end
|
||||
|
||||
{
|
||||
external_url: attachment['payload']['url'],
|
||||
remote_file_url: attachment['payload']['url']
|
||||
external_url: url,
|
||||
remote_file_url: url
|
||||
}
|
||||
end
|
||||
|
||||
@@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder
|
||||
message.save!
|
||||
end
|
||||
|
||||
def fetch_ig_story_link(attachment)
|
||||
message = attachment.message
|
||||
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
|
||||
message.content_attributes[:image_type] = 'ig_story'
|
||||
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
|
||||
message.save!
|
||||
end
|
||||
|
||||
def fetch_ig_post_link(attachment)
|
||||
message = attachment.message
|
||||
message.content_attributes[:image_type] = 'ig_post'
|
||||
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
|
||||
message.save!
|
||||
end
|
||||
|
||||
# This is a placeholder method to be overridden by child classes
|
||||
def get_story_object_from_source_id(_source_id)
|
||||
{}
|
||||
@@ -76,6 +101,6 @@ class Messages::Messenger::MessageBuilder
|
||||
private
|
||||
|
||||
def unsupported_file_type?(attachment_type)
|
||||
[:template, :unsupported_type].include? attachment_type.to_sym
|
||||
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
|
||||
before_action :fetch_inbox
|
||||
before_action :validate_whatsapp_channel
|
||||
|
||||
def show
|
||||
template = @inbox.csat_config&.dig('template')
|
||||
return render json: { template_exists: false } unless template
|
||||
|
||||
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
status_result = @inbox.channel.provider_service.get_template_status(template_name)
|
||||
|
||||
render_template_status_response(status_result, template_name)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
|
||||
render json: { error: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def create
|
||||
template_params = extract_template_params
|
||||
return render_missing_message_error if template_params[:message].blank?
|
||||
|
||||
# Delete existing template even though we are using a new one.
|
||||
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
|
||||
delete_existing_template_if_needed
|
||||
|
||||
result = create_template_via_provider(template_params)
|
||||
render_template_creation_result(result)
|
||||
rescue ActionController::ParameterMissing
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error creating CSAT template: #{e.message}"
|
||||
render json: { error: 'Template creation failed' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
authorize @inbox, :show?
|
||||
end
|
||||
|
||||
def validate_whatsapp_channel
|
||||
return if @inbox.whatsapp?
|
||||
|
||||
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
|
||||
status: :bad_request
|
||||
end
|
||||
|
||||
def extract_template_params
|
||||
params.require(:template).permit(:message, :button_text, :language)
|
||||
end
|
||||
|
||||
def render_missing_message_error
|
||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def create_template_via_provider(template_params)
|
||||
template_config = {
|
||||
message: template_params[:message],
|
||||
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
|
||||
language: template_params[:language] || DEFAULT_LANGUAGE,
|
||||
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
}
|
||||
|
||||
@inbox.channel.provider_service.create_csat_template(template_config)
|
||||
end
|
||||
|
||||
def render_template_creation_result(result)
|
||||
if result[:success]
|
||||
render_successful_template_creation(result)
|
||||
else
|
||||
render_failed_template_creation(result)
|
||||
end
|
||||
end
|
||||
|
||||
def render_successful_template_creation(result)
|
||||
render json: {
|
||||
template: {
|
||||
name: result[:template_name],
|
||||
template_id: result[:template_id],
|
||||
status: 'PENDING',
|
||||
language: result[:language] || DEFAULT_LANGUAGE
|
||||
}
|
||||
}, status: :created
|
||||
end
|
||||
|
||||
def render_failed_template_creation(result)
|
||||
whatsapp_error = parse_whatsapp_error(result[:response_body])
|
||||
error_message = whatsapp_error[:user_message] || result[:error]
|
||||
|
||||
render json: {
|
||||
error: error_message,
|
||||
details: whatsapp_error[:technical_details]
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def delete_existing_template_if_needed
|
||||
template = @inbox.csat_config&.dig('template')
|
||||
return true if template.blank?
|
||||
|
||||
template_name = template['name']
|
||||
return true if template_name.blank?
|
||||
|
||||
template_status = @inbox.channel.provider_service.get_template_status(template_name)
|
||||
return true unless template_status[:success]
|
||||
|
||||
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
|
||||
if deletion_result[:success]
|
||||
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
|
||||
true
|
||||
else
|
||||
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
|
||||
false
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def render_template_status_response(status_result, template_name)
|
||||
if status_result[:success]
|
||||
render json: {
|
||||
template_exists: true,
|
||||
template_name: template_name,
|
||||
status: status_result[:template][:status],
|
||||
template_id: status_result[:template][:id]
|
||||
}
|
||||
else
|
||||
render json: {
|
||||
template_exists: false,
|
||||
error: 'Template not found'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_whatsapp_error(response_body)
|
||||
return { user_message: nil, technical_details: nil } if response_body.blank?
|
||||
|
||||
begin
|
||||
error_data = JSON.parse(response_body)
|
||||
whatsapp_error = error_data['error'] || {}
|
||||
|
||||
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
|
||||
technical_details = {
|
||||
code: whatsapp_error['code'],
|
||||
subcode: whatsapp_error['error_subcode'],
|
||||
type: whatsapp_error['type'],
|
||||
title: whatsapp_error['error_user_title']
|
||||
}.compact
|
||||
|
||||
{ user_message: user_message, technical_details: technical_details }
|
||||
rescue JSON::ParserError
|
||||
{ user_message: nil, technical_details: response_body }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def format_csat_config(config)
|
||||
{
|
||||
display_type: config['display_type'] || 'emoji',
|
||||
message: config['message'] || '',
|
||||
survey_rules: {
|
||||
operator: config.dig('survey_rules', 'operator') || 'contains',
|
||||
values: config.dig('survey_rules', 'values') || []
|
||||
}
|
||||
formatted = {
|
||||
'display_type' => config['display_type'] || 'emoji',
|
||||
'message' => config['message'] || '',
|
||||
:survey_rules => {
|
||||
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
||||
'values' => config.dig('survey_rules', 'values') || []
|
||||
},
|
||||
'button_text' => config['button_text'] || 'Please rate us',
|
||||
'language' => config['language'] || 'en'
|
||||
}
|
||||
format_template_config(config, formatted)
|
||||
formatted
|
||||
end
|
||||
|
||||
def format_template_config(config, formatted)
|
||||
formatted['template'] = config['template'] if config['template'].present?
|
||||
end
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
|
||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||
{ survey_rules: [:operator, { values: [] }],
|
||||
template: [:name, :template_id, :created_at, :language] }] }]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
||||
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
||||
|
||||
params.permit(
|
||||
*inbox_attributes,
|
||||
channel: [:type, *channel_attributes]
|
||||
)
|
||||
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
|
||||
end
|
||||
|
||||
def channel_type_from_params
|
||||
@@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def get_channel_attributes(channel_type)
|
||||
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
||||
channel_type.constantize::EDITABLE_ATTRS.presence
|
||||
else
|
||||
[]
|
||||
end
|
||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
/**
|
||||
* A client for the Captain Editor API.
|
||||
* @extends ApiClient
|
||||
*/
|
||||
class EditorAPI extends ApiClient {
|
||||
/**
|
||||
* Creates a new EditorAPI instance.
|
||||
*/
|
||||
constructor() {
|
||||
super('captain/editor', { accountScoped: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an event using the Captain Editor API.
|
||||
* @param {Object} options - The options for the event.
|
||||
* @param {string} [options.type='improve'] - The type of event to process.
|
||||
* @param {string} [options.content] - The content of the event.
|
||||
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the result of the event processing.
|
||||
*/
|
||||
processEvent({ type = 'improve', content, conversationId }, signal) {
|
||||
// Route to appropriate endpoint based on type
|
||||
if (type === 'summarize') {
|
||||
return this.summarize(conversationId, signal);
|
||||
}
|
||||
|
||||
if (type === 'reply_suggestion') {
|
||||
return this.replySuggestion(conversationId, signal);
|
||||
}
|
||||
|
||||
if (type === 'label_suggestion') {
|
||||
return this.labelSuggestion(conversationId, signal);
|
||||
}
|
||||
|
||||
// All other types are rewrite operations
|
||||
return this.rewrite({ content, operation: type, conversationId }, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites content with a specific operation.
|
||||
* @param {Object} options - The rewrite options.
|
||||
* @param {string} options.content - The content to rewrite.
|
||||
* @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc).
|
||||
* @param {string} [options.conversationId] - The conversation ID for context (required for 'improve').
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the rewritten content.
|
||||
*/
|
||||
rewrite({ content, operation, conversationId }, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/rewrite`,
|
||||
{
|
||||
content,
|
||||
operation,
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes a conversation.
|
||||
* @param {string} conversationId - The conversation ID to summarize.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the summary.
|
||||
*/
|
||||
summarize(conversationId, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/summarize`,
|
||||
{
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a reply suggestion for a conversation.
|
||||
* @param {string} conversationId - The conversation ID.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the reply suggestion.
|
||||
*/
|
||||
replySuggestion(conversationId, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/reply_suggestion`,
|
||||
{
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets label suggestions for a conversation.
|
||||
* @param {string} conversationId - The conversation ID.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with label suggestions.
|
||||
*/
|
||||
labelSuggestion(conversationId, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/label_suggestion`,
|
||||
{
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new EditorAPI();
|
||||
@@ -47,6 +47,12 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${contactId}/labels`);
|
||||
}
|
||||
|
||||
initiateCall(contactId, inboxId) {
|
||||
return axios.post(`${this.url}/${contactId}/call`, {
|
||||
inbox_id: inboxId,
|
||||
});
|
||||
}
|
||||
|
||||
updateContactLabels(contactId, labels) {
|
||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient {
|
||||
action_type: action,
|
||||
});
|
||||
}
|
||||
|
||||
createTopupCheckout(credits) {
|
||||
return axios.post(`${this.url}topup_checkout`, { credits });
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
|
||||
@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
|
||||
expect(accountAPI).toHaveProperty('delete');
|
||||
expect(accountAPI).toHaveProperty('checkout');
|
||||
expect(accountAPI).toHaveProperty('toggleDeletion');
|
||||
expect(accountAPI).toHaveProperty('createTopupCheckout');
|
||||
expect(accountAPI).toHaveProperty('getLimits');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
|
||||
{ action_type: 'undelete' }
|
||||
);
|
||||
});
|
||||
|
||||
it('#createTopupCheckout with credits', () => {
|
||||
accountAPI.createTopupCheckout(1000);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/enterprise/api/v1/topup_checkout',
|
||||
{ credits: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('#createTopupCheckout with different credit amounts', () => {
|
||||
const creditAmounts = [1000, 2500, 6000, 12000];
|
||||
creditAmounts.forEach(credits => {
|
||||
accountAPI.createTopupCheckout(credits);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/enterprise/api/v1/topup_checkout',
|
||||
{ credits }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('#getLimits', () => {
|
||||
accountAPI.getLimits();
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
/**
|
||||
* Represents the data object for a OpenAI hook.
|
||||
* @typedef {Object} ConversationMessageData
|
||||
* @property {string} [tone] - The tone of the message.
|
||||
* @property {string} [content] - The content of the message.
|
||||
* @property {string} [conversation_display_id] - The display ID of the conversation (optional).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A client for the OpenAI API.
|
||||
* @extends ApiClient
|
||||
*/
|
||||
class OpenAIAPI extends ApiClient {
|
||||
/**
|
||||
* Creates a new OpenAIAPI instance.
|
||||
*/
|
||||
constructor() {
|
||||
super('integrations', { accountScoped: true });
|
||||
|
||||
/**
|
||||
* The conversation events supported by the API.
|
||||
* @type {string[]}
|
||||
*/
|
||||
this.conversation_events = [
|
||||
'summarize',
|
||||
'reply_suggestion',
|
||||
'label_suggestion',
|
||||
];
|
||||
|
||||
/**
|
||||
* The message events supported by the API.
|
||||
* @type {string[]}
|
||||
*/
|
||||
this.message_events = ['rephrase'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an event using the OpenAI API.
|
||||
* @param {Object} options - The options for the event.
|
||||
* @param {string} [options.type='rephrase'] - The type of event to process.
|
||||
* @param {string} [options.content] - The content of the event.
|
||||
* @param {string} [options.tone] - The tone of the event.
|
||||
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
|
||||
* @param {string} options.hookId - The ID of the hook to use for processing the event.
|
||||
* @returns {Promise} A promise that resolves with the result of the event processing.
|
||||
*/
|
||||
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
|
||||
/**
|
||||
* @type {ConversationMessageData}
|
||||
*/
|
||||
let data = {
|
||||
tone,
|
||||
content,
|
||||
};
|
||||
|
||||
if (this.conversation_events.includes(type)) {
|
||||
data = {
|
||||
conversation_display_id: conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
|
||||
event: {
|
||||
name: type,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new OpenAIAPI();
|
||||
@@ -94,6 +94,19 @@
|
||||
--gray-11: 100 100 100;
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--violet-1: 253 252 254;
|
||||
--violet-2: 250 248 255;
|
||||
--violet-3: 244 240 254;
|
||||
--violet-4: 235 228 255;
|
||||
--violet-5: 225 217 255;
|
||||
--violet-6: 212 202 254;
|
||||
--violet-7: 194 178 248;
|
||||
--violet-8: 169 153 236;
|
||||
--violet-9: 110 86 207;
|
||||
--violet-10: 100 84 196;
|
||||
--violet-11: 101 85 183;
|
||||
--violet-12: 47 38 95;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
@@ -209,6 +222,19 @@
|
||||
--gray-11: 180 180 180;
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--violet-1: 20 17 31;
|
||||
--violet-2: 27 21 37;
|
||||
--violet-3: 41 31 67;
|
||||
--violet-4: 50 37 85;
|
||||
--violet-5: 60 46 105;
|
||||
--violet-6: 71 56 135;
|
||||
--violet-7: 86 70 151;
|
||||
--violet-8: 110 86 171;
|
||||
--violet-9: 110 86 207;
|
||||
--violet-10: 125 109 217;
|
||||
--violet-11: 169 153 236;
|
||||
--violet-12: 226 221 254;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
|
||||
@@ -102,6 +102,7 @@ const closeMobileSidebar = () => {
|
||||
/>
|
||||
<VoiceCallButton
|
||||
:phone="selectedContact?.phoneNumber"
|
||||
:contact-id="contactId"
|
||||
:label="$t('CONTACT_PANEL.CALL')"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup>
|
||||
import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
phone: { type: String, default: '' },
|
||||
contactId: { type: [String, Number], required: true },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
size: { type: String, default: 'sm' },
|
||||
@@ -18,10 +21,17 @@ const props = defineProps({
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
const attrs = useAttrs();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
@@ -32,20 +42,51 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isInitiatingCall = computed(() => {
|
||||
return contactsUiFlags.value?.isInitiatingCall || false;
|
||||
});
|
||||
|
||||
const onClick = () => {
|
||||
const navigateToConversation = conversationId => {
|
||||
const accountId = route.params.accountId;
|
||||
if (conversationId && accountId) {
|
||||
const path = frontendURL(
|
||||
conversationUrl({
|
||||
accountId,
|
||||
id: conversationId,
|
||||
})
|
||||
);
|
||||
router.push({ path });
|
||||
}
|
||||
};
|
||||
|
||||
const startCall = async inboxId => {
|
||||
if (isInitiatingCall.value) return;
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('contacts/initiateCall', {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
});
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(response?.conversation_id);
|
||||
} catch (error) {
|
||||
const apiError = error?.message;
|
||||
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = async () => {
|
||||
if (voiceInboxes.value.length > 1) {
|
||||
dialogRef.value?.open();
|
||||
return;
|
||||
}
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
const [inbox] = voiceInboxes.value;
|
||||
await startCall(inbox.id);
|
||||
};
|
||||
|
||||
const onPickInbox = () => {
|
||||
// Placeholder until actual call wiring happens
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
const onPickInbox = async inbox => {
|
||||
dialogRef.value?.close();
|
||||
await startCall(inbox.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -55,6 +96,8 @@ const onPickInbox = () => {
|
||||
v-if="shouldRender"
|
||||
v-tooltip.top-end="tooltipLabel || null"
|
||||
v-bind="attrs"
|
||||
:disabled="isInitiatingCall"
|
||||
:is-loading="isInitiatingCall"
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
:size="size"
|
||||
|
||||
@@ -19,12 +19,12 @@ const props = defineProps({
|
||||
},
|
||||
enableVariables: { type: Boolean, default: false },
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
signature: { type: String, default: '' },
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
@@ -102,12 +102,12 @@ watch(
|
||||
:disabled="disabled"
|
||||
:enable-variables="enableVariables"
|
||||
:enable-canned-responses="enableCannedResponses"
|
||||
:enabled-menu-options="enabledMenuOptions"
|
||||
:enable-captain-tools="enableCaptainTools"
|
||||
:signature="signature"
|
||||
:allow-signature="allowSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@@ -139,19 +139,6 @@ watch(
|
||||
.editor-wrapper {
|
||||
::v-deep {
|
||||
.ProseMirror-menubar-wrapper {
|
||||
@apply gap-2 !important;
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply h-5 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
|
||||
}
|
||||
}
|
||||
.ProseMirror.ProseMirror-woot-style {
|
||||
p {
|
||||
@apply first:mt-0 !important;
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ const previewArticle = () => {
|
||||
@apply mr-0;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0 mt-1 !mr-0;
|
||||
@apply p-0 mt-0 !mr-0;
|
||||
|
||||
svg {
|
||||
width: 20px !important;
|
||||
|
||||
+14
-12
@@ -7,7 +7,7 @@ import { vOnClickOutside } from '@vueuse/components';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
@@ -50,12 +50,6 @@ const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
);
|
||||
|
||||
const signatureToApply = computed(() =>
|
||||
props.isEmailOrWebWidgetInbox
|
||||
? props.messageSignature
|
||||
: extractTextFromMarkdown(props.messageSignature)
|
||||
);
|
||||
|
||||
const {
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setSignatureFlagForInbox,
|
||||
@@ -80,12 +74,20 @@ const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||
|
||||
const shouldShowSignatureButton = computed(() => {
|
||||
return (
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
||||
);
|
||||
});
|
||||
|
||||
const setSignature = () => {
|
||||
if (signatureToApply.value) {
|
||||
if (props.messageSignature) {
|
||||
if (sendWithSignature.value) {
|
||||
emit('addSignature', signatureToApply.value);
|
||||
emit('addSignature', props.messageSignature);
|
||||
} else {
|
||||
emit('removeSignature', signatureToApply.value);
|
||||
emit('removeSignature', props.messageSignature);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -101,7 +103,7 @@ watch(
|
||||
() => props.hasSelectedInbox,
|
||||
newValue => {
|
||||
nextTick(() => {
|
||||
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
|
||||
if (newValue && !isVoiceInbox.value) setSignature();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -220,7 +222,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
||||
v-if="shouldShowSignatureButton"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ const removeAttachment = id => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
|
||||
<div
|
||||
v-if="filteredImageAttachments.length > 0"
|
||||
class="flex flex-wrap gap-3"
|
||||
|
||||
+29
-11
@@ -6,7 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
extractTextFromMarkdown,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
buildContactableInboxesList,
|
||||
@@ -87,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
|
||||
|
||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
@@ -194,6 +200,7 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
|
||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
state.message = '';
|
||||
emit('updateTargetInbox', { ...rest });
|
||||
showInboxesDropdown.value = false;
|
||||
state.attachedFiles = [];
|
||||
@@ -202,25 +209,28 @@ const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
||||
? props.messageSignature
|
||||
: extractTextFromMarkdown(props.messageSignature);
|
||||
if (signatureToRemove) {
|
||||
state.message = removeSignature(state.message, signatureToRemove);
|
||||
if (props.messageSignature) {
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
props.messageSignature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
removeSignatureFromMessage();
|
||||
state.message = '';
|
||||
emit('updateTargetInbox', value);
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
emit('clearSelectedContact');
|
||||
state.attachedFiles = [];
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
@@ -228,11 +238,19 @@ const onClickInsertEmoji = emoji => {
|
||||
};
|
||||
|
||||
const handleAddSignature = signature => {
|
||||
state.message = appendSignature(state.message, signature);
|
||||
state.message = appendSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveSignature = signature => {
|
||||
state.message = removeSignature(state.message, signature);
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttachFile = files => {
|
||||
@@ -356,10 +374,10 @@ const shouldShowMessageEditor = computed(() => {
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:has-attachments="state.attachedFiles.length > 0"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
|
||||
+24
-102
@@ -1,127 +1,49 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed, nextTick } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
appendSignature,
|
||||
extractTextFromMarkdown,
|
||||
removeSignature,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
hasAttachments: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const state = ref({
|
||||
hasSlashCommand: false,
|
||||
showMentions: false,
|
||||
mentionSearchKey: '',
|
||||
});
|
||||
|
||||
const plainTextSignature = computed(() =>
|
||||
extractTextFromMarkdown(props.messageSignature)
|
||||
);
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
newValue => {
|
||||
if (props.isEmailOrWebWidgetInbox) return;
|
||||
|
||||
const bodyWithoutSignature = newValue
|
||||
? removeSignature(newValue, plainTextSignature.value)
|
||||
: '';
|
||||
|
||||
// Check if message starts with slash
|
||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
||||
|
||||
// Update slash command and mentions state
|
||||
state.value = {
|
||||
...state.value,
|
||||
hasSlashCommand: startsWithSlash,
|
||||
showMentions: startsWithSlash,
|
||||
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
|
||||
};
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const hideMention = () => {
|
||||
state.value.showMentions = false;
|
||||
};
|
||||
|
||||
const replaceText = async message => {
|
||||
// Only append signature on replace if sendWithSignature is true
|
||||
const finalMessage = props.sendWithSignature
|
||||
? appendSignature(message, plainTextSignature.value)
|
||||
: message;
|
||||
|
||||
await nextTick();
|
||||
modelValue.value = finalMessage;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||
<template v-if="isEmailOrWebWidgetInbox">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TextArea
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
|
||||
:custom-text-area-class="
|
||||
hasErrors
|
||||
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
auto-height
|
||||
allow-signature
|
||||
:signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
>
|
||||
<CannedResponse
|
||||
v-if="state.showMentions && state.hasSlashCommand"
|
||||
v-on-clickaway="hideMention"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="state.mentionSearchKey"
|
||||
@replace="replaceText"
|
||||
/>
|
||||
</TextArea>
|
||||
</template>
|
||||
<Editor
|
||||
:key="editorKey"
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+1
@@ -118,6 +118,7 @@ watch(
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
:message="formErrors.description"
|
||||
:message-type="formErrors.description ? 'error' : 'info'"
|
||||
class="z-0"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
+3
@@ -108,6 +108,7 @@ watch(
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
||||
:message="formErrors.handoffMessage"
|
||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||
class="z-0"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
@@ -116,6 +117,7 @@ watch(
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
||||
:message="formErrors.resolutionMessage"
|
||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||
class="z-0"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
@@ -126,6 +128,7 @@ watch(
|
||||
:message="formErrors.instructions"
|
||||
:max-length="20000"
|
||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||
class="z-0"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -299,7 +299,12 @@ const componentToRender = computed(() => {
|
||||
return DyteBubble;
|
||||
}
|
||||
|
||||
if (props.contentAttributes.imageType === 'story_mention') {
|
||||
const instagramSharedTypes = [
|
||||
ATTACHMENT_TYPES.STORY_MENTION,
|
||||
ATTACHMENT_TYPES.IG_STORY,
|
||||
ATTACHMENT_TYPES.IG_POST,
|
||||
];
|
||||
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
|
||||
return InstagramStoryBubble;
|
||||
}
|
||||
|
||||
@@ -476,7 +481,7 @@ provideMessageContext({
|
||||
<div
|
||||
v-if="shouldRenderMessage"
|
||||
:id="`message${props.id}`"
|
||||
class="flex w-full message-bubble-container mb-2"
|
||||
class="flex mb-2 w-full message-bubble-container"
|
||||
:data-message-id="props.id"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
|
||||
@@ -1,41 +1,102 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
|
||||
const { contentAttributes } = useMessageContext();
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
|
||||
const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const SUBTEXT_MAP = {
|
||||
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { contentAttributes, messageType } = useMessageContext();
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
const status = computed(() => data.value?.status);
|
||||
const direction = computed(() => data.value?.call_direction);
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
|
||||
useVoiceCallStatus(status, direction);
|
||||
|
||||
const containerRingClass = computed(() => {
|
||||
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div
|
||||
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
:class="containerRingClass"
|
||||
>
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
:class="bubbleIconBg"
|
||||
:class="bgColor"
|
||||
>
|
||||
<span class="text-xl" :class="bubbleIconName" />
|
||||
<Icon
|
||||
class="size-5"
|
||||
:icon="iconName"
|
||||
:class="{
|
||||
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
||||
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
|
||||
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,8 @@ export const ATTACHMENT_TYPES = {
|
||||
STORY_MENTION: 'story_mention',
|
||||
CONTACT: 'contact',
|
||||
IG_REEL: 'ig_reel',
|
||||
IG_POST: 'ig_post',
|
||||
IG_STORY: 'ig_story',
|
||||
};
|
||||
|
||||
export const CONTENT_TYPES = {
|
||||
@@ -73,3 +75,16 @@ export const MEDIA_TYPES = [
|
||||
ATTACHMENT_TYPES.AUDIO,
|
||||
ATTACHMENT_TYPES.IG_REEL,
|
||||
];
|
||||
|
||||
export const VOICE_CALL_STATUS = {
|
||||
IN_PROGRESS: 'in-progress',
|
||||
RINGING: 'ringing',
|
||||
COMPLETED: 'completed',
|
||||
NO_ANSWER: 'no-answer',
|
||||
FAILED: 'failed',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_DIRECTION = {
|
||||
INBOUND: 'inbound',
|
||||
OUTBOUND: 'outbound',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -24,6 +25,7 @@ const props = defineProps({
|
||||
});
|
||||
const emit = defineEmits(['update:currentPage']);
|
||||
const { t } = useI18n();
|
||||
const { formatCompactNumber, formatFullNumber } = useNumberFormatter();
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.ceil(props.totalItems / props.itemsPerPage)
|
||||
@@ -43,21 +45,27 @@ const changePage = newPage => {
|
||||
};
|
||||
|
||||
const currentPageInformation = computed(() => {
|
||||
const translationKey = props.currentPageInfo || 'PAGINATION_FOOTER.SHOWING';
|
||||
return t(
|
||||
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
|
||||
translationKey,
|
||||
{
|
||||
startItem: startItem.value,
|
||||
endItem: endItem.value,
|
||||
totalItems: props.totalItems,
|
||||
}
|
||||
startItem: formatFullNumber(startItem.value),
|
||||
endItem: formatFullNumber(endItem.value),
|
||||
totalItems: formatCompactNumber(props.totalItems),
|
||||
},
|
||||
Number(props.totalItems)
|
||||
);
|
||||
});
|
||||
|
||||
const pageInfo = computed(() => {
|
||||
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
|
||||
currentPage: '',
|
||||
totalPages: totalPages.value,
|
||||
});
|
||||
return t(
|
||||
'PAGINATION_FOOTER.CURRENT_PAGE_INFO',
|
||||
{
|
||||
currentPage: '',
|
||||
totalPages: formatCompactNumber(totalPages.value),
|
||||
},
|
||||
Number(totalPages.value)
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -91,9 +99,11 @@ const pageInfo = computed(() => {
|
||||
/>
|
||||
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
||||
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
||||
{{ currentPage }}
|
||||
{{ formatFullNumber(currentPage) }}
|
||||
</span>
|
||||
<span class="truncate">
|
||||
{{ pageInfo }}
|
||||
</span>
|
||||
<span class="truncate">{{ pageInfo }}</span>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-chevron-right"
|
||||
|
||||
@@ -48,7 +48,7 @@ export default {
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
async generateAIContent(type = 'rephrase') {
|
||||
async generateAIContent(type = 'improve') {
|
||||
this.isGenerating = true;
|
||||
this.generatedContent = await this.processEvent(type);
|
||||
this.isGenerating = false;
|
||||
|
||||
@@ -46,11 +46,11 @@ const fileName = file => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex overflow-auto max-h-[12.5rem]">
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2 overflow-auto max-h-[12.5rem]">
|
||||
<div
|
||||
v-for="(attachment, index) in nonRecordedAudioAttachments"
|
||||
:key="attachment.id"
|
||||
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem] mb-1"
|
||||
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem]"
|
||||
>
|
||||
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
|
||||
<img
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
|
||||
|
||||
import {
|
||||
buildMessageSchema,
|
||||
buildEditor,
|
||||
EditorView,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
EditorState,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Give copilot additional prompts, or ask anything else...',
|
||||
},
|
||||
generatedContent: { type: String, default: '' },
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'blur',
|
||||
'input',
|
||||
'update:modelValue',
|
||||
'keyup',
|
||||
'focus',
|
||||
'keydown',
|
||||
'send',
|
||||
]);
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
// Minimal schema with no marks or nodes for copilot input
|
||||
const copilotSchema = buildMessageSchema([], []);
|
||||
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
plugins = [],
|
||||
enabledMenuOptions = []
|
||||
) => {
|
||||
return EditorState.create({
|
||||
doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
|
||||
plugins: buildEditor({
|
||||
schema: copilotSchema,
|
||||
placeholder,
|
||||
plugins,
|
||||
enabledMenuOptions,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// we don't need them to be reactive
|
||||
// It cases weird issues where the objects are proxied
|
||||
// and then the editor doesn't work as expected
|
||||
let editorView = null;
|
||||
let state = null;
|
||||
|
||||
// reactive data
|
||||
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
|
||||
// element refs
|
||||
const editor = useTemplateRef('editor');
|
||||
|
||||
function contentFromEditor() {
|
||||
if (editorView) {
|
||||
return MessageMarkdownSerializer.serialize(editorView.state.doc);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function focusEditorInputField() {
|
||||
const { tr } = editorView.state;
|
||||
const selection = Selection.atEnd(tr.doc);
|
||||
|
||||
editorView.dispatch(tr.setSelection(selection));
|
||||
editorView.focus();
|
||||
}
|
||||
|
||||
function emitOnChange() {
|
||||
emit('update:modelValue', contentFromEditor());
|
||||
emit('input', contentFromEditor());
|
||||
}
|
||||
|
||||
function onKeyup() {
|
||||
emit('keyup');
|
||||
}
|
||||
|
||||
function onKeydown() {
|
||||
emit('keydown');
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
emit('blur');
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
emit('focus');
|
||||
}
|
||||
|
||||
function checkSelection(editorState) {
|
||||
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
||||
if (hasSelection === isTextSelected.value) return;
|
||||
isTextSelected.value = hasSelection;
|
||||
}
|
||||
|
||||
// computed properties
|
||||
const plugins = computed(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
const enabledMenuOptions = computed(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
function reloadState() {
|
||||
state = createState(
|
||||
props.modelValue,
|
||||
props.placeholder,
|
||||
plugins.value,
|
||||
enabledMenuOptions.value
|
||||
);
|
||||
editorView.updateState(state);
|
||||
focusEditorInputField();
|
||||
}
|
||||
|
||||
function createEditorView() {
|
||||
editorView = new EditorView(editor.value, {
|
||||
state: state,
|
||||
dispatchTransaction: tx => {
|
||||
state = state.apply(tx);
|
||||
editorView.updateState(state);
|
||||
if (tx.docChanged) {
|
||||
emitOnChange();
|
||||
}
|
||||
checkSelection(state);
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keyup: onKeyup,
|
||||
focus: onFocus,
|
||||
blur: onBlur,
|
||||
keydown: onKeydown,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
emit('send');
|
||||
}
|
||||
|
||||
// watchers
|
||||
watch(
|
||||
computed(() => props.modelValue),
|
||||
(newValue = '') => {
|
||||
if (newValue !== contentFromEditor()) {
|
||||
reloadState();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
computed(() => props.editorId),
|
||||
() => {
|
||||
reloadState();
|
||||
}
|
||||
);
|
||||
|
||||
// lifecycle
|
||||
onMounted(() => {
|
||||
state = createState(
|
||||
props.modelValue,
|
||||
props.placeholder,
|
||||
plugins.value,
|
||||
enabledMenuOptions.value
|
||||
);
|
||||
|
||||
createEditorView();
|
||||
editorView.updateState(state);
|
||||
|
||||
if (props.autofocus) {
|
||||
focusEditorInputField();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 mb-4">
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
|
||||
>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(generatedContent, false)"
|
||||
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4 underline decoration-n-iris-8 underline-offset-auto decoration-solid decoration-[10%]"
|
||||
/>
|
||||
</div>
|
||||
<div class="editor-root relative editor--copilot space-x-2">
|
||||
<div ref="editor" />
|
||||
<div class="flex items-center justify-end absolute right-2 bottom-2">
|
||||
<NextButton
|
||||
class="bg-n-iris-9 text-white !rounded-full"
|
||||
icon="i-lucide-arrow-up"
|
||||
solid
|
||||
sm
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||
|
||||
.editor--copilot {
|
||||
@apply bg-n-iris-5 rounded;
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
min-height: 5rem;
|
||||
max-height: 7.5rem !important;
|
||||
overflow: auto;
|
||||
@apply px-2 !important;
|
||||
|
||||
.empty-node {
|
||||
&::before {
|
||||
@apply text-n-iris-9 dark:text-n-iris-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
hasSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['executeCopilotAction']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { draftMessage } = useAI();
|
||||
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
|
||||
// Selection-based menu items (when text is selected)
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
if (props.hasSelection) {
|
||||
items.push({
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
|
||||
),
|
||||
key: 'improve_selection',
|
||||
icon: 'i-fluent-pen-sparkle-24-regular',
|
||||
});
|
||||
} else if (
|
||||
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
|
||||
draftMessage.value
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
|
||||
key: 'improve',
|
||||
icon: 'i-fluent-pen-sparkle-24-regular',
|
||||
});
|
||||
}
|
||||
|
||||
if (draftMessage.value) {
|
||||
items.push(
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.TITLE'
|
||||
),
|
||||
key: 'change_tone',
|
||||
icon: 'i-fluent-sound-wave-circle-sparkle-24-regular',
|
||||
subMenuItems: [
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.PROFESSIONAL'
|
||||
),
|
||||
key: 'professional',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CASUAL'
|
||||
),
|
||||
key: 'casual',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.STRAIGHTFORWARD'
|
||||
),
|
||||
key: 'straightforward',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CONFIDENT'
|
||||
),
|
||||
key: 'confident',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
|
||||
),
|
||||
key: 'friendly',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.GRAMMAR'),
|
||||
key: 'fix_spelling_grammar',
|
||||
icon: 'i-fluent-flow-sparkle-24-regular',
|
||||
}
|
||||
);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const generalMenuItems = computed(() => {
|
||||
const items = [];
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
|
||||
key: 'reply_suggestion',
|
||||
icon: 'i-fluent-chat-sparkle-16-regular',
|
||||
});
|
||||
}
|
||||
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
|
||||
key: 'summarize',
|
||||
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.ASK_COPILOT'),
|
||||
key: 'ask_copilot',
|
||||
icon: 'i-fluent-circle-sparkle-24-regular',
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const menuRef = useTemplateRef('menuRef');
|
||||
const { height: menuHeight } = useElementSize(menuRef);
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
// Smart submenu positioning based on available space
|
||||
const submenuPosition = computed(() => {
|
||||
const el = menuRef.value?.$el;
|
||||
if (!el) return 'ltr:right-full rtl:left-full';
|
||||
|
||||
const { left, right } = el.getBoundingClientRect();
|
||||
const SUBMENU_WIDTH = 200;
|
||||
const spaceRight = (windowWidth.value ?? window.innerWidth) - right;
|
||||
const spaceLeft = left;
|
||||
|
||||
// Prefer right, fallback to side with more space
|
||||
const showRight = spaceRight >= SUBMENU_WIDTH || spaceRight >= spaceLeft;
|
||||
|
||||
return showRight ? 'left-full' : 'right-full';
|
||||
});
|
||||
|
||||
// Computed style for selection menu positioning (only dynamic top offset)
|
||||
const selectionMenuStyle = computed(() => {
|
||||
// Dynamically calculate offset based on actual menu height + 10px gap
|
||||
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
|
||||
|
||||
return {
|
||||
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
|
||||
};
|
||||
});
|
||||
|
||||
const handleMenuItemClick = item => {
|
||||
// For items with submenus, do nothing on click (hover will show submenu)
|
||||
if (!item.subMenuItems) {
|
||||
emit('executeCopilotAction', item.key);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubMenuItemClick = (parentItem, subItem) => {
|
||||
emit('executeCopilotAction', subItem.key);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownBody
|
||||
ref="menuRef"
|
||||
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
|
||||
:class="{ 'selection-menu': hasSelection }"
|
||||
:style="hasSelection ? selectionMenuStyle : {}"
|
||||
>
|
||||
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
|
||||
<div
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
class="w-full relative group/submenu"
|
||||
>
|
||||
<Button
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
slate
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
|
||||
@click="handleMenuItemClick(item)"
|
||||
>
|
||||
<template v-if="item.subMenuItems" #default>
|
||||
<div class="flex items-center gap-1 justify-between w-full">
|
||||
<span class="min-w-0 truncate">{{ item.label }}</span>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-right"
|
||||
class="text-n-slate-10 size-3"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<!-- Hover Submenu -->
|
||||
<DropdownBody
|
||||
v-if="item.subMenuItems"
|
||||
class="group-hover/submenu:block hidden [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 [&>ul]:dark:!border-n-strong max-h-[15rem] min-w-32 z-10 top-0"
|
||||
:class="submenuPosition"
|
||||
>
|
||||
<Button
|
||||
v-for="subItem in item.subMenuItems"
|
||||
:key="subItem.key + subItem.label"
|
||||
:label="subItem.label"
|
||||
slate
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start mb-1"
|
||||
@click="handleSubMenuItemClick(item, subItem)"
|
||||
/>
|
||||
</DropdownBody>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="menuItems.length > 0" class="h-px w-full bg-n-strong" />
|
||||
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<Button
|
||||
v-for="(item, index) in generalMenuItems"
|
||||
:key="index"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
slate
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
|
||||
@click="handleMenuItemClick(item)"
|
||||
/>
|
||||
</div>
|
||||
</DropdownBody>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.selection-menu {
|
||||
position: absolute !important;
|
||||
|
||||
// Default/LTR: position from left
|
||||
left: var(--selection-left);
|
||||
|
||||
// RTL: position from right instead
|
||||
[dir='rtl'] & {
|
||||
left: auto;
|
||||
right: var(--selection-right);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
isGeneratingContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isPrivate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between items-center p-3 border-t"
|
||||
:class="{ 'border-n-weak': !isPrivate, 'border-n-amber-12/5': isPrivate }"
|
||||
>
|
||||
<NextButton
|
||||
label="Discard"
|
||||
slate
|
||||
link
|
||||
class="!px-1 hover:!no-underline"
|
||||
sm
|
||||
:disabled="isGeneratingContent"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<NextButton
|
||||
label="Accept"
|
||||
class="bg-n-iris-9 text-white"
|
||||
solid
|
||||
sm
|
||||
:disabled="isGeneratingContent"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@ import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
|
||||
import TagAgents from '../conversation/TagAgents.vue';
|
||||
import VariableList from '../conversation/VariableList.vue';
|
||||
import TagTools from '../conversation/TagTools.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -23,16 +24,15 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
MESSAGE_EDITOR_MENU_OPTIONS,
|
||||
MESSAGE_EDITOR_IMAGE_RESIZES,
|
||||
} from 'dashboard/constants/editor';
|
||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||
|
||||
import {
|
||||
messageSchema,
|
||||
buildMessageSchema,
|
||||
buildEditor,
|
||||
EditorView,
|
||||
MessageMarkdownTransformer,
|
||||
@@ -53,6 +53,11 @@ import {
|
||||
removeSignature as removeSignatureHelper,
|
||||
scrollCursorIntoView,
|
||||
setURLWithQueryAndSize,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -75,12 +80,12 @@ const props = defineProps({
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
variables: { type: Object, default: () => ({}) },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
signature: { type: String, default: '' },
|
||||
// allowSignature is a kill switch, ensuring no signature methods
|
||||
// are triggered except when this flag is true
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
});
|
||||
@@ -97,28 +102,51 @@ const emit = defineEmits([
|
||||
'focus',
|
||||
'input',
|
||||
'update:modelValue',
|
||||
'executeCopilotAction',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
|
||||
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
plugins = [],
|
||||
methods = {},
|
||||
enabledMenuOptions = []
|
||||
) => {
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(props.channelType, props.medium)
|
||||
);
|
||||
|
||||
const editorSchema = computed(() => {
|
||||
if (!props.channelType) return messageSchema;
|
||||
|
||||
const formatType = props.isPrivate
|
||||
? PRIVATE_NOTE_FORMATTING
|
||||
: effectiveChannelType.value;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return buildMessageSchema(formatting.marks, formatting.nodes);
|
||||
});
|
||||
|
||||
const editorMenuOptions = computed(() => {
|
||||
const formatType = props.isPrivate
|
||||
? PRIVATE_NOTE_FORMATTING
|
||||
: effectiveChannelType.value || DEFAULT_FORMATTING;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return formatting.menu;
|
||||
});
|
||||
|
||||
const createState = (content, placeholder, plugins = [], methods = {}) => {
|
||||
const schema = editorSchema.value;
|
||||
// Strip unsupported formatting before parsing to ensure content is compatible
|
||||
// with the current schema for proper rendering in the editor
|
||||
const sanitizedContent = stripUnsupportedFormatting(content, schema);
|
||||
return EditorState.create({
|
||||
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
|
||||
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
|
||||
plugins: buildEditor({
|
||||
schema: messageSchema,
|
||||
schema,
|
||||
placeholder,
|
||||
methods,
|
||||
plugins,
|
||||
enabledMenuOptions,
|
||||
enabledMenuOptions: editorMenuOptions.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -153,6 +181,8 @@ const range = ref(null);
|
||||
const isImageNodeSelected = ref(false);
|
||||
const toolbarPosition = ref({ top: 0, left: 0 });
|
||||
const selectedImageNode = ref(null);
|
||||
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
const showSelectionMenu = ref(false);
|
||||
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
|
||||
|
||||
// element ref
|
||||
@@ -160,6 +190,21 @@ const editorRoot = useTemplateRef('editorRoot');
|
||||
const imageUpload = useTemplateRef('imageUpload');
|
||||
const editor = useTemplateRef('editor');
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
if (actionKey === 'improve_selection' && editorView?.state) {
|
||||
const { from, to } = editorView.state.selection;
|
||||
const selectedText = editorView.state.doc.textBetween(from, to).trim();
|
||||
|
||||
if (from !== to && selectedText) {
|
||||
emit('executeCopilotAction', 'improve', selectedText);
|
||||
}
|
||||
} else {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
}
|
||||
|
||||
showSelectionMenu.value = false;
|
||||
};
|
||||
|
||||
const contentFromEditor = () => {
|
||||
return MessageMarkdownSerializer.serialize(editorView.state.doc);
|
||||
};
|
||||
@@ -174,12 +219,6 @@ const shouldShowCannedResponses = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const editorMenuOptions = computed(() => {
|
||||
return props.enabledMenuOptions.length
|
||||
? props.enabledMenuOptions
|
||||
: MESSAGE_EDITOR_MENU_OPTIONS;
|
||||
});
|
||||
|
||||
function createSuggestionPlugin({
|
||||
trigger,
|
||||
minChars = 0,
|
||||
@@ -293,8 +332,13 @@ function isBodyEmpty(content) {
|
||||
|
||||
// if the signature is present, we need to remove it before checking
|
||||
// note that we don't update the editorView, so this is safe
|
||||
// Use effective channel type to match how signature was appended
|
||||
const bodyWithoutSignature = props.signature
|
||||
? removeSignatureHelper(content, props.signature)
|
||||
? removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
)
|
||||
: content;
|
||||
|
||||
// trimming should remove all the whitespaces, so we can check the length
|
||||
@@ -343,13 +387,23 @@ function openFileBrowser() {
|
||||
imageUpload.value.click();
|
||||
}
|
||||
|
||||
function handleCopilotClick() {
|
||||
showSelectionMenu.value = !showSelectionMenu.value;
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
// Check if the clicked element or its parents have the ignored class
|
||||
if (event.target.closest('.ProseMirror-copilot')) return;
|
||||
showSelectionMenu.value = false;
|
||||
}
|
||||
|
||||
function reloadState(content = props.modelValue) {
|
||||
const unrefContent = unref(content);
|
||||
state = createState(
|
||||
unrefContent,
|
||||
props.placeholder,
|
||||
plugins.value,
|
||||
{ onImageUpload: openFileBrowser },
|
||||
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
|
||||
editorMenuOptions.value
|
||||
);
|
||||
|
||||
@@ -362,7 +416,11 @@ function addSignature() {
|
||||
// see if the content is empty, if it is before appending the signature
|
||||
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||
const contentWasEmpty = isBodyEmpty(content);
|
||||
content = appendSignature(content, props.signature);
|
||||
content = appendSignature(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
// need to reload first, ensuring that the editorView is updated
|
||||
reloadState(content);
|
||||
|
||||
@@ -374,7 +432,11 @@ function addSignature() {
|
||||
function removeSignature() {
|
||||
if (!props.signature) return;
|
||||
let content = props.modelValue;
|
||||
content = removeSignatureHelper(content, props.signature);
|
||||
content = removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
// reload the state, ensuring that the editorView is updated
|
||||
reloadState(content);
|
||||
}
|
||||
@@ -400,6 +462,38 @@ function setToolbarPosition() {
|
||||
};
|
||||
}
|
||||
|
||||
function setMenubarPosition({ selection } = {}) {
|
||||
const wrapper = editorRoot.value;
|
||||
if (!selection || !wrapper) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
|
||||
|
||||
// Calculate coords and final position
|
||||
const coords = getSelectionCoords(editorView, selection, rect);
|
||||
const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
|
||||
|
||||
wrapper.style.setProperty('--selection-left', `${left}px`);
|
||||
wrapper.style.setProperty(
|
||||
'--selection-right',
|
||||
`${rect.width - left - width}px`
|
||||
);
|
||||
wrapper.style.setProperty('--selection-top', `${top}px`);
|
||||
}
|
||||
|
||||
function checkSelection(editorState) {
|
||||
showSelectionMenu.value = false;
|
||||
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
||||
if (hasSelection === isTextSelected.value) return;
|
||||
|
||||
isTextSelected.value = hasSelection;
|
||||
const wrapper = editorRoot.value;
|
||||
if (!wrapper) return;
|
||||
|
||||
wrapper.classList.toggle('has-selection', hasSelection);
|
||||
if (hasSelection) setMenubarPosition(editorState);
|
||||
}
|
||||
|
||||
function setURLWithQueryAndImageSize(size) {
|
||||
if (!props.showImageResizeToolbar) {
|
||||
return;
|
||||
@@ -529,7 +623,14 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
|
||||
|
||||
function insertContentIntoEditor(content, defaultFrom = 0) {
|
||||
const from = defaultFrom || editorView.state.selection.from || 0;
|
||||
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||
// Use the editor's current schema to ensure compatibility with buildMessageSchema
|
||||
const currentSchema = editorView.state.schema;
|
||||
// Strip unsupported formatting before parsing to ensure content can be inserted
|
||||
// into channels that don't support certain markdown features (e.g., API channels)
|
||||
const sanitizedContent = stripUnsupportedFormatting(content, currentSchema);
|
||||
let node = new MessageMarkdownTransformer(currentSchema).parse(
|
||||
sanitizedContent
|
||||
);
|
||||
|
||||
insertNodeIntoEditor(node, from, undefined);
|
||||
}
|
||||
@@ -596,6 +697,7 @@ function createEditorView() {
|
||||
if (tx.docChanged) {
|
||||
emitOnChange();
|
||||
}
|
||||
checkSelection(state);
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keyup: () => {
|
||||
@@ -683,7 +785,7 @@ onMounted(() => {
|
||||
props.modelValue,
|
||||
props.placeholder,
|
||||
plugins.value,
|
||||
{ onImageUpload: openFileBrowser },
|
||||
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
|
||||
editorMenuOptions.value
|
||||
);
|
||||
|
||||
@@ -728,6 +830,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
:search-key="toolSearchKey"
|
||||
@select-tool="content => insertSpecialContent('tool', content)"
|
||||
/>
|
||||
<CopilotMenuBar
|
||||
v-if="showSelectionMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="isTextSelected"
|
||||
:show-selection-menu="showSelectionMenu"
|
||||
:show-general-menu="false"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
<input
|
||||
ref="imageUpload"
|
||||
type="file"
|
||||
@@ -761,15 +871,37 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
@apply flex flex-col;
|
||||
@apply flex flex-col gap-3;
|
||||
|
||||
.ProseMirror-menubar {
|
||||
min-height: 1.25rem !important;
|
||||
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
|
||||
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-n-slate-5 dark:bg-n-solid-3;
|
||||
@apply bg-n-slate-5 dark:bg-n-solid-3 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply mr-0 size-4 flex items-center justify-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply size-4 flex items-center justify-center flex-shrink-0;
|
||||
|
||||
svg {
|
||||
@apply size-full;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-copilot svg {
|
||||
@apply fill-n-violet-9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
max-height: none !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
> .ProseMirror {
|
||||
@@ -860,4 +992,57 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
.editor-warning__message {
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
||||
}
|
||||
|
||||
// Float editor menu
|
||||
.popover-prosemirror-menu {
|
||||
position: relative;
|
||||
|
||||
.ProseMirror p:last-child {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
display: none; // Hide by default
|
||||
}
|
||||
|
||||
&.has-selection {
|
||||
// Hide menu completely when it has no items
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
display: flex;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
|
||||
// Default/LTR: position from left
|
||||
top: var(--selection-top);
|
||||
left: var(--selection-left);
|
||||
|
||||
// RTL: position from right instead
|
||||
[dir='rtl'] & {
|
||||
left: auto;
|
||||
right: var(--selection-right);
|
||||
}
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply mr-0 size-4 flex items-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0.5 flex-shrink-0;
|
||||
}
|
||||
|
||||
.ProseMirror-copilot svg {
|
||||
@apply fill-n-violet-9;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-n-slate-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isReplyRestricted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['toggleMode']);
|
||||
@@ -24,11 +28,17 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
|
||||
|
||||
/**
|
||||
* Computed boolean indicating if the editor is in private note mode
|
||||
* When disabled, always show NOTE mode regardless of actual mode prop
|
||||
* When isReplyRestricted is true, force switch to private note
|
||||
* Otherwise, respect the current mode prop
|
||||
* @type {ComputedRef<boolean>}
|
||||
*/
|
||||
const isPrivate = computed(() => {
|
||||
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
|
||||
if (props.isReplyRestricted) {
|
||||
// Force switch to private note when replies are restricted
|
||||
return true;
|
||||
}
|
||||
// Otherwise respect the current mode
|
||||
return props.mode === REPLY_EDITOR_MODES.NOTE;
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -60,9 +70,9 @@ const translateValue = computed(() => {
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || isReplyRestricted"
|
||||
:class="{
|
||||
'cursor-not-allowed': disabled,
|
||||
'cursor-not-allowed': disabled || isReplyRestricted,
|
||||
}"
|
||||
@click="$emit('toggleMode')"
|
||||
>
|
||||
@@ -75,7 +85,7 @@ const translateValue = computed(() => {
|
||||
<div
|
||||
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
|
||||
:class="{
|
||||
'transition-all duration-300': !disabled,
|
||||
'transition-all duration-300': !disabled && !isReplyRestricted,
|
||||
}"
|
||||
:style="{
|
||||
'--chip-width': width,
|
||||
|
||||
@@ -9,14 +9,13 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReplyBottomPanel',
|
||||
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
|
||||
components: { NextButton, FileUpload, VideoCallButton },
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
isNote: {
|
||||
@@ -78,10 +77,6 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showEditorToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isOnPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -102,6 +97,7 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
@@ -130,7 +126,6 @@ export default {
|
||||
emits: [
|
||||
'replaceText',
|
||||
'toggleInsertArticle',
|
||||
'toggleEditor',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
'toggleQuotedReply',
|
||||
@@ -325,18 +320,8 @@ export default {
|
||||
sm
|
||||
@click="toggleAudioRecorder"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showEditorToggle"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||
icon="i-ph-quotes"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="$emit('toggleEditor')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showAudioPlayStopButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||
:icon="audioRecorderPlayStopIcon"
|
||||
slate
|
||||
faded
|
||||
@@ -385,13 +370,6 @@ export default {
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
<AIAssistanceButton
|
||||
v-if="!isFetchingAppIntegrations"
|
||||
:conversation-id="conversationId"
|
||||
:is-private-note="isOnPrivateNote"
|
||||
:message="message"
|
||||
@replace-text="replaceText"
|
||||
/>
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="uploadRef && uploadRef.dropActive"
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import EditorModeToggle from './EditorModeToggle.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReplyTopPanel',
|
||||
components: {
|
||||
NextButton,
|
||||
EditorModeToggle,
|
||||
CopilotMenuBar,
|
||||
},
|
||||
directives: {
|
||||
OnClickOutside: vOnClickOutside,
|
||||
},
|
||||
props: {
|
||||
mode: {
|
||||
@@ -19,6 +26,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMessageLengthReachingThreshold: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
@@ -28,7 +39,7 @@ export default {
|
||||
default: () => 0,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'togglePopout'],
|
||||
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
const setReplyMode = mode => {
|
||||
emit('setReplyMode', mode);
|
||||
@@ -47,6 +58,21 @@ export default {
|
||||
: REPLY_EDITOR_MODES.REPLY;
|
||||
setReplyMode(newMode);
|
||||
};
|
||||
const showCopilotMenu = ref(false);
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
showCopilotMenu.value = false;
|
||||
};
|
||||
|
||||
const toggleCopilotMenu = () => {
|
||||
showCopilotMenu.value = !showCopilotMenu.value;
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
showCopilotMenu.value = false;
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyP': {
|
||||
action: () => handleNoteClick(),
|
||||
@@ -64,6 +90,10 @@ export default {
|
||||
handleReplyClick,
|
||||
handleNoteClick,
|
||||
REPLY_EDITOR_MODES,
|
||||
handleCopilotAction,
|
||||
showCopilotMenu,
|
||||
toggleCopilotMenu,
|
||||
handleClickOutside,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -90,11 +120,13 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
|
||||
<div
|
||||
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
|
||||
>
|
||||
<EditorModeToggle
|
||||
:mode="mode"
|
||||
:disabled="isReplyRestricted"
|
||||
class="mt-3"
|
||||
:disabled="disabled"
|
||||
:is-reply-restricted="isReplyRestricted"
|
||||
@toggle-mode="handleModeToggle"
|
||||
/>
|
||||
<div class="flex items-center mx-4 my-0">
|
||||
@@ -104,11 +136,34 @@ export default {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<NextButton
|
||||
ghost
|
||||
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
|
||||
icon="i-lucide-maximize-2"
|
||||
@click="$emit('togglePopout')"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<NextButton
|
||||
ghost
|
||||
:disabled="disabled"
|
||||
:class="{
|
||||
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
|
||||
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
|
||||
}"
|
||||
sm
|
||||
icon="i-ph-sparkle-fill"
|
||||
@click="toggleCopilotMenu"
|
||||
/>
|
||||
<CopilotMenuBar
|
||||
v-if="showCopilotMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="false"
|
||||
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
ghost
|
||||
class="text-n-slate-11"
|
||||
sm
|
||||
icon="i-lucide-maximize-2"
|
||||
@click="$emit('togglePopout')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
@@ -14,6 +13,7 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
@@ -83,15 +83,10 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const callStatus = computed(
|
||||
() => props.chat.additional_attributes?.call_status
|
||||
);
|
||||
const callDirection = computed(
|
||||
() => props.chat.additional_attributes?.call_direction
|
||||
);
|
||||
|
||||
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
|
||||
useVoiceCallStatus(callStatus, callDirection);
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
@@ -317,20 +312,13 @@ const deleteConversation = () => {
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<div
|
||||
v-if="callStatus"
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallData.status"
|
||||
key="voice-status-row"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<span
|
||||
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
|
||||
:class="[voiceIconColor]"
|
||||
/>
|
||||
<span class="mx-1">
|
||||
{{ $t(voiceLabelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
:status="voiceCallData.status"
|
||||
:direction="voiceCallData.direction"
|
||||
:message-preview-class="messagePreviewClass"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessageInChat"
|
||||
key="message-preview"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
|
||||
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
|
||||
|
||||
defineProps({
|
||||
showCopilotEditor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isGeneratingContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
generatedContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['focus', 'blur', 'clearSelection', 'contentReady']);
|
||||
|
||||
const copilotEditorContent = ref('');
|
||||
|
||||
const onFocus = () => {
|
||||
emit('focus');
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
emit('blur');
|
||||
};
|
||||
|
||||
const clearEditorSelection = () => {
|
||||
emit('clearSelection');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
@after-enter="emit('contentReady')"
|
||||
>
|
||||
<CopilotEditor
|
||||
v-if="showCopilotEditor && !isGeneratingContent"
|
||||
key="copilot-editor"
|
||||
v-model="copilotEditorContent"
|
||||
class="copilot-editor"
|
||||
:generated-content="generatedContent"
|
||||
:min-height="4"
|
||||
:enabled-menu-options="[]"
|
||||
:is-popout="isPopout"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@clear-selection="clearEditorSelection"
|
||||
/>
|
||||
<div
|
||||
v-else-if="isGeneratingContent"
|
||||
key="loading-state"
|
||||
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<CaptainLoader class="text-n-iris-10 size-4" />
|
||||
<span class="text-sm text-n-iris-10">
|
||||
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.copilot-editor {
|
||||
.ProseMirror-menubar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -11,7 +11,7 @@ const openProfileSettings = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
|
||||
class="my-0 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
|
||||
>
|
||||
<p class="w-fit !m-0">
|
||||
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
|
||||
|
||||
@@ -258,8 +258,6 @@ export default {
|
||||
|
||||
created() {
|
||||
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||
// when a new message comes in, we refetch the label suggestions
|
||||
emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
|
||||
// when a message is sent we set the flag to true this hides the label suggestions,
|
||||
// until the chat is changed and the flag is reset in the watch for currentChat
|
||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
||||
@@ -291,6 +289,10 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if conversation already has labels - no need to suggest more
|
||||
const existingLabels = this.currentChat?.labels || [];
|
||||
if (existingLabels.length > 0) return;
|
||||
|
||||
// method available in mixin, need to ensure that integrations are present
|
||||
await this.fetchIntegrationsIfRequired();
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ import { useTrack } from 'dashboard/composables';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import CannedResponse from './CannedResponse.vue';
|
||||
import ReplyToMessage from './ReplyToMessage.vue';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
||||
import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
||||
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
|
||||
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
|
||||
import CopilotEditorSection from './CopilotEditorSection.vue';
|
||||
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
|
||||
import ReplyBoxBanner from './ReplyBoxBanner.vue';
|
||||
import QuotedEmailPreview from './QuotedEmailPreview.vue';
|
||||
@@ -45,9 +45,10 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
replaceSignature,
|
||||
getEffectiveChannelType,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
@@ -61,7 +62,6 @@ export default {
|
||||
ArticleSearchPopover,
|
||||
AttachmentPreview,
|
||||
AudioRecorder,
|
||||
CannedResponse,
|
||||
ReplyBoxBanner,
|
||||
EmojiInput,
|
||||
MessageSignatureMissingAlert,
|
||||
@@ -69,11 +69,12 @@ export default {
|
||||
ReplyEmailHead,
|
||||
ReplyToMessage,
|
||||
ReplyTopPanel,
|
||||
ResizableTextArea,
|
||||
ContentTemplates,
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
QuotedEmailPreview,
|
||||
CopilotEditorSection,
|
||||
CopilotReplyBottomPanel,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@@ -86,7 +87,6 @@ export default {
|
||||
setup() {
|
||||
const {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
isEditorHotKeyEnabled,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setQuotedReplyFlagForInbox,
|
||||
@@ -94,15 +94,16 @@ export default {
|
||||
} = useUISettings();
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
const copilot = useCopilotReply();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
isEditorHotKeyEnabled,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setQuotedReplyFlagForInbox,
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
replyEditor,
|
||||
copilot,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -115,7 +116,6 @@ export default {
|
||||
isRecordingAudio: false,
|
||||
recordingAudioState: '',
|
||||
recordingAudioDurationText: '',
|
||||
isUploading: false,
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
mentionSearchKey: '',
|
||||
hasSlashCommand: false,
|
||||
@@ -147,9 +147,12 @@ export default {
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
currentContact() {
|
||||
return this.$store.getters['contacts/getContact'](
|
||||
this.currentChat.meta.sender.id
|
||||
);
|
||||
const senderId = this.currentChat?.meta?.sender?.id;
|
||||
if (!senderId) return {};
|
||||
return this.$store.getters['contacts/getContact'](senderId);
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
|
||||
},
|
||||
shouldShowReplyToMessage() {
|
||||
return (
|
||||
@@ -159,34 +162,32 @@ export default {
|
||||
!this.is360DialogWhatsAppChannel
|
||||
);
|
||||
},
|
||||
showRichContentEditor() {
|
||||
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isAPIInbox) {
|
||||
const {
|
||||
display_rich_content_editor: displayRichContentEditor = false,
|
||||
} = this.uiSettings;
|
||||
return displayRichContentEditor;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||
// We support templates for API channels if someone updates templates manually via API
|
||||
// That's why we don't explicitly check for channel type here
|
||||
const templates = this.$store.getters['inboxes/getWhatsAppTemplates'](
|
||||
this.inboxId
|
||||
);
|
||||
return !!(templates && templates.length) && !this.isPrivate;
|
||||
},
|
||||
showContentTemplates() {
|
||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||
if (
|
||||
this.currentChat.can_reply ||
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAPIInbox
|
||||
) {
|
||||
return this.isOnPrivateNote;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
isReplyRestricted() {
|
||||
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
|
||||
return (
|
||||
!this.currentChat?.can_reply &&
|
||||
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
);
|
||||
},
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
@@ -288,9 +289,6 @@ export default {
|
||||
hasAttachments() {
|
||||
return this.attachedFiles.length;
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel;
|
||||
},
|
||||
showAudioRecorder() {
|
||||
return !this.isOnPrivateNote && this.showFileUpload;
|
||||
},
|
||||
@@ -330,21 +328,11 @@ export default {
|
||||
return !this.isPrivate && this.sendWithSignature;
|
||||
},
|
||||
isSignatureAvailable() {
|
||||
return !!this.signatureToApply;
|
||||
return !!this.messageSignature;
|
||||
},
|
||||
sendWithSignature() {
|
||||
return this.fetchSignatureFlagFromUISettings(this.channelType);
|
||||
},
|
||||
editorMessageKey() {
|
||||
const { editor_message_key: isEnabled } = this.uiSettings;
|
||||
return isEnabled;
|
||||
},
|
||||
commandPlusEnterToSendEnabled() {
|
||||
return this.editorMessageKey === 'cmd_enter';
|
||||
},
|
||||
enterToSendEnabled() {
|
||||
return this.editorMessageKey === 'enter';
|
||||
},
|
||||
conversationId() {
|
||||
return this.currentChat.id;
|
||||
},
|
||||
@@ -371,12 +359,6 @@ export default {
|
||||
});
|
||||
return variables;
|
||||
},
|
||||
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||
signatureToApply() {
|
||||
return this.showRichContentEditor
|
||||
? this.messageSignature
|
||||
: extractTextFromMarkdown(this.messageSignature);
|
||||
},
|
||||
connectedPortalSlug() {
|
||||
const { help_center: portal = {} } = this.inbox;
|
||||
const { slug = '' } = portal;
|
||||
@@ -427,6 +409,22 @@ export default {
|
||||
!!this.quotedEmailText
|
||||
);
|
||||
},
|
||||
isDefaultEditorMode() {
|
||||
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
|
||||
},
|
||||
showRichContentEditor() {
|
||||
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||
signatureToApply() {
|
||||
return this.showRichContentEditor
|
||||
? this.messageSignature
|
||||
: extractTextFromMarkdown(this.messageSignature);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation, oldConversation) {
|
||||
@@ -436,13 +434,15 @@ export default {
|
||||
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
|
||||
// like self-assign or other updates that do not actually change the conversation context
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
// Reset Copilot editor state (includes cancelling ongoing generation)
|
||||
this.copilot.reset();
|
||||
}
|
||||
|
||||
if (this.isOnPrivateNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canReply || this.isAWhatsAppChannel) {
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
|
||||
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
||||
} else {
|
||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||
@@ -500,7 +500,7 @@ export default {
|
||||
mounted() {
|
||||
this.getFromDraft();
|
||||
// Don't use the keyboard listener mixin here as the events here are supposed to be
|
||||
// working even if input/textarea is focussed.
|
||||
// working even if the editor is focussed.
|
||||
document.addEventListener('paste', this.onPaste);
|
||||
document.addEventListener('keydown', this.handleKeyEvents);
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
@@ -554,28 +554,6 @@ export default {
|
||||
|
||||
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
||||
},
|
||||
toggleRichContentEditor() {
|
||||
this.updateUISettings({
|
||||
display_rich_content_editor: !this.showRichContentEditor,
|
||||
});
|
||||
|
||||
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
|
||||
|
||||
if (!this.showRichContentEditor && this.messageSignature) {
|
||||
// remove the old signature -> extract text from markdown -> attach new signature
|
||||
let message = removeSignature(this.message, this.messageSignature);
|
||||
message = extractTextFromMarkdown(message);
|
||||
message = appendSignature(message, plainTextSignature);
|
||||
|
||||
this.message = message;
|
||||
} else {
|
||||
this.message = replaceSignature(
|
||||
this.message,
|
||||
plainTextSignature,
|
||||
this.messageSignature
|
||||
);
|
||||
}
|
||||
},
|
||||
toggleQuotedReply() {
|
||||
if (!this.isAnEmailChannel) {
|
||||
return;
|
||||
@@ -641,7 +619,23 @@ export default {
|
||||
if (this.isPrivate) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
return this.sendWithSignature
|
||||
? appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
)
|
||||
: removeSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.signatureToApply)
|
||||
: removeSignature(message, this.signatureToApply);
|
||||
@@ -704,7 +698,7 @@ export default {
|
||||
onPaste(e) {
|
||||
const data = e.clipboardData.files;
|
||||
if (!this.showRichContentEditor && data.length !== 0) {
|
||||
this.$refs.messageInput.$el.blur();
|
||||
this.$refs.messageInput?.$el?.blur();
|
||||
}
|
||||
if (!data.length || !data[0]) {
|
||||
return;
|
||||
@@ -839,7 +833,19 @@ export default {
|
||||
// if signature is enabled, append it to the message
|
||||
// appendSignature ensures that the signature is not duplicated
|
||||
// so we don't need to check if the signature is already present
|
||||
message = appendSignature(message, this.signatureToApply);
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
} else {
|
||||
message = appendSignature(message, this.signatureToApply);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
@@ -861,7 +867,8 @@ export default {
|
||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||
mode,
|
||||
});
|
||||
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.replyType = mode;
|
||||
if (this.showRichContentEditor) {
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
@@ -891,11 +898,26 @@ export default {
|
||||
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
|
||||
}
|
||||
},
|
||||
executeCopilotAction(action, data) {
|
||||
this.copilot.execute(action, data);
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
} else {
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
}
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
this.isRecordingAudio = false;
|
||||
@@ -913,19 +935,15 @@ export default {
|
||||
},
|
||||
toggleAudioRecorder() {
|
||||
this.isRecordingAudio = !this.isRecordingAudio;
|
||||
this.isRecorderAudioStopped = !this.isRecordingAudio;
|
||||
if (!this.isRecordingAudio) {
|
||||
this.resetAudioRecorderInput();
|
||||
}
|
||||
},
|
||||
toggleAudioRecorderPlayPause() {
|
||||
if (!this.isRecordingAudio) {
|
||||
return;
|
||||
}
|
||||
if (!this.isRecorderAudioStopped) {
|
||||
this.isRecorderAudioStopped = true;
|
||||
if (!this.$refs.audioRecorderInput) return;
|
||||
if (!this.recordingAudioState) {
|
||||
this.$refs.audioRecorderInput.stopRecording();
|
||||
} else if (this.isRecorderAudioStopped) {
|
||||
} else {
|
||||
this.$refs.audioRecorderInput.playPause();
|
||||
}
|
||||
},
|
||||
@@ -1155,6 +1173,9 @@ export default {
|
||||
togglePopout() {
|
||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||
},
|
||||
onSubmitCopilotReply() {
|
||||
this.message = this.copilot.accept();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1165,11 +1186,17 @@ export default {
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
:is-reply-restricted="isReplyRestricted"
|
||||
:disabled="
|
||||
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
||||
showAudioRecorderEditor
|
||||
"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-popout="togglePopout"
|
||||
@toggle-copilot="copilot.toggleEditor"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
<ArticleSearchPopover
|
||||
v-if="showArticleSearchPopover && connectedPortalSlug"
|
||||
@@ -1177,136 +1204,167 @@ export default {
|
||||
@insert="handleInsert"
|
||||
@close="onSearchPopoverClose"
|
||||
/>
|
||||
<div class="reply-box__top">
|
||||
<ReplyToMessage
|
||||
v-if="shouldShowReplyToMessage"
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<CannedResponse
|
||||
v-if="showMentions && hasSlashCommand"
|
||||
v-on-clickaway="hideMentions"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="mentionSearchKey"
|
||||
@replace="replaceText"
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:class="{
|
||||
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
|
||||
}"
|
||||
:on-click="addIntoEditor"
|
||||
/>
|
||||
<ReplyEmailHead
|
||||
v-if="showReplyHead"
|
||||
v-model:cc-emails="ccEmails"
|
||||
v-model:bcc-emails="bccEmails"
|
||||
v-model:to-emails="toEmails"
|
||||
/>
|
||||
<AudioRecorder
|
||||
v-if="showAudioRecorderEditor"
|
||||
ref="audioRecorderInput"
|
||||
:audio-record-format="audioRecordFormat"
|
||||
@recorder-progress-changed="onRecordProgressChanged"
|
||||
@finish-record="onFinishRecorder"
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@pause="recordingAudioState = 'paused'"
|
||||
/>
|
||||
<ResizableTextArea
|
||||
v-else-if="!showRichContentEditor"
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="rounded-none input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
:signature="signatureToApply"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else
|
||||
v-model="message"
|
||||
:editor-id="editorStateId"
|
||||
class="input"
|
||||
:is-private="isOnPrivateNote"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
enable-variables
|
||||
:variables="messageVariables"
|
||||
:signature="signatureToApply"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@toggle-user-mention="toggleUserMention"
|
||||
@toggle-canned-menu="toggleCannedMenu"
|
||||
@toggle-variables-menu="toggleVariablesMenu"
|
||||
@clear-selection="clearEditorSelection"
|
||||
/>
|
||||
<QuotedEmailPreview
|
||||
v-if="shouldShowQuotedPreview"
|
||||
:quoted-email-text="quotedEmailText"
|
||||
:preview-text="quotedEmailPreviewText"
|
||||
@toggle="toggleQuotedReply"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasAttachments && !showAudioRecorderEditor"
|
||||
class="attachment-preview-box"
|
||||
@paste="onPaste"
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
>
|
||||
<AttachmentPreview
|
||||
class="flex-col mt-4"
|
||||
:attachments="attachedFiles"
|
||||
@remove-attachment="removeAttachment"
|
||||
<div :key="copilot.editorTransitionKey.value" class="reply-box__top">
|
||||
<ReplyToMessage
|
||||
v-if="shouldShowReplyToMessage"
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:class="{
|
||||
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
|
||||
}"
|
||||
:on-click="addIntoEditor"
|
||||
/>
|
||||
<ReplyEmailHead
|
||||
v-if="showReplyHead && isDefaultEditorMode"
|
||||
v-model:cc-emails="ccEmails"
|
||||
v-model:bcc-emails="bccEmails"
|
||||
v-model:to-emails="toEmails"
|
||||
/>
|
||||
<AudioRecorder
|
||||
v-if="showAudioRecorderEditor"
|
||||
ref="audioRecorderInput"
|
||||
:audio-record-format="audioRecordFormat"
|
||||
@recorder-progress-changed="onRecordProgressChanged"
|
||||
@finish-record="onFinishRecorder"
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@pause="recordingAudioState = 'paused'"
|
||||
/>
|
||||
<CopilotEditorSection
|
||||
v-if="copilot.isActive.value && !showAudioRecorderEditor"
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
:is-popout="popOutReplyBox"
|
||||
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@clear-selection="clearEditorSelection"
|
||||
@close="copilot.showEditor.value = false"
|
||||
@content-ready="copilot.setContentReady"
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
v-model="message"
|
||||
:editor-id="editorStateId"
|
||||
class="input popover-prosemirror-menu"
|
||||
:is-private="isOnPrivateNote"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
enable-variables
|
||||
:variables="messageVariables"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@toggle-user-mention="toggleUserMention"
|
||||
@toggle-canned-menu="toggleCannedMenu"
|
||||
@toggle-variables-menu="toggleVariablesMenu"
|
||||
@clear-selection="clearEditorSelection"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
|
||||
<QuotedEmailPreview
|
||||
v-if="shouldShowQuotedPreview && isDefaultEditorMode"
|
||||
:quoted-email-text="quotedEmailText"
|
||||
:preview-text="quotedEmailPreviewText"
|
||||
class="mb-2"
|
||||
@toggle="toggleQuotedReply"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="hasAttachments && isDefaultEditorMode"
|
||||
class="bg-transparent py-0 mb-2"
|
||||
@paste="onPaste"
|
||||
>
|
||||
<AttachmentPreview
|
||||
class="mt-2"
|
||||
:attachments="attachedFiles"
|
||||
@remove-attachment="removeAttachment"
|
||||
/>
|
||||
</div>
|
||||
<MessageSignatureMissingAlert
|
||||
v-if="
|
||||
isSignatureEnabledForInbox &&
|
||||
!isSignatureAvailable &&
|
||||
isDefaultEditorMode
|
||||
"
|
||||
class="mb-2"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
>
|
||||
<CopilotReplyBottomPanel
|
||||
v-if="copilot.isActive.value"
|
||||
key="copilot-bottom-panel"
|
||||
:is-private="isOnPrivateNote"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.toggleEditor"
|
||||
/>
|
||||
</div>
|
||||
<MessageSignatureMissingAlert
|
||||
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
|
||||
/>
|
||||
<ReplyBottomPanel
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:enable-content-templates="showContentTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:is-note="isPrivate"
|
||||
:on-file-upload="onFileUpload"
|
||||
:on-send="onSendReply"
|
||||
:conversation-type="conversationType"
|
||||
:recording-audio-duration-text="recordingAudioDurationText"
|
||||
:recording-audio-state="recordingAudioState"
|
||||
:send-button-text="replyButtonLabel"
|
||||
:show-audio-recorder="showAudioRecorder"
|
||||
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:show-file-upload="showFileUpload"
|
||||
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
||||
:quoted-reply-enabled="quotedReplyPreference"
|
||||
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
||||
:toggle-audio-recorder="toggleAudioRecorder"
|
||||
:toggle-emoji-picker="toggleEmojiPicker"
|
||||
:message="message"
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@toggle-editor="toggleRichContentEditor"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
/>
|
||||
<ReplyBottomPanel
|
||||
v-else
|
||||
key="reply-bottom-panel"
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:enable-content-templates="showContentTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:is-note="isPrivate"
|
||||
:on-file-upload="onFileUpload"
|
||||
:on-send="onSendReply"
|
||||
:conversation-type="conversationType"
|
||||
:recording-audio-duration-text="recordingAudioDurationText"
|
||||
:recording-audio-state="recordingAudioState"
|
||||
:send-button-text="replyButtonLabel"
|
||||
:show-audio-recorder="showAudioRecorder"
|
||||
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:show-file-upload="showFileUpload"
|
||||
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
||||
:quoted-reply-enabled="quotedReplyPreference"
|
||||
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
||||
:toggle-audio-recorder="toggleAudioRecorder"
|
||||
:toggle-emoji-picker="toggleEmojiPicker"
|
||||
:message="message"
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<WhatsappTemplates
|
||||
:inbox-id="inbox.id"
|
||||
:show="showWhatsAppTemplatesModal"
|
||||
@@ -1336,13 +1394,7 @@ export default {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.attachment-preview-box {
|
||||
@apply bg-transparent py-0 px-4;
|
||||
}
|
||||
|
||||
.reply-box {
|
||||
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
|
||||
|
||||
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
|
||||
|
||||
&.is-private {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, default: '' },
|
||||
direction: { type: String, default: '' },
|
||||
messagePreviewClass: { type: [String, Array, Object], default: '' },
|
||||
});
|
||||
|
||||
const LABEL_KEYS = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||
};
|
||||
|
||||
const isOutbound = computed(
|
||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
||||
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const statusColor = computed(
|
||||
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<Icon
|
||||
class="inline-block -mt-0.5 align-middle size-4"
|
||||
:icon="iconName"
|
||||
:class="statusColor"
|
||||
/>
|
||||
<span class="mx-1" :class="statusColor">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon v-once icon="i-woot-captain" class="jumping-logo" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.jumping-logo {
|
||||
transform-origin: center bottom;
|
||||
animation: jump 1s cubic-bezier(0.28, 0.84, 0.42, 1) infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes jump {
|
||||
0% {
|
||||
transform: translateY(0) scale(1, 1);
|
||||
}
|
||||
20% {
|
||||
transform: translateY(0) scale(1.05, 0.95);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px) scale(0.95, 1.05);
|
||||
}
|
||||
80% {
|
||||
transform: translateY(0) scale(1.02, 0.98);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1, 1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -102,8 +102,8 @@ const createNonDraftMessageAIAssistActions = (t, replyMode) => {
|
||||
const createDraftMessageAIAssistActions = t => {
|
||||
return [
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
|
||||
key: 'rephrase',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CONFIDENT'),
|
||||
key: 'confident',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
@@ -112,28 +112,23 @@ const createDraftMessageAIAssistActions = t => {
|
||||
icon: ICON_AI_GRAMMAR,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
|
||||
key: 'expand',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.PROFESSIONAL'),
|
||||
key: 'professional',
|
||||
icon: ICON_AI_EXPAND,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
|
||||
key: 'shorten',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'),
|
||||
key: 'casual',
|
||||
icon: ICON_AI_SHORTEN,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
|
||||
key: 'make_friendly',
|
||||
key: 'friendly',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
|
||||
key: 'make_formal',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
|
||||
key: 'simplify',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'),
|
||||
key: 'straightforward',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
useMapGetter,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
import EditorAPI from 'dashboard/api/captain/editor';
|
||||
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('vue-i18n');
|
||||
vi.mock('dashboard/api/integrations/openapi');
|
||||
vi.mock('dashboard/api/captain/editor');
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
actual.default = {
|
||||
@@ -94,7 +94,7 @@ describe('useAI', () => {
|
||||
});
|
||||
|
||||
it('fetches label suggestions', async () => {
|
||||
OpenAPI.processEvent.mockResolvedValue({
|
||||
EditorAPI.processEvent.mockResolvedValue({
|
||||
data: { message: 'label1, label2' },
|
||||
});
|
||||
|
||||
@@ -111,9 +111,8 @@ describe('useAI', () => {
|
||||
const { fetchLabelSuggestions } = useAI();
|
||||
const result = await fetchLabelSuggestions();
|
||||
|
||||
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
|
||||
expect(EditorAPI.processEvent).toHaveBeenCalledWith({
|
||||
type: 'label_suggestion',
|
||||
hookId: 'hook1',
|
||||
conversationId: '123',
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
import EditorAPI from 'dashboard/api/captain/editor';
|
||||
|
||||
/**
|
||||
* Cleans and normalizes a list of labels.
|
||||
@@ -57,7 +57,7 @@ export function useAI() {
|
||||
* Computed property to check if AI integration is enabled.
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
|
||||
const isAIIntegrationEnabled = computed(() => true);
|
||||
|
||||
/**
|
||||
* Computed property to check if label suggestion feature is enabled.
|
||||
@@ -77,12 +77,6 @@ export function useAI() {
|
||||
*/
|
||||
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
/**
|
||||
* Computed property for the hook ID.
|
||||
* @type {import('vue').ComputedRef<string|undefined>}
|
||||
*/
|
||||
const hookId = computed(() => aiIntegration.value?.id);
|
||||
|
||||
/**
|
||||
* Computed property for the conversation ID.
|
||||
* @type {import('vue').ComputedRef<string|undefined>}
|
||||
@@ -139,9 +133,8 @@ export function useAI() {
|
||||
if (!conversationId.value) return [];
|
||||
|
||||
try {
|
||||
const result = await OpenAPI.processEvent({
|
||||
const result = await EditorAPI.processEvent({
|
||||
type: 'label_suggestion',
|
||||
hookId: hookId.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
|
||||
@@ -156,24 +149,33 @@ export function useAI() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes an AI event, such as rephrasing content.
|
||||
* @param {string} [type='rephrase'] - The type of AI event to process.
|
||||
* Processes an AI event, such as improving content.
|
||||
* @param {string} [type='improve'] - The type of AI event to process.
|
||||
* @param {string} [content=''] - The content to process (for full message) or selected text (for selection-based).
|
||||
* @param {Object} [options={}] - Additional options.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
|
||||
*/
|
||||
const processEvent = async (type = 'rephrase') => {
|
||||
const processEvent = async (type = 'improve', content = '', options = {}) => {
|
||||
try {
|
||||
const result = await OpenAPI.processEvent({
|
||||
hookId: hookId.value,
|
||||
type,
|
||||
content: draftMessage.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
const result = await EditorAPI.processEvent(
|
||||
{
|
||||
type,
|
||||
content: content || draftMessage.value,
|
||||
conversationId: conversationId.value,
|
||||
},
|
||||
options.signal
|
||||
);
|
||||
const {
|
||||
data: { message: generatedMessage },
|
||||
} = result;
|
||||
return generatedMessage;
|
||||
} catch (error) {
|
||||
const errorData = error.response.data.error;
|
||||
// Don't show error for aborted requests
|
||||
if (error.name === 'AbortError' || error.name === 'CanceledError') {
|
||||
return '';
|
||||
}
|
||||
const errorData = error.response?.data?.error;
|
||||
const errorMessage =
|
||||
errorData?.error?.message ||
|
||||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
@@ -9,6 +9,7 @@ export function useCaptain() {
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
|
||||
const captainEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
||||
@@ -34,6 +35,8 @@ export function useCaptain() {
|
||||
return null;
|
||||
});
|
||||
|
||||
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
|
||||
|
||||
const fetchLimits = () => {
|
||||
if (isEnterprise) {
|
||||
store.dispatch('accounts/limits');
|
||||
@@ -46,5 +49,6 @@ export function useCaptain() {
|
||||
documentLimits,
|
||||
responseLimits,
|
||||
fetchLimits,
|
||||
isFetchingLimits,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
/**
|
||||
* Composable for managing Copilot reply generation state and actions.
|
||||
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
|
||||
*
|
||||
* @returns {Object} Copilot reply state and methods
|
||||
*/
|
||||
export function useCopilotReply() {
|
||||
const { processEvent } = useAI();
|
||||
const { updateUISettings } = useUISettings();
|
||||
|
||||
const showEditor = ref(false);
|
||||
const isGenerating = ref(false);
|
||||
const isContentReady = ref(false);
|
||||
const generatedContent = ref('');
|
||||
const abortController = ref(null);
|
||||
|
||||
const isActive = computed(() => showEditor.value || isGenerating.value);
|
||||
const isButtonDisabled = computed(
|
||||
() => isGenerating.value || !isContentReady.value
|
||||
);
|
||||
const editorTransitionKey = computed(() =>
|
||||
isActive.value ? 'copilot' : 'rich'
|
||||
);
|
||||
|
||||
/**
|
||||
* Resets all copilot editor state and cancels any ongoing generation.
|
||||
*/
|
||||
function reset() {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
abortController.value = null;
|
||||
}
|
||||
showEditor.value = false;
|
||||
isGenerating.value = false;
|
||||
isContentReady.value = false;
|
||||
generatedContent.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the copilot editor visibility.
|
||||
*/
|
||||
function toggleEditor() {
|
||||
showEditor.value = !showEditor.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks content as ready (called after transition completes).
|
||||
*/
|
||||
function setContentReady() {
|
||||
isContentReady.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a copilot action (e.g., improve, fix grammar).
|
||||
* @param {string} action - The action type
|
||||
* @param {string} data - The content to process
|
||||
*/
|
||||
async function execute(action, data) {
|
||||
if (action === 'ask_copilot') {
|
||||
updateUISettings({
|
||||
is_contact_sidebar_open: false,
|
||||
is_copilot_panel_open: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset and start new generation
|
||||
reset();
|
||||
abortController.value = new AbortController();
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
|
||||
try {
|
||||
const content = await processEvent(action, data, {
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
generatedContent.value = content;
|
||||
if (content) showEditor.value = true;
|
||||
isGenerating.value = false;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts the generated content and returns it.
|
||||
* Note: Formatting is automatically stripped by the Editor component's
|
||||
* createState function based on the channel's schema.
|
||||
* @returns {string} The content ready for the editor
|
||||
*/
|
||||
function accept() {
|
||||
const content = generatedContent.value;
|
||||
showEditor.value = false;
|
||||
return content;
|
||||
}
|
||||
|
||||
return {
|
||||
showEditor,
|
||||
isGenerating,
|
||||
isContentReady,
|
||||
generatedContent,
|
||||
|
||||
isActive,
|
||||
isButtonDisabled,
|
||||
editorTransitionKey,
|
||||
|
||||
reset,
|
||||
toggleEditor,
|
||||
setContentReady,
|
||||
execute,
|
||||
accept,
|
||||
};
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { computed, unref } from 'vue';
|
||||
|
||||
const CALL_STATUSES = {
|
||||
IN_PROGRESS: 'in-progress',
|
||||
RINGING: 'ringing',
|
||||
NO_ANSWER: 'no-answer',
|
||||
BUSY: 'busy',
|
||||
FAILED: 'failed',
|
||||
COMPLETED: 'completed',
|
||||
CANCELED: 'canceled',
|
||||
};
|
||||
|
||||
const CALL_DIRECTIONS = {
|
||||
INBOUND: 'inbound',
|
||||
OUTBOUND: 'outbound',
|
||||
};
|
||||
|
||||
/**
|
||||
* Composable for handling voice call status display logic
|
||||
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
|
||||
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
|
||||
* @returns {Object} UI properties for displaying call status
|
||||
*/
|
||||
export function useVoiceCallStatus(statusRef, directionRef) {
|
||||
const status = computed(() => unref(statusRef)?.toString());
|
||||
const direction = computed(() => unref(directionRef)?.toString());
|
||||
|
||||
// Status group helpers
|
||||
const isFailedStatus = computed(() =>
|
||||
[
|
||||
CALL_STATUSES.NO_ANSWER,
|
||||
CALL_STATUSES.BUSY,
|
||||
CALL_STATUSES.FAILED,
|
||||
].includes(status.value)
|
||||
);
|
||||
const isEndedStatus = computed(() =>
|
||||
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
|
||||
);
|
||||
const isOutbound = computed(
|
||||
() => direction.value === CALL_DIRECTIONS.OUTBOUND
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
|
||||
}
|
||||
|
||||
if (s === CALL_STATUSES.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
|
||||
if (s === CALL_STATUSES.NO_ANSWER) {
|
||||
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
||||
}
|
||||
|
||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.RINGING) {
|
||||
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
}
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
||||
}
|
||||
|
||||
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const bubbleIconName = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-fill'
|
||||
: 'i-ph-phone-incoming-fill';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'i-ph-phone-x-fill';
|
||||
}
|
||||
|
||||
// For ringing/completed/canceled show direction when possible
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-fill'
|
||||
: 'i-ph-phone-incoming-fill';
|
||||
});
|
||||
|
||||
const bubbleIconBg = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return 'bg-n-teal-9';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'bg-n-ruby-9';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'bg-n-slate-11';
|
||||
}
|
||||
|
||||
// default (e.g., ringing)
|
||||
return 'bg-n-teal-9 animate-pulse';
|
||||
});
|
||||
|
||||
const listIconColor = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
|
||||
return 'text-n-teal-9';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'text-n-ruby-9';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'text-n-slate-11';
|
||||
}
|
||||
|
||||
return 'text-n-teal-9';
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
labelKey,
|
||||
subtextKey,
|
||||
bubbleIconName,
|
||||
bubbleIconBg,
|
||||
listIconColor,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +1,164 @@
|
||||
export const MESSAGE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'code',
|
||||
];
|
||||
|
||||
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
'imageUpload',
|
||||
];
|
||||
// Formatting rules for different contexts (channels and special contexts)
|
||||
// marks: inline formatting (strong, em, code, link, strike)
|
||||
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
|
||||
export const FORMATTING = {
|
||||
// Channel formatting
|
||||
'Channel::Email': {
|
||||
marks: ['strong', 'em', 'code', 'link'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::WebWidget': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Api': {
|
||||
marks: ['strong', 'em'],
|
||||
nodes: [],
|
||||
menu: ['copilot', 'strong', 'em', 'undo', 'redo'],
|
||||
},
|
||||
'Channel::FacebookPage': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::TwitterProfile': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::TwilioSms': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::Sms': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::Whatsapp': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Line': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['codeBlock'],
|
||||
menu: ['copilot', 'strong', 'em', 'code', 'strike', 'undo', 'redo'],
|
||||
},
|
||||
'Channel::Telegram': {
|
||||
marks: ['strong', 'em', 'link', 'code'],
|
||||
nodes: [],
|
||||
menu: ['copilot', 'strong', 'em', 'link', 'code', 'undo', 'redo'],
|
||||
},
|
||||
'Channel::Instagram': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'strike',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Voice': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
// Special contexts (not actual channels)
|
||||
'Context::PrivateNote': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Context::Default': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Context::MessageSignature': {
|
||||
marks: ['strong', 'em', 'link'],
|
||||
nodes: ['image'],
|
||||
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
|
||||
},
|
||||
'Context::InboxSettings': {
|
||||
marks: ['strong', 'em', 'link'],
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'link', 'undo', 'redo'],
|
||||
},
|
||||
};
|
||||
|
||||
// Editor menu options for Full Editor
|
||||
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
@@ -33,14 +174,86 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'code',
|
||||
];
|
||||
|
||||
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
/**
|
||||
* Markdown formatting patterns for stripping unsupported formatting.
|
||||
*
|
||||
* Maps camelCase type names to ProseMirror snake_case schema names.
|
||||
* Order matters: codeBlock before code to avoid partial matches.
|
||||
*/
|
||||
export const MARKDOWN_PATTERNS = [
|
||||
// --- BLOCK NODES ---
|
||||
{
|
||||
type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n```
|
||||
patterns: [
|
||||
{ pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'blockquote', // PM: blockquote, eg: > quote
|
||||
patterns: [{ pattern: /^> ?/gm, replacement: '' }],
|
||||
},
|
||||
{
|
||||
type: 'bulletList', // PM: bullet_list, eg: - item
|
||||
patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }],
|
||||
},
|
||||
{
|
||||
type: 'orderedList', // PM: ordered_list, eg: 1. item
|
||||
patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }],
|
||||
},
|
||||
{
|
||||
type: 'heading', // PM: heading, eg: ## Heading
|
||||
patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }],
|
||||
},
|
||||
{
|
||||
type: 'horizontalRule', // PM: horizontal_rule, eg: ---
|
||||
patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }],
|
||||
},
|
||||
{
|
||||
type: 'image', // PM: image, eg: 
|
||||
patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }],
|
||||
},
|
||||
{
|
||||
type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n
|
||||
patterns: [
|
||||
{ pattern: /\\\n/g, replacement: '\n' },
|
||||
{ pattern: / {2,}\n/g, replacement: '\n' },
|
||||
],
|
||||
},
|
||||
// --- INLINE MARKS ---
|
||||
{
|
||||
type: 'strong', // PM: strong, eg: **bold** or __bold__
|
||||
patterns: [
|
||||
{ pattern: /\*\*(.+?)\*\*/g, replacement: '$1' },
|
||||
{ pattern: /__(.+?)__/g, replacement: '$1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'em', // PM: em, eg: *italic* or _italic_
|
||||
patterns: [
|
||||
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
|
||||
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||
{
|
||||
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||
replacement: '$1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'strike', // PM: strike, eg: ~~strikethrough~~
|
||||
patterns: [{ pattern: /~~(.+?)~~/g, replacement: '$1' }],
|
||||
},
|
||||
{
|
||||
type: 'code', // PM: code, eg: `inline code`
|
||||
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
|
||||
},
|
||||
{
|
||||
type: 'link', // PM: link, eg: [text](url)
|
||||
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
|
||||
},
|
||||
];
|
||||
|
||||
// Editor image resize options for Message Editor
|
||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||
{
|
||||
name: 'Small',
|
||||
|
||||
@@ -88,6 +88,7 @@ export const OPEN_AI_EVENTS = Object.freeze({
|
||||
SUMMARIZE: 'OpenAI: Used summarize',
|
||||
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
|
||||
REPHRASE: 'OpenAI: Used rephrase',
|
||||
IMPROVE: 'OpenAI: Used improve',
|
||||
FIX_SPELLING_AND_GRAMMAR: 'OpenAI: Used fix spelling and grammar',
|
||||
SHORTEN: 'OpenAI: Used shorten',
|
||||
EXPAND: 'OpenAI: Used expand',
|
||||
|
||||
@@ -5,6 +5,82 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
* Links will be converted to text, and not removed.
|
||||
*
|
||||
* @param {string} markdown - markdown text to be extracted
|
||||
* @returns {string} - The extracted text.
|
||||
*/
|
||||
export function extractTextFromMarkdown(markdown) {
|
||||
if (!markdown) return '';
|
||||
return markdown
|
||||
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
||||
.replace(/`.*?`/g, '') // Remove inline code
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
|
||||
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n') // Trim each line & remove any lines only having spaces
|
||||
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
|
||||
.trim(); // Trim any extra space
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unsupported markdown formatting based on channel capabilities.
|
||||
*
|
||||
* @param {string} markdown - markdown text to process
|
||||
* @param {string} channelType - The channel type to check supported formatting
|
||||
* @returns {string} - The markdown with unsupported formatting removed
|
||||
*/
|
||||
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
||||
if (!markdown) return '';
|
||||
|
||||
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
|
||||
const has = (arr, key) => arr.includes(key);
|
||||
|
||||
// Define stripping rules: [condition, pattern, replacement]
|
||||
const rules = [
|
||||
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
|
||||
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
|
||||
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
|
||||
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
|
||||
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
|
||||
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
|
||||
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
|
||||
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||
[
|
||||
!has(marks, 'em'),
|
||||
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||
'$1',
|
||||
],
|
||||
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
|
||||
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
|
||||
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
|
||||
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
|
||||
];
|
||||
|
||||
const result = rules.reduce(
|
||||
(text, [shouldStrip, pattern, replacement]) =>
|
||||
shouldStrip ? text.replace(pattern, replacement) : text,
|
||||
markdown
|
||||
);
|
||||
|
||||
return result
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.replace(/\n{2,}/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The delimiter used to separate the signature from the rest of the body.
|
||||
@@ -67,15 +143,39 @@ export function findSignatureInBody(body, signature) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the effective channel type for formatting purposes.
|
||||
* For Twilio channels, returns WhatsApp or Twilio based on medium.
|
||||
*
|
||||
* @param {string} channelType - The channel type
|
||||
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
|
||||
* @returns {string} - The effective channel type for formatting
|
||||
*/
|
||||
export function getEffectiveChannelType(channelType, medium) {
|
||||
if (channelType === INBOX_TYPES.TWILIO) {
|
||||
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
? INBOX_TYPES.WHATSAPP
|
||||
: INBOX_TYPES.TWILIO;
|
||||
}
|
||||
return channelType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the signature to the body, separated by the signature delimiter.
|
||||
* Automatically strips unsupported formatting based on channel capabilities.
|
||||
*
|
||||
* @param {string} body - The body to append the signature to.
|
||||
* @param {string} signature - The signature to append.
|
||||
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
|
||||
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||
* @returns {string} - The body with the signature appended.
|
||||
*/
|
||||
export function appendSignature(body, signature) {
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
export function appendSignature(body, signature, channelType) {
|
||||
// Strip only unsupported formatting based on channel capabilities
|
||||
const preparedSignature = channelType
|
||||
? stripUnsupportedSignatureMarkdown(signature, channelType)
|
||||
: signature;
|
||||
const cleanedSignature = cleanSignature(preparedSignature);
|
||||
// if signature is already present, return body
|
||||
if (findSignatureInBody(body, cleanedSignature) > -1) {
|
||||
return body;
|
||||
@@ -86,16 +186,34 @@ export function appendSignature(body, signature) {
|
||||
|
||||
/**
|
||||
* Removes the signature from the body, along with the signature delimiter.
|
||||
* Tries to find both the original signature and the stripped version.
|
||||
*
|
||||
* @param {string} body - The body to remove the signature from.
|
||||
* @param {string} signature - The signature to remove.
|
||||
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
|
||||
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||
* @returns {string} - The body with the signature removed.
|
||||
*/
|
||||
export function removeSignature(body, signature) {
|
||||
// this will find the index of the signature if it exists
|
||||
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
|
||||
export function removeSignature(body, signature, channelType) {
|
||||
// Build list of signatures to try: original, channel-stripped, and fully stripped
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
const signatureIndex = findSignatureInBody(body, cleanedSignature);
|
||||
const channelStripped = channelType
|
||||
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
|
||||
: null;
|
||||
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
|
||||
|
||||
// Try signatures in order: original → channel-specific → fully stripped
|
||||
const signaturesToTry = [
|
||||
cleanedSignature,
|
||||
channelStripped,
|
||||
fullyStripped,
|
||||
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
|
||||
|
||||
// Find the first matching signature
|
||||
const signatureIndex = signaturesToTry.reduce(
|
||||
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
|
||||
-1
|
||||
);
|
||||
|
||||
// no need to trim the ends here, because it will simply be removed in the next method
|
||||
let newBody = body;
|
||||
@@ -136,28 +254,6 @@ export function replaceSignature(body, oldSignature, newSignature) {
|
||||
return appendSignature(withoutSignature, newSignature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
* Links will be converted to text, and not removed.
|
||||
*
|
||||
* @param {string} markdown - markdown text to be extracted
|
||||
* @returns
|
||||
*/
|
||||
export function extractTextFromMarkdown(markdown) {
|
||||
return markdown
|
||||
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
||||
.replace(/`.*?`/g, '') // Remove inline code
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
|
||||
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n') // Trim each line & remove any lines only having spaces
|
||||
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
|
||||
.trim(); // Trim any extra space
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the editor view into current cursor position
|
||||
*
|
||||
@@ -283,6 +379,47 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips unsupported markdown formatting from content based on the editor schema.
|
||||
* This ensures canned responses with rich formatting can be inserted into channels
|
||||
* that don't support certain formatting (e.g., API channels don't support bold).
|
||||
*
|
||||
* @param {string} content - The markdown content to sanitize
|
||||
* @param {Object} schema - The ProseMirror schema with supported marks and nodes
|
||||
* @returns {string} - Content with unsupported formatting stripped
|
||||
*/
|
||||
export function stripUnsupportedFormatting(content, schema) {
|
||||
if (!content || typeof content !== 'string') return content;
|
||||
if (!schema) return content;
|
||||
|
||||
let sanitizedContent = content;
|
||||
|
||||
// Get supported marks and nodes from the schema
|
||||
// Note: ProseMirror uses snake_case internally (code_block, bullet_list, etc.)
|
||||
// but our FORMATTING constant uses camelCase (codeBlock, bulletList, etc.)
|
||||
// We use camelcase-keys to normalize node names for comparison
|
||||
const supportedMarks = Object.keys(schema.marks || {});
|
||||
const nodeKeys = Object.keys(schema.nodes || {});
|
||||
const nodeKeysObj = Object.fromEntries(nodeKeys.map(k => [k, true]));
|
||||
const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj));
|
||||
|
||||
// Process each formatting type in order (codeBlock before code is important!)
|
||||
MARKDOWN_PATTERNS.forEach(({ type, patterns }) => {
|
||||
// Check if this format type is supported by the schema
|
||||
const isMarkSupported = supportedMarks.includes(type);
|
||||
const isNodeSupported = supportedNodes.includes(type);
|
||||
|
||||
// If not supported, strip the formatting
|
||||
if (!isMarkSupported && !isNodeSupported) {
|
||||
patterns.forEach(({ pattern, replacement }) => {
|
||||
sanitizedContent = sanitizedContent.replace(pattern, replacement);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return sanitizedContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content Node Creation Helper Functions for
|
||||
* - mention
|
||||
@@ -313,8 +450,17 @@ const createNode = (editorView, nodeType, content) => {
|
||||
|
||||
return mentionNode;
|
||||
}
|
||||
case 'cannedResponse':
|
||||
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||
case 'cannedResponse': {
|
||||
// Strip unsupported formatting before parsing to ensure content can be inserted
|
||||
// into channels that don't support certain markdown features (e.g., API channels)
|
||||
const sanitizedContent = stripUnsupportedFormatting(
|
||||
content,
|
||||
state.schema
|
||||
);
|
||||
return new MessageMarkdownTransformer(state.schema).parse(
|
||||
sanitizedContent
|
||||
);
|
||||
}
|
||||
case 'variable':
|
||||
return state.schema.text(`{{${content}}}`);
|
||||
case 'emoji':
|
||||
@@ -389,3 +535,85 @@ export const getContentNode = (
|
||||
? creator(editorView, content, from, to, variables)
|
||||
: { node: null, from, to };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the formatting configuration for a specific channel type.
|
||||
* Returns the appropriate marks, nodes, and menu items for the editor.
|
||||
*
|
||||
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
|
||||
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
|
||||
*/
|
||||
export function getFormattingForEditor(channelType) {
|
||||
return FORMATTING[channelType] || FORMATTING['Context::Default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu Positioning Helpers
|
||||
* Handles floating menu bar positioning for text selection in the editor.
|
||||
*/
|
||||
|
||||
const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
|
||||
|
||||
/**
|
||||
* Calculate selection coordinates with bias to handle line-wraps correctly.
|
||||
* @param {EditorView} editorView - ProseMirror editor view
|
||||
* @param {Selection} selection - Current text selection
|
||||
* @param {DOMRect} rect - Container bounding rect
|
||||
* @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
|
||||
*/
|
||||
export function getSelectionCoords(editorView, selection, rect) {
|
||||
const start = editorView.coordsAtPos(selection.from, 1);
|
||||
const end = editorView.coordsAtPos(selection.to, -1);
|
||||
|
||||
const selTop = Math.min(start.top, end.top);
|
||||
const spaceAbove = selTop - rect.top;
|
||||
const onTop =
|
||||
spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
|
||||
|
||||
return { start, end, selTop, onTop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate anchor position based on selection visibility and RTL direction.
|
||||
* @param {Object} coords - Selection coordinates from getSelectionCoords
|
||||
* @param {DOMRect} rect - Container bounding rect
|
||||
* @param {boolean} isRtl - Whether text direction is RTL
|
||||
* @returns {number} Anchor x-position for menu
|
||||
*/
|
||||
export function getMenuAnchor(coords, rect, isRtl) {
|
||||
const { start, end, onTop } = coords;
|
||||
|
||||
if (!onTop) return end.left;
|
||||
|
||||
// If start of selection is visible, align to text. Else stick to container edge.
|
||||
if (start.top >= rect.top) return isRtl ? start.right : start.left;
|
||||
|
||||
return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate final menu position (left, top) within container bounds.
|
||||
* @param {Object} coords - Selection coordinates from getSelectionCoords
|
||||
* @param {DOMRect} rect - Container bounding rect
|
||||
* @param {boolean} isRtl - Whether text direction is RTL
|
||||
* @returns {{left: number, top: number, width: number}}
|
||||
*/
|
||||
export function calculateMenuPosition(coords, rect, isRtl) {
|
||||
const { start, end, selTop, onTop } = coords;
|
||||
|
||||
const anchor = getMenuAnchor(coords, rect, isRtl);
|
||||
|
||||
// Calculate Left: shift by width if RTL, then make relative to container
|
||||
const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
|
||||
|
||||
// Ensure menu stays within container bounds
|
||||
const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
|
||||
|
||||
// Calculate Top: align to selection or bottom of selection
|
||||
const top = onTop
|
||||
? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
|
||||
: Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
|
||||
return { left, top, width: MENU_CONFIG.W };
|
||||
}
|
||||
|
||||
/* End Menu Positioning Helpers */
|
||||
|
||||
@@ -13,6 +13,11 @@ export const INBOX_TYPES = {
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
|
||||
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
|
||||
import { getContentNode } from '../editorHelper';
|
||||
import {
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
|
||||
vi.mock('@chatwoot/prosemirror-schema', () => ({
|
||||
MessageMarkdownTransformer: vi.fn(),
|
||||
messageSchema: {},
|
||||
}));
|
||||
|
||||
vi.mock('@chatwoot/utils', () => ({
|
||||
@@ -62,12 +58,18 @@ describe('getContentNode', () => {
|
||||
const to = 10;
|
||||
const updatedMessage = 'Hello John';
|
||||
|
||||
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
||||
MessageMarkdownTransformer.mockImplementation(() => ({
|
||||
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
|
||||
}));
|
||||
// Mock the node that will be returned by parse
|
||||
const mockNode = { textContent: updatedMessage };
|
||||
|
||||
const { node } = getContentNode(
|
||||
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
||||
|
||||
// Mock MessageMarkdownTransformer instance with parse method
|
||||
const mockTransformer = {
|
||||
parse: vi.fn().mockReturnValue(mockNode),
|
||||
};
|
||||
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
|
||||
|
||||
const result = getContentNode(
|
||||
editorView,
|
||||
'cannedResponse',
|
||||
content,
|
||||
@@ -79,8 +81,15 @@ describe('getContentNode', () => {
|
||||
message: content,
|
||||
variables,
|
||||
});
|
||||
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
|
||||
expect(node.textContent).toBe(updatedMessage);
|
||||
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
|
||||
editorView.state.schema
|
||||
);
|
||||
expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
|
||||
expect(result.node).toBe(mockNode);
|
||||
expect(result.node.textContent).toBe(updatedMessage);
|
||||
// When textContent matches updatedMessage, from should remain unchanged
|
||||
expect(result.from).toBe(from);
|
||||
expect(result.to).toBe(to);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,11 +5,18 @@ import {
|
||||
replaceSignature,
|
||||
cleanSignature,
|
||||
extractTextFromMarkdown,
|
||||
stripUnsupportedSignatureMarkdown,
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
getContentNode,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
getMenuAnchor,
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
} from '../editorHelper';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
import { EditorView } from '@chatwoot/prosemirror-schema';
|
||||
import { Schema } from 'prosemirror-model';
|
||||
@@ -138,6 +145,107 @@ describe('appendSignature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUnsupportedSignatureMarkdown', () => {
|
||||
const richSignature =
|
||||
'**Bold** _italic_ [link](http://example.com) ';
|
||||
|
||||
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Email'
|
||||
);
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('[link](http://example.com)');
|
||||
expect(result).toContain('');
|
||||
});
|
||||
it('strips images but keeps bold/italic for Api channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Api'
|
||||
);
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('link'); // link text kept
|
||||
expect(result).not.toContain('[link]('); // link syntax removed
|
||||
expect(result).not.toContain('; // image removed
|
||||
});
|
||||
it('strips images but keeps bold/italic/link for Telegram channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Telegram'
|
||||
);
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('[link](http://example.com)');
|
||||
expect(result).not.toContain(';
|
||||
});
|
||||
it('strips all formatting for SMS channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Sms'
|
||||
);
|
||||
expect(result).toContain('Bold');
|
||||
expect(result).toContain('italic');
|
||||
expect(result).toContain('link');
|
||||
expect(result).not.toContain('**');
|
||||
expect(result).not.toContain('_');
|
||||
expect(result).not.toContain('[');
|
||||
expect(result).not.toContain(';
|
||||
});
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
|
||||
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendSignature with channelType', () => {
|
||||
const signatureWithImage =
|
||||
'Thanks\n';
|
||||
|
||||
it('keeps images for Email channel', () => {
|
||||
const result = appendSignature(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::Email'
|
||||
);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
it('keeps images for WebWidget channel', () => {
|
||||
const result = appendSignature(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::WebWidget'
|
||||
);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
it('strips images but keeps text for Api channel', () => {
|
||||
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
|
||||
expect(result).not.toContain(';
|
||||
expect(result).toContain('Thanks');
|
||||
});
|
||||
it('strips images but keeps text for WhatsApp channel', () => {
|
||||
const result = appendSignature(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::Whatsapp'
|
||||
);
|
||||
expect(result).not.toContain(';
|
||||
expect(result).toContain('Thanks');
|
||||
});
|
||||
it('keeps images when channelType is not provided', () => {
|
||||
const result = appendSignature('Hello', signatureWithImage);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
it('keeps bold/italic for channels that support them', () => {
|
||||
const boldSignature = '**Bold** *italic* Thanks';
|
||||
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
|
||||
// Api supports strong and em
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('*italic*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSignature', () => {
|
||||
it('removes any instance of horizontal rule', () => {
|
||||
const options = [
|
||||
@@ -196,6 +304,37 @@ describe('removeSignature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSignature with stripped signature', () => {
|
||||
const signatureWithImage =
|
||||
'Thanks\n';
|
||||
|
||||
it('removes stripped signature from body', () => {
|
||||
// Simulate a body where signature was added with images stripped
|
||||
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
|
||||
const result = removeSignature(
|
||||
bodyWithStrippedSignature,
|
||||
signatureWithImage
|
||||
);
|
||||
expect(result).toBe('Hello\n\n');
|
||||
});
|
||||
it('removes original signature from body', () => {
|
||||
// Simulate a body where signature was added with images (using cleanSignature format)
|
||||
const cleanedSig = cleanSignature(signatureWithImage);
|
||||
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
|
||||
const result = removeSignature(
|
||||
bodyWithOriginalSignature,
|
||||
signatureWithImage
|
||||
);
|
||||
expect(result).toBe('Hello\n\n');
|
||||
});
|
||||
it('handles signature without images', () => {
|
||||
const simpleSignature = 'Best regards';
|
||||
const body = 'Hello\n\n--\n\nBest regards';
|
||||
const result = removeSignature(body, simpleSignature);
|
||||
expect(result).toBe('Hello\n\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceSignature', () => {
|
||||
it('appends the new signature if not present', () => {
|
||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||
@@ -258,15 +397,11 @@ describe('insertAtCursor', () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
|
||||
const docNode = schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [schema.text('Hello')]),
|
||||
]);
|
||||
|
||||
it('should insert text node at cursor position', () => {
|
||||
const editorState = createEditorState();
|
||||
const editorView = new EditorView(document.body, { state: editorState });
|
||||
|
||||
insertAtCursor(editorView, docNode, 0);
|
||||
insertAtCursor(editorView, schema.text('Hello'), 0);
|
||||
|
||||
// Check if node was unwrapped and inserted correctly
|
||||
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
|
||||
@@ -626,3 +761,349 @@ describe('getContentNode', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFormattingForEditor', () => {
|
||||
describe('channel-specific formatting', () => {
|
||||
it('returns full formatting for Email channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Email');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Email']);
|
||||
});
|
||||
|
||||
it('returns full formatting for WebWidget channel', () => {
|
||||
const result = getFormattingForEditor('Channel::WebWidget');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
|
||||
});
|
||||
|
||||
it('returns limited formatting for WhatsApp channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Whatsapp');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
|
||||
});
|
||||
|
||||
it('returns no formatting for API channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Api');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Api']);
|
||||
});
|
||||
|
||||
it('returns limited formatting for FacebookPage channel', () => {
|
||||
const result = getFormattingForEditor('Channel::FacebookPage');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
|
||||
});
|
||||
|
||||
it('returns no formatting for TwitterProfile channel', () => {
|
||||
const result = getFormattingForEditor('Channel::TwitterProfile');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
|
||||
});
|
||||
|
||||
it('returns no formatting for SMS channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Sms');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Sms']);
|
||||
});
|
||||
|
||||
it('returns limited formatting for Telegram channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Telegram');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Telegram']);
|
||||
});
|
||||
|
||||
it('returns formatting for Instagram channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Instagram');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Instagram']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context-specific formatting', () => {
|
||||
it('returns default formatting for Context::Default', () => {
|
||||
const result = getFormattingForEditor('Context::Default');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||
});
|
||||
|
||||
it('returns signature formatting for Context::MessageSignature', () => {
|
||||
const result = getFormattingForEditor('Context::MessageSignature');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::MessageSignature']);
|
||||
});
|
||||
|
||||
it('returns widget builder formatting for Context::InboxSettings', () => {
|
||||
const result = getFormattingForEditor('Context::InboxSettings');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::InboxSettings']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback behavior', () => {
|
||||
it('returns default formatting for unknown channel type', () => {
|
||||
const result = getFormattingForEditor('Channel::Unknown');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||
});
|
||||
|
||||
it('returns default formatting for null channel type', () => {
|
||||
const result = getFormattingForEditor(null);
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||
});
|
||||
|
||||
it('returns default formatting for undefined channel type', () => {
|
||||
const result = getFormattingForEditor(undefined);
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||
});
|
||||
|
||||
it('returns default formatting for empty string', () => {
|
||||
const result = getFormattingForEditor('');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return value structure', () => {
|
||||
it('always returns an object with marks, nodes, and menu properties', () => {
|
||||
const result = getFormattingForEditor('Channel::Email');
|
||||
|
||||
expect(result).toHaveProperty('marks');
|
||||
expect(result).toHaveProperty('nodes');
|
||||
expect(result).toHaveProperty('menu');
|
||||
expect(Array.isArray(result.marks)).toBe(true);
|
||||
expect(Array.isArray(result.nodes)).toBe(true);
|
||||
expect(Array.isArray(result.menu)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUnsupportedFormatting', () => {
|
||||
describe('when schema supports all formatting', () => {
|
||||
const fullSchema = {
|
||||
marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} },
|
||||
nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} },
|
||||
};
|
||||
|
||||
it('preserves all formatting when schema supports it', () => {
|
||||
const content = '**bold** and *italic* and `code`';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
});
|
||||
|
||||
it('preserves links when schema supports them', () => {
|
||||
const content = 'Check [this link](https://example.com)';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
});
|
||||
|
||||
it('preserves lists when schema supports them', () => {
|
||||
const content = '- item 1\n- item 2\n1. first\n2. second';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when schema has no formatting support (eg:SMS channel)', () => {
|
||||
const emptySchema = {
|
||||
marks: {},
|
||||
nodes: {},
|
||||
};
|
||||
|
||||
it('strips bold formatting', () => {
|
||||
expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe(
|
||||
'bold text'
|
||||
);
|
||||
expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe(
|
||||
'bold text'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips italic formatting', () => {
|
||||
expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe(
|
||||
'italic text'
|
||||
);
|
||||
expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe(
|
||||
'italic text'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves underscores in URLs and mid-word positions', () => {
|
||||
// Underscores in URLs should not be stripped as italic formatting
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'https://www.chatwoot.com/new_first_second-third/ssd',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
|
||||
|
||||
// Underscores in variable names should not be stripped
|
||||
expect(
|
||||
stripUnsupportedFormatting('some_variable_name', emptySchema)
|
||||
).toBe('some_variable_name');
|
||||
|
||||
// But actual italic formatting with spaces should still be stripped
|
||||
expect(
|
||||
stripUnsupportedFormatting('hello _world_ there', emptySchema)
|
||||
).toBe('hello world there');
|
||||
});
|
||||
|
||||
it('strips inline code formatting', () => {
|
||||
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
|
||||
'inline code'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips strikethrough formatting', () => {
|
||||
expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe(
|
||||
'strikethrough'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips links but keeps text', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'Check [this link](https://example.com)',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('Check this link');
|
||||
});
|
||||
|
||||
it('strips bullet list markers', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
|
||||
).toBe('item 1\nitem 2');
|
||||
expect(
|
||||
stripUnsupportedFormatting('* item 1\n* item 2', emptySchema)
|
||||
).toBe('item 1\nitem 2');
|
||||
});
|
||||
|
||||
it('strips ordered list markers', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting('1. first\n2. second', emptySchema)
|
||||
).toBe('first\nsecond');
|
||||
});
|
||||
|
||||
it('strips code block markers', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema)
|
||||
).toBe('code here\n');
|
||||
});
|
||||
|
||||
it('strips blockquote markers', () => {
|
||||
expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe(
|
||||
'quoted text'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles complex content with multiple formatting types', () => {
|
||||
const content =
|
||||
'**Bold** and *italic* with `code` and [link](url)\n- list item';
|
||||
const expected = 'Bold and italic with code and link\nlist item';
|
||||
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when schema has partial support', () => {
|
||||
const partialSchema = {
|
||||
marks: { strong: {}, em: {} },
|
||||
nodes: {},
|
||||
};
|
||||
|
||||
it('preserves supported marks and strips unsupported ones', () => {
|
||||
const content = '**bold** and `code`';
|
||||
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
|
||||
'**bold** and code'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips unsupported nodes but keeps supported marks', () => {
|
||||
const content = '**bold** text\n- list item';
|
||||
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
|
||||
'**bold** text\nlist item'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns content unchanged if content is empty', () => {
|
||||
expect(stripUnsupportedFormatting('', {})).toBe('');
|
||||
});
|
||||
|
||||
it('returns content unchanged if content is null', () => {
|
||||
expect(stripUnsupportedFormatting(null, {})).toBe(null);
|
||||
});
|
||||
|
||||
it('returns content unchanged if content is undefined', () => {
|
||||
expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns content unchanged if schema is null', () => {
|
||||
expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**');
|
||||
});
|
||||
|
||||
it('handles nested formatting correctly', () => {
|
||||
const emptySchema = { marks: {}, nodes: {} };
|
||||
// After stripping bold (**), the remaining *and italic* becomes italic and is stripped too
|
||||
expect(
|
||||
stripUnsupportedFormatting('**bold *and italic***', emptySchema)
|
||||
).toBe('bold and italic');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu positioning helpers', () => {
|
||||
const mockEditorView = {
|
||||
coordsAtPos: vi.fn((pos, bias) => {
|
||||
// Return different coords based on position
|
||||
if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
|
||||
return { top: 100, bottom: 120, left: 150, right: 200 };
|
||||
}),
|
||||
};
|
||||
|
||||
const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
|
||||
|
||||
describe('getSelectionCoords', () => {
|
||||
it('returns selection coordinates with onTop flag', () => {
|
||||
const selection = { from: 0, to: 10 };
|
||||
const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
|
||||
|
||||
expect(result).toHaveProperty('start');
|
||||
expect(result).toHaveProperty('end');
|
||||
expect(result).toHaveProperty('selTop');
|
||||
expect(result).toHaveProperty('onTop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMenuAnchor', () => {
|
||||
it('returns end.left when menu is below selection', () => {
|
||||
const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
|
||||
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
|
||||
});
|
||||
|
||||
it('returns start.left for LTR when menu is above and visible', () => {
|
||||
const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
|
||||
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
|
||||
});
|
||||
|
||||
it('returns start.right for RTL when menu is above and visible', () => {
|
||||
const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
|
||||
expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateMenuPosition', () => {
|
||||
it('returns bounded left and top positions', () => {
|
||||
const coords = {
|
||||
start: { top: 100, bottom: 120, left: 50 },
|
||||
end: { top: 100, bottom: 120, left: 150 },
|
||||
selTop: 100,
|
||||
onTop: false,
|
||||
};
|
||||
const result = calculateMenuPosition(coords, wrapperRect, false);
|
||||
|
||||
expect(result).toHaveProperty('left');
|
||||
expect(result).toHaveProperty('top');
|
||||
expect(result).toHaveProperty('width', 300);
|
||||
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"COMPANIES_LAYOUT": {
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} companies"
|
||||
"SHOWING": "Showing {startItem} – {endItem} of {totalItems} company | Showing {startItem} – {endItem} of {totalItems} companies"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} items",
|
||||
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} pages"
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} item | Showing {startItem} - {endItem} of {totalItems} items",
|
||||
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} page | {currentPage} of {totalPages} pages"
|
||||
},
|
||||
"COMBOBOX": {
|
||||
"PLACEHOLDER": "Select an option...",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "New message",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"CALL_INITIATED": "Calling the contact…",
|
||||
"CALL_FAILED": "Unable to start the call. Please try again.",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
@@ -376,7 +377,7 @@
|
||||
}
|
||||
},
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contact | Showing {startItem} - {endItem} of {totalItems} contacts"
|
||||
},
|
||||
"FILTER": {
|
||||
"NAME": "Name",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else...",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
|
||||
},
|
||||
@@ -196,7 +197,6 @@
|
||||
"INSERT_READ_MORE": "Read more",
|
||||
"DISMISS_REPLY": "Dismiss reply",
|
||||
"REPLYING_TO": "Replying to:",
|
||||
"TIP_FORMAT_ICON": "Show rich text editor",
|
||||
"TIP_EMOJI_ICON": "Show emoji selector",
|
||||
"TIP_ATTACH_ICON": "Attach files",
|
||||
"TIP_AUDIORECORDER_ICON": "Record audio",
|
||||
@@ -205,7 +205,7 @@
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"": "",
|
||||
"COPILOT_THINKING": "Copilot is thinking",
|
||||
"EMAIL_HEAD": {
|
||||
"TO": "TO",
|
||||
"ADD_BCC": "Add bcc",
|
||||
|
||||
@@ -145,7 +145,29 @@
|
||||
"EXPAND": "Expand",
|
||||
"MAKE_FRIENDLY": "Change message tone to friendly",
|
||||
"MAKE_FORMAL": "Use formal tone",
|
||||
"SIMPLIFY": "Simplify"
|
||||
"SIMPLIFY": "Simplify",
|
||||
"CONFIDENT": "Use confident tone",
|
||||
"PROFESSIONAL": "Use professional tone",
|
||||
"CASUAL": "Use casual tone",
|
||||
"STRAIGHTFORWARD": "Use straightforward tone"
|
||||
},
|
||||
"REPLY_OPTIONS": {
|
||||
"IMPROVE_REPLY": "Improve reply",
|
||||
"IMPROVE_REPLY_SELECTION": "Improve the selection",
|
||||
"CHANGE_TONE": {
|
||||
"TITLE": "Change tone",
|
||||
"OPTIONS": {
|
||||
"PROFESSIONAL": "Professional",
|
||||
"CASUAL": "Casual",
|
||||
"STRAIGHTFORWARD": "Straightforward",
|
||||
"CONFIDENT": "Confident",
|
||||
"FRIENDLY": "Friendly"
|
||||
}
|
||||
},
|
||||
"GRAMMAR": "Fix grammar & spelling",
|
||||
"SUGGESTION": "Suggest a reply",
|
||||
"SUMMARIZE": "Summarize the conversation",
|
||||
"ASK_COPILOT": "Ask Copilot"
|
||||
},
|
||||
"ASSISTANCE_MODAL": {
|
||||
"DRAFT_TITLE": "Draft content",
|
||||
|
||||
@@ -399,15 +399,39 @@
|
||||
"DESCRIPTION": "Manage usage and credits for Captain AI.",
|
||||
"BUTTON_TXT": "Buy more credits",
|
||||
"DOCUMENTS": "Documents",
|
||||
"RESPONSES": "Responses",
|
||||
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
|
||||
"RESPONSES": "Credits",
|
||||
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
|
||||
"REFRESH_CREDITS": "Refresh"
|
||||
},
|
||||
"CHAT_WITH_US": {
|
||||
"TITLE": "Need help?",
|
||||
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
|
||||
"BUTTON_TXT": "Chat with us"
|
||||
},
|
||||
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
|
||||
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
|
||||
"TOPUP": {
|
||||
"BUY_CREDITS": "Buy more credits",
|
||||
"MODAL_TITLE": "Buy AI Credits",
|
||||
"MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.",
|
||||
"CREDITS": "CREDITS",
|
||||
"ONE_TIME": "one-time",
|
||||
"POPULAR": "Most Popular",
|
||||
"NOTE_TITLE": "Note:",
|
||||
"NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.",
|
||||
"CANCEL": "Cancel",
|
||||
"PURCHASE": "Purchase Credits",
|
||||
"LOADING": "Loading options...",
|
||||
"FETCH_ERROR": "Failed to load credit options. Please try again.",
|
||||
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
|
||||
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm Purchase",
|
||||
"DESCRIPTION": "You are about to purchase {credits} credits for {amount}.",
|
||||
"INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.",
|
||||
"GO_BACK": "Go Back",
|
||||
"CONFIRM_PURCHASE": "Confirm Purchase"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SECURITY_SETTINGS": {
|
||||
"TITLE": "Security",
|
||||
|
||||
@@ -13,6 +13,8 @@ import InboxPageEmptyState from 'dashboard/components-next/captain/pageComponent
|
||||
const store = useStore();
|
||||
const dialogType = ref('');
|
||||
const route = useRoute();
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const assistantUiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
|
||||
const isFetchingAssistant = computed(() => assistantUiFlags.value.fetchingItem);
|
||||
@@ -47,10 +49,9 @@ const handleCreateClose = () => {
|
||||
selectedInbox.value = null;
|
||||
};
|
||||
|
||||
const assistantId = Number(route.params.assistantId);
|
||||
onMounted(() =>
|
||||
store.dispatch('captainInboxes/get', {
|
||||
assistantId: assistantId,
|
||||
assistantId: assistantId.value,
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -34,7 +34,7 @@ const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedAssistantId = Number(route.params.assistantId);
|
||||
const selectedAssistantId = computed(() => route.params.assistantId);
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
@@ -45,7 +45,7 @@ const backUrl = computed(() => ({
|
||||
name: 'captain_assistants_responses_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -125,8 +125,8 @@ const updateURLWithFilters = (page, search) => {
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'pending' };
|
||||
|
||||
if (selectedAssistantId) {
|
||||
filterParams.assistantId = selectedAssistantId;
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
filterParams.search = searchQuery.value;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
|
||||
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
|
||||
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
|
||||
@@ -13,13 +12,18 @@ import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/Com
|
||||
const DEFAULT_SORT_FIELD = 'created_at';
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
|
||||
const store = useStore();
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { updateUISettings, uiSettings } = useUISettings();
|
||||
|
||||
const companies = computed(() => companiesStore.getCompaniesList);
|
||||
const meta = computed(() => companiesStore.getMeta);
|
||||
const uiFlags = computed(() => companiesStore.getUIFlags);
|
||||
|
||||
const searchQuery = computed(() => route.query?.search || '');
|
||||
const searchValue = ref(searchQuery.value);
|
||||
const pageNumber = computed(() => Number(route.query?.page) || 1);
|
||||
@@ -46,10 +50,6 @@ const sortState = reactive({
|
||||
const activeSort = computed(() => sortState.activeSort);
|
||||
const activeOrdering = computed(() => sortState.activeOrdering);
|
||||
|
||||
const companies = useMapGetter('companies/getCompaniesList');
|
||||
const meta = useMapGetter('companies/getMeta');
|
||||
const uiFlags = useMapGetter('companies/getUIFlags');
|
||||
|
||||
const isFetchingList = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const buildSortAttr = () =>
|
||||
@@ -89,13 +89,13 @@ const fetchCompanies = async (page, search, sort) => {
|
||||
}
|
||||
|
||||
if (currentSearch) {
|
||||
await store.dispatch('companies/search', {
|
||||
await companiesStore.search({
|
||||
search: currentSearch,
|
||||
page: currentPage,
|
||||
sort: currentSort,
|
||||
});
|
||||
} else {
|
||||
await store.dispatch('companies/get', {
|
||||
await companiesStore.get({
|
||||
page: currentPage,
|
||||
sort: currentSort,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
useFunctionGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
|
||||
import ContactConversations from './ContactConversations.vue';
|
||||
@@ -52,12 +54,22 @@ const isShopifyFeatureEnabled = computed(
|
||||
() => shopifyIntegration.value.enabled
|
||||
);
|
||||
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const isLinearFeatureEnabled = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
|
||||
);
|
||||
|
||||
const linearIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
'linear'
|
||||
);
|
||||
|
||||
const isLinearIntegrationEnabled = computed(
|
||||
const isLinearClientIdConfigured = computed(() => {
|
||||
return !!linearIntegration.value?.id;
|
||||
});
|
||||
|
||||
const isLinearConnected = computed(
|
||||
() => linearIntegration.value?.enabled || false
|
||||
);
|
||||
|
||||
@@ -238,7 +250,13 @@ onMounted(() => {
|
||||
<MacrosList :conversation-id="conversationId" />
|
||||
</AccordionItem>
|
||||
</woot-feature-toggle>
|
||||
<div v-else-if="element.name === 'linear_issues'">
|
||||
<div
|
||||
v-else-if="
|
||||
element.name === 'linear_issues' &&
|
||||
isLinearFeatureEnabled &&
|
||||
isLinearClientIdConfigured
|
||||
"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.LINEAR_ISSUES')"
|
||||
:is-open="isContactSidebarItemOpen('is_linear_issues_open')"
|
||||
@@ -247,7 +265,7 @@ onMounted(() => {
|
||||
value => toggleSidebarUIState('is_linear_issues_open', value)
|
||||
"
|
||||
>
|
||||
<LinearSetupCTA v-if="!isLinearIntegrationEnabled" />
|
||||
<LinearSetupCTA v-if="!isLinearConnected" />
|
||||
<LinearIssuesList v-else :conversation-id="conversationId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
|
||||
@@ -282,6 +282,7 @@ export default {
|
||||
</ComposeConversation>
|
||||
<VoiceCallButton
|
||||
:phone="contact.phone_number"
|
||||
:contact-id="contact.id"
|
||||
icon="i-ri-phone-fill"
|
||||
size="sm"
|
||||
:tooltip-label="$t('CONTACT_PANEL.CALL')"
|
||||
|
||||
@@ -11,6 +11,7 @@ import BillingMeter from './components/BillingMeter.vue';
|
||||
import BillingCard from './components/BillingCard.vue';
|
||||
import BillingHeader from './components/BillingHeader.vue';
|
||||
import DetailItem from './components/DetailItem.vue';
|
||||
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
@@ -23,6 +24,7 @@ const {
|
||||
documentLimits,
|
||||
responseLimits,
|
||||
fetchLimits,
|
||||
isFetchingLimits,
|
||||
} = useCaptain();
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
@@ -32,6 +34,7 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
|
||||
// State for handling refresh attempts and loading
|
||||
const isWaitingForBilling = ref(false);
|
||||
const purchaseCreditsModalRef = ref(null);
|
||||
|
||||
const customAttributes = computed(() => {
|
||||
return currentAccount.value.custom_attributes || {};
|
||||
@@ -45,6 +48,11 @@ const planName = computed(() => {
|
||||
return customAttributes.value.plan_name;
|
||||
});
|
||||
|
||||
const canPurchaseCredits = computed(() => {
|
||||
const plan = planName.value?.toLowerCase();
|
||||
return plan && plan !== 'hacker';
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property for subscribed quantity
|
||||
* @returns {number|undefined}
|
||||
@@ -71,8 +79,9 @@ const hasABillingPlan = computed(() => {
|
||||
const fetchAccountDetails = async () => {
|
||||
if (!hasABillingPlan.value) {
|
||||
await store.dispatch('accounts/subscription');
|
||||
fetchLimits();
|
||||
}
|
||||
// Always fetch limits for billing page to show credit usage
|
||||
fetchLimits();
|
||||
};
|
||||
|
||||
const handleBillingPageLogic = async () => {
|
||||
@@ -119,6 +128,15 @@ const onToggleChatWindow = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openPurchaseCreditsModal = () => {
|
||||
purchaseCreditsModalRef.value?.open();
|
||||
};
|
||||
|
||||
const handleTopupSuccess = () => {
|
||||
// Refresh limits to show updated credit balance
|
||||
fetchLimits();
|
||||
};
|
||||
|
||||
onMounted(handleBillingPageLogic);
|
||||
</script>
|
||||
|
||||
@@ -178,9 +196,27 @@ onMounted(handleBillingPageLogic);
|
||||
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonV4 sm faded slate disabled>
|
||||
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
<div class="flex gap-2">
|
||||
<ButtonV4
|
||||
sm
|
||||
flushed
|
||||
slate
|
||||
icon="i-lucide-refresh-cw"
|
||||
:is-loading="isFetchingLimits"
|
||||
@click="fetchLimits"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
|
||||
</ButtonV4>
|
||||
<ButtonV4
|
||||
v-if="canPurchaseCredits"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
@click="openPurchaseCreditsModal"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="captainLimits && responseLimits" class="px-5">
|
||||
<BillingMeter
|
||||
@@ -217,12 +253,16 @@ onMounted(handleBillingPageLogic);
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-life-buoy"
|
||||
@open="onToggleChatWindow"
|
||||
@click="onToggleChatWindow"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
</BillingHeader>
|
||||
</section>
|
||||
<PurchaseCreditsModal
|
||||
ref="purchaseCreditsModalRef"
|
||||
@success="handleTopupSuccess"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
credits: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
amount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: 'usd',
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isPopular: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const formatCredits = credits => {
|
||||
return credits.toLocaleString();
|
||||
};
|
||||
|
||||
const formatAmount = (amount, currency) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
class="relative flex flex-col p-6 border-2 rounded-xl transition-all cursor-pointer bg-n-solid-1 hover:bg-n-solid-2"
|
||||
:class="[
|
||||
isSelected ? 'border-woot-500' : 'border-n-weak hover:border-n-strong',
|
||||
]"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:name="name"
|
||||
:value="credits"
|
||||
:checked="isSelected"
|
||||
class="sr-only"
|
||||
@change="emit('select')"
|
||||
/>
|
||||
<span
|
||||
v-if="isPopular"
|
||||
class="absolute -top-3 left-4 px-3 py-1 text-xs font-medium rounded"
|
||||
:class="
|
||||
isSelected ? 'bg-woot-500 text-white' : 'bg-n-solid-3 text-n-slate-11'
|
||||
"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.POPULAR') }}
|
||||
</span>
|
||||
<div
|
||||
v-if="isSelected"
|
||||
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 rounded-full bg-woot-500"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-3xl font-normal text-n-slate-12 mb-2 tracking-tighter">
|
||||
{{ formatCredits(credits) }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-normal text-n-slate-11 uppercase tracking-tight mb-6"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.CREDITS') }}
|
||||
</span>
|
||||
<span class="text-2xl font-normal text-n-slate-12 tracking-tight">
|
||||
{{ formatAmount(amount, currency) }}
|
||||
<span class="text-sm text-n-slate-11 ml-0.5">{{
|
||||
$t('BILLING_SETTINGS.TOPUP.ONE_TIME')
|
||||
}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CreditPackageCard from './CreditPackageCard.vue';
|
||||
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
|
||||
|
||||
const emit = defineEmits(['close', 'success']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const TOPUP_OPTIONS = [
|
||||
{ credits: 1000, amount: 20.0, currency: 'usd' },
|
||||
{ credits: 2500, amount: 50.0, currency: 'usd' },
|
||||
{ credits: 6000, amount: 100.0, currency: 'usd' },
|
||||
{ credits: 12000, amount: 200.0, currency: 'usd' },
|
||||
];
|
||||
|
||||
const POPULAR_CREDITS_AMOUNT = 6000;
|
||||
const STEP_SELECT = 'select';
|
||||
const STEP_CONFIRM = 'confirm';
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const selectedCredits = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const currentStep = ref(STEP_SELECT);
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
|
||||
});
|
||||
|
||||
const formattedAmount = computed(() => {
|
||||
if (!selectedOption.value) return '';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: selectedOption.value.currency.toUpperCase(),
|
||||
}).format(selectedOption.value.amount);
|
||||
});
|
||||
|
||||
const formattedCredits = computed(() => {
|
||||
if (!selectedOption.value) return '';
|
||||
return selectedOption.value.credits.toLocaleString();
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return currentStep.value === STEP_SELECT
|
||||
? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')
|
||||
: t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE');
|
||||
});
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
return currentStep.value === STEP_SELECT
|
||||
? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')
|
||||
: '';
|
||||
});
|
||||
|
||||
const dialogWidth = computed(() => {
|
||||
return currentStep.value === STEP_SELECT ? 'xl' : 'md';
|
||||
});
|
||||
|
||||
const handlePackageSelect = credits => {
|
||||
selectedCredits.value = credits;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
const popularOption = TOPUP_OPTIONS.find(
|
||||
o => o.credits === POPULAR_CREDITS_AMOUNT
|
||||
);
|
||||
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
|
||||
currentStep.value = STEP_SELECT;
|
||||
isLoading.value = false;
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const goToConfirmStep = () => {
|
||||
if (!selectedOption.value) return;
|
||||
currentStep.value = STEP_CONFIRM;
|
||||
};
|
||||
|
||||
const goBackToSelectStep = () => {
|
||||
currentStep.value = STEP_SELECT;
|
||||
};
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!selectedOption.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.createTopupCheckout(
|
||||
selectedOption.value.credits
|
||||
);
|
||||
|
||||
close();
|
||||
emit('success', response.data);
|
||||
useAlert(
|
||||
t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', {
|
||||
credits: response.data.credits,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="dialogTitle"
|
||||
:description="dialogDescription"
|
||||
:width="dialogWidth"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Step 1: Select Credits Package -->
|
||||
<template v-if="currentStep === 'select'">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<CreditPackageCard
|
||||
v-for="option in TOPUP_OPTIONS"
|
||||
:key="option.credits"
|
||||
name="credit-package"
|
||||
:credits="option.credits"
|
||||
:amount="option.amount"
|
||||
:currency="option.currency"
|
||||
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
|
||||
:is-selected="selectedCredits === option.credits"
|
||||
@select="handlePackageSelect(option.credits)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
<span class="font-semibold text-n-slate-12">{{
|
||||
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
|
||||
}}</span>
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Confirm Purchase -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('BILLING_SETTINGS.TOPUP.CONFIRM.DESCRIPTION', {
|
||||
credits: formattedCredits,
|
||||
amount: formattedAmount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
|
||||
<p class="text-sm text-n-amber-11">
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.CONFIRM.INSTANT_DEDUCTION_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<!-- Step 1 Footer -->
|
||||
<div
|
||||
v-if="currentStep === 'select'"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.CANCEL')"
|
||||
class="w-full"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
color="blue"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
||||
class="w-full"
|
||||
:disabled="!selectedCredits"
|
||||
@click="goToConfirmStep"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 Footer -->
|
||||
<div v-else class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.GO_BACK')"
|
||||
class="w-full"
|
||||
:disabled="isLoading"
|
||||
@click="goBackToSelectStep"
|
||||
/>
|
||||
<Button
|
||||
color="blue"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.CONFIRM_PURCHASE')"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
@click="handlePurchase"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -110,6 +110,7 @@ export default {
|
||||
v-model="content"
|
||||
class="message-editor [&>div]:px-1"
|
||||
:class="{ editor_warning: v$.content.$error }"
|
||||
channel-type="Context::Default"
|
||||
enable-variables
|
||||
:enable-canned-responses="false"
|
||||
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
|
||||
|
||||
@@ -114,6 +114,7 @@ export default {
|
||||
v-model="content"
|
||||
class="message-editor [&>div]:px-1"
|
||||
:class="{ editor_warning: v$.content.$error }"
|
||||
channel-type="Context::Default"
|
||||
enable-variables
|
||||
:enable-canned-responses="false"
|
||||
:placeholder="$t('CANNED_MGMT.EDIT.FORM.CONTENT.PLACEHOLDER')"
|
||||
|
||||
@@ -27,7 +27,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
@@ -81,7 +80,6 @@ export default {
|
||||
selectedTabIndex: 0,
|
||||
selectedPortalSlug: '',
|
||||
showBusinessNameInput: false,
|
||||
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
|
||||
healthData: null,
|
||||
isLoadingHealth: false,
|
||||
healthError: null,
|
||||
@@ -626,7 +624,7 @@ export default {
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
|
||||
channel-type="Context::InboxSettings"
|
||||
/>
|
||||
|
||||
<label v-if="isAWebWidgetInbox" class="pb-4">
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
@@ -76,7 +75,6 @@ export default {
|
||||
checked: false,
|
||||
},
|
||||
],
|
||||
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -337,7 +335,7 @@ export default {
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
|
||||
channel-type="Context::InboxSettings"
|
||||
class="mb-4"
|
||||
/>
|
||||
<label>
|
||||
|
||||
@@ -5,7 +5,6 @@ import router from '../../../../index';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
export default {
|
||||
@@ -24,7 +23,6 @@ export default {
|
||||
channelWelcomeTagline: '',
|
||||
greetingEnabled: false,
|
||||
greetingMessage: '',
|
||||
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -147,7 +145,7 @@ export default {
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
|
||||
channel-type="Context::InboxSettings"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -12,7 +11,6 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updateSignature']);
|
||||
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
|
||||
const signature = ref(props.messageSignature);
|
||||
watch(
|
||||
() => props.messageSignature ?? '',
|
||||
@@ -34,7 +32,7 @@ const updateSignature = () => {
|
||||
class="message-editor h-[10rem] !px-3"
|
||||
is-format-mode
|
||||
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
|
||||
:enabled-menu-options="customEditorMenuList"
|
||||
channel-type="Context::MessageSignature"
|
||||
:enable-suggestions="false"
|
||||
show-image-resize-toolbar
|
||||
/>
|
||||
|
||||
+5
-4
@@ -52,11 +52,12 @@ const dayMenuItemConfigs = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const resolvedLocale = computed(
|
||||
() =>
|
||||
const resolvedLocale = computed(() => {
|
||||
const currentLocale =
|
||||
locale.value ||
|
||||
(typeof navigator !== 'undefined' ? navigator.language : 'en')
|
||||
);
|
||||
(typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
return currentLocale.replace('_', '-');
|
||||
});
|
||||
|
||||
const monthFormatter = computed(
|
||||
() =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainAssistantAPI from 'dashboard/api/captain/assistant';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainAssistant',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainBulkActionsAPI from 'dashboard/api/captain/bulkActions';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export default createStore({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CopilotMessages',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CopilotThreads',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainCustomTools from 'dashboard/api/captain/customTools';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export default createStore({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainDocumentAPI from 'dashboard/api/captain/document';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainDocument',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainInboxes from 'dashboard/api/captain/inboxes';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export default createStore({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainScenarios from 'dashboard/api/captain/scenarios';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export default createStore({
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import {
|
||||
createRecord,
|
||||
deleteRecord,
|
||||
getRecords,
|
||||
showRecord,
|
||||
updateRecord,
|
||||
} from './storeFactoryHelper';
|
||||
|
||||
export const generateMutationTypes = name => {
|
||||
const capitalizedName = name.toUpperCase();
|
||||
return {
|
||||
SET_UI_FLAG: `SET_${capitalizedName}_UI_FLAG`,
|
||||
SET: `SET_${capitalizedName}`,
|
||||
ADD: `ADD_${capitalizedName}`,
|
||||
EDIT: `EDIT_${capitalizedName}`,
|
||||
DELETE: `DELETE_${capitalizedName}`,
|
||||
SET_META: `SET_${capitalizedName}_META`,
|
||||
UPSERT: `UPSERT_${capitalizedName}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const createInitialState = () => ({
|
||||
records: [],
|
||||
meta: {},
|
||||
uiFlags: {
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
creatingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const createGetters = () => ({
|
||||
getRecords: state => state.records.sort((r1, r2) => r2.id - r1.id),
|
||||
getRecord: state => id =>
|
||||
state.records.find(record => record.id === Number(id)) || {},
|
||||
getUIFlags: state => state.uiFlags,
|
||||
getMeta: state => state.meta,
|
||||
});
|
||||
|
||||
export const createMutations = mutationTypes => ({
|
||||
[mutationTypes.SET_UI_FLAG](state, data) {
|
||||
state.uiFlags = {
|
||||
...state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
[mutationTypes.SET_META](state, meta) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
totalCount: Number(meta.total_count),
|
||||
page: Number(meta.page),
|
||||
};
|
||||
},
|
||||
[mutationTypes.SET]: MutationHelpers.set,
|
||||
[mutationTypes.ADD]: MutationHelpers.create,
|
||||
[mutationTypes.EDIT]: MutationHelpers.update,
|
||||
[mutationTypes.DELETE]: MutationHelpers.destroy,
|
||||
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
|
||||
});
|
||||
|
||||
export const createCrudActions = (API, mutationTypes) => ({
|
||||
get: getRecords(mutationTypes, API),
|
||||
show: showRecord(mutationTypes, API),
|
||||
create: createRecord(mutationTypes, API),
|
||||
update: updateRecord(mutationTypes, API),
|
||||
delete: deleteRecord(mutationTypes, API),
|
||||
});
|
||||
|
||||
export const createStore = options => {
|
||||
const { name, API, actions, getters, mutations } = options;
|
||||
const mutationTypes = generateMutationTypes(name);
|
||||
|
||||
const customActions = actions ? actions(mutationTypes) : {};
|
||||
|
||||
return {
|
||||
namespaced: true,
|
||||
state: createInitialState(),
|
||||
getters: {
|
||||
...createGetters(),
|
||||
...(getters || {}),
|
||||
},
|
||||
mutations: {
|
||||
...createMutations(mutationTypes),
|
||||
...(mutations || {}),
|
||||
},
|
||||
actions: {
|
||||
...createCrudActions(API, mutationTypes),
|
||||
...customActions,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,380 +0,0 @@
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import {
|
||||
generateMutationTypes,
|
||||
createInitialState,
|
||||
createGetters,
|
||||
createMutations,
|
||||
createCrudActions,
|
||||
createStore,
|
||||
} from './storeFactory';
|
||||
|
||||
vi.mock('dashboard/store/utils/api', () => ({
|
||||
throwErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
|
||||
set: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
setSingleRecord: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('storeFactory', () => {
|
||||
describe('generateMutationTypes', () => {
|
||||
it('generates correct mutation types with capitalized name', () => {
|
||||
const result = generateMutationTypes('test');
|
||||
expect(result).toEqual({
|
||||
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
|
||||
SET: 'SET_TEST',
|
||||
ADD: 'ADD_TEST',
|
||||
EDIT: 'EDIT_TEST',
|
||||
DELETE: 'DELETE_TEST',
|
||||
SET_META: 'SET_TEST_META',
|
||||
UPSERT: 'UPSERT_TEST',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInitialState', () => {
|
||||
it('returns the correct initial state structure', () => {
|
||||
const result = createInitialState();
|
||||
expect(result).toEqual({
|
||||
records: [],
|
||||
meta: {},
|
||||
uiFlags: {
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
creatingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGetters', () => {
|
||||
it('returns getters with correct implementations', () => {
|
||||
const getters = createGetters();
|
||||
|
||||
const state = {
|
||||
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
|
||||
uiFlags: { fetchingList: true },
|
||||
meta: { totalCount: 10, page: 1 },
|
||||
};
|
||||
expect(getters.getRecords(state)).toEqual([
|
||||
{ id: 3 },
|
||||
{ id: 2 },
|
||||
{ id: 1 },
|
||||
]);
|
||||
|
||||
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
|
||||
expect(getters.getRecord(state)(4)).toEqual({});
|
||||
|
||||
expect(getters.getUIFlags(state)).toEqual({
|
||||
fetchingList: true,
|
||||
});
|
||||
|
||||
expect(getters.getMeta(state)).toEqual({
|
||||
totalCount: 10,
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMutations', () => {
|
||||
it('creates mutations with correct implementations', () => {
|
||||
const mutationTypes = generateMutationTypes('test');
|
||||
const mutations = createMutations(mutationTypes);
|
||||
|
||||
const state = { uiFlags: { fetchingList: false } };
|
||||
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
|
||||
expect(state.uiFlags).toEqual({ fetchingList: true });
|
||||
|
||||
const metaState = { meta: {} };
|
||||
mutations[mutationTypes.SET_META](metaState, {
|
||||
total_count: '10',
|
||||
page: '2',
|
||||
});
|
||||
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
|
||||
|
||||
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
|
||||
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
|
||||
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
|
||||
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
|
||||
expect(mutations[mutationTypes.UPSERT]).toBe(
|
||||
MutationHelpers.setSingleRecord
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCrudActions', () => {
|
||||
let API;
|
||||
let commit;
|
||||
let mutationTypes;
|
||||
let actions;
|
||||
|
||||
beforeEach(() => {
|
||||
API = {
|
||||
get: vi.fn(),
|
||||
show: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
commit = vi.fn();
|
||||
mutationTypes = generateMutationTypes('test');
|
||||
actions = createCrudActions(API, mutationTypes);
|
||||
});
|
||||
|
||||
describe('get action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const payload = [{ id: 1 }];
|
||||
const meta = { total_count: 10, page: 1 };
|
||||
API.get.mockResolvedValue({ data: { payload, meta } });
|
||||
|
||||
const result = await actions.get({ commit });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: false,
|
||||
});
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.get.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.get({ commit });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Test' };
|
||||
API.show.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.show({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.show.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.show({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Test' };
|
||||
API.create.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.create({ commit }, { name: 'Test' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.create.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.create({ commit }, { name: 'Test' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Updated' };
|
||||
API.update.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.update(
|
||||
{ commit },
|
||||
{ id: 1, name: 'Updated' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: true,
|
||||
});
|
||||
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.update.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.update(
|
||||
{ commit },
|
||||
{ id: 1, name: 'Updated' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
API.delete.mockResolvedValue({});
|
||||
|
||||
const result = await actions.delete({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: true,
|
||||
});
|
||||
expect(API.delete).toHaveBeenCalledWith(1);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: false,
|
||||
});
|
||||
expect(result).toEqual(1);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.delete.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.delete({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStore', () => {
|
||||
it('creates a complete store with default options', () => {
|
||||
const API = {};
|
||||
const store = createStore({ name: 'test', API });
|
||||
|
||||
expect(store.namespaced).toBe(true);
|
||||
expect(store.state).toEqual(createInitialState());
|
||||
expect(Object.keys(store.getters)).toEqual([
|
||||
'getRecords',
|
||||
'getRecord',
|
||||
'getUIFlags',
|
||||
'getMeta',
|
||||
]);
|
||||
expect(Object.keys(store.mutations)).toEqual([
|
||||
'SET_TEST_UI_FLAG',
|
||||
'SET_TEST_META',
|
||||
'SET_TEST',
|
||||
'ADD_TEST',
|
||||
'EDIT_TEST',
|
||||
'DELETE_TEST',
|
||||
'UPSERT_TEST',
|
||||
]);
|
||||
expect(Object.keys(store.actions)).toEqual([
|
||||
'get',
|
||||
'show',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a store with custom actions and getters', () => {
|
||||
const API = {};
|
||||
const customGetters = { customGetter: () => 'custom' };
|
||||
const customActions = () => ({
|
||||
customAction: () => 'custom',
|
||||
});
|
||||
|
||||
const store = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
getters: customGetters,
|
||||
actions: customActions,
|
||||
});
|
||||
|
||||
expect(store.getters).toHaveProperty('customGetter');
|
||||
expect(store.actions).toHaveProperty('customAction');
|
||||
expect(Object.keys(store.getters)).toEqual([
|
||||
'getRecords',
|
||||
'getRecord',
|
||||
'getUIFlags',
|
||||
'getMeta',
|
||||
'customGetter',
|
||||
]);
|
||||
expect(Object.keys(store.actions)).toEqual([
|
||||
'get',
|
||||
'show',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
'customAction',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export const getRecords =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, params = {}) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
||||
try {
|
||||
const response = await API.get(params);
|
||||
commit(mutationTypes.SET, response.data.payload);
|
||||
commit(mutationTypes.SET_META, response.data.meta);
|
||||
return response.data.payload;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const showRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, id) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
|
||||
try {
|
||||
const response = await API.show(id);
|
||||
commit(mutationTypes.UPSERT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const createRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, dataObj) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
|
||||
try {
|
||||
const response = await API.create(dataObj);
|
||||
commit(mutationTypes.UPSERT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, { id, ...updateObj }) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
|
||||
try {
|
||||
const response = await API.update(id, updateObj);
|
||||
commit(mutationTypes.EDIT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, id) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
|
||||
try {
|
||||
await API.delete(id);
|
||||
commit(mutationTypes.DELETE, id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
import CaptainToolsAPI from '../../api/captain/tools';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import bulkActions from './modules/bulkActions';
|
||||
import campaigns from './modules/campaigns';
|
||||
import cannedResponse from './modules/cannedResponse';
|
||||
import categories from './modules/helpCenterCategories';
|
||||
import companies from './modules/companies';
|
||||
import contactConversations from './modules/contactConversations';
|
||||
import contactLabels from './modules/contactLabels';
|
||||
import contactNotes from './modules/contactNotes';
|
||||
@@ -78,7 +77,6 @@ export default createStore({
|
||||
campaigns,
|
||||
cannedResponse,
|
||||
categories,
|
||||
companies,
|
||||
contactConversations,
|
||||
contactLabels,
|
||||
contactNotes,
|
||||
|
||||
@@ -18,6 +18,7 @@ const state = {
|
||||
isFetchingItem: false,
|
||||
isUpdating: false,
|
||||
isCheckoutInProcess: false,
|
||||
isFetchingLimits: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -141,11 +142,14 @@ export const actions = {
|
||||
},
|
||||
|
||||
limits: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.getLimits();
|
||||
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
|
||||
} catch (error) {
|
||||
// silent error
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { createStore } from 'dashboard/store/captain/storeFactory';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
export default createStore({
|
||||
name: 'Company',
|
||||
API: CompanyAPI,
|
||||
getters: {
|
||||
getCompaniesList: state => {
|
||||
return camelcaseKeys(state.records, { deep: true });
|
||||
},
|
||||
},
|
||||
actions: mutationTypes => ({
|
||||
search: async ({ commit }, { search, page, sort }) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.search(search, page, sort);
|
||||
commit(mutationTypes.SET, payload);
|
||||
commit(mutationTypes.SET_META, meta);
|
||||
} catch (error) {
|
||||
// Error
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -302,4 +302,22 @@ export const actions = {
|
||||
clearContactFilters({ commit }) {
|
||||
commit(types.CLEAR_CONTACT_FILTERS);
|
||||
},
|
||||
|
||||
initiateCall: async ({ commit }, { contactId, inboxId }) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
|
||||
try {
|
||||
const response = await ContactAPI.initiateCall(contactId, inboxId);
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
|
||||
if (error.response?.data?.message) {
|
||||
throw new ExceptionWithMessage(error.response.data.message);
|
||||
} else if (error.response?.data?.error) {
|
||||
throw new ExceptionWithMessage(error.response.data.error);
|
||||
} else {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ const state = {
|
||||
isDeleting: false,
|
||||
isExporting: false,
|
||||
isImporting: false,
|
||||
isInitiatingCall: false,
|
||||
},
|
||||
sortOrder: [],
|
||||
appliedFilters: [],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user