Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef19fcd8c0 | ||
|
|
d261dfd394 | ||
|
|
1b9673b272 |
@@ -220,7 +220,6 @@ 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
|
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||||
# DD_TRACE_AGENT_URL=
|
# DD_TRACE_AGENT_URL=
|
||||||
|
|
||||||
|
|
||||||
# MaxMindDB API key to download GeoLite2 City database
|
# MaxMindDB API key to download GeoLite2 City database
|
||||||
# IP_LOOKUP_API_KEY=
|
# IP_LOOKUP_API_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -99,5 +99,3 @@ CLAUDE.local.md
|
|||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
.netlify
|
.netlify
|
||||||
.histoire
|
.histoire
|
||||||
.pnpm-store/*
|
|
||||||
local/
|
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ exclude_patterns = [
|
|||||||
"**/target/**",
|
"**/target/**",
|
||||||
"**/templates/**",
|
"**/templates/**",
|
||||||
"**/testdata/**",
|
"**/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/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/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",
|
||||||
]
|
]
|
||||||
|
|
||||||
test_patterns = [
|
test_patterns = [
|
||||||
|
|||||||
+1
-12
@@ -7,8 +7,6 @@ plugins:
|
|||||||
require:
|
require:
|
||||||
- ./rubocop/use_from_email.rb
|
- ./rubocop/use_from_email.rb
|
||||||
- ./rubocop/custom_cop_location.rb
|
- ./rubocop/custom_cop_location.rb
|
||||||
- ./rubocop/attachment_download.rb
|
|
||||||
- ./rubocop/one_class_per_file.rb
|
|
||||||
|
|
||||||
Layout/LineLength:
|
Layout/LineLength:
|
||||||
Max: 150
|
Max: 150
|
||||||
@@ -42,12 +40,6 @@ Style/SymbolArray:
|
|||||||
Style/OpenStructUse:
|
Style/OpenStructUse:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Chatwoot/AttachmentDownload:
|
|
||||||
Enabled: true
|
|
||||||
Exclude:
|
|
||||||
- 'spec/**/*'
|
|
||||||
- 'test/**/*'
|
|
||||||
|
|
||||||
Style/OptionalBooleanParameter:
|
Style/OptionalBooleanParameter:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/services/email_templates/db_resolver_service.rb'
|
- 'app/services/email_templates/db_resolver_service.rb'
|
||||||
@@ -95,7 +87,7 @@ Metrics/ModuleLength:
|
|||||||
Rails/HelperInstanceVariable:
|
Rails/HelperInstanceVariable:
|
||||||
Exclude:
|
Exclude:
|
||||||
- enterprise/app/helpers/captain/chat_helper.rb
|
- enterprise/app/helpers/captain/chat_helper.rb
|
||||||
- enterprise/app/helpers/captain/chat_response_helper.rb
|
|
||||||
Rails/ApplicationController:
|
Rails/ApplicationController:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||||
@@ -213,9 +205,6 @@ UseFromEmail:
|
|||||||
CustomCopLocation:
|
CustomCopLocation:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
Style/OneClassPerFile:
|
|
||||||
Enabled: true
|
|
||||||
|
|
||||||
AllCops:
|
AllCops:
|
||||||
NewCops: enable
|
NewCops: enable
|
||||||
Exclude:
|
Exclude:
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
||||||
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
||||||
- **Run Project**: `overmind start -f Procfile.dev`
|
- **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
|
## Code Style
|
||||||
|
|
||||||
@@ -40,20 +37,11 @@
|
|||||||
|
|
||||||
- MVP focus: Least code change, happy-path only
|
- MVP focus: Least code change, happy-path only
|
||||||
- No unnecessary defensive programming
|
- 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
|
- Break down complex tasks into small, testable units
|
||||||
- Iterate after confirmation
|
- Iterate after confirmation
|
||||||
- Avoid writing specs unless explicitly asked
|
- Avoid writing specs unless explicitly asked
|
||||||
- Remove dead/unreachable/unused code
|
- Remove dead/unreachable/unused code
|
||||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
|
||||||
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
|
|
||||||
|
|
||||||
## Commit Messages
|
|
||||||
|
|
||||||
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
|
||||||
- Example: `feat(auth): add user authentication`
|
|
||||||
- Don't reference Claude in commit messages
|
- Don't reference Claude in commit messages
|
||||||
|
|
||||||
## Project-Specific
|
## Project-Specific
|
||||||
@@ -85,4 +73,3 @@ Practical checklist for any change impacting core logic or public APIs
|
|||||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
||||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||||
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ gem 'working_hours'
|
|||||||
gem 'pg_search'
|
gem 'pg_search'
|
||||||
|
|
||||||
# Subscriptions, Billing
|
# Subscriptions, Billing
|
||||||
gem 'stripe', '~> 18.0'
|
gem 'stripe'
|
||||||
|
|
||||||
## - helper gems --##
|
## - helper gems --##
|
||||||
## to populate db with sample data
|
## to populate db with sample data
|
||||||
@@ -191,16 +191,11 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.7.0'
|
gem 'ai-agents', '>= 0.4.3'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
gem 'ruby_llm', '>= 1.8.2'
|
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
# OpenTelemetry for LLM observability
|
|
||||||
gem 'opentelemetry-sdk'
|
|
||||||
gem 'opentelemetry-exporter-otlp'
|
|
||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|
||||||
### Gems required only in specific deployment environments ###
|
### Gems required only in specific deployment environments ###
|
||||||
@@ -215,7 +210,7 @@ group :production do
|
|||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem 'annotaterb'
|
gem 'annotate'
|
||||||
gem 'bullet'
|
gem 'bullet'
|
||||||
gem 'letter_opener'
|
gem 'letter_opener'
|
||||||
gem 'scss_lint', require: false
|
gem 'scss_lint', require: false
|
||||||
|
|||||||
+11
-34
@@ -126,11 +126,11 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
ai-agents (0.7.0)
|
ai-agents (0.4.3)
|
||||||
ruby_llm (~> 1.8.2)
|
ruby_llm (~> 1.3)
|
||||||
annotaterb (4.20.0)
|
annotate (3.2.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 3.2, < 8.0)
|
||||||
activesupport (>= 6.0.0)
|
rake (>= 10.4, < 14.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_extras (7.1.0)
|
attr_extras (7.1.0)
|
||||||
audited (5.4.1)
|
audited (5.4.1)
|
||||||
@@ -625,25 +625,6 @@ GEM
|
|||||||
faraday (>= 1.0, < 3)
|
faraday (>= 1.0, < 3)
|
||||||
multi_json (>= 1.0)
|
multi_json (>= 1.0)
|
||||||
openssl (3.2.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)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
@@ -819,7 +800,7 @@ GEM
|
|||||||
ruby2ruby (2.5.0)
|
ruby2ruby (2.5.0)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
ruby_llm (1.8.2)
|
ruby_llm (1.5.1)
|
||||||
base64
|
base64
|
||||||
event_stream_parser (~> 1)
|
event_stream_parser (~> 1)
|
||||||
faraday (>= 1.10.0)
|
faraday (>= 1.10.0)
|
||||||
@@ -827,9 +808,8 @@ GEM
|
|||||||
faraday-net_http (>= 1)
|
faraday-net_http (>= 1)
|
||||||
faraday-retry (>= 1)
|
faraday-retry (>= 1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
ruby_llm-schema (~> 0.2.1)
|
|
||||||
zeitwerk (~> 2)
|
zeitwerk (~> 2)
|
||||||
ruby_llm-schema (0.2.5)
|
ruby_llm-schema (0.1.0)
|
||||||
ruby_parser (3.20.0)
|
ruby_parser (3.20.0)
|
||||||
sexp_processor (~> 4.16)
|
sexp_processor (~> 4.16)
|
||||||
sass (3.7.4)
|
sass (3.7.4)
|
||||||
@@ -929,7 +909,7 @@ GEM
|
|||||||
squasher (0.7.2)
|
squasher (0.7.2)
|
||||||
stackprof (0.2.25)
|
stackprof (0.2.25)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stripe (18.0.1)
|
stripe (8.5.0)
|
||||||
telephone_number (1.4.20)
|
telephone_number (1.4.20)
|
||||||
test-prof (1.2.1)
|
test-prof (1.2.1)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
@@ -1018,8 +998,8 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
ai-agents (>= 0.7.0)
|
ai-agents (>= 0.4.3)
|
||||||
annotaterb
|
annotate
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
aws-actionmailbox-ses (~> 0)
|
aws-actionmailbox-ses (~> 0)
|
||||||
@@ -1093,8 +1073,6 @@ DEPENDENCIES
|
|||||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||||
omniauth-saml
|
omniauth-saml
|
||||||
opensearch-ruby
|
opensearch-ruby
|
||||||
opentelemetry-exporter-otlp
|
|
||||||
opentelemetry-sdk
|
|
||||||
pg
|
pg
|
||||||
pg_search
|
pg_search
|
||||||
pgvector
|
pgvector
|
||||||
@@ -1120,7 +1098,6 @@ DEPENDENCIES
|
|||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
ruby-openai
|
ruby-openai
|
||||||
ruby_llm (>= 1.8.2)
|
|
||||||
ruby_llm-schema
|
ruby_llm-schema
|
||||||
scout_apm
|
scout_apm
|
||||||
scss_lint
|
scss_lint
|
||||||
@@ -1141,7 +1118,7 @@ DEPENDENCIES
|
|||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
squasher
|
squasher
|
||||||
stackprof
|
stackprof
|
||||||
stripe (~> 18.0)
|
stripe
|
||||||
telephone_number
|
telephone_number
|
||||||
test-prof
|
test-prof
|
||||||
tidewave
|
tidewave
|
||||||
|
|||||||
@@ -103,5 +103,3 @@ class ContactInboxBuilder
|
|||||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
|
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ class Messages::MessageBuilder
|
|||||||
private: @private,
|
private: @private,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
content_type: @params[:content_type],
|
content_type: @params[:content_type],
|
||||||
content_attributes: content_attributes.presence,
|
|
||||||
items: @items,
|
items: @items,
|
||||||
in_reply_to: @in_reply_to,
|
in_reply_to: @in_reply_to,
|
||||||
echo_id: @params[:echo_id],
|
echo_id: @params[:echo_id],
|
||||||
@@ -223,5 +222,3 @@ class Messages::MessageBuilder
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
attachment_obj.save!
|
attachment_obj.save!
|
||||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
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_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)
|
update_attachment_file_type(attachment_obj)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -29,7 +27,7 @@ class Messages::Messenger::MessageBuilder
|
|||||||
file_type = attachment['type'].to_sym
|
file_type = attachment['type'].to_sym
|
||||||
params = { file_type: file_type, account_id: @message.account_id }
|
params = { file_type: file_type, account_id: @message.account_id }
|
||||||
|
|
||||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
|
||||||
params.merge!(file_type_params(attachment))
|
params.merge!(file_type_params(attachment))
|
||||||
elsif file_type == :location
|
elsif file_type == :location
|
||||||
params.merge!(location_params(attachment))
|
params.merge!(location_params(attachment))
|
||||||
@@ -41,17 +39,9 @@ class Messages::Messenger::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def file_type_params(attachment)
|
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: url,
|
external_url: attachment['payload']['url'],
|
||||||
remote_file_url: url
|
remote_file_url: attachment['payload']['url']
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -78,21 +68,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
message.save!
|
message.save!
|
||||||
end
|
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
|
# This is a placeholder method to be overridden by child classes
|
||||||
def get_story_object_from_source_id(_source_id)
|
def get_story_object_from_source_id(_source_id)
|
||||||
{}
|
{}
|
||||||
@@ -101,6 +76,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def unsupported_file_type?(attachment_type)
|
def unsupported_file_type?(attachment_type)
|
||||||
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
[:template, :unsupported_type].include? attachment_type.to_sym
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
class YearInReviewBuilder
|
|
||||||
attr_reader :account, :user_id, :year
|
|
||||||
|
|
||||||
def initialize(account:, user_id:, year:)
|
|
||||||
@account = account
|
|
||||||
@user_id = user_id
|
|
||||||
@year = year
|
|
||||||
end
|
|
||||||
|
|
||||||
def build
|
|
||||||
{
|
|
||||||
year: year,
|
|
||||||
total_conversations: total_conversations_count,
|
|
||||||
busiest_day: busiest_day_data,
|
|
||||||
support_personality: support_personality_data
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def year_range
|
|
||||||
@year_range ||= begin
|
|
||||||
start_time = Time.zone.local(year, 1, 1).beginning_of_day
|
|
||||||
end_time = Time.zone.local(year, 12, 31).end_of_day
|
|
||||||
start_time..end_time
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def total_conversations_count
|
|
||||||
account.conversations
|
|
||||||
.where(assignee_id: user_id, created_at: year_range)
|
|
||||||
.count
|
|
||||||
end
|
|
||||||
|
|
||||||
def busiest_day_data
|
|
||||||
daily_counts = account.conversations
|
|
||||||
.where(assignee_id: user_id, created_at: year_range)
|
|
||||||
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
|
|
||||||
.count
|
|
||||||
|
|
||||||
return nil if daily_counts.empty?
|
|
||||||
|
|
||||||
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
|
|
||||||
|
|
||||||
return nil if count.zero?
|
|
||||||
|
|
||||||
{
|
|
||||||
date: busiest_date.strftime('%b %d'),
|
|
||||||
count: count
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def support_personality_data
|
|
||||||
response_time = average_response_time
|
|
||||||
|
|
||||||
return { avg_response_time_seconds: 0 } if response_time.nil?
|
|
||||||
|
|
||||||
{
|
|
||||||
avg_response_time_seconds: response_time.to_i
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def average_response_time
|
|
||||||
avg_time = account.reporting_events
|
|
||||||
.where(
|
|
||||||
name: 'first_response',
|
|
||||||
user_id: user_id,
|
|
||||||
created_at: year_range
|
|
||||||
)
|
|
||||||
.average(:value)
|
|
||||||
|
|
||||||
avg_time&.to_f
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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,37 +152,31 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def format_csat_config(config)
|
def format_csat_config(config)
|
||||||
formatted = {
|
{
|
||||||
'display_type' => config['display_type'] || 'emoji',
|
display_type: config['display_type'] || 'emoji',
|
||||||
'message' => config['message'] || '',
|
message: config['message'] || '',
|
||||||
:survey_rules => {
|
survey_rules: {
|
||||||
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
operator: config.dig('survey_rules', 'operator') || 'contains',
|
||||||
'values' => config.dig('survey_rules', 'values') || []
|
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
|
end
|
||||||
|
|
||||||
def inbox_attributes
|
def inbox_attributes
|
||||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
[: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,
|
: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,
|
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
|
||||||
{ survey_rules: [:operator, { values: [] }],
|
|
||||||
template: [:name, :template_id, :created_at, :language] }] }]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
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
|
# 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.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
|
end
|
||||||
|
|
||||||
def channel_type_from_params
|
def channel_type_from_params
|
||||||
@@ -198,7 +192,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_channel_attributes(channel_type)
|
def get_channel_attributes(channel_type)
|
||||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
||||||
|
channel_type.constantize::EDITABLE_ATTRS.presence
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_channel?
|
def whatsapp_channel?
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
|
||||||
include Tiktok::IntegrationHelper
|
|
||||||
|
|
||||||
def create
|
|
||||||
redirect_url = Tiktok::AuthClient.authorize_url(
|
|
||||||
state: generate_tiktok_token(Current.account.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if redirect_url
|
|
||||||
render json: { success: true, url: redirect_url }
|
|
||||||
else
|
|
||||||
render json: { success: false }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -92,8 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
|
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
|
||||||
conversation_required_attributes: [])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
|
|
||||||
def show
|
|
||||||
year = params[:year] || 2025
|
|
||||||
cache_key = "year_in_review_#{Current.account.id}_#{year}"
|
|
||||||
|
|
||||||
cached_data = Current.user.ui_settings&.dig(cache_key)
|
|
||||||
|
|
||||||
if cached_data.present?
|
|
||||||
render json: cached_data
|
|
||||||
else
|
|
||||||
builder = YearInReviewBuilder.new(
|
|
||||||
account: Current.account,
|
|
||||||
user_id: Current.user.id,
|
|
||||||
year: year
|
|
||||||
)
|
|
||||||
|
|
||||||
data = builder.build
|
|
||||||
|
|
||||||
ui_settings = Current.user.ui_settings || {}
|
|
||||||
ui_settings[cache_key] = data
|
|
||||||
Current.user.update(ui_settings: ui_settings)
|
|
||||||
|
|
||||||
render json: data
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -73,7 +73,6 @@ class DashboardController < ActionController::Base
|
|||||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||||
TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
|
|
||||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
||||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||||
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
|
|
||||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
|
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
|
||||||
|
|||||||
@@ -1,41 +1,11 @@
|
|||||||
class SuperAdmin::DashboardController < SuperAdmin::ApplicationController
|
class SuperAdmin::DashboardController < SuperAdmin::ApplicationController
|
||||||
include ActionView::Helpers::NumberHelper
|
include ActionView::Helpers::NumberHelper
|
||||||
|
|
||||||
ALLOWED_CHART_DAYS = [7, 30].freeze
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@chart_days = parse_chart_days
|
@data = Conversation.unscoped.group_by_day(:created_at, range: 30.days.ago..2.seconds.ago).count.to_a
|
||||||
@data = conversations_by_day
|
@accounts_count = number_with_delimiter(Account.count)
|
||||||
@accounts_count = format_count(Account.count)
|
@users_count = number_with_delimiter(User.count)
|
||||||
@users_count = format_count(User.count)
|
@inboxes_count = number_with_delimiter(Inbox.count)
|
||||||
@inboxes_count = format_count(Inbox.count)
|
@conversations_count = number_with_delimiter(Conversation.count)
|
||||||
@conversations_count = format_count(recent_conversations.count)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def parse_chart_days
|
|
||||||
days = params[:chart_period].to_i
|
|
||||||
ALLOWED_CHART_DAYS.include?(days) ? days : 7
|
|
||||||
end
|
|
||||||
|
|
||||||
def chart_date_range
|
|
||||||
(@chart_days - 1).days.ago.beginning_of_day..Time.current
|
|
||||||
end
|
|
||||||
|
|
||||||
def last_30_days
|
|
||||||
29.days.ago.beginning_of_day..Time.current
|
|
||||||
end
|
|
||||||
|
|
||||||
def recent_conversations
|
|
||||||
Conversation.unscoped.where(created_at: last_30_days)
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversations_by_day
|
|
||||||
Conversation.unscoped.group_by_day(:created_at, range: chart_date_range, default_value: 0).count.to_a
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_count(count)
|
|
||||||
number_with_delimiter(count)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
class Tiktok::CallbacksController < ApplicationController
|
|
||||||
include Tiktok::IntegrationHelper
|
|
||||||
|
|
||||||
def show
|
|
||||||
return handle_authorization_error if params[:error].present?
|
|
||||||
return handle_ungranted_scopes_error unless all_scopes_granted?
|
|
||||||
|
|
||||||
process_successful_authorization
|
|
||||||
rescue StandardError => e
|
|
||||||
handle_error(e)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def all_scopes_granted?
|
|
||||||
granted_scopes = short_term_access_token[:scope].to_s.split(',')
|
|
||||||
(Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank?
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_successful_authorization
|
|
||||||
inbox, already_exists = find_or_create_inbox
|
|
||||||
|
|
||||||
if already_exists
|
|
||||||
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
|
||||||
else
|
|
||||||
redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_error(error)
|
|
||||||
Rails.logger.error("TikTok Channel creation Error: #{error.message}")
|
|
||||||
ChatwootExceptionTracker.new(error).capture_exception
|
|
||||||
|
|
||||||
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Handles the case when a user denies permissions or cancels the authorization flow
|
|
||||||
def handle_authorization_error
|
|
||||||
redirect_to_error_page(
|
|
||||||
error_type: params[:error] || 'access_denied',
|
|
||||||
code: params[:error_code],
|
|
||||||
error_message: params[:error_description] || 'User cancelled the Authorization'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Handles the case when a user partially accepted the required scopes
|
|
||||||
def handle_ungranted_scopes_error
|
|
||||||
redirect_to_error_page(
|
|
||||||
error_type: 'ungranted_scopes',
|
|
||||||
code: 400,
|
|
||||||
error_message: 'User did not grant all the required scopes'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Centralized method to redirect to error page with appropriate parameters
|
|
||||||
# This ensures consistent error handling across different error scenarios
|
|
||||||
# Frontend will handle the error page based on the error_type
|
|
||||||
def redirect_to_error_page(error_type:, code:, error_message:)
|
|
||||||
redirect_to app_new_tiktok_inbox_url(
|
|
||||||
account_id: account_id,
|
|
||||||
error_type: error_type,
|
|
||||||
code: code,
|
|
||||||
error_message: error_message
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_or_create_inbox
|
|
||||||
business_details = tiktok_client.business_account_details
|
|
||||||
channel_tiktok = find_channel
|
|
||||||
channel_exists = channel_tiktok.present?
|
|
||||||
|
|
||||||
if channel_tiktok
|
|
||||||
update_channel(channel_tiktok, business_details)
|
|
||||||
else
|
|
||||||
channel_tiktok = create_channel_with_inbox(business_details)
|
|
||||||
end
|
|
||||||
|
|
||||||
# reauthorized will also update cache keys for the associated inbox
|
|
||||||
channel_tiktok.reauthorized!
|
|
||||||
|
|
||||||
set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present?
|
|
||||||
|
|
||||||
[channel_tiktok.inbox, channel_exists]
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_channel_with_inbox(business_details)
|
|
||||||
ActiveRecord::Base.transaction do
|
|
||||||
channel_tiktok = Channel::Tiktok.create!(
|
|
||||||
account: account,
|
|
||||||
business_id: short_term_access_token[:business_id],
|
|
||||||
access_token: short_term_access_token[:access_token],
|
|
||||||
refresh_token: short_term_access_token[:refresh_token],
|
|
||||||
expires_at: short_term_access_token[:expires_at],
|
|
||||||
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
|
||||||
)
|
|
||||||
|
|
||||||
account.inboxes.create!(
|
|
||||||
account: account,
|
|
||||||
channel: channel_tiktok,
|
|
||||||
name: business_details[:display_name].presence || business_details[:username]
|
|
||||||
)
|
|
||||||
|
|
||||||
channel_tiktok
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_channel
|
|
||||||
Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_channel(channel_tiktok, business_details)
|
|
||||||
channel_tiktok.update!(
|
|
||||||
access_token: short_term_access_token[:access_token],
|
|
||||||
refresh_token: short_term_access_token[:refresh_token],
|
|
||||||
expires_at: short_term_access_token[:expires_at],
|
|
||||||
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
|
||||||
)
|
|
||||||
|
|
||||||
channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username])
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_avatar(inbox, avatar_url)
|
|
||||||
Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url)
|
|
||||||
end
|
|
||||||
|
|
||||||
def account_id
|
|
||||||
@account_id ||= verify_tiktok_token(params[:state])
|
|
||||||
end
|
|
||||||
|
|
||||||
def account
|
|
||||||
@account ||= Account.find(account_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def short_term_access_token
|
|
||||||
@short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code])
|
|
||||||
end
|
|
||||||
|
|
||||||
def tiktok_client
|
|
||||||
@tiktok_client ||= Tiktok::Client.new(
|
|
||||||
business_id: short_term_access_token[:business_id],
|
|
||||||
access_token: short_term_access_token[:access_token]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
class Webhooks::TiktokController < ActionController::API
|
|
||||||
before_action :verify_signature!
|
|
||||||
|
|
||||||
def events
|
|
||||||
event = JSON.parse(request_payload)
|
|
||||||
if echo_event?
|
|
||||||
# Add delay to prevent race condition where echo arrives before send message API completes
|
|
||||||
# This avoids duplicate messages when echo comes early during API processing
|
|
||||||
::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event)
|
|
||||||
else
|
|
||||||
::Webhooks::TiktokEventsJob.perform_later(event)
|
|
||||||
end
|
|
||||||
|
|
||||||
head :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def request_payload
|
|
||||||
@request_payload ||= request.body.read
|
|
||||||
end
|
|
||||||
|
|
||||||
def verify_signature!
|
|
||||||
signature_header = request.headers['Tiktok-Signature']
|
|
||||||
client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
|
||||||
received_timestamp, received_signature = extract_signature_parts(signature_header)
|
|
||||||
|
|
||||||
return head :unauthorized unless client_secret && received_timestamp && received_signature
|
|
||||||
|
|
||||||
signature_payload = "#{received_timestamp}.#{request_payload}"
|
|
||||||
computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload)
|
|
||||||
|
|
||||||
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature)
|
|
||||||
|
|
||||||
# Check timestamp delay (acceptable delay: 5 seconds)
|
|
||||||
current_timestamp = Time.current.to_i
|
|
||||||
delay = current_timestamp - received_timestamp
|
|
||||||
|
|
||||||
return head :unauthorized if delay > 5
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_signature_parts(signature_header)
|
|
||||||
return [nil, nil] if signature_header.blank?
|
|
||||||
|
|
||||||
keys = signature_header.split(',')
|
|
||||||
signature_parts = keys.map { |part| part.split('=') }.to_h
|
|
||||||
[signature_parts['t']&.to_i, signature_parts['s']]
|
|
||||||
end
|
|
||||||
|
|
||||||
def echo_event?
|
|
||||||
params[:event] == 'im_send_msg'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -31,8 +31,8 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
updated_at: Field::DateTime,
|
updated_at: Field::DateTime,
|
||||||
users: CountField,
|
users: CountField,
|
||||||
conversations: CountField,
|
conversations: CountField,
|
||||||
locale: LocaleField,
|
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
|
||||||
status: StatusField,
|
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
|
||||||
account_users: Field::HasMany,
|
account_users: Field::HasMany,
|
||||||
custom_attributes: Field::String
|
custom_attributes: Field::String
|
||||||
}.merge(enterprise_attribute_types).freeze
|
}.merge(enterprise_attribute_types).freeze
|
||||||
@@ -45,9 +45,10 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
COLLECTION_ATTRIBUTES = %i[
|
COLLECTION_ATTRIBUTES = %i[
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
status
|
|
||||||
users
|
|
||||||
locale
|
locale
|
||||||
|
users
|
||||||
|
conversations
|
||||||
|
status
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
# SHOW_PAGE_ATTRIBUTES
|
# SHOW_PAGE_ATTRIBUTES
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
require 'administrate/field/base'
|
|
||||||
|
|
||||||
class LocaleField < Administrate::Field::Base
|
|
||||||
def to_s
|
|
||||||
language = LANGUAGES_CONFIG.find { |_key, val| val[:iso_639_1_code] == data }
|
|
||||||
language ? language[1][:name] : data
|
|
||||||
end
|
|
||||||
|
|
||||||
def selectable_options
|
|
||||||
LANGUAGES_CONFIG.map { |_key, val| [val[:name], val[:iso_639_1_code]] }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
require 'administrate/field/base'
|
|
||||||
|
|
||||||
class StatusField < Administrate::Field::Base
|
|
||||||
def to_s
|
|
||||||
data.to_s.capitalize
|
|
||||||
end
|
|
||||||
|
|
||||||
def active?
|
|
||||||
data.to_s == 'active'
|
|
||||||
end
|
|
||||||
|
|
||||||
def selectable_options
|
|
||||||
[%w[Active active], %w[Suspended suspended]]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -78,12 +78,6 @@ instagram:
|
|||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-instagram'
|
icon: 'icon-instagram'
|
||||||
config_key: 'instagram'
|
config_key: 'instagram'
|
||||||
tiktok:
|
|
||||||
name: 'TikTok'
|
|
||||||
description: 'Stay connected with your customers on TikTok'
|
|
||||||
enabled: true
|
|
||||||
icon: 'icon-tiktok'
|
|
||||||
config_key: 'tiktok'
|
|
||||||
whatsapp:
|
whatsapp:
|
||||||
name: 'WhatsApp'
|
name: 'WhatsApp'
|
||||||
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ module SuperAdmin::NavigationHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Add general at the beginning
|
# Add general at the beginning
|
||||||
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General', 'icon' => 'icon-gear' }]]
|
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
|
||||||
|
|
||||||
general_feature + features.to_a
|
general_feature + features.to_a
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
module Tiktok::IntegrationHelper
|
|
||||||
# Generates a signed JWT token for Tiktok integration
|
|
||||||
#
|
|
||||||
# @param account_id [Integer] The account ID to encode in the token
|
|
||||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
|
||||||
def generate_tiktok_token(account_id)
|
|
||||||
return if client_secret.blank?
|
|
||||||
|
|
||||||
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# Verifies and decodes a Tiktok JWT token
|
|
||||||
#
|
|
||||||
# @param token [String] The JWT token to verify
|
|
||||||
# @return [Integer, nil] The account ID from the token or nil if invalid
|
|
||||||
def verify_tiktok_token(token)
|
|
||||||
return if token.blank? || client_secret.blank?
|
|
||||||
|
|
||||||
decode_token(token, client_secret)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def client_secret
|
|
||||||
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
def token_payload(account_id)
|
|
||||||
{
|
|
||||||
sub: account_id,
|
|
||||||
iat: Time.current.to_i
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def decode_token(token, secret)
|
|
||||||
JWT.decode(token, secret, true, {
|
|
||||||
algorithm: 'HS256',
|
|
||||||
verify_expiration: true
|
|
||||||
}).first['sub']
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/* global axios */
|
|
||||||
import ApiClient from '../ApiClient';
|
|
||||||
|
|
||||||
class TiktokChannel extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('tiktok', { accountScoped: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
generateAuthorization(payload) {
|
|
||||||
return axios.post(`${this.url}/authorization`, payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new TiktokChannel();
|
|
||||||
@@ -47,12 +47,6 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${contactId}/labels`);
|
return axios.get(`${this.url}/${contactId}/labels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
initiateCall(contactId, inboxId) {
|
|
||||||
return axios.post(`${this.url}/${contactId}/call`, {
|
|
||||||
inbox_id: inboxId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateContactLabels(contactId, labels) {
|
updateContactLabels(contactId, labels) {
|
||||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
action_type: action,
|
action_type: action,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createTopupCheckout(credits) {
|
|
||||||
return axios.post(`${this.url}topup_checkout`, { credits });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new EnterpriseAccountAPI();
|
export default new EnterpriseAccountAPI();
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
expect(accountAPI).toHaveProperty('delete');
|
expect(accountAPI).toHaveProperty('delete');
|
||||||
expect(accountAPI).toHaveProperty('checkout');
|
expect(accountAPI).toHaveProperty('checkout');
|
||||||
expect(accountAPI).toHaveProperty('toggleDeletion');
|
expect(accountAPI).toHaveProperty('toggleDeletion');
|
||||||
expect(accountAPI).toHaveProperty('createTopupCheckout');
|
|
||||||
expect(accountAPI).toHaveProperty('getLimits');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('API calls', () => {
|
||||||
@@ -61,29 +59,5 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
{ action_type: 'undelete' }
|
{ 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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,12 +57,6 @@ class OpenAIAPI extends ApiClient {
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always include conversation_display_id when available for session tracking
|
|
||||||
if (conversationId) {
|
|
||||||
data.conversation_display_id = conversationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For conversation-level events, only send conversation_display_id
|
|
||||||
if (this.conversation_events.includes(type)) {
|
if (this.conversation_events.includes(type)) {
|
||||||
data = {
|
data = {
|
||||||
conversation_display_id: conversationId,
|
conversation_display_id: conversationId,
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import ApiClient from '../ApiClient';
|
|
||||||
import tiktokClient from '../channel/tiktokClient';
|
|
||||||
|
|
||||||
describe('#TiktokClient', () => {
|
|
||||||
it('creates correct instance', () => {
|
|
||||||
expect(tiktokClient).toBeInstanceOf(ApiClient);
|
|
||||||
expect(tiktokClient).toHaveProperty('generateAuthorization');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#generateAuthorization', () => {
|
|
||||||
const originalAxios = window.axios;
|
|
||||||
const originalPathname = window.location.pathname;
|
|
||||||
const axiosMock = {
|
|
||||||
post: vi.fn(() => Promise.resolve()),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
window.axios = axiosMock;
|
|
||||||
window.history.pushState({}, '', '/app/accounts/1/settings');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
window.axios = originalAxios;
|
|
||||||
window.history.pushState({}, '', originalPathname);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('posts to the authorization endpoint', () => {
|
|
||||||
tiktokClient.generateAuthorization({ state: 'test-state' });
|
|
||||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/accounts/1/tiktok/authorization',
|
|
||||||
{ state: 'test-state' }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/* global axios */
|
|
||||||
import ApiClient from './ApiClient';
|
|
||||||
|
|
||||||
class YearInReviewAPI extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
|
|
||||||
}
|
|
||||||
|
|
||||||
get(year) {
|
|
||||||
return axios.get(`${this.url}`, {
|
|
||||||
params: { year },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new YearInReviewAPI();
|
|
||||||
@@ -102,7 +102,6 @@ const closeMobileSidebar = () => {
|
|||||||
/>
|
/>
|
||||||
<VoiceCallButton
|
<VoiceCallButton
|
||||||
:phone="selectedContact?.phoneNumber"
|
:phone="selectedContact?.phoneNumber"
|
||||||
:contact-id="contactId"
|
|
||||||
:label="$t('CONTACT_PANEL.CALL')"
|
:label="$t('CONTACT_PANEL.CALL')"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ const SOCIAL_CONFIG = {
|
|||||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||||
INSTAGRAM: 'i-ri-instagram-line',
|
INSTAGRAM: 'i-ri-instagram-line',
|
||||||
TIKTOK: 'i-ri-tiktok-fill',
|
|
||||||
TWITTER: 'i-ri-twitter-x-fill',
|
TWITTER: 'i-ri-twitter-x-fill',
|
||||||
GITHUB: 'i-ri-github-fill',
|
GITHUB: 'i-ri-github-fill',
|
||||||
};
|
};
|
||||||
@@ -66,7 +65,6 @@ const defaultState = {
|
|||||||
facebook: '',
|
facebook: '',
|
||||||
github: '',
|
github: '',
|
||||||
instagram: '',
|
instagram: '',
|
||||||
tiktok: '',
|
|
||||||
linkedin: '',
|
linkedin: '',
|
||||||
twitter: '',
|
twitter: '',
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-6
@@ -40,12 +40,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
|
||||||
ref="dialogRef"
|
|
||||||
width="3xl"
|
|
||||||
overflow-y-auto
|
|
||||||
@confirm="handleDialogConfirm"
|
|
||||||
>
|
|
||||||
<ContactsForm
|
<ContactsForm
|
||||||
ref="contactsFormRef"
|
ref="contactsFormRef"
|
||||||
is-new-contact
|
is-new-contact
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, useAttrs } from 'vue';
|
import { computed, ref, useAttrs } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
phone: { type: String, default: '' },
|
phone: { type: String, default: '' },
|
||||||
contactId: { type: [String, Number], required: true },
|
|
||||||
label: { type: String, default: '' },
|
label: { type: String, default: '' },
|
||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
size: { type: String, default: 'sm' },
|
size: { type: String, default: 'sm' },
|
||||||
@@ -21,17 +18,10 @@ const props = defineProps({
|
|||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
|
||||||
|
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
|
||||||
|
|
||||||
const voiceInboxes = computed(() =>
|
const voiceInboxes = computed(() =>
|
||||||
(inboxesList.value || []).filter(
|
(inboxesList.value || []).filter(
|
||||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||||
@@ -42,51 +32,20 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
|||||||
// Unified behavior: hide when no phone
|
// Unified behavior: hide when no phone
|
||||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||||
|
|
||||||
const isInitiatingCall = computed(() => {
|
const dialogRef = ref(null);
|
||||||
return contactsUiFlags.value?.isInitiatingCall || false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const navigateToConversation = conversationId => {
|
const onClick = () => {
|
||||||
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) {
|
if (voiceInboxes.value.length > 1) {
|
||||||
dialogRef.value?.open();
|
dialogRef.value?.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [inbox] = voiceInboxes.value;
|
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||||
await startCall(inbox.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPickInbox = async inbox => {
|
const onPickInbox = () => {
|
||||||
|
// Placeholder until actual call wiring happens
|
||||||
|
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||||
dialogRef.value?.close();
|
dialogRef.value?.close();
|
||||||
await startCall(inbox.id);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -96,8 +55,6 @@ const onPickInbox = async inbox => {
|
|||||||
v-if="shouldRender"
|
v-if="shouldRender"
|
||||||
v-tooltip.top-end="tooltipLabel || null"
|
v-tooltip.top-end="tooltipLabel || null"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
:disabled="isInitiatingCall"
|
|
||||||
:is-loading="isInitiatingCall"
|
|
||||||
:label="label"
|
:label="label"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:size="size"
|
:size="size"
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
attribute: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
badges: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['edit', 'delete']);
|
|
||||||
|
|
||||||
const iconByType = {
|
|
||||||
text: 'i-lucide-align-justify',
|
|
||||||
checkbox: 'i-lucide-circle-check-big',
|
|
||||||
list: 'i-lucide-list',
|
|
||||||
date: 'i-lucide-calendar',
|
|
||||||
link: 'i-lucide-link',
|
|
||||||
number: 'i-lucide-hash',
|
|
||||||
};
|
|
||||||
|
|
||||||
const attributeIcon = computed(() => {
|
|
||||||
const typeKey = props.attribute.type?.toLowerCase();
|
|
||||||
return iconByType[typeKey] || 'i-lucide-align-justify';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap gap-2 justify-between items-center">
|
|
||||||
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
|
||||||
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
|
||||||
{{ attribute.label }}
|
|
||||||
</h4>
|
|
||||||
<div class="w-px h-3 bg-n-strong" />
|
|
||||||
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
|
||||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
|
||||||
<Icon :icon="attributeIcon" class="size-4" />
|
|
||||||
<span class="text-sm">{{ attribute.type }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-px h-3 bg-n-weak" />
|
|
||||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
|
||||||
<Icon icon="i-lucide-key-round" class="size-4" />
|
|
||||||
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center">
|
|
||||||
<AttributeBadge
|
|
||||||
v-for="badge in badges"
|
|
||||||
:key="badge.type"
|
|
||||||
:type="badge.type"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="badges.length > 0"
|
|
||||||
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
icon="i-lucide-pencil-line"
|
|
||||||
size="sm"
|
|
||||||
color="slate"
|
|
||||||
ghost
|
|
||||||
@click="emit('edit', attribute)"
|
|
||||||
/>
|
|
||||||
<div class="w-px h-3 bg-n-strong" />
|
|
||||||
<Button
|
|
||||||
icon="i-lucide-trash"
|
|
||||||
size="sm"
|
|
||||||
color="slate"
|
|
||||||
ghost
|
|
||||||
@click="emit('delete', attribute)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mb-0 text-sm text-n-slate-11">
|
|
||||||
{{ attribute.attribute_description || attribute.description || '' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
default: 'resolution',
|
|
||||||
validator: value => ['pre-chat', 'resolution'].includes(value),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const attributeConfig = {
|
|
||||||
'pre-chat': {
|
|
||||||
colorClass: 'text-n-blue-11',
|
|
||||||
icon: 'i-lucide-message-circle',
|
|
||||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
|
||||||
},
|
|
||||||
resolution: {
|
|
||||||
colorClass: 'text-n-teal-11',
|
|
||||||
icon: 'i-lucide-circle-check-big',
|
|
||||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const config = computed(
|
|
||||||
() => attributeConfig[props.type] || attributeConfig.resolution
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
|
||||||
>
|
|
||||||
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
|
||||||
<span class="text-xs" :class="config.colorClass">{{
|
|
||||||
t(config.labelKey)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -19,12 +19,12 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
enableVariables: { type: Boolean, default: false },
|
enableVariables: { type: Boolean, default: false },
|
||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
|
enabledMenuOptions: { type: Array, default: () => [] },
|
||||||
enableCaptainTools: { type: Boolean, default: false },
|
enableCaptainTools: { type: Boolean, default: false },
|
||||||
signature: { type: String, default: '' },
|
signature: { type: String, default: '' },
|
||||||
allowSignature: { type: Boolean, default: false },
|
allowSignature: { type: Boolean, default: false },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
medium: { type: String, default: '' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
@@ -102,12 +102,12 @@ watch(
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:enable-variables="enableVariables"
|
:enable-variables="enableVariables"
|
||||||
:enable-canned-responses="enableCannedResponses"
|
:enable-canned-responses="enableCannedResponses"
|
||||||
|
:enabled-menu-options="enabledMenuOptions"
|
||||||
:enable-captain-tools="enableCaptainTools"
|
:enable-captain-tools="enableCaptainTools"
|
||||||
:signature="signature"
|
:signature="signature"
|
||||||
:allow-signature="allowSignature"
|
:allow-signature="allowSignature"
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
:medium="medium"
|
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
@@ -139,6 +139,19 @@ watch(
|
|||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
::v-deep {
|
::v-deep {
|
||||||
.ProseMirror-menubar-wrapper {
|
.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 {
|
.ProseMirror.ProseMirror-woot-style {
|
||||||
p {
|
p {
|
||||||
@apply first:mt-0 !important;
|
@apply first:mt-0 !important;
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@ const previewArticle = () => {
|
|||||||
@apply mr-0;
|
@apply mr-0;
|
||||||
|
|
||||||
.ProseMirror-icon {
|
.ProseMirror-icon {
|
||||||
@apply p-0 mt-0 !mr-0;
|
@apply p-0 mt-1 !mr-0;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 20px !important;
|
width: 20px !important;
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
|
||||||
import {
|
import {
|
||||||
searchContacts,
|
searchContacts,
|
||||||
createNewContact,
|
createNewContact,
|
||||||
@@ -228,8 +226,6 @@ const keyboardEvents = {
|
|||||||
action: () => {
|
action: () => {
|
||||||
if (showComposeNewConversation.value) {
|
if (showComposeNewConversation.value) {
|
||||||
showComposeNewConversation.value = false;
|
showComposeNewConversation.value = false;
|
||||||
emit('close');
|
|
||||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+12
-29
@@ -4,11 +4,10 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { useEventListener } from '@vueuse/core';
|
|
||||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import FileUpload from 'vue-upload-component';
|
import FileUpload from 'vue-upload-component';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||||
@@ -51,6 +50,12 @@ const EmojiInput = defineAsyncComponent(
|
|||||||
() => import('shared/components/emoji/EmojiInput.vue')
|
() => import('shared/components/emoji/EmojiInput.vue')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const signatureToApply = computed(() =>
|
||||||
|
props.isEmailOrWebWidgetInbox
|
||||||
|
? props.messageSignature
|
||||||
|
: extractTextFromMarkdown(props.messageSignature)
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
setSignatureFlagForInbox,
|
setSignatureFlagForInbox,
|
||||||
@@ -75,20 +80,12 @@ const isRegularMessageMode = computed(() => {
|
|||||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
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 = () => {
|
const setSignature = () => {
|
||||||
if (props.messageSignature) {
|
if (signatureToApply.value) {
|
||||||
if (sendWithSignature.value) {
|
if (sendWithSignature.value) {
|
||||||
emit('addSignature', props.messageSignature);
|
emit('addSignature', signatureToApply.value);
|
||||||
} else {
|
} else {
|
||||||
emit('removeSignature', props.messageSignature);
|
emit('removeSignature', signatureToApply.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -104,7 +101,7 @@ watch(
|
|||||||
() => props.hasSelectedInbox,
|
() => props.hasSelectedInbox,
|
||||||
newValue => {
|
newValue => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (newValue && !isVoiceInbox.value) setSignature();
|
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -164,20 +161,6 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
const onPaste = e => {
|
|
||||||
if (!props.isEmailOrWebWidgetInbox) return;
|
|
||||||
|
|
||||||
const files = e.clipboardData?.files;
|
|
||||||
if (!files?.length) return;
|
|
||||||
|
|
||||||
Array.from(files).forEach(file => {
|
|
||||||
const { name, type, size } = file;
|
|
||||||
onFileUpload({ file, name, type, size });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEventListener(document, 'paste', onPaste);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -237,7 +220,7 @@ useEventListener(document, 'paste', onPaste);
|
|||||||
/>
|
/>
|
||||||
</FileUpload>
|
</FileUpload>
|
||||||
<Button
|
<Button
|
||||||
v-if="shouldShowSignatureButton"
|
v-if="hasSelectedInbox && isRegularMessageMode"
|
||||||
icon="i-lucide-signature"
|
icon="i-lucide-signature"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ const removeAttachment = id => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
|
<div class="flex flex-col gap-4 p-4">
|
||||||
<div
|
<div
|
||||||
v-if="filteredImageAttachments.length > 0"
|
v-if="filteredImageAttachments.length > 0"
|
||||||
class="flex flex-wrap gap-3"
|
class="flex flex-wrap gap-3"
|
||||||
|
|||||||
+10
-28
@@ -6,7 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
getEffectiveChannelType,
|
extractTextFromMarkdown,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
import {
|
import {
|
||||||
buildContactableInboxesList,
|
buildContactableInboxesList,
|
||||||
@@ -87,12 +87,6 @@ const whatsappMessageTemplates = computed(() =>
|
|||||||
|
|
||||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||||
|
|
||||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
|
||||||
|
|
||||||
const effectiveChannelType = computed(() =>
|
|
||||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const validationRules = computed(() => ({
|
const validationRules = computed(() => ({
|
||||||
selectedContact: { required },
|
selectedContact: { required },
|
||||||
targetInbox: { required },
|
targetInbox: { required },
|
||||||
@@ -200,7 +194,6 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
|||||||
|
|
||||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
const handleInboxAction = ({ value, action, ...rest }) => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
state.message = '';
|
|
||||||
emit('updateTargetInbox', { ...rest });
|
emit('updateTargetInbox', { ...rest });
|
||||||
showInboxesDropdown.value = false;
|
showInboxesDropdown.value = false;
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
@@ -209,28 +202,25 @@ const handleInboxAction = ({ value, action, ...rest }) => {
|
|||||||
const removeSignatureFromMessage = () => {
|
const removeSignatureFromMessage = () => {
|
||||||
// Always remove the signature from message content when inbox/contact is removed
|
// Always remove the signature from message content when inbox/contact is removed
|
||||||
// to ensure no leftover signature content remains
|
// to ensure no leftover signature content remains
|
||||||
if (props.messageSignature) {
|
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
||||||
state.message = removeSignature(
|
? props.messageSignature
|
||||||
state.message,
|
: extractTextFromMarkdown(props.messageSignature);
|
||||||
props.messageSignature,
|
if (signatureToRemove) {
|
||||||
effectiveChannelType.value
|
state.message = removeSignature(state.message, signatureToRemove);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTargetInbox = value => {
|
const removeTargetInbox = value => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
removeSignatureFromMessage();
|
removeSignatureFromMessage();
|
||||||
state.message = '';
|
|
||||||
emit('updateTargetInbox', value);
|
emit('updateTargetInbox', value);
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
removeSignatureFromMessage();
|
|
||||||
emit('clearSelectedContact');
|
emit('clearSelectedContact');
|
||||||
state.message = '';
|
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
|
removeSignatureFromMessage();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickInsertEmoji = emoji => {
|
const onClickInsertEmoji = emoji => {
|
||||||
@@ -238,19 +228,11 @@ const onClickInsertEmoji = emoji => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSignature = signature => {
|
const handleAddSignature = signature => {
|
||||||
state.message = appendSignature(
|
state.message = appendSignature(state.message, signature);
|
||||||
state.message,
|
|
||||||
signature,
|
|
||||||
effectiveChannelType.value
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveSignature = signature => {
|
const handleRemoveSignature = signature => {
|
||||||
state.message = removeSignature(
|
state.message = removeSignature(state.message, signature);
|
||||||
state.message,
|
|
||||||
signature,
|
|
||||||
effectiveChannelType.value
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachFile = files => {
|
const handleAttachFile = files => {
|
||||||
@@ -374,10 +356,10 @@ const shouldShowMessageEditor = computed(() => {
|
|||||||
v-model="state.message"
|
v-model="state.message"
|
||||||
:message-signature="messageSignature"
|
:message-signature="messageSignature"
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
|
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||||
:has-errors="validationStates.isMessageInvalid"
|
:has-errors="validationStates.isMessageInvalid"
|
||||||
:has-attachments="state.attachedFiles.length > 0"
|
:has-attachments="state.attachedFiles.length > 0"
|
||||||
:channel-type="inboxChannelType"
|
:channel-type="inboxChannelType"
|
||||||
:medium="targetInbox?.medium || ''"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AttachmentPreviews
|
<AttachmentPreviews
|
||||||
|
|||||||
+102
-24
@@ -1,49 +1,127 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { ref, watch, computed, nextTick } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
appendSignature,
|
||||||
|
extractTextFromMarkdown,
|
||||||
|
removeSignature,
|
||||||
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
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({
|
const props = defineProps({
|
||||||
|
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
|
||||||
hasErrors: { type: Boolean, default: false },
|
hasErrors: { type: Boolean, default: false },
|
||||||
hasAttachments: { type: Boolean, default: false },
|
hasAttachments: { type: Boolean, default: false },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
medium: { type: String, default: '' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const modelValue = defineModel({
|
const modelValue = defineModel({
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||||
<Editor
|
<template v-if="isEmailOrWebWidgetInbox">
|
||||||
:key="editorKey"
|
<Editor
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.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="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||||
:class="
|
:class="
|
||||||
hasErrors
|
hasErrors
|
||||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||||
: ''
|
: ''
|
||||||
"
|
"
|
||||||
enable-variables
|
enable-variables
|
||||||
:show-character-count="false"
|
:show-character-count="false"
|
||||||
:signature="messageSignature"
|
:signature="messageSignature"
|
||||||
allow-signature
|
allow-signature
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
:medium="medium"
|
/>
|
||||||
/>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
-1
@@ -118,7 +118,6 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
:message="formErrors.description"
|
:message="formErrors.description"
|
||||||
:message-type="formErrors.description ? 'error' : 'info'"
|
:message-type="formErrors.description ? 'error' : 'info'"
|
||||||
class="z-0"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
-3
@@ -108,7 +108,6 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
||||||
:message="formErrors.handoffMessage"
|
:message="formErrors.handoffMessage"
|
||||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||||
class="z-0"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
@@ -117,7 +116,6 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
||||||
:message="formErrors.resolutionMessage"
|
:message="formErrors.resolutionMessage"
|
||||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||||
class="z-0"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
@@ -128,7 +126,6 @@ watch(
|
|||||||
:message="formErrors.instructions"
|
:message="formErrors.instructions"
|
||||||
:max-length="20000"
|
:max-length="20000"
|
||||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||||
class="z-0"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
+1
-4
@@ -1,6 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useBranding } from 'shared/composables/useBranding';
|
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||||
@@ -10,8 +9,6 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
|
|||||||
const emit = defineEmits(['click']);
|
const emit = defineEmits(['click']);
|
||||||
const { isOnChatwootCloud } = useAccount();
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
|
||||||
const { replaceInstallationName } = useBranding();
|
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
emit('click');
|
emit('click');
|
||||||
};
|
};
|
||||||
@@ -38,7 +35,7 @@ const onClick = () => {
|
|||||||
v-for="(document, index) in documentsList.slice(0, 5)"
|
v-for="(document, index) in documentsList.slice(0, 5)"
|
||||||
:id="document.id"
|
:id="document.id"
|
||||||
:key="`document-${index}`"
|
:key="`document-${index}`"
|
||||||
:name="replaceInstallationName(document.name)"
|
:name="document.name"
|
||||||
:assistant="document.assistant"
|
:assistant="document.assistant"
|
||||||
:external-link="document.external_link"
|
:external-link="document.external_link"
|
||||||
:created-at="document.created_at"
|
:created-at="document.created_at"
|
||||||
|
|||||||
+2
-4
@@ -1,6 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useBranding } from 'shared/composables/useBranding';
|
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||||
@@ -27,7 +26,6 @@ const isApproved = computed(() => props.variant === 'approved');
|
|||||||
const isPending = computed(() => props.variant === 'pending');
|
const isPending = computed(() => props.variant === 'pending');
|
||||||
|
|
||||||
const { isOnChatwootCloud } = useAccount();
|
const { isOnChatwootCloud } = useAccount();
|
||||||
const { replaceInstallationName } = useBranding();
|
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
emit('click');
|
emit('click');
|
||||||
@@ -65,8 +63,8 @@ const onClearFilters = () => {
|
|||||||
v-for="(response, index) in responsesList.slice(0, 5)"
|
v-for="(response, index) in responsesList.slice(0, 5)"
|
||||||
:id="response.id"
|
:id="response.id"
|
||||||
:key="`response-${index}`"
|
:key="`response-${index}`"
|
||||||
:question="replaceInstallationName(response.question)"
|
:question="response.question"
|
||||||
:answer="replaceInstallationName(response.answer)"
|
:answer="response.answer"
|
||||||
:status="response.status"
|
:status="response.status"
|
||||||
:assistant="response.assistant"
|
:assistant="response.assistant"
|
||||||
:created-at="response.created_at"
|
:created-at="response.created_at"
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function useChannelIcon(inbox) {
|
|||||||
'Channel::WebWidget': 'i-woot-website',
|
'Channel::WebWidget': 'i-woot-website',
|
||||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||||
'Channel::Instagram': 'i-woot-instagram',
|
'Channel::Instagram': 'i-woot-instagram',
|
||||||
'Channel::Tiktok': 'i-woot-tiktok',
|
|
||||||
'Channel::Voice': 'i-ri-phone-fill',
|
'Channel::Voice': 'i-ri-phone-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,12 +61,6 @@ describe('useChannelIcon', () => {
|
|||||||
expect(icon).toBe('i-woot-instagram');
|
expect(icon).toBe('i-woot-instagram');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct icon for TikTok channel', () => {
|
|
||||||
const inbox = { channel_type: 'Channel::Tiktok' };
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-woot-tiktok');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TwilioSms channel', () => {
|
describe('TwilioSms channel', () => {
|
||||||
it('returns chat icon for regular Twilio SMS channel', () => {
|
it('returns chat icon for regular Twilio SMS channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::TwilioSms' };
|
const inbox = { channel_type: 'Channel::TwilioSms' };
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import ImageBubble from './bubbles/Image.vue';
|
|||||||
import FileBubble from './bubbles/File.vue';
|
import FileBubble from './bubbles/File.vue';
|
||||||
import AudioBubble from './bubbles/Audio.vue';
|
import AudioBubble from './bubbles/Audio.vue';
|
||||||
import VideoBubble from './bubbles/Video.vue';
|
import VideoBubble from './bubbles/Video.vue';
|
||||||
import EmbedBubble from './bubbles/Embed.vue';
|
|
||||||
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
||||||
import EmailBubble from './bubbles/Email/Index.vue';
|
import EmailBubble from './bubbles/Email/Index.vue';
|
||||||
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
||||||
@@ -300,12 +299,7 @@ const componentToRender = computed(() => {
|
|||||||
return DyteBubble;
|
return DyteBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
const instagramSharedTypes = [
|
if (props.contentAttributes.imageType === 'story_mention') {
|
||||||
ATTACHMENT_TYPES.STORY_MENTION,
|
|
||||||
ATTACHMENT_TYPES.IG_STORY,
|
|
||||||
ATTACHMENT_TYPES.IG_POST,
|
|
||||||
];
|
|
||||||
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
|
|
||||||
return InstagramStoryBubble;
|
return InstagramStoryBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +312,6 @@ const componentToRender = computed(() => {
|
|||||||
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
|
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
|
||||||
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
|
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
|
||||||
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
|
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
|
||||||
if (fileType === ATTACHMENT_TYPES.EMBED) return EmbedBubble;
|
|
||||||
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
|
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
|
||||||
}
|
}
|
||||||
// Attachment content is the name of the contact
|
// Attachment content is the name of the contact
|
||||||
@@ -483,7 +476,7 @@ provideMessageContext({
|
|||||||
<div
|
<div
|
||||||
v-if="shouldRenderMessage"
|
v-if="shouldRenderMessage"
|
||||||
:id="`message${props.id}`"
|
:id="`message${props.id}`"
|
||||||
class="flex mb-2 w-full message-bubble-container"
|
class="flex w-full message-bubble-container mb-2"
|
||||||
:data-message-id="props.id"
|
:data-message-id="props.id"
|
||||||
:class="[
|
:class="[
|
||||||
flexOrientationClass,
|
flexOrientationClass,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const {
|
|||||||
isAWhatsAppChannel,
|
isAWhatsAppChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAnInstagramChannel,
|
isAnInstagramChannel,
|
||||||
isATiktokChannel,
|
|
||||||
} = useInbox();
|
} = useInbox();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -61,8 +60,7 @@ const isSent = computed(() => {
|
|||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isASmsInbox.value ||
|
isASmsInbox.value ||
|
||||||
isATelegramChannel.value ||
|
isATelegramChannel.value ||
|
||||||
isAnInstagramChannel.value ||
|
isAnInstagramChannel.value
|
||||||
isATiktokChannel.value
|
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||||
}
|
}
|
||||||
@@ -80,8 +78,7 @@ const isDelivered = computed(() => {
|
|||||||
isAWhatsAppChannel.value ||
|
isAWhatsAppChannel.value ||
|
||||||
isATwilioChannel.value ||
|
isATwilioChannel.value ||
|
||||||
isASmsInbox.value ||
|
isASmsInbox.value ||
|
||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value
|
||||||
isATiktokChannel.value
|
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||||
}
|
}
|
||||||
@@ -103,8 +100,7 @@ const isRead = computed(() => {
|
|||||||
isAWhatsAppChannel.value ||
|
isAWhatsAppChannel.value ||
|
||||||
isATwilioChannel.value ||
|
isATwilioChannel.value ||
|
||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isAnInstagramChannel.value ||
|
isAnInstagramChannel.value
|
||||||
isATiktokChannel.value
|
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import BaseBubble from './Base.vue';
|
|
||||||
import { useMessageContext } from '../provider.js';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const { attachments } = useMessageContext();
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const attachment = computed(() => {
|
|
||||||
return attachments.value[0];
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BaseBubble class="overflow-hidden p-3" data-bubble-name="embed">
|
|
||||||
<div
|
|
||||||
class="w-full max-w-[360px] sm:max-w-[420px] min-h-[520px] h-[70vh] max-h-[680px]"
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
class="w-full h-full border-0 rounded-lg"
|
|
||||||
:title="t('CHAT_LIST.ATTACHMENTS.embed.CONTENT')"
|
|
||||||
:src="attachment.dataUrl"
|
|
||||||
loading="lazy"
|
|
||||||
allow="autoplay; encrypted-media; picture-in-picture"
|
|
||||||
allowfullscreen
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</BaseBubble>
|
|
||||||
</template>
|
|
||||||
@@ -1,102 +1,41 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useMessageContext } from '../provider.js';
|
|
||||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||||
|
import { useMessageContext } from '../provider.js';
|
||||||
|
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||||
|
|
||||||
const LABEL_MAP = {
|
const { contentAttributes } = useMessageContext();
|
||||||
[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 data = computed(() => contentAttributes.value?.data);
|
||||||
const status = computed(() => data.value?.status?.toString());
|
|
||||||
|
|
||||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
const status = computed(() => data.value?.status);
|
||||||
const isFailed = computed(() =>
|
const direction = computed(() => data.value?.call_direction);
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelKey = computed(() => {
|
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
|
||||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
useVoiceCallStatus(status, direction);
|
||||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
|
||||||
return isOutbound.value
|
const containerRingClass = computed(() => {
|
||||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
|
||||||
: '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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseBubble class="p-0 border-none" hide-meta>
|
<BaseBubble class="p-0 border-none" hide-meta>
|
||||||
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
<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 gap-3 items-center p-3 w-full">
|
<div class="flex gap-3 items-center p-3 w-full">
|
||||||
<div
|
<div
|
||||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||||
:class="bgColor"
|
:class="bubbleIconBg"
|
||||||
>
|
>
|
||||||
<Icon
|
<span class="text-xl" :class="bubbleIconName" />
|
||||||
class="size-5"
|
|
||||||
:icon="iconName"
|
|
||||||
:class="{
|
|
||||||
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
|
||||||
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex overflow-hidden flex-col flex-grow">
|
<div class="flex overflow-hidden flex-col flex-grow">
|
||||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
|
||||||
{{ $t(labelKey) }}
|
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
|
||||||
</span>
|
|
||||||
<span class="text-xs text-n-slate-11">
|
|
||||||
{{ $t(subtextKey) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,9 +49,6 @@ export const ATTACHMENT_TYPES = {
|
|||||||
STORY_MENTION: 'story_mention',
|
STORY_MENTION: 'story_mention',
|
||||||
CONTACT: 'contact',
|
CONTACT: 'contact',
|
||||||
IG_REEL: 'ig_reel',
|
IG_REEL: 'ig_reel',
|
||||||
EMBED: 'embed',
|
|
||||||
IG_POST: 'ig_post',
|
|
||||||
IG_STORY: 'ig_story',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONTENT_TYPES = {
|
export const CONTENT_TYPES = {
|
||||||
@@ -76,16 +73,3 @@ export const MEDIA_TYPES = [
|
|||||||
ATTACHMENT_TYPES.AUDIO,
|
ATTACHMENT_TYPES.AUDIO,
|
||||||
ATTACHMENT_TYPES.IG_REEL,
|
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,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
const emit = defineEmits(['update:currentPage']);
|
const emit = defineEmits(['update:currentPage']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { formatCompactNumber, formatFullNumber } = useNumberFormatter();
|
|
||||||
|
|
||||||
const totalPages = computed(() =>
|
const totalPages = computed(() =>
|
||||||
Math.ceil(props.totalItems / props.itemsPerPage)
|
Math.ceil(props.totalItems / props.itemsPerPage)
|
||||||
@@ -45,27 +43,21 @@ const changePage = newPage => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentPageInformation = computed(() => {
|
const currentPageInformation = computed(() => {
|
||||||
const translationKey = props.currentPageInfo || 'PAGINATION_FOOTER.SHOWING';
|
|
||||||
return t(
|
return t(
|
||||||
translationKey,
|
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
|
||||||
{
|
{
|
||||||
startItem: formatFullNumber(startItem.value),
|
startItem: startItem.value,
|
||||||
endItem: formatFullNumber(endItem.value),
|
endItem: endItem.value,
|
||||||
totalItems: formatCompactNumber(props.totalItems),
|
totalItems: props.totalItems,
|
||||||
},
|
}
|
||||||
Number(props.totalItems)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageInfo = computed(() => {
|
const pageInfo = computed(() => {
|
||||||
return t(
|
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
|
||||||
'PAGINATION_FOOTER.CURRENT_PAGE_INFO',
|
currentPage: '',
|
||||||
{
|
totalPages: totalPages.value,
|
||||||
currentPage: '',
|
});
|
||||||
totalPages: formatCompactNumber(totalPages.value),
|
|
||||||
},
|
|
||||||
Number(totalPages.value)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -99,11 +91,9 @@ const pageInfo = computed(() => {
|
|||||||
/>
|
/>
|
||||||
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
<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">
|
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
||||||
{{ formatFullNumber(currentPage) }}
|
{{ currentPage }}
|
||||||
</span>
|
|
||||||
<span class="truncate">
|
|
||||||
{{ pageInfo }}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span class="truncate">{{ pageInfo }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-chevron-right"
|
icon="i-lucide-chevron-right"
|
||||||
|
|||||||
@@ -9,14 +9,11 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import SidebarGroup from './SidebarGroup.vue';
|
import SidebarGroup from './SidebarGroup.vue';
|
||||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||||
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
|
|
||||||
import ChannelLeaf from './ChannelLeaf.vue';
|
import ChannelLeaf from './ChannelLeaf.vue';
|
||||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||||
import Logo from 'next/icon/Logo.vue';
|
import Logo from 'next/icon/Logo.vue';
|
||||||
@@ -99,15 +96,6 @@ const closeMobileSidebar = () => {
|
|||||||
emit('closeMobileSidebar');
|
emit('closeMobileSidebar');
|
||||||
};
|
};
|
||||||
|
|
||||||
const onComposeOpen = toggleFn => {
|
|
||||||
toggleFn();
|
|
||||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onComposeClose = () => {
|
|
||||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const newReportRoutes = () => [
|
const newReportRoutes = () => [
|
||||||
{
|
{
|
||||||
name: 'Reports Agent',
|
name: 'Reports Agent',
|
||||||
@@ -635,14 +623,14 @@ const menuItems = computed(() => {
|
|||||||
{{ searchShortcut }}
|
{{ searchShortcut }}
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<ComposeConversation align-position="right" @close="onComposeClose">
|
<ComposeConversation align-position="right">
|
||||||
<template #trigger="{ toggle }">
|
<template #trigger="{ toggle }">
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-pen-line"
|
icon="i-lucide-pen-line"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
||||||
@click="onComposeOpen(toggle)"
|
@click="toggle"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ComposeConversation>
|
</ComposeConversation>
|
||||||
@@ -663,7 +651,6 @@ const menuItems = computed(() => {
|
|||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||||
/>
|
/>
|
||||||
<YearInReviewBanner />
|
|
||||||
<SidebarChangelogCard
|
<SidebarChangelogCard
|
||||||
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Auth from 'dashboard/api/auth';
|
import Auth from 'dashboard/api/auth';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownContainer,
|
DropdownContainer,
|
||||||
@@ -24,7 +22,6 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { uiSettings } = useUISettings();
|
|
||||||
|
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||||
@@ -34,29 +31,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
'accounts/isFeatureEnabledonAccount'
|
'accounts/isFeatureEnabledonAccount'
|
||||||
);
|
);
|
||||||
|
|
||||||
const showYearInReviewModal = ref(false);
|
|
||||||
|
|
||||||
const bannerClosedKey = computed(() => {
|
|
||||||
return `yir_closed_${accountId.value}_2025`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const isBannerClosed = computed(() => {
|
|
||||||
return uiSettings.value?.[bannerClosedKey.value] === true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const showYearInReviewMenuItem = computed(() => {
|
|
||||||
return isBannerClosed.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
const openYearInReviewModal = () => {
|
|
||||||
showYearInReviewModal.value = true;
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeYearInReviewModal = () => {
|
|
||||||
showYearInReviewModal.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const showChatSupport = computed(() => {
|
const showChatSupport = computed(() => {
|
||||||
return (
|
return (
|
||||||
isFeatureEnabledonAccount.value(
|
isFeatureEnabledonAccount.value(
|
||||||
@@ -68,13 +42,6 @@ const showChatSupport = computed(() => {
|
|||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
show: showYearInReviewMenuItem.value,
|
|
||||||
showOnCustomBrandedInstance: false,
|
|
||||||
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
|
|
||||||
icon: 'i-lucide-gift',
|
|
||||||
click: openYearInReviewModal,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
show: showChatSupport.value,
|
show: showChatSupport.value,
|
||||||
showOnCustomBrandedInstance: false,
|
showOnCustomBrandedInstance: false,
|
||||||
@@ -190,9 +157,4 @@ const allowedMenuItems = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
</DropdownBody>
|
</DropdownBody>
|
||||||
</DropdownContainer>
|
</DropdownContainer>
|
||||||
|
|
||||||
<YearInReviewModal
|
|
||||||
:show="showYearInReviewModal"
|
|
||||||
@close="closeYearInReviewModal"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { toPng } from 'html-to-image';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
show: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
slideElement: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
slideBackground: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
year: {
|
|
||||||
type: [Number, String],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const isGenerating = ref(false);
|
|
||||||
const shareImageUrl = ref(null);
|
|
||||||
|
|
||||||
const generateImage = async () => {
|
|
||||||
if (!props.slideElement) return;
|
|
||||||
|
|
||||||
isGenerating.value = true;
|
|
||||||
try {
|
|
||||||
let slideElement = props.slideElement;
|
|
||||||
|
|
||||||
if (slideElement && '$el' in slideElement) {
|
|
||||||
slideElement = slideElement.$el;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!slideElement) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error('No slide element found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorMap = {
|
|
||||||
'bg-[#5BD58A]': '#5BD58A',
|
|
||||||
'bg-[#60a5fa]': '#60a5fa',
|
|
||||||
'bg-[#fb923c]': '#fb923c',
|
|
||||||
'bg-[#f87171]': '#f87171',
|
|
||||||
'bg-[#fbbf24]': '#fbbf24',
|
|
||||||
};
|
|
||||||
const bgColor = colorMap[props.slideBackground] || '#ffffff';
|
|
||||||
|
|
||||||
const dataUrl = await toPng(slideElement, {
|
|
||||||
pixelRatio: 1.2,
|
|
||||||
backgroundColor: bgColor,
|
|
||||||
// Skip font/CSS embedding to avoid CORS issues with CDN stylesheets
|
|
||||||
// See: https://github.com/bubkoo/html-to-image/issues/49#issuecomment-762222100
|
|
||||||
fontEmbedCSS: '',
|
|
||||||
cacheBust: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const img = new Image();
|
|
||||||
img.src = dataUrl;
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
img.onload = resolve;
|
|
||||||
img.onerror = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalCanvas = document.createElement('canvas');
|
|
||||||
const borderSize = 20;
|
|
||||||
const bottomPadding = 50;
|
|
||||||
|
|
||||||
finalCanvas.width = img.width + borderSize * 2;
|
|
||||||
finalCanvas.height = img.height + borderSize * 2 + bottomPadding;
|
|
||||||
|
|
||||||
const ctx = finalCanvas.getContext('2d');
|
|
||||||
|
|
||||||
ctx.fillStyle = 'white';
|
|
||||||
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
|
|
||||||
|
|
||||||
ctx.drawImage(img, borderSize, borderSize);
|
|
||||||
|
|
||||||
ctx.fillStyle = '#1f2d3d';
|
|
||||||
ctx.font = 'normal 16px system-ui, -apple-system, sans-serif';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.fillText(
|
|
||||||
t('YEAR_IN_REVIEW.SHARE_MODAL.BRANDING'),
|
|
||||||
borderSize,
|
|
||||||
img.height + borderSize + 35
|
|
||||||
);
|
|
||||||
|
|
||||||
const logo = new Image();
|
|
||||||
logo.src = '/brand-assets/logo.svg';
|
|
||||||
await new Promise(resolve => {
|
|
||||||
logo.onload = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
const logoHeight = 30;
|
|
||||||
const logoWidth = (logo.width / logo.height) * logoHeight;
|
|
||||||
const logoX = finalCanvas.width - borderSize - logoWidth;
|
|
||||||
const logoY = img.height + borderSize + 15;
|
|
||||||
|
|
||||||
ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight);
|
|
||||||
|
|
||||||
shareImageUrl.value = finalCanvas.toDataURL('image/png');
|
|
||||||
} catch (err) {
|
|
||||||
// Handle errors silently for now
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error('Failed to generate image:', err);
|
|
||||||
} finally {
|
|
||||||
isGenerating.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadImage = () => {
|
|
||||||
if (!shareImageUrl.value) return;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = shareImageUrl.value;
|
|
||||||
link.download = `chatwoot-year-in-review-${props.year}.png`;
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareImage = async () => {
|
|
||||||
if (!shareImageUrl.value) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(shareImageUrl.value);
|
|
||||||
const blob = await response.blob();
|
|
||||||
const file = new File([blob], `chatwoot-year-in-review-${props.year}.png`, {
|
|
||||||
type: 'image/png',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
navigator.share &&
|
|
||||||
navigator.canShare &&
|
|
||||||
navigator.canShare({ files: [file] })
|
|
||||||
) {
|
|
||||||
await navigator.share({
|
|
||||||
title: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TITLE', {
|
|
||||||
year: props.year,
|
|
||||||
}),
|
|
||||||
text: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TEXT', { year: props.year }),
|
|
||||||
files: [file],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadImage();
|
|
||||||
} catch (err) {
|
|
||||||
// Fallback to download if sharing fails
|
|
||||||
downloadImage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
shareImageUrl.value = null;
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpen = async () => {
|
|
||||||
if (props.show && !shareImageUrl.value) {
|
|
||||||
await generateImage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({ handleOpen });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="show"
|
|
||||||
class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-[10001]"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<div v-if="isGenerating" class="flex items-center justify-center">
|
|
||||||
<div class="text-center">
|
|
||||||
<div
|
|
||||||
class="inline-block w-12 h-12 border-4 rounded-full border-white border-t-transparent animate-spin"
|
|
||||||
/>
|
|
||||||
<p class="mt-4 text-sm text-white">
|
|
||||||
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="shareImageUrl"
|
|
||||||
class="max-w-2xl w-full mx-4 flex flex-col gap-6 bg-slate-800 rounded-2xl p-6"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-xl font-medium text-white">
|
|
||||||
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white hover:bg-opacity-20 transition-colors"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-x w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<img
|
|
||||||
:src="shareImageUrl"
|
|
||||||
alt="Year in Review"
|
|
||||||
class="w-full h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
|
||||||
class="flex-[2] px-4 py-3 flex items-center justify-center gap-2 rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
|
||||||
@click="downloadImage"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-download w-5 h-5" />
|
|
||||||
<span class="text-sm font-medium">{{
|
|
||||||
t('YEAR_IN_REVIEW.SHARE_MODAL.DOWNLOAD')
|
|
||||||
}}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="w-10 h-10 flex items-center justify-center rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
|
||||||
@click="shareImage"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-share-2 w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
|
||||||
import { useStoreGetters } from 'dashboard/composables/store';
|
|
||||||
import YearInReviewModal from './YearInReviewModal.vue';
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
|
|
||||||
const yearInReviewBannerImage =
|
|
||||||
'/assets/images/dashboard/year-in-review/year-in-review-sidebar.png';
|
|
||||||
const { t } = useI18n();
|
|
||||||
const { uiSettings, updateUISettings } = useUISettings();
|
|
||||||
const getters = useStoreGetters();
|
|
||||||
const showModal = ref(false);
|
|
||||||
const modalRef = ref(null);
|
|
||||||
|
|
||||||
const currentYear = 2025;
|
|
||||||
|
|
||||||
const isACustomBrandedInstance =
|
|
||||||
getters['globalConfig/isACustomBrandedInstance'];
|
|
||||||
|
|
||||||
const bannerClosedKey = computed(() => {
|
|
||||||
const accountId = getters.getCurrentAccountId.value;
|
|
||||||
return `yir_closed_${accountId}_${currentYear}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const isBannerClosed = computed(() => {
|
|
||||||
return uiSettings.value?.[bannerClosedKey.value] === true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const shouldShowBanner = computed(
|
|
||||||
() => !isBannerClosed.value && !isACustomBrandedInstance.value
|
|
||||||
);
|
|
||||||
|
|
||||||
const openModal = () => {
|
|
||||||
showModal.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
showModal.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeBanner = event => {
|
|
||||||
event.stopPropagation();
|
|
||||||
updateUISettings({ [bannerClosedKey.value]: true });
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="shouldShowBanner" class="relative">
|
|
||||||
<div
|
|
||||||
class="mx-2 my-1 p-3 bg-n-iris-9 rounded-lg cursor-pointer hover:shadow-md transition-all"
|
|
||||||
@click="openModal"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2 mb-3">
|
|
||||||
<span
|
|
||||||
class="text-sm font-semibold text-white leading-tight tracking-tight flex-1"
|
|
||||||
>
|
|
||||||
{{ t('YEAR_IN_REVIEW.BANNER.TITLE', { year: currentYear }) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center justify-center rounded hover:bg-white hover:bg-opacity-20 transition-colors p-0"
|
|
||||||
@click="closeBanner"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="i-lucide-x size-4 mt-0.5 text-n-slate-1 dark:text-n-slate-12"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<img
|
|
||||||
:src="yearInReviewBannerImage"
|
|
||||||
alt="Year in Review"
|
|
||||||
class="w-full h-auto rounded"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="w-full px-3 py-2 bg-white text-n-iris-9 text-xs font-medium rounded-mdtracking-tight"
|
|
||||||
@click.stop="openModal"
|
|
||||||
>
|
|
||||||
{{ t('YEAR_IN_REVIEW.BANNER.BUTTON') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<YearInReviewModal ref="modalRef" :show="showModal" @close="closeModal" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, watch, nextTick } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import YearInReviewAPI from 'dashboard/api/yearInReview';
|
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
|
||||||
import { useStoreGetters } from 'dashboard/composables/store';
|
|
||||||
import { useTrack } from 'dashboard/composables';
|
|
||||||
import { YEAR_IN_REVIEW_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
|
||||||
import IntroSlide from './slides/IntroSlide.vue';
|
|
||||||
import ConversationsSlide from './slides/ConversationsSlide.vue';
|
|
||||||
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
|
|
||||||
import PersonalitySlide from './slides/PersonalitySlide.vue';
|
|
||||||
import ThankYouSlide from './slides/ThankYouSlide.vue';
|
|
||||||
import ShareModal from './ShareModal.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
show: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const { uiSettings } = useUISettings();
|
|
||||||
const getters = useStoreGetters();
|
|
||||||
const isOpen = ref(false);
|
|
||||||
const currentSlide = ref(0);
|
|
||||||
const yearData = ref(null);
|
|
||||||
const isLoading = ref(false);
|
|
||||||
const error = ref(null);
|
|
||||||
const slideRefs = ref([]);
|
|
||||||
const showShareModal = ref(false);
|
|
||||||
const shareModalRef = ref(null);
|
|
||||||
const drumrollAudio = ref(null);
|
|
||||||
|
|
||||||
const hasConversations = computed(() => {
|
|
||||||
return yearData.value?.total_conversations > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalSlides = computed(() => {
|
|
||||||
if (!hasConversations.value) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
return 5;
|
|
||||||
});
|
|
||||||
|
|
||||||
const slideIndexMap = computed(() => {
|
|
||||||
if (!hasConversations.value) {
|
|
||||||
return [0, 1, 4];
|
|
||||||
}
|
|
||||||
return [0, 1, 2, 3, 4];
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentVisualSlide = computed(() => {
|
|
||||||
return slideIndexMap.value.indexOf(currentSlide.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const slideBackgrounds = [
|
|
||||||
'bg-[#5BD58A]',
|
|
||||||
'bg-[#60a5fa]',
|
|
||||||
'bg-[#fb923c]',
|
|
||||||
'bg-[#f87171]',
|
|
||||||
'bg-[#fbbf24]',
|
|
||||||
];
|
|
||||||
|
|
||||||
const playDrumroll = () => {
|
|
||||||
try {
|
|
||||||
if (!drumrollAudio.value) {
|
|
||||||
drumrollAudio.value = new Audio('/audio/dashboard/drumroll.mp3');
|
|
||||||
drumrollAudio.value.volume = 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
drumrollAudio.value.currentTime = 0;
|
|
||||||
drumrollAudio.value.play().catch(err => {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('Could not play drumroll:', err);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('Error playing drumroll:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchYearInReviewData = async () => {
|
|
||||||
const year = 2025;
|
|
||||||
const accountId = getters.getCurrentAccountId.value;
|
|
||||||
const cacheKey = `year_in_review_${accountId}_${year}`;
|
|
||||||
|
|
||||||
const cachedData = uiSettings.value?.[cacheKey];
|
|
||||||
|
|
||||||
if (cachedData) {
|
|
||||||
yearData.value = cachedData;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const response = await YearInReviewAPI.get(year);
|
|
||||||
yearData.value = response.data;
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err.message;
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextSlide = () => {
|
|
||||||
if (currentSlide.value < 4) {
|
|
||||||
useTrack(YEAR_IN_REVIEW_EVENTS.NEXT_CLICKED);
|
|
||||||
if (!hasConversations.value && currentSlide.value === 1) {
|
|
||||||
currentSlide.value = 4;
|
|
||||||
} else {
|
|
||||||
currentSlide.value += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousSlide = () => {
|
|
||||||
if (currentSlide.value > 0) {
|
|
||||||
if (!hasConversations.value && currentSlide.value === 4) {
|
|
||||||
currentSlide.value = 1;
|
|
||||||
} else {
|
|
||||||
currentSlide.value -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToSlide = visualIndex => {
|
|
||||||
currentSlide.value = slideIndexMap.value[visualIndex];
|
|
||||||
};
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
currentSlide.value = 0;
|
|
||||||
isOpen.value = false;
|
|
||||||
yearData.value = null;
|
|
||||||
isLoading.value = false;
|
|
||||||
error.value = null;
|
|
||||||
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
const open = () => {
|
|
||||||
useTrack(YEAR_IN_REVIEW_EVENTS.MODAL_OPENED);
|
|
||||||
isOpen.value = true;
|
|
||||||
fetchYearInReviewData();
|
|
||||||
playDrumroll();
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentSlideBackground = computed(
|
|
||||||
() => slideBackgrounds[currentSlide.value]
|
|
||||||
);
|
|
||||||
|
|
||||||
const shareCurrentSlide = async () => {
|
|
||||||
useTrack(YEAR_IN_REVIEW_EVENTS.SHARE_CLICKED);
|
|
||||||
showShareModal.value = true;
|
|
||||||
nextTick(() => {
|
|
||||||
if (shareModalRef.value) {
|
|
||||||
shareModalRef.value.handleOpen();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeShareModal = () => {
|
|
||||||
showShareModal.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const keyboardEvents = {
|
|
||||||
Escape: { action: close },
|
|
||||||
ArrowLeft: { action: previousSlide },
|
|
||||||
ArrowRight: { action: nextSlide },
|
|
||||||
};
|
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents);
|
|
||||||
|
|
||||||
defineExpose({ open, close });
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.show,
|
|
||||||
newValue => {
|
|
||||||
if (newValue) {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="isOpen"
|
|
||||||
class="fixed inset-0 z-[9999] bg-black font-interDisplay"
|
|
||||||
>
|
|
||||||
<div class="relative w-full h-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
|
||||||
class="flex items-center justify-center w-full h-full bg-n-slate-2"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<div
|
|
||||||
class="inline-block w-12 h-12 border-4 rounded-full border-n-slate-6 border-t-n-slate-11 animate-spin"
|
|
||||||
/>
|
|
||||||
<p class="mt-4 text-sm text-n-slate-11">
|
|
||||||
{{ t('YEAR_IN_REVIEW.LOADING') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="error"
|
|
||||||
class="flex items-center justify-center w-full h-full bg-n-slate-2"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-lg font-semibold text-red-600">
|
|
||||||
{{ t('YEAR_IN_REVIEW.ERROR') }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-sm text-n-slate-11">{{ error }}</p>
|
|
||||||
<button
|
|
||||||
class="mt-4 px-4 py-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<span class="text-sm font-medium">{{
|
|
||||||
t('YEAR_IN_REVIEW.CLOSE')
|
|
||||||
}}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="yearData"
|
|
||||||
class="relative w-full h-full"
|
|
||||||
:class="currentSlideBackground"
|
|
||||||
>
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
|
||||||
leave-active-class="transition-all duration-300 ease-out"
|
|
||||||
enter-from-class="opacity-0 translate-x-[30px]"
|
|
||||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
|
||||||
>
|
|
||||||
<IntroSlide
|
|
||||||
v-if="currentSlide === 0"
|
|
||||||
:key="0"
|
|
||||||
:ref="el => (slideRefs[0] = el)"
|
|
||||||
:year="yearData.year"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
|
||||||
leave-active-class="transition-all duration-300 ease-out"
|
|
||||||
enter-from-class="opacity-0 translate-x-[30px]"
|
|
||||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
|
||||||
>
|
|
||||||
<ConversationsSlide
|
|
||||||
v-if="currentSlide === 1"
|
|
||||||
:key="1"
|
|
||||||
:ref="el => (slideRefs[1] = el)"
|
|
||||||
:total-conversations="yearData.total_conversations"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
|
||||||
leave-active-class="transition-all duration-300 ease-out"
|
|
||||||
enter-from-class="opacity-0 translate-x-[30px]"
|
|
||||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
|
||||||
>
|
|
||||||
<BusiestDaySlide
|
|
||||||
v-if="
|
|
||||||
currentSlide === 2 && hasConversations && yearData.busiest_day
|
|
||||||
"
|
|
||||||
:key="2"
|
|
||||||
:ref="el => (slideRefs[2] = el)"
|
|
||||||
:busiest-day="yearData.busiest_day"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
|
||||||
leave-active-class="transition-all duration-300 ease-out"
|
|
||||||
enter-from-class="opacity-0 translate-x-[30px]"
|
|
||||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
|
||||||
>
|
|
||||||
<PersonalitySlide
|
|
||||||
v-if="
|
|
||||||
currentSlide === 3 &&
|
|
||||||
hasConversations &&
|
|
||||||
yearData.support_personality
|
|
||||||
"
|
|
||||||
:key="3"
|
|
||||||
:ref="el => (slideRefs[3] = el)"
|
|
||||||
:support-personality="yearData.support_personality"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
|
||||||
leave-active-class="transition-all duration-300 ease-out"
|
|
||||||
enter-from-class="opacity-0 translate-x-[30px]"
|
|
||||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
|
||||||
>
|
|
||||||
<ThankYouSlide
|
|
||||||
v-if="currentSlide === 4"
|
|
||||||
:key="4"
|
|
||||||
:ref="el => (slideRefs[4] = el)"
|
|
||||||
:year="yearData.year"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute bottom-8 left-0 right-0 flex items-center justify-between px-8"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-if="currentSlide > 0"
|
|
||||||
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
|
||||||
@click="previousSlide"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-chevron-left w-5 h-5" />
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{{ t('YEAR_IN_REVIEW.NAVIGATION.PREVIOUS') }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<div v-else class="w-20" />
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
v-for="index in totalSlides"
|
|
||||||
:key="index"
|
|
||||||
class="w-2 h-2 rounded-full transition-all"
|
|
||||||
:class="
|
|
||||||
currentVisualSlide === index - 1
|
|
||||||
? 'bg-white w-8'
|
|
||||||
: 'bg-white bg-opacity-50'
|
|
||||||
"
|
|
||||||
@click="goToSlide(index - 1)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
|
||||||
:class="{ invisible: currentVisualSlide === totalSlides - 1 }"
|
|
||||||
@click="nextSlide"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="currentVisualSlide < totalSlides - 1"
|
|
||||||
class="text-sm font-medium"
|
|
||||||
>
|
|
||||||
{{ t('YEAR_IN_REVIEW.NAVIGATION.NEXT') }}
|
|
||||||
</span>
|
|
||||||
<i
|
|
||||||
v-if="currentVisualSlide < totalSlides - 1"
|
|
||||||
class="i-lucide-chevron-right w-5 h-5"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="absolute top-4 left-4 px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
|
||||||
@click="shareCurrentSlide"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-share-2 w-5 h-5" />
|
|
||||||
<span class="text-sm font-medium">{{
|
|
||||||
t('YEAR_IN_REVIEW.NAVIGATION.SHARE')
|
|
||||||
}}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full text-n-slate-12 dark:text-n-slate-1 hover:bg-white hover:bg-opacity-20 transition-colors"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-x w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ShareModal
|
|
||||||
ref="shareModalRef"
|
|
||||||
:show="showShareModal"
|
|
||||||
:slide-element="slideRefs[currentSlide]"
|
|
||||||
:slide-background="currentSlideBackground"
|
|
||||||
:year="yearData?.year"
|
|
||||||
@close="closeShareModal"
|
|
||||||
/>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
busiestDay: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const coffeeImage =
|
|
||||||
'/assets/images/dashboard/year-in-review/third-frame-coffee.png';
|
|
||||||
const doubleQuotesImage =
|
|
||||||
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
|
||||||
|
|
||||||
const performanceHelperText = computed(() => {
|
|
||||||
const count = props.busiestDay.count;
|
|
||||||
if (count <= 5) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.0_5');
|
|
||||||
if (count <= 10) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.5_10');
|
|
||||||
if (count <= 25) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.10_25');
|
|
||||||
if (count <= 50) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.25_50');
|
|
||||||
if (count <= 100) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.50_100');
|
|
||||||
if (count <= 500) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.100_500');
|
|
||||||
return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.500_PLUS');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
|
|
||||||
<div class="flex flex-col gap-4 w-full max-w-3xl">
|
|
||||||
<div class="flex items-center justify-center flex-1">
|
|
||||||
<div class="flex items-center justify-between gap-6 flex-1 md:gap-16">
|
|
||||||
<div class="text-white flex gap-2 flex-col">
|
|
||||||
<div class="text-2xl lg:text-3xl xl:text-4xl tracking-tight">
|
|
||||||
{{ t('YEAR_IN_REVIEW.BUSIEST_DAY.TITLE') }}
|
|
||||||
</div>
|
|
||||||
<div class="text-6xl md:text-8xl lg:text-[140px] tracking-tighter">
|
|
||||||
{{ busiestDay.date }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img
|
|
||||||
:src="coffeeImage"
|
|
||||||
alt="Coffee"
|
|
||||||
class="w-auto h-32 md:h-56 lg:h-72"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 flex-1">
|
|
||||||
<div class="flex items-center justify-center gap-3 md:gap-8">
|
|
||||||
<img
|
|
||||||
:src="doubleQuotesImage"
|
|
||||||
alt="Quote"
|
|
||||||
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
|
||||||
/>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p
|
|
||||||
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
|
|
||||||
count: busiestDay.count,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
{{ performanceHelperText }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
totalConversations: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const cloudImage =
|
|
||||||
'/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png';
|
|
||||||
const doubleQuotesImage =
|
|
||||||
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
|
||||||
|
|
||||||
const hasData = computed(() => {
|
|
||||||
return props.totalConversations > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatNumber = num => {
|
|
||||||
if (num >= 100000) {
|
|
||||||
return '100k+';
|
|
||||||
}
|
|
||||||
return new Intl.NumberFormat().format(num);
|
|
||||||
};
|
|
||||||
|
|
||||||
const performanceHelperText = computed(() => {
|
|
||||||
if (!hasData.value) {
|
|
||||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.FALLBACK');
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = props.totalConversations;
|
|
||||||
if (count <= 50) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.0_50');
|
|
||||||
if (count <= 100) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.50_100');
|
|
||||||
if (count <= 500) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.100_500');
|
|
||||||
if (count <= 2000)
|
|
||||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.500_2000');
|
|
||||||
if (count <= 10000)
|
|
||||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.2000_10000');
|
|
||||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.10000_PLUS');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-16"
|
|
||||||
:class="totalConversations > 100 ? 'max-w-4xl' : 'max-w-3xl'"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center flex-1">
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between gap-6 flex-1"
|
|
||||||
:class="totalConversations > 100 ? 'md:gap-16' : 'md:gap-8'"
|
|
||||||
>
|
|
||||||
<div class="text-white flex gap-3 flex-col">
|
|
||||||
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight">
|
|
||||||
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.TITLE') }}
|
|
||||||
</div>
|
|
||||||
<div class="text-6xl md:text-8xl lg:text-[180px] tracking-tighter">
|
|
||||||
{{ formatNumber(totalConversations) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight -mt-2">
|
|
||||||
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.SUBTITLE') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img
|
|
||||||
:src="cloudImage"
|
|
||||||
alt="Cloud"
|
|
||||||
class="w-auto h-32 md:h-56 lg:h-80 -mr-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-center gap-3 md:gap-6">
|
|
||||||
<img
|
|
||||||
:src="doubleQuotesImage"
|
|
||||||
alt="Quote"
|
|
||||||
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
|
||||||
>
|
|
||||||
{{ performanceHelperText }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
year: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const candlesImagePath =
|
|
||||||
'/assets/images/dashboard/year-in-review/first-frame-candles.png';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 flex flex-col items-center justify-center text-black px-8 md:px-16 lg:px-24 py-10 md:py-16 lg:py-20 bg-cover bg-center min-h-[700px]"
|
|
||||||
:style="{
|
|
||||||
backgroundImage: `url('/assets/images/dashboard/year-in-review/first-frame-bg.png')`,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="text-center max-w-3xl">
|
|
||||||
<h1
|
|
||||||
class="text-8xl md:text-9xl lg:text-[220px] font-semibold mb-4 md:mb-6 leading-none tracking-tight text-n-slate-12 dark:text-n-slate-1"
|
|
||||||
>
|
|
||||||
{{ year }}
|
|
||||||
</h1>
|
|
||||||
<h2
|
|
||||||
class="text-3xl md:text-4xl lg:text-5xl font-medium mb-12 md:mb-16 lg:mb-20 text-n-slate-12 dark:text-n-slate-1"
|
|
||||||
>
|
|
||||||
{{ t('YEAR_IN_REVIEW.TITLE') }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img
|
|
||||||
:src="candlesImagePath"
|
|
||||||
alt="Candles"
|
|
||||||
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-auto h-32 md:h-48 lg:h-64"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
supportPersonality: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const clockImage =
|
|
||||||
'/assets/images/dashboard/year-in-review/fourth-frame-clock.png';
|
|
||||||
const doubleQuotesImage =
|
|
||||||
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
|
||||||
|
|
||||||
const formatResponseTime = seconds => {
|
|
||||||
if (seconds < 60) {
|
|
||||||
return 'less than a minute';
|
|
||||||
}
|
|
||||||
if (seconds < 3600) {
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
|
|
||||||
}
|
|
||||||
if (seconds < 86400) {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
return hours === 1 ? '1 hour' : `${hours} hours`;
|
|
||||||
}
|
|
||||||
return 'more than a day';
|
|
||||||
};
|
|
||||||
|
|
||||||
const personality = computed(() => {
|
|
||||||
const seconds = props.supportPersonality.avg_response_time_seconds;
|
|
||||||
const minutes = seconds / 60;
|
|
||||||
|
|
||||||
if (minutes < 2) {
|
|
||||||
return 'Swift Helper';
|
|
||||||
}
|
|
||||||
if (minutes < 5) {
|
|
||||||
return 'Quick Responder';
|
|
||||||
}
|
|
||||||
if (minutes < 15) {
|
|
||||||
return 'Steady Support';
|
|
||||||
}
|
|
||||||
return 'Thoughtful Advisor';
|
|
||||||
});
|
|
||||||
|
|
||||||
const personalityMessage = computed(() => {
|
|
||||||
const seconds = props.supportPersonality.avg_response_time_seconds;
|
|
||||||
const time = formatResponseTime(seconds);
|
|
||||||
|
|
||||||
const personalityKeyMap = {
|
|
||||||
'Swift Helper': 'SWIFT_HELPER',
|
|
||||||
'Quick Responder': 'QUICK_RESPONDER',
|
|
||||||
'Steady Support': 'STEADY_SUPPORT',
|
|
||||||
'Thoughtful Advisor': 'THOUGHTFUL_ADVISOR',
|
|
||||||
};
|
|
||||||
|
|
||||||
const key = personalityKeyMap[personality.value];
|
|
||||||
if (!key) return '';
|
|
||||||
|
|
||||||
return t(`YEAR_IN_REVIEW.PERSONALITY.MESSAGES.${key}`, { time });
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
|
|
||||||
<div class="flex flex-col gap-9 max-w-3xl">
|
|
||||||
<div class="mb-4 md:mb-6">
|
|
||||||
<img :src="clockImage" alt="Clock" class="w-auto h-28" />
|
|
||||||
<div class="flex items-center justify-start flex-1 mt-9">
|
|
||||||
<div class="text-n-slate-1 dark:text-n-slate-12 flex gap-3 flex-col">
|
|
||||||
<div class="text-2xl md:text-4xl tracking-tight">
|
|
||||||
{{ t('YEAR_IN_REVIEW.PERSONALITY.TITLE') }}
|
|
||||||
</div>
|
|
||||||
<div class="text-6xl md:text-7xl lg:text-8xl tracking-tighter">
|
|
||||||
{{ personality }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-center gap-3 md:gap-6">
|
|
||||||
<img
|
|
||||||
:src="doubleQuotesImage"
|
|
||||||
alt="Quote"
|
|
||||||
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
class="text-xl md:text-3xl lg:text-3xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
|
||||||
>
|
|
||||||
{{ personalityMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
year: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const signatureImage =
|
|
||||||
'/assets/images/dashboard/year-in-review/fifth-frame-signature.png';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col items-start max-w-4xl">
|
|
||||||
<div
|
|
||||||
class="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight !leading-tight text-n-slate-12 dark:text-n-slate-1"
|
|
||||||
>
|
|
||||||
{{ t('YEAR_IN_REVIEW.THANK_YOU.TITLE', { year }) }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-xl lg:text-3xl mt-8 font-medium !leading-snug text-n-slate-12 dark:text-n-slate-1"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('YEAR_IN_REVIEW.THANK_YOU.MESSAGE', { nextYear: Number(year) + 1 })
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div class="mt-12">
|
|
||||||
<img
|
|
||||||
:src="signatureImage"
|
|
||||||
alt="Chatwoot Team Signature"
|
|
||||||
class="w-auto h-8 md:h-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -23,10 +23,6 @@ const hasInstagramConfigured = computed(() => {
|
|||||||
return window.chatwootConfig?.instagramAppId;
|
return window.chatwootConfig?.instagramAppId;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasTiktokConfigured = computed(() => {
|
|
||||||
return window.chatwootConfig?.tiktokAppId;
|
|
||||||
});
|
|
||||||
|
|
||||||
const isActive = computed(() => {
|
const isActive = computed(() => {
|
||||||
const { key } = props.channel;
|
const { key } = props.channel;
|
||||||
if (Object.keys(props.enabledFeatures).length === 0) {
|
if (Object.keys(props.enabledFeatures).length === 0) {
|
||||||
@@ -48,10 +44,6 @@ const isActive = computed(() => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'tiktok') {
|
|
||||||
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'voice') {
|
if (key === 'voice') {
|
||||||
return props.enabledFeatures.channel_voice;
|
return props.enabledFeatures.channel_voice;
|
||||||
}
|
}
|
||||||
@@ -65,7 +57,6 @@ const isActive = computed(() => {
|
|||||||
'telegram',
|
'telegram',
|
||||||
'line',
|
'line',
|
||||||
'instagram',
|
'instagram',
|
||||||
'tiktok',
|
|
||||||
'voice',
|
'voice',
|
||||||
].includes(key);
|
].includes(key);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
|
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
import {
|
||||||
|
MESSAGE_EDITOR_MENU_OPTIONS,
|
||||||
|
MESSAGE_EDITOR_IMAGE_RESIZES,
|
||||||
|
} from 'dashboard/constants/editor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
messageSchema,
|
messageSchema,
|
||||||
buildMessageSchema,
|
|
||||||
buildEditor,
|
buildEditor,
|
||||||
EditorView,
|
EditorView,
|
||||||
MessageMarkdownTransformer,
|
MessageMarkdownTransformer,
|
||||||
@@ -51,10 +53,6 @@ import {
|
|||||||
removeSignature as removeSignatureHelper,
|
removeSignature as removeSignatureHelper,
|
||||||
scrollCursorIntoView,
|
scrollCursorIntoView,
|
||||||
setURLWithQueryAndSize,
|
setURLWithQueryAndSize,
|
||||||
getFormattingForEditor,
|
|
||||||
getSelectionCoords,
|
|
||||||
calculateMenuPosition,
|
|
||||||
getEffectiveChannelType,
|
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
import {
|
import {
|
||||||
hasPressedEnterAndNotCmdOrShift,
|
hasPressedEnterAndNotCmdOrShift,
|
||||||
@@ -77,12 +75,12 @@ const props = defineProps({
|
|||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
enableCaptainTools: { type: Boolean, default: false },
|
enableCaptainTools: { type: Boolean, default: false },
|
||||||
variables: { type: Object, default: () => ({}) },
|
variables: { type: Object, default: () => ({}) },
|
||||||
|
enabledMenuOptions: { type: Array, default: () => [] },
|
||||||
signature: { type: String, default: '' },
|
signature: { type: String, default: '' },
|
||||||
// allowSignature is a kill switch, ensuring no signature methods
|
// allowSignature is a kill switch, ensuring no signature methods
|
||||||
// are triggered except when this flag is true
|
// are triggered except when this flag is true
|
||||||
allowSignature: { type: Boolean, default: false },
|
allowSignature: { type: Boolean, default: false },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
medium: { type: String, default: '' },
|
|
||||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||||
focusOnMount: { type: Boolean, default: true },
|
focusOnMount: { type: Boolean, default: true },
|
||||||
});
|
});
|
||||||
@@ -105,40 +103,22 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||||
const DEFAULT_FORMATTING = 'Context::Default';
|
|
||||||
|
|
||||||
const effectiveChannelType = computed(() =>
|
const createState = (
|
||||||
getEffectiveChannelType(props.channelType, props.medium)
|
content,
|
||||||
);
|
placeholder,
|
||||||
|
plugins = [],
|
||||||
const editorSchema = computed(() => {
|
methods = {},
|
||||||
if (!props.channelType) return messageSchema;
|
enabledMenuOptions = []
|
||||||
|
) => {
|
||||||
const formatType = props.isPrivate
|
|
||||||
? DEFAULT_FORMATTING
|
|
||||||
: effectiveChannelType.value;
|
|
||||||
const formatting = getFormattingForEditor(formatType);
|
|
||||||
return buildMessageSchema(formatting.marks, formatting.nodes);
|
|
||||||
});
|
|
||||||
|
|
||||||
const editorMenuOptions = computed(() => {
|
|
||||||
const formatType = props.isPrivate
|
|
||||||
? DEFAULT_FORMATTING
|
|
||||||
: effectiveChannelType.value || DEFAULT_FORMATTING;
|
|
||||||
const formatting = getFormattingForEditor(formatType);
|
|
||||||
return formatting.menu;
|
|
||||||
});
|
|
||||||
|
|
||||||
const createState = (content, placeholder, plugins = [], methods = {}) => {
|
|
||||||
const schema = editorSchema.value;
|
|
||||||
return EditorState.create({
|
return EditorState.create({
|
||||||
doc: new MessageMarkdownTransformer(schema).parse(content),
|
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
|
||||||
plugins: buildEditor({
|
plugins: buildEditor({
|
||||||
schema,
|
schema: messageSchema,
|
||||||
placeholder,
|
placeholder,
|
||||||
methods,
|
methods,
|
||||||
plugins,
|
plugins,
|
||||||
enabledMenuOptions: editorMenuOptions.value,
|
enabledMenuOptions,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -173,8 +153,6 @@ const range = ref(null);
|
|||||||
const isImageNodeSelected = ref(false);
|
const isImageNodeSelected = ref(false);
|
||||||
const toolbarPosition = ref({ top: 0, left: 0 });
|
const toolbarPosition = ref({ top: 0, left: 0 });
|
||||||
const selectedImageNode = ref(null);
|
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;
|
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
|
||||||
|
|
||||||
// element ref
|
// element ref
|
||||||
@@ -196,6 +174,12 @@ const shouldShowCannedResponses = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editorMenuOptions = computed(() => {
|
||||||
|
return props.enabledMenuOptions.length
|
||||||
|
? props.enabledMenuOptions
|
||||||
|
: MESSAGE_EDITOR_MENU_OPTIONS;
|
||||||
|
});
|
||||||
|
|
||||||
function createSuggestionPlugin({
|
function createSuggestionPlugin({
|
||||||
trigger,
|
trigger,
|
||||||
minChars = 0,
|
minChars = 0,
|
||||||
@@ -309,13 +293,8 @@ function isBodyEmpty(content) {
|
|||||||
|
|
||||||
// if the signature is present, we need to remove it before checking
|
// if the signature is present, we need to remove it before checking
|
||||||
// note that we don't update the editorView, so this is safe
|
// 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
|
const bodyWithoutSignature = props.signature
|
||||||
? removeSignatureHelper(
|
? removeSignatureHelper(content, props.signature)
|
||||||
content,
|
|
||||||
props.signature,
|
|
||||||
effectiveChannelType.value
|
|
||||||
)
|
|
||||||
: content;
|
: content;
|
||||||
|
|
||||||
// trimming should remove all the whitespaces, so we can check the length
|
// trimming should remove all the whitespaces, so we can check the length
|
||||||
@@ -383,11 +362,7 @@ function addSignature() {
|
|||||||
// see if the content is empty, if it is before appending the signature
|
// 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
|
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||||
const contentWasEmpty = isBodyEmpty(content);
|
const contentWasEmpty = isBodyEmpty(content);
|
||||||
content = appendSignature(
|
content = appendSignature(content, props.signature);
|
||||||
content,
|
|
||||||
props.signature,
|
|
||||||
effectiveChannelType.value
|
|
||||||
);
|
|
||||||
// need to reload first, ensuring that the editorView is updated
|
// need to reload first, ensuring that the editorView is updated
|
||||||
reloadState(content);
|
reloadState(content);
|
||||||
|
|
||||||
@@ -399,11 +374,7 @@ function addSignature() {
|
|||||||
function removeSignature() {
|
function removeSignature() {
|
||||||
if (!props.signature) return;
|
if (!props.signature) return;
|
||||||
let content = props.modelValue;
|
let content = props.modelValue;
|
||||||
content = removeSignatureHelper(
|
content = removeSignatureHelper(content, props.signature);
|
||||||
content,
|
|
||||||
props.signature,
|
|
||||||
effectiveChannelType.value
|
|
||||||
);
|
|
||||||
// reload the state, ensuring that the editorView is updated
|
// reload the state, ensuring that the editorView is updated
|
||||||
reloadState(content);
|
reloadState(content);
|
||||||
}
|
}
|
||||||
@@ -429,38 +400,6 @@ 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) {
|
function setURLWithQueryAndImageSize(size) {
|
||||||
if (!props.showImageResizeToolbar) {
|
if (!props.showImageResizeToolbar) {
|
||||||
return;
|
return;
|
||||||
@@ -590,9 +529,7 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
|
|||||||
|
|
||||||
function insertContentIntoEditor(content, defaultFrom = 0) {
|
function insertContentIntoEditor(content, defaultFrom = 0) {
|
||||||
const from = defaultFrom || editorView.state.selection.from || 0;
|
const from = defaultFrom || editorView.state.selection.from || 0;
|
||||||
// Use the editor's current schema to ensure compatibility with buildMessageSchema
|
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||||
const currentSchema = editorView.state.schema;
|
|
||||||
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
|
|
||||||
|
|
||||||
insertNodeIntoEditor(node, from, undefined);
|
insertNodeIntoEditor(node, from, undefined);
|
||||||
}
|
}
|
||||||
@@ -659,7 +596,6 @@ function createEditorView() {
|
|||||||
if (tx.docChanged) {
|
if (tx.docChanged) {
|
||||||
emitOnChange();
|
emitOnChange();
|
||||||
}
|
}
|
||||||
checkSelection(state);
|
|
||||||
},
|
},
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keyup: () => {
|
keyup: () => {
|
||||||
@@ -825,33 +761,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||||
|
|
||||||
.ProseMirror-menubar-wrapper {
|
.ProseMirror-menubar-wrapper {
|
||||||
@apply flex flex-col gap-3;
|
@apply flex flex-col;
|
||||||
|
|
||||||
.ProseMirror-menubar {
|
.ProseMirror-menubar {
|
||||||
min-height: 1.25rem !important;
|
min-height: 1.25rem !important;
|
||||||
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
|
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
|
||||||
|
|
||||||
.ProseMirror-menu-active {
|
.ProseMirror-menu-active {
|
||||||
@apply bg-n-slate-5 dark:bg-n-solid-3 !important;
|
@apply bg-n-slate-5 dark:bg-n-solid-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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-menubar:not(:has(*)) {
|
|
||||||
max-height: none !important;
|
|
||||||
min-height: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
> .ProseMirror {
|
> .ProseMirror {
|
||||||
@@ -942,53 +860,4 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
.editor-warning__message {
|
.editor-warning__message {
|
||||||
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
@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-menu-active {
|
|
||||||
@apply bg-n-slate-3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
showEditorToggle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
isOnPrivateNote: {
|
isOnPrivateNote: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -126,6 +130,7 @@ export default {
|
|||||||
emits: [
|
emits: [
|
||||||
'replaceText',
|
'replaceText',
|
||||||
'toggleInsertArticle',
|
'toggleInsertArticle',
|
||||||
|
'toggleEditor',
|
||||||
'selectWhatsappTemplate',
|
'selectWhatsappTemplate',
|
||||||
'selectContentTemplate',
|
'selectContentTemplate',
|
||||||
'toggleQuotedReply',
|
'toggleQuotedReply',
|
||||||
@@ -320,8 +325,18 @@ export default {
|
|||||||
sm
|
sm
|
||||||
@click="toggleAudioRecorder"
|
@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
|
<NextButton
|
||||||
v-if="showAudioPlayStopButton"
|
v-if="showAudioPlayStopButton"
|
||||||
|
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||||
:icon="audioRecorderPlayStopIcon"
|
:icon="audioRecorderPlayStopIcon"
|
||||||
slate
|
slate
|
||||||
faded
|
faded
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||||
|
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import MessagePreview from './MessagePreview.vue';
|
import MessagePreview from './MessagePreview.vue';
|
||||||
@@ -13,7 +14,6 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
|
|||||||
import PriorityMark from './PriorityMark.vue';
|
import PriorityMark from './PriorityMark.vue';
|
||||||
import SLACardLabel from './components/SLACardLabel.vue';
|
import SLACardLabel from './components/SLACardLabel.vue';
|
||||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
activeLabel: { type: String, default: '' },
|
activeLabel: { type: String, default: '' },
|
||||||
@@ -83,10 +83,15 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
|||||||
|
|
||||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||||
|
|
||||||
const voiceCallData = computed(() => ({
|
const callStatus = computed(
|
||||||
status: props.chat.additional_attributes?.call_status,
|
() => props.chat.additional_attributes?.call_status
|
||||||
direction: props.chat.additional_attributes?.call_direction,
|
);
|
||||||
}));
|
const callDirection = computed(
|
||||||
|
() => props.chat.additional_attributes?.call_direction
|
||||||
|
);
|
||||||
|
|
||||||
|
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
|
||||||
|
useVoiceCallStatus(callStatus, callDirection);
|
||||||
|
|
||||||
const inboxId = computed(() => props.chat.inbox_id);
|
const inboxId = computed(() => props.chat.inbox_id);
|
||||||
|
|
||||||
@@ -312,13 +317,20 @@ const deleteConversation = () => {
|
|||||||
>
|
>
|
||||||
{{ currentContact.name }}
|
{{ currentContact.name }}
|
||||||
</h4>
|
</h4>
|
||||||
<VoiceCallStatus
|
<div
|
||||||
v-if="voiceCallData.status"
|
v-if="callStatus"
|
||||||
key="voice-status-row"
|
key="voice-status-row"
|
||||||
:status="voiceCallData.status"
|
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
:direction="voiceCallData.direction"
|
:class="messagePreviewClass"
|
||||||
:message-preview-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>
|
||||||
<MessagePreview
|
<MessagePreview
|
||||||
v-else-if="lastMessageInChat"
|
v-else-if="lastMessageInChat"
|
||||||
key="message-preview"
|
key="message-preview"
|
||||||
|
|||||||
@@ -205,9 +205,6 @@ export default {
|
|||||||
if (this.isAWhatsAppCloudChannel) {
|
if (this.isAWhatsAppCloudChannel) {
|
||||||
return REPLY_POLICY.WHATSAPP_CLOUD;
|
return REPLY_POLICY.WHATSAPP_CLOUD;
|
||||||
}
|
}
|
||||||
if (this.isATiktokChannel) {
|
|
||||||
return REPLY_POLICY.TIKTOK;
|
|
||||||
}
|
|
||||||
if (!this.isAPIInbox) {
|
if (!this.isAPIInbox) {
|
||||||
return REPLY_POLICY.TWILIO_WHATSAPP;
|
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||||
}
|
}
|
||||||
@@ -221,9 +218,6 @@ export default {
|
|||||||
) {
|
) {
|
||||||
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
||||||
}
|
}
|
||||||
if (this.isATiktokChannel) {
|
|
||||||
return this.$t('CONVERSATION.48_HOURS_WINDOW');
|
|
||||||
}
|
|
||||||
if (!this.isAPIInbox) {
|
if (!this.isAPIInbox) {
|
||||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
|
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
|
||||||
}
|
}
|
||||||
@@ -264,6 +258,8 @@ export default {
|
|||||||
|
|
||||||
created() {
|
created() {
|
||||||
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
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,
|
// 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
|
// until the chat is changed and the flag is reset in the watch for currentChat
|
||||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
||||||
@@ -295,10 +291,6 @@ export default {
|
|||||||
return;
|
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
|
// method available in mixin, need to ensure that integrations are present
|
||||||
await this.fetchIntegrationsIfRequired();
|
await this.fetchIntegrationsIfRequired();
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
|||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
getEffectiveChannelType,
|
replaceSignature,
|
||||||
extractTextFromMarkdown,
|
extractTextFromMarkdown,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@ export default {
|
|||||||
ArticleSearchPopover,
|
ArticleSearchPopover,
|
||||||
AttachmentPreview,
|
AttachmentPreview,
|
||||||
AudioRecorder,
|
AudioRecorder,
|
||||||
|
CannedResponse,
|
||||||
ReplyBoxBanner,
|
ReplyBoxBanner,
|
||||||
EmojiInput,
|
EmojiInput,
|
||||||
MessageSignatureMissingAlert,
|
MessageSignatureMissingAlert,
|
||||||
@@ -68,12 +69,11 @@ export default {
|
|||||||
ReplyEmailHead,
|
ReplyEmailHead,
|
||||||
ReplyToMessage,
|
ReplyToMessage,
|
||||||
ReplyTopPanel,
|
ReplyTopPanel,
|
||||||
|
ResizableTextArea,
|
||||||
ContentTemplates,
|
ContentTemplates,
|
||||||
WhatsappTemplates,
|
WhatsappTemplates,
|
||||||
WootMessageEditor,
|
WootMessageEditor,
|
||||||
QuotedEmailPreview,
|
QuotedEmailPreview,
|
||||||
ResizableTextArea,
|
|
||||||
CannedResponse,
|
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||||
props: {
|
props: {
|
||||||
@@ -86,6 +86,7 @@ export default {
|
|||||||
setup() {
|
setup() {
|
||||||
const {
|
const {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
|
updateUISettings,
|
||||||
isEditorHotKeyEnabled,
|
isEditorHotKeyEnabled,
|
||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
setQuotedReplyFlagForInbox,
|
setQuotedReplyFlagForInbox,
|
||||||
@@ -96,6 +97,7 @@ export default {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
|
updateUISettings,
|
||||||
isEditorHotKeyEnabled,
|
isEditorHotKeyEnabled,
|
||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
setQuotedReplyFlagForInbox,
|
setQuotedReplyFlagForInbox,
|
||||||
@@ -113,6 +115,7 @@ export default {
|
|||||||
isRecordingAudio: false,
|
isRecordingAudio: false,
|
||||||
recordingAudioState: '',
|
recordingAudioState: '',
|
||||||
recordingAudioDurationText: '',
|
recordingAudioDurationText: '',
|
||||||
|
isUploading: false,
|
||||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||||
mentionSearchKey: '',
|
mentionSearchKey: '',
|
||||||
hasSlashCommand: false,
|
hasSlashCommand: false,
|
||||||
@@ -144,12 +147,9 @@ export default {
|
|||||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||||
}),
|
}),
|
||||||
currentContact() {
|
currentContact() {
|
||||||
const senderId = this.currentChat?.meta?.sender?.id;
|
return this.$store.getters['contacts/getContact'](
|
||||||
if (!senderId) return {};
|
this.currentChat.meta.sender.id
|
||||||
return this.$store.getters['contacts/getContact'](senderId);
|
);
|
||||||
},
|
|
||||||
isRichEditorEnabled() {
|
|
||||||
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
|
|
||||||
},
|
},
|
||||||
shouldShowReplyToMessage() {
|
shouldShowReplyToMessage() {
|
||||||
return (
|
return (
|
||||||
@@ -159,32 +159,34 @@ export default {
|
|||||||
!this.is360DialogWhatsAppChannel
|
!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() {
|
showWhatsappTemplates() {
|
||||||
// We support templates for API channels if someone updates templates manually via API
|
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||||
// 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() {
|
showContentTemplates() {
|
||||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||||
},
|
},
|
||||||
isPrivate() {
|
isPrivate() {
|
||||||
if (
|
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||||
this.currentChat.can_reply ||
|
|
||||||
this.isAWhatsAppChannel ||
|
|
||||||
this.isAPIInbox
|
|
||||||
) {
|
|
||||||
return this.isOnPrivateNote;
|
return this.isOnPrivateNote;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
isReplyRestricted() {
|
isReplyRestricted() {
|
||||||
return (
|
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
|
||||||
!this.currentChat?.can_reply &&
|
|
||||||
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
inboxId() {
|
inboxId() {
|
||||||
return this.currentChat.inbox_id;
|
return this.currentChat.inbox_id;
|
||||||
@@ -234,9 +236,6 @@ export default {
|
|||||||
if (this.isAnInstagramChannel) {
|
if (this.isAnInstagramChannel) {
|
||||||
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||||
}
|
}
|
||||||
if (this.isATiktokChannel) {
|
|
||||||
return MESSAGE_MAX_LENGTH.TIKTOK;
|
|
||||||
}
|
|
||||||
if (this.isATwilioWhatsAppChannel) {
|
if (this.isATwilioWhatsAppChannel) {
|
||||||
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
||||||
}
|
}
|
||||||
@@ -289,6 +288,9 @@ export default {
|
|||||||
hasAttachments() {
|
hasAttachments() {
|
||||||
return this.attachedFiles.length;
|
return this.attachedFiles.length;
|
||||||
},
|
},
|
||||||
|
isRichEditorEnabled() {
|
||||||
|
return this.isAWebWidgetInbox || this.isAnEmailChannel;
|
||||||
|
},
|
||||||
showAudioRecorder() {
|
showAudioRecorder() {
|
||||||
return !this.isOnPrivateNote && this.showFileUpload;
|
return !this.isOnPrivateNote && this.showFileUpload;
|
||||||
},
|
},
|
||||||
@@ -328,11 +330,21 @@ export default {
|
|||||||
return !this.isPrivate && this.sendWithSignature;
|
return !this.isPrivate && this.sendWithSignature;
|
||||||
},
|
},
|
||||||
isSignatureAvailable() {
|
isSignatureAvailable() {
|
||||||
return !!this.messageSignature;
|
return !!this.signatureToApply;
|
||||||
},
|
},
|
||||||
sendWithSignature() {
|
sendWithSignature() {
|
||||||
return this.fetchSignatureFlagFromUISettings(this.channelType);
|
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() {
|
conversationId() {
|
||||||
return this.currentChat.id;
|
return this.currentChat.id;
|
||||||
},
|
},
|
||||||
@@ -359,6 +371,12 @@ export default {
|
|||||||
});
|
});
|
||||||
return variables;
|
return variables;
|
||||||
},
|
},
|
||||||
|
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||||
|
signatureToApply() {
|
||||||
|
return this.showRichContentEditor
|
||||||
|
? this.messageSignature
|
||||||
|
: extractTextFromMarkdown(this.messageSignature);
|
||||||
|
},
|
||||||
connectedPortalSlug() {
|
connectedPortalSlug() {
|
||||||
const { help_center: portal = {} } = this.inbox;
|
const { help_center: portal = {} } = this.inbox;
|
||||||
const { slug = '' } = portal;
|
const { slug = '' } = portal;
|
||||||
@@ -409,19 +427,6 @@ export default {
|
|||||||
!!this.quotedEmailText
|
!!this.quotedEmailText
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
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: {
|
watch: {
|
||||||
currentChat(conversation, oldConversation) {
|
currentChat(conversation, oldConversation) {
|
||||||
@@ -437,7 +442,7 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
|
if (canReply || this.isAWhatsAppChannel) {
|
||||||
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
||||||
} else {
|
} else {
|
||||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||||
@@ -495,7 +500,7 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
this.getFromDraft();
|
this.getFromDraft();
|
||||||
// Don't use the keyboard listener mixin here as the events here are supposed to be
|
// Don't use the keyboard listener mixin here as the events here are supposed to be
|
||||||
// working even if the editor is focussed.
|
// working even if input/textarea is focussed.
|
||||||
document.addEventListener('paste', this.onPaste);
|
document.addEventListener('paste', this.onPaste);
|
||||||
document.addEventListener('keydown', this.handleKeyEvents);
|
document.addEventListener('keydown', this.handleKeyEvents);
|
||||||
this.setCCAndToEmailsFromLastChat();
|
this.setCCAndToEmailsFromLastChat();
|
||||||
@@ -549,6 +554,28 @@ export default {
|
|||||||
|
|
||||||
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
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() {
|
toggleQuotedReply() {
|
||||||
if (!this.isAnEmailChannel) {
|
if (!this.isAnEmailChannel) {
|
||||||
return;
|
return;
|
||||||
@@ -614,23 +641,7 @@ export default {
|
|||||||
if (this.isPrivate) {
|
if (this.isPrivate) {
|
||||||
return message;
|
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
|
return this.sendWithSignature
|
||||||
? appendSignature(message, this.signatureToApply)
|
? appendSignature(message, this.signatureToApply)
|
||||||
: removeSignature(message, this.signatureToApply);
|
: removeSignature(message, this.signatureToApply);
|
||||||
@@ -691,13 +702,9 @@ export default {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onPaste(e) {
|
onPaste(e) {
|
||||||
// Don't handle paste if compose new conversation modal is open
|
|
||||||
if (this.newConversationModalActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = e.clipboardData.files;
|
const data = e.clipboardData.files;
|
||||||
if (!this.showRichContentEditor && data.length !== 0) {
|
if (!this.showRichContentEditor && data.length !== 0) {
|
||||||
this.$refs.messageInput?.$el?.blur();
|
this.$refs.messageInput.$el.blur();
|
||||||
}
|
}
|
||||||
if (!data.length || !data[0]) {
|
if (!data.length || !data[0]) {
|
||||||
return;
|
return;
|
||||||
@@ -832,19 +839,7 @@ export default {
|
|||||||
// if signature is enabled, append it to the message
|
// if signature is enabled, append it to the message
|
||||||
// appendSignature ensures that the signature is not duplicated
|
// appendSignature ensures that the signature is not duplicated
|
||||||
// so we don't need to check if the signature is already present
|
// so we don't need to check if the signature is already present
|
||||||
if (this.showRichContentEditor) {
|
message = appendSignature(message, this.signatureToApply);
|
||||||
const effectiveChannelType = getEffectiveChannelType(
|
|
||||||
this.channelType,
|
|
||||||
this.inbox?.medium || ''
|
|
||||||
);
|
|
||||||
message = appendSignature(
|
|
||||||
message,
|
|
||||||
this.messageSignature,
|
|
||||||
effectiveChannelType
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
message = appendSignature(message, this.signatureToApply);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedMessage = replaceVariablesInMessage({
|
const updatedMessage = replaceVariablesInMessage({
|
||||||
@@ -866,8 +861,7 @@ export default {
|
|||||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
|
||||||
this.replyType = mode;
|
|
||||||
if (this.showRichContentEditor) {
|
if (this.showRichContentEditor) {
|
||||||
if (this.isRecordingAudio) {
|
if (this.isRecordingAudio) {
|
||||||
this.toggleAudioRecorder();
|
this.toggleAudioRecorder();
|
||||||
@@ -901,19 +895,7 @@ export default {
|
|||||||
this.message = '';
|
this.message = '';
|
||||||
if (this.sendWithSignature && !this.isPrivate) {
|
if (this.sendWithSignature && !this.isPrivate) {
|
||||||
// if signature is enabled, append it to the message
|
// if signature is enabled, append it to the message
|
||||||
if (this.showRichContentEditor) {
|
this.message = appendSignature(this.message, this.signatureToApply);
|
||||||
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.attachedFiles = [];
|
||||||
this.isRecordingAudio = false;
|
this.isRecordingAudio = false;
|
||||||
@@ -931,15 +913,19 @@ export default {
|
|||||||
},
|
},
|
||||||
toggleAudioRecorder() {
|
toggleAudioRecorder() {
|
||||||
this.isRecordingAudio = !this.isRecordingAudio;
|
this.isRecordingAudio = !this.isRecordingAudio;
|
||||||
|
this.isRecorderAudioStopped = !this.isRecordingAudio;
|
||||||
if (!this.isRecordingAudio) {
|
if (!this.isRecordingAudio) {
|
||||||
this.resetAudioRecorderInput();
|
this.resetAudioRecorderInput();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleAudioRecorderPlayPause() {
|
toggleAudioRecorderPlayPause() {
|
||||||
if (!this.$refs.audioRecorderInput) return;
|
if (!this.isRecordingAudio) {
|
||||||
if (!this.recordingAudioState) {
|
return;
|
||||||
|
}
|
||||||
|
if (!this.isRecorderAudioStopped) {
|
||||||
|
this.isRecorderAudioStopped = true;
|
||||||
this.$refs.audioRecorderInput.stopRecording();
|
this.$refs.audioRecorderInput.stopRecording();
|
||||||
} else {
|
} else if (this.isRecorderAudioStopped) {
|
||||||
this.$refs.audioRecorderInput.playPause();
|
this.$refs.audioRecorderInput.playPause();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1246,17 +1232,16 @@ export default {
|
|||||||
v-else
|
v-else
|
||||||
v-model="message"
|
v-model="message"
|
||||||
:editor-id="editorStateId"
|
:editor-id="editorStateId"
|
||||||
class="input popover-prosemirror-menu"
|
class="input"
|
||||||
:is-private="isOnPrivateNote"
|
:is-private="isOnPrivateNote"
|
||||||
:placeholder="messagePlaceHolder"
|
:placeholder="messagePlaceHolder"
|
||||||
:update-selection-with="updateEditorSelectionWith"
|
:update-selection-with="updateEditorSelectionWith"
|
||||||
:min-height="4"
|
:min-height="4"
|
||||||
enable-variables
|
enable-variables
|
||||||
:variables="messageVariables"
|
:variables="messageVariables"
|
||||||
:signature="messageSignature"
|
:signature="signatureToApply"
|
||||||
allow-signature
|
allow-signature
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
:medium="inbox.medium"
|
|
||||||
@typing-off="onTypingOff"
|
@typing-off="onTypingOff"
|
||||||
@typing-on="onTypingOn"
|
@typing-on="onTypingOn"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@@ -1304,6 +1289,7 @@ export default {
|
|||||||
:recording-audio-state="recordingAudioState"
|
:recording-audio-state="recordingAudioState"
|
||||||
:send-button-text="replyButtonLabel"
|
:send-button-text="replyButtonLabel"
|
||||||
:show-audio-recorder="showAudioRecorder"
|
:show-audio-recorder="showAudioRecorder"
|
||||||
|
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
|
||||||
:show-emoji-picker="showEmojiPicker"
|
:show-emoji-picker="showEmojiPicker"
|
||||||
:show-file-upload="showFileUpload"
|
:show-file-upload="showFileUpload"
|
||||||
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
||||||
@@ -1316,6 +1302,7 @@ export default {
|
|||||||
:new-conversation-modal-active="newConversationModalActive"
|
:new-conversation-modal-active="newConversationModalActive"
|
||||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||||
@select-content-template="openContentTemplateModal"
|
@select-content-template="openContentTemplateModal"
|
||||||
|
@toggle-editor="toggleRichContentEditor"
|
||||||
@replace-text="replaceText"
|
@replace-text="replaceText"
|
||||||
@toggle-insert-article="toggleInsertArticle"
|
@toggle-insert-article="toggleInsertArticle"
|
||||||
@toggle-quoted-reply="toggleQuotedReply"
|
@toggle-quoted-reply="toggleQuotedReply"
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -48,7 +48,6 @@ const mockStore = createStore({
|
|||||||
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
||||||
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
||||||
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
|
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
|
||||||
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
|
|
||||||
};
|
};
|
||||||
return inboxes[id] || null;
|
return inboxes[id] || null;
|
||||||
},
|
},
|
||||||
@@ -216,12 +215,6 @@ describe('useInbox', () => {
|
|||||||
global: { plugins: [mockStore] },
|
global: { plugins: [mockStore] },
|
||||||
});
|
});
|
||||||
expect(wrapper.vm.isAVoiceChannel).toBe(true);
|
expect(wrapper.vm.isAVoiceChannel).toBe(true);
|
||||||
|
|
||||||
// Test Tiktok
|
|
||||||
wrapper = mount(createTestComponent(15), {
|
|
||||||
global: { plugins: [mockStore] },
|
|
||||||
});
|
|
||||||
expect(wrapper.vm.isATiktokChannel).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,7 +266,6 @@ describe('useInbox', () => {
|
|||||||
'is360DialogWhatsAppChannel',
|
'is360DialogWhatsAppChannel',
|
||||||
'isAnEmailChannel',
|
'isAnEmailChannel',
|
||||||
'isAnInstagramChannel',
|
'isAnInstagramChannel',
|
||||||
'isATiktokChannel',
|
|
||||||
'isAVoiceChannel',
|
'isAVoiceChannel',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
import { useStore } from 'dashboard/composables/store.js';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
@@ -9,7 +9,6 @@ export function useCaptain() {
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
||||||
const { isEnterprise } = useConfig();
|
const { isEnterprise } = useConfig();
|
||||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
|
||||||
|
|
||||||
const captainEnabled = computed(() => {
|
const captainEnabled = computed(() => {
|
||||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
||||||
@@ -35,8 +34,6 @@ export function useCaptain() {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
|
|
||||||
|
|
||||||
const fetchLimits = () => {
|
const fetchLimits = () => {
|
||||||
if (isEnterprise) {
|
if (isEnterprise) {
|
||||||
store.dispatch('accounts/limits');
|
store.dispatch('accounts/limits');
|
||||||
@@ -49,6 +46,5 @@ export function useCaptain() {
|
|||||||
documentLimits,
|
documentLimits,
|
||||||
responseLimits,
|
responseLimits,
|
||||||
fetchLimits,
|
fetchLimits,
|
||||||
isFetchingLimits,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export const INBOX_FEATURE_MAP = {
|
|||||||
INBOX_TYPES.TWITTER,
|
INBOX_TYPES.TWITTER,
|
||||||
INBOX_TYPES.WHATSAPP,
|
INBOX_TYPES.WHATSAPP,
|
||||||
INBOX_TYPES.TELEGRAM,
|
INBOX_TYPES.TELEGRAM,
|
||||||
INBOX_TYPES.TIKTOK,
|
|
||||||
INBOX_TYPES.API,
|
INBOX_TYPES.API,
|
||||||
],
|
],
|
||||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||||
@@ -25,7 +24,6 @@ export const INBOX_FEATURE_MAP = {
|
|||||||
INBOX_TYPES.TWITTER,
|
INBOX_TYPES.TWITTER,
|
||||||
INBOX_TYPES.WHATSAPP,
|
INBOX_TYPES.WHATSAPP,
|
||||||
INBOX_TYPES.TELEGRAM,
|
INBOX_TYPES.TELEGRAM,
|
||||||
INBOX_TYPES.TIKTOK,
|
|
||||||
INBOX_TYPES.API,
|
INBOX_TYPES.API,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -130,10 +128,6 @@ export const useInbox = (inboxId = null) => {
|
|||||||
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isATiktokChannel = computed(() => {
|
|
||||||
return channelType.value === INBOX_TYPES.TIKTOK;
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAVoiceChannel = computed(() => {
|
const isAVoiceChannel = computed(() => {
|
||||||
return channelType.value === INBOX_TYPES.VOICE;
|
return channelType.value === INBOX_TYPES.VOICE;
|
||||||
});
|
});
|
||||||
@@ -155,7 +149,6 @@ export const useInbox = (inboxId = null) => {
|
|||||||
is360DialogWhatsAppChannel,
|
is360DialogWhatsAppChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAnInstagramChannel,
|
isAnInstagramChannel,
|
||||||
isATiktokChannel,
|
|
||||||
isAVoiceChannel,
|
isAVoiceChannel,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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,148 +1,23 @@
|
|||||||
// Formatting rules for different contexts (channels and special contexts)
|
export const MESSAGE_EDITOR_MENU_OPTIONS = [
|
||||||
// marks: inline formatting (strong, em, code, link, strike)
|
'strong',
|
||||||
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
|
'em',
|
||||||
export const FORMATTING = {
|
'link',
|
||||||
// Channel formatting
|
'undo',
|
||||||
'Channel::Email': {
|
'redo',
|
||||||
marks: ['strong', 'em', 'code', 'link'],
|
'bulletList',
|
||||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
'orderedList',
|
||||||
menu: [
|
'code',
|
||||||
'strong',
|
];
|
||||||
'em',
|
|
||||||
'code',
|
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
|
||||||
'link',
|
'strong',
|
||||||
'bulletList',
|
'em',
|
||||||
'orderedList',
|
'link',
|
||||||
'undo',
|
'undo',
|
||||||
'redo',
|
'redo',
|
||||||
],
|
'imageUpload',
|
||||||
},
|
];
|
||||||
'Channel::WebWidget': {
|
|
||||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
|
||||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
|
||||||
menu: [
|
|
||||||
'strong',
|
|
||||||
'em',
|
|
||||||
'code',
|
|
||||||
'link',
|
|
||||||
'strike',
|
|
||||||
'bulletList',
|
|
||||||
'orderedList',
|
|
||||||
'undo',
|
|
||||||
'redo',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Channel::Api': {
|
|
||||||
marks: ['strong', 'em'],
|
|
||||||
nodes: [],
|
|
||||||
menu: ['strong', 'em', 'undo', 'redo'],
|
|
||||||
},
|
|
||||||
'Channel::FacebookPage': {
|
|
||||||
marks: ['strong', 'em', 'code', 'strike'],
|
|
||||||
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
|
||||||
menu: [
|
|
||||||
'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: [
|
|
||||||
'strong',
|
|
||||||
'em',
|
|
||||||
'code',
|
|
||||||
'strike',
|
|
||||||
'bulletList',
|
|
||||||
'orderedList',
|
|
||||||
'undo',
|
|
||||||
'redo',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Channel::Line': {
|
|
||||||
marks: ['strong', 'em', 'code', 'strike'],
|
|
||||||
nodes: ['codeBlock'],
|
|
||||||
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
|
|
||||||
},
|
|
||||||
'Channel::Telegram': {
|
|
||||||
marks: ['strong', 'em', 'link', 'code'],
|
|
||||||
nodes: [],
|
|
||||||
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
|
|
||||||
},
|
|
||||||
'Channel::Instagram': {
|
|
||||||
marks: ['strong', 'em', 'code', 'strike'],
|
|
||||||
nodes: ['bulletList', 'orderedList'],
|
|
||||||
menu: [
|
|
||||||
'strong',
|
|
||||||
'em',
|
|
||||||
'code',
|
|
||||||
'bulletList',
|
|
||||||
'orderedList',
|
|
||||||
'strike',
|
|
||||||
'undo',
|
|
||||||
'redo',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Channel::Voice': {
|
|
||||||
marks: [],
|
|
||||||
nodes: [],
|
|
||||||
menu: [],
|
|
||||||
},
|
|
||||||
'Channel::Tiktok': {
|
|
||||||
marks: [],
|
|
||||||
nodes: [],
|
|
||||||
menu: [],
|
|
||||||
},
|
|
||||||
// Special contexts (not actual channels)
|
|
||||||
'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 = [
|
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||||
'strong',
|
'strong',
|
||||||
'em',
|
'em',
|
||||||
@@ -158,86 +33,14 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
|||||||
'code',
|
'code',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
|
||||||
* Markdown formatting patterns for stripping unsupported formatting.
|
'strong',
|
||||||
*
|
'em',
|
||||||
* Maps camelCase type names to ProseMirror snake_case schema names.
|
'link',
|
||||||
* Order matters: codeBlock before code to avoid partial matches.
|
'undo',
|
||||||
*/
|
'redo',
|
||||||
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 = [
|
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||||
{
|
{
|
||||||
name: 'Small',
|
name: 'Small',
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export const FEATURE_FLAGS = {
|
|||||||
CHATWOOT_V4: 'chatwoot_v4',
|
CHATWOOT_V4: 'chatwoot_v4',
|
||||||
REPORT_V4: 'report_v4',
|
REPORT_V4: 'report_v4',
|
||||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
|
||||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||||
CAPTAIN_V2: 'captain_integration_v2',
|
CAPTAIN_V2: 'captain_integration_v2',
|
||||||
SAML: 'saml',
|
SAML: 'saml',
|
||||||
|
|||||||
@@ -131,9 +131,3 @@ export const LINEAR_EVENTS = Object.freeze({
|
|||||||
LINK_ISSUE: 'Linked a linear issue',
|
LINK_ISSUE: 'Linked a linear issue',
|
||||||
UNLINK_ISSUE: 'Unlinked a linear issue',
|
UNLINK_ISSUE: 'Unlinked a linear issue',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
|
||||||
MODAL_OPENED: 'Year in Review: Modal opened',
|
|
||||||
NEXT_CLICKED: 'Year in Review: Next clicked',
|
|
||||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,82 +5,6 @@ import {
|
|||||||
} from '@chatwoot/prosemirror-schema';
|
} from '@chatwoot/prosemirror-schema';
|
||||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||||
import * as Sentry from '@sentry/vue';
|
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.
|
* The delimiter used to separate the signature from the rest of the body.
|
||||||
@@ -143,39 +67,15 @@ export function findSignatureInBody(body, signature) {
|
|||||||
return -1;
|
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.
|
* 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} body - The body to append the signature to.
|
||||||
* @param {string} signature - The signature to append.
|
* @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.
|
* @returns {string} - The body with the signature appended.
|
||||||
*/
|
*/
|
||||||
export function appendSignature(body, signature, channelType) {
|
export function appendSignature(body, signature) {
|
||||||
// Strip only unsupported formatting based on channel capabilities
|
const cleanedSignature = cleanSignature(signature);
|
||||||
const preparedSignature = channelType
|
|
||||||
? stripUnsupportedSignatureMarkdown(signature, channelType)
|
|
||||||
: signature;
|
|
||||||
const cleanedSignature = cleanSignature(preparedSignature);
|
|
||||||
// if signature is already present, return body
|
// if signature is already present, return body
|
||||||
if (findSignatureInBody(body, cleanedSignature) > -1) {
|
if (findSignatureInBody(body, cleanedSignature) > -1) {
|
||||||
return body;
|
return body;
|
||||||
@@ -186,34 +86,16 @@ export function appendSignature(body, signature, channelType) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the signature from the body, along with the signature delimiter.
|
* 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} body - The body to remove the signature from.
|
||||||
* @param {string} signature - The signature to remove.
|
* @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.
|
* @returns {string} - The body with the signature removed.
|
||||||
*/
|
*/
|
||||||
export function removeSignature(body, signature, channelType) {
|
export function removeSignature(body, signature) {
|
||||||
// Build list of signatures to try: original, channel-stripped, and fully stripped
|
// 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
|
||||||
const cleanedSignature = cleanSignature(signature);
|
const cleanedSignature = cleanSignature(signature);
|
||||||
const channelStripped = channelType
|
const signatureIndex = findSignatureInBody(body, cleanedSignature);
|
||||||
? 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
|
// no need to trim the ends here, because it will simply be removed in the next method
|
||||||
let newBody = body;
|
let newBody = body;
|
||||||
@@ -254,6 +136,28 @@ export function replaceSignature(body, oldSignature, newSignature) {
|
|||||||
return appendSignature(withoutSignature, 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
|
* Scrolls the editor view into current cursor position
|
||||||
*
|
*
|
||||||
@@ -379,47 +283,6 @@ 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
|
* Content Node Creation Helper Functions for
|
||||||
* - mention
|
* - mention
|
||||||
@@ -450,17 +313,8 @@ const createNode = (editorView, nodeType, content) => {
|
|||||||
|
|
||||||
return mentionNode;
|
return mentionNode;
|
||||||
}
|
}
|
||||||
case 'cannedResponse': {
|
case 'cannedResponse':
|
||||||
// Strip unsupported formatting before parsing to ensure content can be inserted
|
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||||
// 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':
|
case 'variable':
|
||||||
return state.schema.text(`{{${content}}}`);
|
return state.schema.text(`{{${content}}}`);
|
||||||
case 'emoji':
|
case 'emoji':
|
||||||
@@ -535,85 +389,3 @@ export const getContentNode = (
|
|||||||
? creator(editorView, content, from, to, variables)
|
? creator(editorView, content, from, to, variables)
|
||||||
: { node: null, from, to };
|
: { 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 */
|
|
||||||
|
|||||||
@@ -10,15 +10,9 @@ export const INBOX_TYPES = {
|
|||||||
LINE: 'Channel::Line',
|
LINE: 'Channel::Line',
|
||||||
SMS: 'Channel::Sms',
|
SMS: 'Channel::Sms',
|
||||||
INSTAGRAM: 'Channel::Instagram',
|
INSTAGRAM: 'Channel::Instagram',
|
||||||
TIKTOK: 'Channel::Tiktok',
|
|
||||||
VOICE: 'Channel::Voice',
|
VOICE: 'Channel::Voice',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TWILIO_CHANNEL_MEDIUM = {
|
|
||||||
WHATSAPP: 'whatsapp',
|
|
||||||
SMS: 'sms',
|
|
||||||
};
|
|
||||||
|
|
||||||
const INBOX_ICON_MAP_FILL = {
|
const INBOX_ICON_MAP_FILL = {
|
||||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||||
@@ -29,7 +23,6 @@ const INBOX_ICON_MAP_FILL = {
|
|||||||
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
|
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
|
||||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
|
|
||||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,7 +38,6 @@ const INBOX_ICON_MAP_LINE = {
|
|||||||
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
|
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
|
||||||
[INBOX_TYPES.LINE]: 'i-ri-line-line',
|
[INBOX_TYPES.LINE]: 'i-ri-line-line',
|
||||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
|
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
|
||||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-line',
|
|
||||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
|
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,9 +131,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
|||||||
case INBOX_TYPES.INSTAGRAM:
|
case INBOX_TYPES.INSTAGRAM:
|
||||||
return 'brand-instagram';
|
return 'brand-instagram';
|
||||||
|
|
||||||
case INBOX_TYPES.TIKTOK:
|
|
||||||
return 'brand-tiktok';
|
|
||||||
|
|
||||||
case INBOX_TYPES.VOICE:
|
case INBOX_TYPES.VOICE:
|
||||||
return 'phone';
|
return 'phone';
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
|
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
|
||||||
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
|
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
|
||||||
import { getContentNode } from '../editorHelper';
|
import { getContentNode } from '../editorHelper';
|
||||||
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
|
import {
|
||||||
|
MessageMarkdownTransformer,
|
||||||
|
messageSchema,
|
||||||
|
} from '@chatwoot/prosemirror-schema';
|
||||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||||
|
|
||||||
vi.mock('@chatwoot/prosemirror-schema', () => ({
|
vi.mock('@chatwoot/prosemirror-schema', () => ({
|
||||||
MessageMarkdownTransformer: vi.fn(),
|
MessageMarkdownTransformer: vi.fn(),
|
||||||
|
messageSchema: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@chatwoot/utils', () => ({
|
vi.mock('@chatwoot/utils', () => ({
|
||||||
@@ -58,18 +62,12 @@ describe('getContentNode', () => {
|
|||||||
const to = 10;
|
const to = 10;
|
||||||
const updatedMessage = 'Hello John';
|
const updatedMessage = 'Hello John';
|
||||||
|
|
||||||
// Mock the node that will be returned by parse
|
|
||||||
const mockNode = { textContent: updatedMessage };
|
|
||||||
|
|
||||||
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
||||||
|
MessageMarkdownTransformer.mockImplementation(() => ({
|
||||||
|
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock MessageMarkdownTransformer instance with parse method
|
const { node } = getContentNode(
|
||||||
const mockTransformer = {
|
|
||||||
parse: vi.fn().mockReturnValue(mockNode),
|
|
||||||
};
|
|
||||||
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
|
|
||||||
|
|
||||||
const result = getContentNode(
|
|
||||||
editorView,
|
editorView,
|
||||||
'cannedResponse',
|
'cannedResponse',
|
||||||
content,
|
content,
|
||||||
@@ -81,15 +79,8 @@ describe('getContentNode', () => {
|
|||||||
message: content,
|
message: content,
|
||||||
variables,
|
variables,
|
||||||
});
|
});
|
||||||
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
|
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
|
||||||
editorView.state.schema
|
expect(node.textContent).toBe(updatedMessage);
|
||||||
);
|
|
||||||
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,18 +5,11 @@ import {
|
|||||||
replaceSignature,
|
replaceSignature,
|
||||||
cleanSignature,
|
cleanSignature,
|
||||||
extractTextFromMarkdown,
|
extractTextFromMarkdown,
|
||||||
stripUnsupportedSignatureMarkdown,
|
|
||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
findNodeToInsertImage,
|
findNodeToInsertImage,
|
||||||
setURLWithQueryAndSize,
|
setURLWithQueryAndSize,
|
||||||
getContentNode,
|
getContentNode,
|
||||||
getFormattingForEditor,
|
|
||||||
getSelectionCoords,
|
|
||||||
getMenuAnchor,
|
|
||||||
calculateMenuPosition,
|
|
||||||
stripUnsupportedFormatting,
|
|
||||||
} from '../editorHelper';
|
} from '../editorHelper';
|
||||||
import { FORMATTING } from 'dashboard/constants/editor';
|
|
||||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||||
import { EditorView } from '@chatwoot/prosemirror-schema';
|
import { EditorView } from '@chatwoot/prosemirror-schema';
|
||||||
import { Schema } from 'prosemirror-model';
|
import { Schema } from 'prosemirror-model';
|
||||||
@@ -145,107 +138,6 @@ 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', () => {
|
describe('cleanSignature', () => {
|
||||||
it('removes any instance of horizontal rule', () => {
|
it('removes any instance of horizontal rule', () => {
|
||||||
const options = [
|
const options = [
|
||||||
@@ -304,37 +196,6 @@ 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', () => {
|
describe('replaceSignature', () => {
|
||||||
it('appends the new signature if not present', () => {
|
it('appends the new signature if not present', () => {
|
||||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||||
@@ -397,11 +258,15 @@ describe('insertAtCursor', () => {
|
|||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should insert text node at cursor position', () => {
|
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
|
||||||
|
const docNode = schema.node('doc', null, [
|
||||||
|
schema.node('paragraph', null, [schema.text('Hello')]),
|
||||||
|
]);
|
||||||
|
|
||||||
const editorState = createEditorState();
|
const editorState = createEditorState();
|
||||||
const editorView = new EditorView(document.body, { state: editorState });
|
const editorView = new EditorView(document.body, { state: editorState });
|
||||||
|
|
||||||
insertAtCursor(editorView, schema.text('Hello'), 0);
|
insertAtCursor(editorView, docNode, 0);
|
||||||
|
|
||||||
// Check if node was unwrapped and inserted correctly
|
// Check if node was unwrapped and inserted correctly
|
||||||
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
|
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
|
||||||
@@ -761,349 +626,3 @@ 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ describe('#Inbox Helpers', () => {
|
|||||||
it('should return correct class for Email', () => {
|
it('should return correct class for Email', () => {
|
||||||
expect(getInboxClassByType('Channel::Email')).toEqual('mail');
|
expect(getInboxClassByType('Channel::Email')).toEqual('mail');
|
||||||
});
|
});
|
||||||
it('should return correct class for TikTok', () => {
|
|
||||||
expect(getInboxClassByType(INBOX_TYPES.TIKTOK)).toEqual('brand-tiktok');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getInboxIconByType', () => {
|
describe('getInboxIconByType', () => {
|
||||||
@@ -83,10 +80,6 @@ describe('#Inbox Helpers', () => {
|
|||||||
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
|
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct icon for TikTok', () => {
|
|
||||||
expect(getInboxIconByType(INBOX_TYPES.TIKTOK)).toBe('i-ri-tiktok-fill');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns default icon for unknown type', () => {
|
it('returns default icon for unknown type', () => {
|
||||||
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
|
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
|
||||||
});
|
});
|
||||||
@@ -109,12 +102,6 @@ describe('#Inbox Helpers', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct line icon for TikTok', () => {
|
|
||||||
expect(getInboxIconByType(INBOX_TYPES.TIKTOK, null, 'line')).toBe(
|
|
||||||
'i-ri-tiktok-line'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct line icon for unknown type', () => {
|
it('returns correct line icon for unknown type', () => {
|
||||||
expect(getInboxIconByType('UNKNOWN_TYPE', null, 'line')).toBe(
|
expect(getInboxIconByType('UNKNOWN_TYPE', null, 'line')).toBe(
|
||||||
'i-ri-chat-1-line'
|
'i-ri-chat-1-line'
|
||||||
|
|||||||
@@ -129,10 +129,6 @@
|
|||||||
"ENABLE_REGEX": {
|
"ENABLE_REGEX": {
|
||||||
"LABEL": "Enable regex validation"
|
"LABEL": "Enable regex validation"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"BADGES": {
|
|
||||||
"PRE_CHAT": "Pre-chat",
|
|
||||||
"RESOLUTION": "Resolution"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,9 +102,6 @@
|
|||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"CONTENT": "Shared contact"
|
"CONTENT": "Shared contact"
|
||||||
},
|
|
||||||
"embed": {
|
|
||||||
"CONTENT": "Embedded content"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"CHAT_SORT_BY_FILTER": {
|
"CHAT_SORT_BY_FILTER": {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"COMPANIES": {
|
|
||||||
"HEADER": "Companies",
|
|
||||||
"SORT_BY": {
|
|
||||||
"LABEL": "Sort by",
|
|
||||||
"OPTIONS": {
|
|
||||||
"NAME": "Name",
|
|
||||||
"DOMAIN": "Domain",
|
|
||||||
"CREATED_AT": "Created at"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ORDER": {
|
|
||||||
"LABEL": "Order",
|
|
||||||
"OPTIONS": {
|
|
||||||
"ASCENDING": "Ascending",
|
|
||||||
"DESCENDING": "Descending"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"SEARCH_PLACEHOLDER": "Search companies...",
|
|
||||||
"LOADING": "Loading companies...",
|
|
||||||
"UNNAMED": "Unnamed Company",
|
|
||||||
"CONTACTS_COUNT": "{n} contact | {n} contacts",
|
|
||||||
"EMPTY_STATE": {
|
|
||||||
"TITLE": "No companies found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"COMPANIES_LAYOUT": {
|
|
||||||
"PAGINATION_FOOTER": {
|
|
||||||
"SHOWING": "Showing {startItem} – {endItem} of {totalItems} company | Showing {startItem} – {endItem} of {totalItems} companies"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,8 +18,7 @@
|
|||||||
"CREATED_AT_LABEL": "Created",
|
"CREATED_AT_LABEL": "Created",
|
||||||
"NEW_MESSAGE": "New message",
|
"NEW_MESSAGE": "New message",
|
||||||
"CALL": "ደውል",
|
"CALL": "ደውል",
|
||||||
"CALL_INITIATED": "Calling the contact…",
|
"CALL_UNDER_DEVELOPMENT": "መደወል በልማት ላይ ነው",
|
||||||
"CALL_FAILED": "Unable to start the call. Please try again.",
|
|
||||||
"VOICE_INBOX_PICKER": {
|
"VOICE_INBOX_PICKER": {
|
||||||
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
|
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
|
||||||
},
|
},
|
||||||
@@ -457,9 +456,6 @@
|
|||||||
"INSTAGRAM": {
|
"INSTAGRAM": {
|
||||||
"PLACEHOLDER": "Add Instagram"
|
"PLACEHOLDER": "Add Instagram"
|
||||||
},
|
},
|
||||||
"TIKTOK": {
|
|
||||||
"PLACEHOLDER": "Add TikTok"
|
|
||||||
},
|
|
||||||
"LINKEDIN": {
|
"LINKEDIN": {
|
||||||
"PLACEHOLDER": "Add LinkedIn"
|
"PLACEHOLDER": "Add LinkedIn"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||||
"CANNOT_REPLY": "You cannot reply due to",
|
"CANNOT_REPLY": "You cannot reply due to",
|
||||||
"24_HOURS_WINDOW": "24 hour message window restriction",
|
"24_HOURS_WINDOW": "24 hour message window restriction",
|
||||||
"48_HOURS_WINDOW": "48 hour message window restriction",
|
|
||||||
"API_HOURS_WINDOW": "ለዚህ ውይይት መመለስ በ{hours} ሰአታት ውስጥ ብቻ ይቻላል",
|
"API_HOURS_WINDOW": "ለዚህ ውይይት መመለስ በ{hours} ሰአታት ውስጥ ብቻ ይቻላል",
|
||||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||||
"ASSIGN_TO_ME": "Assign to me",
|
"ASSIGN_TO_ME": "Assign to me",
|
||||||
@@ -197,6 +196,7 @@
|
|||||||
"INSERT_READ_MORE": "Read more",
|
"INSERT_READ_MORE": "Read more",
|
||||||
"DISMISS_REPLY": "Dismiss reply",
|
"DISMISS_REPLY": "Dismiss reply",
|
||||||
"REPLYING_TO": "Replying to:",
|
"REPLYING_TO": "Replying to:",
|
||||||
|
"TIP_FORMAT_ICON": "Show rich text editor",
|
||||||
"TIP_EMOJI_ICON": "Show emoji selector",
|
"TIP_EMOJI_ICON": "Show emoji selector",
|
||||||
"TIP_ATTACH_ICON": "Attach files",
|
"TIP_ATTACH_ICON": "Attach files",
|
||||||
"TIP_AUDIORECORDER_ICON": "Record audio",
|
"TIP_AUDIORECORDER_ICON": "Record audio",
|
||||||
|
|||||||
@@ -57,13 +57,6 @@
|
|||||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||||
},
|
},
|
||||||
"TIKTOK": {
|
|
||||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
|
||||||
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
|
|
||||||
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
|
|
||||||
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
|
|
||||||
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
|
|
||||||
},
|
|
||||||
"TWITTER": {
|
"TWITTER": {
|
||||||
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
||||||
"ERROR_MESSAGE": "There was an error connecting to Twitter, please try again",
|
"ERROR_MESSAGE": "There was an error connecting to Twitter, please try again",
|
||||||
@@ -388,11 +381,7 @@
|
|||||||
"API": {
|
"API": {
|
||||||
"ERROR_MESSAGE": "We were not able to save the email channel"
|
"ERROR_MESSAGE": "We were not able to save the email channel"
|
||||||
},
|
},
|
||||||
"FINISH_MESSAGE": "Start forwarding your emails to the following email address.",
|
"FINISH_MESSAGE": "Start forwarding your emails to the following email address."
|
||||||
"FINISH_MESSAGE_NO_FORWARDING": "Your email inbox has been created successfully! You need to configure SMTP and IMAP credentials to send and receive emails. Without these settings, no emails will be processed.",
|
|
||||||
"FORWARDING_ADDRESS_LABEL": "Forward emails to this address:",
|
|
||||||
"CONFIGURE_SMTP_IMAP_LINK": "Click here",
|
|
||||||
"CONFIGURE_SMTP_IMAP_TEXT": " to configure IMAP and SMTP settings"
|
|
||||||
},
|
},
|
||||||
"LINE_CHANNEL": {
|
"LINE_CHANNEL": {
|
||||||
"TITLE": "LINE Channel",
|
"TITLE": "LINE Channel",
|
||||||
@@ -478,10 +467,6 @@
|
|||||||
"TITLE": "Instagram",
|
"TITLE": "Instagram",
|
||||||
"DESCRIPTION": "Connect your instagram account"
|
"DESCRIPTION": "Connect your instagram account"
|
||||||
},
|
},
|
||||||
"TIKTOK": {
|
|
||||||
"TITLE": "TikTok",
|
|
||||||
"DESCRIPTION": "Connect your TikTok account"
|
|
||||||
},
|
|
||||||
"VOICE": {
|
"VOICE": {
|
||||||
"TITLE": "Voice",
|
"TITLE": "Voice",
|
||||||
"DESCRIPTION": "Integrate with Twilio Voice"
|
"DESCRIPTION": "Integrate with Twilio Voice"
|
||||||
@@ -722,7 +707,6 @@
|
|||||||
"INBOX_IDENTIFIER_SUB_TEXT": "Use the `inbox_identifier` token shown here to authentication your API clients.",
|
"INBOX_IDENTIFIER_SUB_TEXT": "Use the `inbox_identifier` token shown here to authentication your API clients.",
|
||||||
"FORWARD_EMAIL_TITLE": "Forward to Email",
|
"FORWARD_EMAIL_TITLE": "Forward to Email",
|
||||||
"FORWARD_EMAIL_SUB_TEXT": "Start forwarding your emails to the following email address.",
|
"FORWARD_EMAIL_SUB_TEXT": "Start forwarding your emails to the following email address.",
|
||||||
"FORWARD_EMAIL_NOT_CONFIGURED": "Forwarding emails to your inbox is currently disabled on this installation. To use this feature, it must be enabled by your administrator. Please get in touch with them to proceed.",
|
|
||||||
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
|
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
|
||||||
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
|
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
|
||||||
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
|
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
|
||||||
@@ -1020,7 +1004,6 @@
|
|||||||
"LINE": "Line",
|
"LINE": "Line",
|
||||||
"API": "API Channel",
|
"API": "API Channel",
|
||||||
"INSTAGRAM": "Instagram",
|
"INSTAGRAM": "Instagram",
|
||||||
"TIKTOK": "TikTok",
|
|
||||||
"VOICE": "Voice"
|
"VOICE": "Voice"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,7 +234,6 @@
|
|||||||
"CONTACT_SUPPORT": "Contact support",
|
"CONTACT_SUPPORT": "Contact support",
|
||||||
"SELECTOR_SUBTITLE": "Select an account from the following list",
|
"SELECTOR_SUBTITLE": "Select an account from the following list",
|
||||||
"PROFILE_SETTINGS": "Profile settings",
|
"PROFILE_SETTINGS": "Profile settings",
|
||||||
"YEAR_IN_REVIEW": "Year in Review",
|
|
||||||
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
|
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
|
||||||
"APPEARANCE": "Change appearance",
|
"APPEARANCE": "Change appearance",
|
||||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||||
@@ -307,8 +306,6 @@
|
|||||||
"SETTINGS": "Settings",
|
"SETTINGS": "Settings",
|
||||||
"CONTACTS": "Contacts",
|
"CONTACTS": "Contacts",
|
||||||
"ACTIVE": "Active",
|
"ACTIVE": "Active",
|
||||||
"COMPANIES": "Companies",
|
|
||||||
"ALL_COMPANIES": "All Companies",
|
|
||||||
"CAPTAIN": "Captain",
|
"CAPTAIN": "Captain",
|
||||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||||
"CAPTAIN_DOCUMENTS": "Documents",
|
"CAPTAIN_DOCUMENTS": "Documents",
|
||||||
@@ -401,44 +398,19 @@
|
|||||||
"BUTTON_TXT": "Buy more credits",
|
"BUTTON_TXT": "Buy more credits",
|
||||||
"DOCUMENTS": "Documents",
|
"DOCUMENTS": "Documents",
|
||||||
"RESPONSES": "Responses",
|
"RESPONSES": "Responses",
|
||||||
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
|
"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": {
|
"CHAT_WITH_US": {
|
||||||
"TITLE": "Need help?",
|
"TITLE": "Need help?",
|
||||||
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
|
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
|
||||||
"BUTTON_TXT": "Chat with us"
|
"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": {
|
"SECURITY_SETTINGS": {
|
||||||
"TITLE": "Security",
|
"TITLE": "Security",
|
||||||
"DESCRIPTION": "Manage your account security settings.",
|
"DESCRIPTION": "Manage your account security settings.",
|
||||||
"LINK_TEXT": "Learn more about SAML SSO",
|
"LINK_TEXT": "Learn more about SAML SSO",
|
||||||
"SAML_DISABLED_MESSAGE": "SAML SSO is currently disabled. Please contact your administrator to enable this feature.",
|
|
||||||
"SAML": {
|
"SAML": {
|
||||||
"TITLE": "SAML SSO",
|
"TITLE": "SAML SSO",
|
||||||
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
|
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
{
|
|
||||||
"YEAR_IN_REVIEW": {
|
|
||||||
"TITLE": "Year in Review",
|
|
||||||
"LOADING": "Loading your year in review...",
|
|
||||||
"ERROR": "Failed to load year in review",
|
|
||||||
"CLOSE": "Close",
|
|
||||||
"CONVERSATIONS": {
|
|
||||||
"TITLE": "You have handled",
|
|
||||||
"SUBTITLE": "conversations",
|
|
||||||
"FALLBACK": "This year wasn't about the numbers. It was about showing up.",
|
|
||||||
"COMPARISON": {
|
|
||||||
"0_50": "You showed up, and that's how every good inbox begins.",
|
|
||||||
"50_100": "You kept the replies flowing and the conversations alive.",
|
|
||||||
"100_500": "You handled serious volume and kept everything on track.",
|
|
||||||
"500_2000": "You kept things moving while the volume kept climbing.",
|
|
||||||
"2000_10000": "You ran high traffic through your inbox without breaking a sweat.",
|
|
||||||
"10000_PLUS": "That's a full city of customers knocking on your door. You made it look effortless."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"BUSIEST_DAY": {
|
|
||||||
"TITLE": "Your busiest day was",
|
|
||||||
"MESSAGE": "{count} conversations that day.",
|
|
||||||
"COMPARISON": {
|
|
||||||
"0_5": "A warm-up lap that barely woke the inbox.",
|
|
||||||
"5_10": "Enough action to justify a second cup of coffee.",
|
|
||||||
"10_25": "Things got busy and the inbox stayed on its toes.",
|
|
||||||
"25_50": "A proper rush that barely broke a sweat.",
|
|
||||||
"50_100": "Controlled chaos, handled like a normal Tuesday.",
|
|
||||||
"100_500": "Absolute dumpster fire, somehow still shipping replies.",
|
|
||||||
"500_PLUS": "The inbox lost all chill and never slowed down."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"PERSONALITY": {
|
|
||||||
"TITLE": "Your support personality is",
|
|
||||||
"MESSAGES": {
|
|
||||||
"SWIFT_HELPER": "You replied in {time} on average. Faster than most notifications.",
|
|
||||||
"QUICK_RESPONDER": "You replied in {time} on average. The inbox barely waited.",
|
|
||||||
"STEADY_SUPPORT": "You replied in {time} on average. Calm pace, solid replies.",
|
|
||||||
"THOUGHTFUL_ADVISOR": "You replied in {time} on average. Took the time to get it right."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"THANK_YOU": {
|
|
||||||
"TITLE": "Congratulations on surviving the inbox of {year}.",
|
|
||||||
"MESSAGE": "Thank you for your incredible dedication to supporting customers throughout this year. Your hard work has made a real difference, and we're grateful to have you on this journey. Here's to making {nextYear} even better together!"
|
|
||||||
},
|
|
||||||
"SHARE_MODAL": {
|
|
||||||
"TITLE": "Share Your Year in Review",
|
|
||||||
"PREPARING": "Preparing your image...",
|
|
||||||
"DOWNLOAD": "Download",
|
|
||||||
"SHARE_TITLE": "My {year} Year in Review",
|
|
||||||
"SHARE_TEXT": "Check out my {year} Year in Review with Chatwoot!",
|
|
||||||
"BRANDING": "Made with Chatwoot"
|
|
||||||
},
|
|
||||||
"BANNER": {
|
|
||||||
"TITLE": "Your {year} Year in Review is here",
|
|
||||||
"BUTTON": "See your impact"
|
|
||||||
},
|
|
||||||
"NAVIGATION": {
|
|
||||||
"PREVIOUS": "Previous",
|
|
||||||
"NEXT": "Next",
|
|
||||||
"SHARE": "Share"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -129,10 +129,6 @@
|
|||||||
"ENABLE_REGEX": {
|
"ENABLE_REGEX": {
|
||||||
"LABEL": "تمكين التحقق من صحة regex"
|
"LABEL": "تمكين التحقق من صحة regex"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"BADGES": {
|
|
||||||
"PRE_CHAT": "Pre-chat",
|
|
||||||
"RESOLUTION": "Resolution"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,9 +102,6 @@
|
|||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"CONTENT": "Shared contact"
|
"CONTENT": "Shared contact"
|
||||||
},
|
|
||||||
"embed": {
|
|
||||||
"CONTENT": "Embedded content"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"CHAT_SORT_BY_FILTER": {
|
"CHAT_SORT_BY_FILTER": {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"COMPANIES": {
|
|
||||||
"HEADER": "Companies",
|
|
||||||
"SORT_BY": {
|
|
||||||
"LABEL": "ترتيب حسب",
|
|
||||||
"OPTIONS": {
|
|
||||||
"NAME": "الاسم",
|
|
||||||
"DOMAIN": "النطاق",
|
|
||||||
"CREATED_AT": "تم إنشاؤها في"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ORDER": {
|
|
||||||
"LABEL": "Order",
|
|
||||||
"OPTIONS": {
|
|
||||||
"ASCENDING": "Ascending",
|
|
||||||
"DESCENDING": "Descending"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"SEARCH_PLACEHOLDER": "Search companies...",
|
|
||||||
"LOADING": "Loading companies...",
|
|
||||||
"UNNAMED": "Unnamed Company",
|
|
||||||
"CONTACTS_COUNT": "{n} contact | {n} contacts",
|
|
||||||
"EMPTY_STATE": {
|
|
||||||
"TITLE": "No companies found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"COMPANIES_LAYOUT": {
|
|
||||||
"PAGINATION_FOOTER": {
|
|
||||||
"SHOWING": "Showing {startItem} – {endItem} of {totalItems} company | Showing {startItem} – {endItem} of {totalItems} companies"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user