Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41a7c88aef | ||
|
|
19b5496c56 | ||
|
|
0bd0cab868 | ||
|
|
2dee7457cd | ||
|
|
d693d468d8 | ||
|
|
ea87610999 | ||
|
|
0b1ad1a854 | ||
|
|
d00867d636 | ||
|
|
a01adf860a | ||
|
|
2a30e7b082 | ||
|
|
28ec1794f4 | ||
|
|
6179adac22 | ||
|
|
3c959f0604 | ||
|
|
772734840d | ||
|
|
77c57acffa | ||
|
|
499ac053ee | ||
|
|
5aa1df81a8 | ||
|
|
81464a9087 | ||
|
|
6ed9500792 | ||
|
|
6835f9c85b | ||
|
|
442631102c | ||
|
|
78c615fe42 | ||
|
|
907011e280 | ||
|
|
a766ebf642 | ||
|
|
0e0e0868d7 | ||
|
|
64790ea204 | ||
|
|
d9077c64d3 | ||
|
|
1abb51992b | ||
|
|
0282156821 | ||
|
|
257a9129d7 | ||
|
|
1ce316efc7 | ||
|
|
ba94d8c2b0 | ||
|
|
6e11bebeae | ||
|
|
069f25f88a | ||
|
|
63c193358c | ||
|
|
7663e2ac30 | ||
|
|
517b67dfd6 | ||
|
|
353089473e | ||
|
|
0567655840 | ||
|
|
c25ed094fa | ||
|
|
112a8f9e9e | ||
|
|
723d416ce3 | ||
|
|
f3df486ce6 | ||
|
|
c7b6a87e77 | ||
|
|
19d91e47f1 | ||
|
|
1c945899c7 | ||
|
|
2c025755e2 | ||
|
|
0618e6e229 | ||
|
|
cbef2d4e66 | ||
|
|
cb93aba55c | ||
|
|
beef43f2bf | ||
|
|
cbd3f759b5 | ||
|
|
a7fdc45977 | ||
|
|
41e3ea0d87 | ||
|
|
fd22017292 | ||
|
|
555894793e | ||
|
|
7c87d3d150 | ||
|
|
c37d1b1d6b | ||
|
|
e5a03d08ea | ||
|
|
bc80985f55 | ||
|
|
83cd568ca2 | ||
|
|
f32a7b3f77 | ||
|
|
ba0fcbfc7f | ||
|
|
546193de2a | ||
|
|
67b522c433 | ||
|
|
f5eca9d1d0 | ||
|
|
5c4db3a6bc | ||
|
|
792dc359d4 | ||
|
|
2b0aaa237f | ||
|
|
48022833ea | ||
|
|
e2b3965a08 | ||
|
|
17e25fa3c4 | ||
|
|
37963cc654 | ||
|
|
c7f5277277 | ||
|
|
1124c1b4c2 | ||
|
|
cd9c8e3303 | ||
|
|
80fccbc526 | ||
|
|
bcdb73502e | ||
|
|
d634ced4e9 | ||
|
|
e723c6b6f2 | ||
|
|
5325e05143 | ||
|
|
c09206c22e | ||
|
|
568aae875b | ||
|
|
2324a344dc | ||
|
|
7c7d67fd06 | ||
|
|
b058d84034 | ||
|
|
0e122188e9 | ||
|
|
f8f0caf443 | ||
|
|
6aeda0ddf6 | ||
|
|
fed32d5964 | ||
|
|
735bc73c96 | ||
|
|
5e79dd699e | ||
|
|
05dd31389e | ||
|
|
f7bbd40816 | ||
|
|
7820739f3a | ||
|
|
224556fd1b | ||
|
|
51eb626b88 | ||
|
|
b0aa844a32 | ||
|
|
c8e551820b | ||
|
|
035d2858f5 | ||
|
|
16b8693e1b | ||
|
|
279dd1876c | ||
|
|
2266eb493b | ||
|
|
0920a01e66 | ||
|
|
06467057be | ||
|
|
8faa5a74b1 | ||
|
|
a651949c33 | ||
|
|
2ada713f29 | ||
|
|
751c28d94d | ||
|
|
4959a1ff1e | ||
|
|
661608c0b1 | ||
|
|
c5fb8d73cc | ||
|
|
9a89e1f522 | ||
|
|
667cd1ba1f | ||
|
|
ecdeb891ff |
@@ -11,6 +11,9 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deployment_check:
|
||||
name: Check Deployment
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
log_lines_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -14,6 +14,9 @@ on:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -3,6 +3,10 @@ name: Publish Codespace Base Image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish-code-space-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -18,6 +18,9 @@ on:
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
|
||||
@@ -18,6 +18,9 @@ on:
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
|
||||
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -7,6 +7,9 @@ on:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
strategy:
|
||||
|
||||
@@ -22,6 +22,7 @@ gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'email-provider-info'
|
||||
gem 'gemoji'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
@@ -84,6 +85,7 @@ gem 'barnes'
|
||||
gem 'devise', '>= 4.9.4'
|
||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||
gem 'devise_token_auth', '>= 1.2.3'
|
||||
gem 'rails-i18n', '~> 7.0'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
|
||||
+9
-3
@@ -108,8 +108,8 @@ GEM
|
||||
acts-as-taggable-on (12.0.0)
|
||||
activerecord (>= 7.1, < 8.1)
|
||||
zeitwerk (>= 2.4, < 3.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
administrate (0.20.1)
|
||||
actionpack (>= 6.0, < 8.0)
|
||||
actionview (>= 6.0, < 8.0)
|
||||
@@ -349,6 +349,7 @@ GEM
|
||||
googleapis-common-protos-types (>= 1.3.1, < 2.a)
|
||||
googleauth (~> 1.0)
|
||||
grpc (~> 1.36)
|
||||
gemoji (4.1.0)
|
||||
geocoder (1.8.1)
|
||||
gli (2.22.2)
|
||||
ostruct
|
||||
@@ -676,7 +677,7 @@ GEM
|
||||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (6.0.2)
|
||||
public_suffix (7.0.5)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
@@ -727,6 +728,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.1)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
railties (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
@@ -1072,6 +1076,7 @@ DEPENDENCIES
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
geocoder
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||
@@ -1125,6 +1130,7 @@ DEPENDENCIES
|
||||
rack-mini-profiler (>= 3.2.0)
|
||||
rack-timeout
|
||||
rails (~> 7.1)
|
||||
rails-i18n (~> 7.0)
|
||||
redis
|
||||
redis-namespace
|
||||
responders (>= 3.1.1)
|
||||
|
||||
@@ -44,7 +44,11 @@ class AccountBuilder
|
||||
end
|
||||
|
||||
def create_account
|
||||
@account = Account.create!(name: account_name, locale: I18n.locale)
|
||||
@account = Account.create!(
|
||||
name: account_name,
|
||||
locale: I18n.locale,
|
||||
custom_attributes: { 'onboarding_step' => 'account_details' }
|
||||
)
|
||||
Current.account = @account
|
||||
end
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ class AgentBuilder
|
||||
user = User.from_email(email)
|
||||
return user if user
|
||||
|
||||
@name = email.split('@').first if @name.blank?
|
||||
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
|
||||
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
|
||||
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
|
||||
end
|
||||
|
||||
# Checks if the user needs confirmation.
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
|
||||
include ::Messages::Instagram::StoryReplyAttachmentHandling
|
||||
|
||||
attr_reader :messaging
|
||||
|
||||
def initialize(messaging, inbox, outgoing_echo: false)
|
||||
@@ -117,6 +115,24 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
create_story_reply_attachment(story_reply_attributes['url'])
|
||||
end
|
||||
|
||||
def create_story_reply_attachment(story_url)
|
||||
return if story_url.blank?
|
||||
|
||||
attachment = @message.attachments.new(
|
||||
file_type: :ig_story,
|
||||
account_id: @message.account_id,
|
||||
external_url: story_url
|
||||
)
|
||||
attachment.save!
|
||||
begin
|
||||
attach_file(attachment, story_url)
|
||||
rescue Down::Error, StandardError => e
|
||||
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
|
||||
end
|
||||
@message.content_attributes[:image_type] = 'ig_story_reply'
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||
Conversation.create!(conversation_params.merge(
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
module Messages::Instagram::StoryReplyAttachmentHandling
|
||||
private
|
||||
|
||||
def create_story_reply_attachment(story_url)
|
||||
return if story_url.blank?
|
||||
|
||||
attach_story_reply_file(build_story_reply_attachment(story_url), story_url)
|
||||
update_story_reply_message
|
||||
end
|
||||
|
||||
def build_story_reply_attachment(story_url)
|
||||
@message.attachments.new(
|
||||
file_type: :ig_story,
|
||||
account_id: @message.account_id,
|
||||
external_url: story_url
|
||||
)
|
||||
end
|
||||
|
||||
def attach_story_reply_file(attachment, story_url)
|
||||
downloaded_file = nil
|
||||
|
||||
SafeFetch.fetch(story_url, allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES) do |attachment_file|
|
||||
downloaded_file = attachment_file
|
||||
persist_story_reply_attachment(attachment, attachment_file)
|
||||
end
|
||||
rescue SafeFetch::Error => e
|
||||
Rails.logger.info "Error downloading Messenger attachment from #{story_url}: #{e.message}: Skipping"
|
||||
discard_story_reply_attachment(attachment)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
|
||||
discard_story_reply_attachment(attachment)
|
||||
ensure
|
||||
downloaded_file&.close!
|
||||
end
|
||||
|
||||
def persist_story_reply_attachment(attachment, attachment_file)
|
||||
attachment.file.attach(
|
||||
io: attachment_file.tempfile,
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
)
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
def discard_story_reply_attachment(attachment)
|
||||
return attachment.destroy if attachment.persisted?
|
||||
|
||||
@message.attachments.proxy_association.target.delete(attachment)
|
||||
end
|
||||
|
||||
def update_story_reply_message
|
||||
@message.content_attributes[:image_type] = 'ig_story_reply'
|
||||
@message.save!
|
||||
end
|
||||
end
|
||||
@@ -5,46 +5,14 @@ class Messages::Messenger::MessageBuilder
|
||||
# This check handles very rare case if there are multiple files to attach with only one unsupported file
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
attachment_obj, downloaded_file = prepare_attachment(attachment)
|
||||
return unless attachment_obj
|
||||
|
||||
finalize_attachment(attachment_obj)
|
||||
ensure
|
||||
downloaded_file&.close!
|
||||
end
|
||||
|
||||
def prepare_attachment(attachment)
|
||||
params = attachment_params(attachment)
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
downloaded_file = persist_attachment(attachment, attachment_obj, params[:remote_file_url])
|
||||
return [nil, downloaded_file] if params[:remote_file_url].present? && downloaded_file.blank?
|
||||
|
||||
[attachment_obj, downloaded_file]
|
||||
end
|
||||
|
||||
def persist_attachment(attachment, attachment_obj, remote_file_url)
|
||||
return save_facebook_reel_attachment(attachment, attachment_obj) if facebook_reel?(attachment)
|
||||
return save_remote_file_attachment(attachment_obj, remote_file_url) if remote_file_url.present?
|
||||
|
||||
attachment_obj.save!
|
||||
nil
|
||||
end
|
||||
|
||||
def save_facebook_reel_attachment(attachment, attachment_obj)
|
||||
attachment_obj.save!
|
||||
update_facebook_reel_content(attachment)
|
||||
nil
|
||||
end
|
||||
|
||||
def save_remote_file_attachment(attachment_obj, remote_file_url)
|
||||
downloaded_file = attach_file(attachment_obj, remote_file_url)
|
||||
return if downloaded_file.blank?
|
||||
|
||||
attachment_obj.save!
|
||||
downloaded_file
|
||||
end
|
||||
|
||||
def finalize_attachment(attachment_obj)
|
||||
if facebook_reel?(attachment)
|
||||
update_facebook_reel_content(attachment)
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_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'
|
||||
@@ -52,22 +20,14 @@ class Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
downloaded_file = nil
|
||||
SafeFetch.fetch(
|
||||
file_url,
|
||||
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES
|
||||
) do |attachment_file|
|
||||
downloaded_file = attachment_file
|
||||
attachment.file.attach(
|
||||
io: attachment_file.tempfile,
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
)
|
||||
end
|
||||
downloaded_file
|
||||
rescue SafeFetch::Error => e
|
||||
Rails.logger.info "Error downloading Messenger attachment from #{file_url}: #{e.message}: Skipping"
|
||||
nil
|
||||
attachment_file = Down.download(
|
||||
file_url
|
||||
)
|
||||
attachment.file.attach(
|
||||
io: attachment_file,
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
)
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :set_articles, only: [:update_status, :delete_articles]
|
||||
|
||||
def translate
|
||||
head :not_implemented
|
||||
end
|
||||
|
||||
def update_status
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@articles.find_each { |article| article.update!(status: params[:status]) }
|
||||
end
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def delete_articles
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
|
||||
@articles.destroy_all
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Article, :create?)
|
||||
end
|
||||
|
||||
def set_articles
|
||||
@articles = @portal.articles.where(id: params[:ids])
|
||||
end
|
||||
end
|
||||
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
|
||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
sort_on :phone_number, type: :string
|
||||
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
||||
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
||||
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
|
||||
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
|
||||
|
||||
|
||||
@@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def process_attached_logo
|
||||
blob_id = params[:blob_id]
|
||||
blob = ActiveStorage::Blob.find_signed(blob_id)
|
||||
@portal.logo.attach(blob)
|
||||
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
|
||||
@portal.logo.attach(blob) if blob
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -21,8 +21,6 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
def create_from_url
|
||||
SafeFetch.fetch(params[:external_url].to_s) do |result|
|
||||
create_and_save_blob(result.tempfile, result.filename, result.content_type)
|
||||
ensure
|
||||
result.close!
|
||||
end
|
||||
rescue SafeFetch::HttpError => e
|
||||
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
|
||||
|
||||
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.settings.merge!(settings_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||
@account.save!
|
||||
end
|
||||
@@ -71,9 +72,10 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
private
|
||||
|
||||
def enqueue_branding_enrichment
|
||||
return if account_params[:email].blank?
|
||||
email = account_params[:email].presence || @user&.email
|
||||
return if email.blank?
|
||||
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, email)
|
||||
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
|
||||
rescue StandardError => e
|
||||
# Enrichment is optional — never let queue/Redis failures abort signup
|
||||
@@ -109,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone)
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
|
||||
end
|
||||
|
||||
def settings_params
|
||||
|
||||
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
before_action :check_mfa_feature_available
|
||||
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
|
||||
before_action :check_mfa_disabled, only: [:create, :verify]
|
||||
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
||||
before_action :validate_password, only: [:destroy]
|
||||
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
def validate_otp
|
||||
authenticated = Mfa::AuthenticationService.new(
|
||||
user: current_user,
|
||||
otp_code: mfa_params[:otp_code]
|
||||
otp_code: mfa_params[:otp_code],
|
||||
backup_code: mfa_params[:backup_code]
|
||||
).authenticate
|
||||
|
||||
return if authenticated
|
||||
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
end
|
||||
|
||||
def mfa_params
|
||||
params.permit(:otp_code, :password)
|
||||
params.permit(:otp_code, :backup_code, :password)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -80,10 +80,17 @@ class DashboardController < ActionController::Base
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH,
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods,
|
||||
ACTIVE_PLATFORM_BANNERS: active_platform_banners
|
||||
}
|
||||
end
|
||||
|
||||
def active_platform_banners
|
||||
return [] unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
|
||||
end
|
||||
|
||||
def allowed_login_methods
|
||||
methods = ['email']
|
||||
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
|
||||
|
||||
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
before_action :validate_platform_app_permissible, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
|
||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
|
||||
before_action :ensure_chatwoot_cloud
|
||||
|
||||
private
|
||||
|
||||
def ensure_chatwoot_cloud
|
||||
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
require 'administrate/base_dashboard'
|
||||
|
||||
class PlatformBannerDashboard < Administrate::BaseDashboard
|
||||
ATTRIBUTE_TYPES = {
|
||||
id: Field::Number,
|
||||
banner_message: Field::Text.with_options(truncate: 200),
|
||||
banner_type: Field::Select.with_options(collection: %w[info warning error]),
|
||||
active: Field::Boolean,
|
||||
created_at: Field::DateTime,
|
||||
updated_at: Field::DateTime
|
||||
}.freeze
|
||||
|
||||
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
|
||||
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
|
||||
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
|
||||
|
||||
def display_resource(platform_banner)
|
||||
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
|
||||
end
|
||||
end
|
||||
@@ -7,11 +7,15 @@ class UserDrop < BaseDrop
|
||||
@obj.try(:available_name)
|
||||
end
|
||||
|
||||
def email
|
||||
@obj.try(:email)
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
end
|
||||
|
||||
def last_name
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,3 +48,5 @@ class MessageFinder
|
||||
messages.reorder('created_at desc').limit(20).reverse
|
||||
end
|
||||
end
|
||||
|
||||
MessageFinder.prepend_mod_with('MessageFinder')
|
||||
|
||||
@@ -7,7 +7,7 @@ module EmailHelper
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s
|
||||
end
|
||||
|
||||
# Raise a standard error if any email address is invalid
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import StatusBanner from './components/app/StatusBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
@@ -27,6 +28,7 @@ export default {
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
StatusBanner,
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
PendingEmailVerificationBanner,
|
||||
@@ -59,7 +61,6 @@ export default {
|
||||
isRTL: 'accounts/isRTL',
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
}),
|
||||
hideOnOnboardingView() {
|
||||
return !isOnOnboardingView(this.$route);
|
||||
@@ -107,8 +108,9 @@ export default {
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
accountId: this.currentAccountId,
|
||||
});
|
||||
const account = this.getAccount(this.currentAccountId);
|
||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||
this.getAccount(this.currentAccountId);
|
||||
account;
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(this.uiSettings?.locale || locale);
|
||||
@@ -131,12 +133,13 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
v-if="!authUIFlags.isFetching"
|
||||
id="app"
|
||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<StatusBanner />
|
||||
<template v-if="currentAccountId">
|
||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||
|
||||
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
|
||||
this.inboxId = null;
|
||||
}
|
||||
|
||||
async joinClientCall({ to, conversationId }) {
|
||||
async joinClientCall({ to, conversationId, callSid }) {
|
||||
if (!this.device || !this.initialized || !to) return null;
|
||||
if (this.activeConnection) return this.activeConnection;
|
||||
|
||||
@@ -76,6 +76,7 @@ class TwilioVoiceClient extends EventTarget {
|
||||
To: to,
|
||||
is_agent: 'true',
|
||||
conversation_id: conversationId,
|
||||
call_sid: callSid,
|
||||
};
|
||||
|
||||
const connection = await this.device.connect({ params });
|
||||
|
||||
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
|
||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||
}
|
||||
|
||||
leaveConference(inboxId, conversationId) {
|
||||
leaveConference({ inboxId, conversationId, callSid }) {
|
||||
return axios
|
||||
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||
params: { conversation_id: conversationId },
|
||||
params: { conversation_id: conversationId, call_sid: callSid },
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../../ApiClient';
|
||||
|
||||
class WhatsappCallsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('whatsapp_calls', { accountScoped: true });
|
||||
}
|
||||
|
||||
show(callId) {
|
||||
return axios.get(`${this.url}/${callId}`).then(r => r.data);
|
||||
}
|
||||
|
||||
initiate(conversationId, sdpOffer) {
|
||||
return axios
|
||||
.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
sdp_offer: sdpOffer,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
accept(callId, sdpAnswer) {
|
||||
return axios
|
||||
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
reject(callId) {
|
||||
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
|
||||
}
|
||||
|
||||
terminate(callId) {
|
||||
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
|
||||
}
|
||||
|
||||
uploadRecording(callId, blob, filename = 'call-recording.webm') {
|
||||
const formData = new FormData();
|
||||
formData.append('recording', blob, filename);
|
||||
return axios
|
||||
.post(`${this.url}/${callId}/upload_recording`, formData)
|
||||
.then(r => r.data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new WhatsappCallsAPI();
|
||||
@@ -72,6 +72,27 @@ class ArticlesAPI extends PortalsAPI {
|
||||
category_slug: categorySlug,
|
||||
});
|
||||
}
|
||||
|
||||
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
|
||||
return axios.post(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
|
||||
{ ids: articleIds, locale, category_id: categoryId, force }
|
||||
);
|
||||
}
|
||||
|
||||
bulkUpdateStatus({ portalSlug, articleIds, status }) {
|
||||
return axios.patch(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
|
||||
{ ids: articleIds, status }
|
||||
);
|
||||
}
|
||||
|
||||
bulkDelete({ portalSlug, articleIds }) {
|
||||
return axios.delete(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
|
||||
{ data: { ids: articleIds } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ArticlesAPI();
|
||||
|
||||
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
||||
}
|
||||
|
||||
disable(password, otpCode) {
|
||||
disable(password, { otpCode, backupCode } = {}) {
|
||||
return axios.delete(this.url, {
|
||||
data: { password, otp_code: otpCode },
|
||||
data: { password, otp_code: otpCode, backup_code: backupCode },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -3,10 +3,16 @@ import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
isVoiceCallEnabled,
|
||||
getVoiceCallProvider,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -34,13 +40,10 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const isInitiatingCall = computed(() => {
|
||||
@@ -60,9 +63,63 @@ const navigateToConversation = conversationId => {
|
||||
}
|
||||
};
|
||||
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
|
||||
// Find the most recent open conversation for this contact in the picked inbox.
|
||||
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
|
||||
const findWhatsappConversationId = async inboxId => {
|
||||
const { data } = await ContactAPI.getConversations(props.contactId);
|
||||
const conversations = data?.payload || [];
|
||||
const match = conversations
|
||||
.filter(c => c.inbox_id === inboxId)
|
||||
.sort((a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0))[0];
|
||||
return match?.id || null;
|
||||
};
|
||||
|
||||
const startWhatsappCall = async inboxId => {
|
||||
const conversationId = await findWhatsappConversationId(inboxId);
|
||||
if (!conversationId) {
|
||||
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
|
||||
return;
|
||||
}
|
||||
|
||||
const response =
|
||||
await whatsappCallSession.initiateOutboundCall(conversationId);
|
||||
if (!response?.id) {
|
||||
// Permission flow returns no id — banner already handled server-side; surface to user.
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
callsStore.setCallActive(response.call_id);
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(conversationId);
|
||||
};
|
||||
|
||||
const startCall = async inboxId => {
|
||||
if (isInitiatingCall.value) return;
|
||||
|
||||
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
|
||||
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
|
||||
try {
|
||||
await startWhatsappCall(inboxId);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('contacts/initiateCall', {
|
||||
contactId: props.contactId,
|
||||
@@ -70,7 +127,6 @@ const startCall = async inboxId => {
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
|
||||
+10
-4
@@ -35,10 +35,16 @@ const emit = defineEmits([
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
|
||||
@@ -9,11 +9,15 @@ import {
|
||||
ARTICLE_STATUSES,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -44,14 +48,46 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSelectionControl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['openArticle', 'articleAction']);
|
||||
const emit = defineEmits([
|
||||
'openArticle',
|
||||
'articleAction',
|
||||
'toggleSelect',
|
||||
'hover',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const isTranslationAvailable = computed(
|
||||
() =>
|
||||
isEnterprise &&
|
||||
isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_TASKS
|
||||
)
|
||||
);
|
||||
|
||||
const articleMenuItems = computed(() => {
|
||||
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
|
||||
(acc, [key, item]) => {
|
||||
@@ -64,7 +100,9 @@ const articleMenuItems = computed(() => {
|
||||
const statusItems = (
|
||||
ARTICLE_MENU_OPTIONS[props.status] ||
|
||||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
|
||||
).map(key => commonItems[key]);
|
||||
)
|
||||
.filter(key => key !== 'translate' || isTranslationAvailable.value)
|
||||
.map(key => commonItems[key]);
|
||||
|
||||
return [...statusItems, commonItems.delete];
|
||||
});
|
||||
@@ -123,14 +161,27 @@ const handleClick = id => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<CardLayout
|
||||
:selectable="selectable"
|
||||
class="relative"
|
||||
@mouseenter="emit('hover', true)"
|
||||
@mouseleave="emit('hover', false)"
|
||||
>
|
||||
<div
|
||||
v-show="showSelectionControl"
|
||||
class="absolute top-7 ltr:left-3 rtl:right-3"
|
||||
>
|
||||
<Checkbox :model-value="isSelected" @change="emit('toggleSelect', id)" />
|
||||
</div>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
|
||||
+2
-2
@@ -121,7 +121,7 @@ const handleCreateArticle = event => {
|
||||
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
|
||||
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
||||
placeholder="Title"
|
||||
autofocus
|
||||
:autofocus="isNewArticle"
|
||||
@blur="handleCreateArticle"
|
||||
/>
|
||||
<ArticleEditorControls
|
||||
@@ -138,7 +138,7 @@ const handleCreateArticle = event => {
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
|
||||
"
|
||||
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
||||
:autofocus="false"
|
||||
:autofocus="!isNewArticle"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
|
||||
+31
-2
@@ -20,8 +20,14 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedArticleIds: {
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
|
||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
const router = useRouter();
|
||||
@@ -30,12 +36,26 @@ const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const localArticles = ref(props.articles);
|
||||
const hoveredArticleId = ref(null);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
// Enable dragging only for category articles and when there's more than one article
|
||||
return props.isCategoryArticles && localArticles.value?.length > 1;
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
localArticles.value?.length > 1 &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
});
|
||||
|
||||
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
|
||||
|
||||
const shouldShowSelectionControl = id => {
|
||||
return hoveredArticleId.value === id || hasBulkSelection.value;
|
||||
};
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredArticleId.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const getCategoryById = useMapGetter('categories/categoryById');
|
||||
|
||||
const openArticle = id => {
|
||||
@@ -152,6 +172,10 @@ const handleArticleAction = async (action, { status, id }) => {
|
||||
};
|
||||
|
||||
const updateArticle = ({ action, value, id }) => {
|
||||
if (action === 'translate') {
|
||||
emit('translateArticle', id);
|
||||
return;
|
||||
}
|
||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||
handleArticleAction(action, { status, id });
|
||||
};
|
||||
@@ -187,9 +211,14 @@ watch(
|
||||
:category="getCategory(element.category.id)"
|
||||
:views="element.views || 0"
|
||||
:updated-at="element.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(element.id)"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(element.id)"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, element.id)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
+231
-7
@@ -1,9 +1,13 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||
@@ -11,6 +15,10 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
|
||||
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
@@ -39,7 +47,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal']);
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -47,6 +55,42 @@ const { t } = useI18n();
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isFetching = useMapGetter('articles/isFetching');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const isTranslationAvailable = computed(
|
||||
() =>
|
||||
isEnterprise &&
|
||||
isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_TASKS
|
||||
)
|
||||
);
|
||||
|
||||
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
|
||||
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
if (!visibleArticleIds.value.length) return '';
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
|
||||
count: visibleArticleIds.value.length,
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
|
||||
count: selectedArticleIds.value.size,
|
||||
})
|
||||
);
|
||||
|
||||
const bulkTranslateDialogRef = ref(null);
|
||||
|
||||
const hasNoArticles = computed(
|
||||
() => !isFetching.value && !props.articles.length
|
||||
@@ -128,6 +172,80 @@ const navigateToNewArticlePage = () => {
|
||||
params: { categorySlug, locale },
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSelect = articleId => {
|
||||
const newSet = new Set(selectedArticleIds.value);
|
||||
if (newSet.has(articleId)) {
|
||||
newSet.delete(articleId);
|
||||
} else {
|
||||
newSet.add(articleId);
|
||||
}
|
||||
selectedArticleIds.value = newSet;
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedArticleIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleTranslateArticle = articleId => {
|
||||
selectedArticleIds.value = new Set([articleId]);
|
||||
bulkTranslateDialogRef.value?.dialogRef?.open();
|
||||
};
|
||||
|
||||
const openTranslateDialog = () => {
|
||||
bulkTranslateDialogRef.value?.dialogRef?.open();
|
||||
};
|
||||
|
||||
const onBulkActionSuccess = message => {
|
||||
useAlert(message);
|
||||
clearSelection();
|
||||
emit('refreshArticles');
|
||||
};
|
||||
|
||||
const bulkUpdateStatus = async status => {
|
||||
try {
|
||||
await articlesAPI.bulkUpdateStatus({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
status,
|
||||
});
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
deleteConfirmDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const bulkDelete = async () => {
|
||||
try {
|
||||
await articlesAPI.bulkDelete({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
});
|
||||
deleteConfirmDialogRef.value?.close();
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
deleteConfirmDialogRef.value?.close();
|
||||
useAlert(
|
||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear selection when articles change (page change, filter change)
|
||||
watch(
|
||||
() => props.articles,
|
||||
() => clearSelection()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,11 +284,91 @@ const navigateToNewArticlePage = () => {
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<ArticleList
|
||||
v-else-if="!hasNoArticles"
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
/>
|
||||
<template v-else-if="!hasNoArticles">
|
||||
<div
|
||||
v-if="selectedArticleIds.size > 0"
|
||||
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
|
||||
>
|
||||
<BulkSelectBar
|
||||
v-model="selectedArticleIds"
|
||||
:all-items="allItems"
|
||||
:select-all-label="selectAllLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
:label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
|
||||
"
|
||||
class="!px-1.5"
|
||||
@click="clearSelection"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-check"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('published')"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-pencil-line"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('draft')"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-archive-restore"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('archived')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isTranslationAvailable"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-languages"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="openTranslateDialog"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
ruby
|
||||
icon="i-lucide-trash"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
|
||||
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||
@click="confirmBulkDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</div>
|
||||
<ArticleList
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@toggle-select="handleToggleSelect"
|
||||
/>
|
||||
</template>
|
||||
<ArticleEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
@@ -183,5 +381,31 @@ const navigateToNewArticlePage = () => {
|
||||
@click="navigateToNewArticlePage"
|
||||
/>
|
||||
</template>
|
||||
<BulkTranslateDialog
|
||||
ref="bulkTranslateDialogRef"
|
||||
:selected-article-ids="[...selectedArticleIds]"
|
||||
:allowed-locales="allowedLocales"
|
||||
@translate-started="clearSelection"
|
||||
/>
|
||||
<Dialog
|
||||
ref="deleteConfirmDialogRef"
|
||||
type="alert"
|
||||
:title="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
|
||||
selectedArticleIds.size
|
||||
)
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
|
||||
selectedArticleIds.size
|
||||
)
|
||||
"
|
||||
:confirm-button-label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
|
||||
"
|
||||
@confirm="bulkDelete"
|
||||
/>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedArticleIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateStarted']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isSubmitting = ref(false);
|
||||
const selectedLocale = ref('');
|
||||
const selectedCategoryId = ref('');
|
||||
const targetCategories = ref([]);
|
||||
const isFetchingCategories = ref(false);
|
||||
const duplicateArticles = ref([]);
|
||||
|
||||
const currentLocale = computed(() => route.params.locale);
|
||||
|
||||
const localeOptions = computed(() => {
|
||||
return props.allowedLocales
|
||||
.filter(locale => locale.code !== currentLocale.value)
|
||||
.map(locale => ({
|
||||
value: locale.code,
|
||||
label: `${locale.name} (${locale.code})`,
|
||||
}));
|
||||
});
|
||||
|
||||
const categoryOptions = computed(() => {
|
||||
return targetCategories.value.map(category => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
}));
|
||||
});
|
||||
|
||||
const articleCount = computed(() => props.selectedArticleIds.length);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
|
||||
);
|
||||
|
||||
const description = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
|
||||
);
|
||||
|
||||
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
if (hasDuplicates.value) {
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
|
||||
}
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
|
||||
});
|
||||
|
||||
const isConfirmDisabled = computed(() => {
|
||||
return !selectedLocale.value || isSubmitting.value;
|
||||
});
|
||||
|
||||
const articleEditUrl = articleId => {
|
||||
const { portalSlug, categorySlug, tab } = route.params;
|
||||
const resolved = router.resolve({
|
||||
name: 'portals_articles_edit',
|
||||
params: {
|
||||
portalSlug,
|
||||
locale: selectedLocale.value,
|
||||
categorySlug,
|
||||
tab,
|
||||
articleSlug: articleId,
|
||||
},
|
||||
});
|
||||
return resolved.href;
|
||||
};
|
||||
|
||||
const fetchCategoriesForLocale = async locale => {
|
||||
if (!locale) {
|
||||
targetCategories.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isFetchingCategories.value = true;
|
||||
try {
|
||||
const { data } = await categoriesAPI.get({
|
||||
portalSlug: route.params.portalSlug,
|
||||
locale,
|
||||
});
|
||||
targetCategories.value = data.payload;
|
||||
} catch {
|
||||
targetCategories.value = [];
|
||||
} finally {
|
||||
isFetchingCategories.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(selectedLocale, newLocale => {
|
||||
selectedCategoryId.value = '';
|
||||
duplicateArticles.value = [];
|
||||
fetchCategoriesForLocale(newLocale);
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
selectedLocale.value = '';
|
||||
selectedCategoryId.value = '';
|
||||
targetCategories.value = [];
|
||||
duplicateArticles.value = [];
|
||||
};
|
||||
|
||||
const submitTranslation = async (force = false) => {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await store.dispatch('articles/bulkTranslate', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: props.selectedArticleIds,
|
||||
locale: selectedLocale.value,
|
||||
categoryId: selectedCategoryId.value,
|
||||
force,
|
||||
});
|
||||
|
||||
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
|
||||
resetForm();
|
||||
dialogRef.value?.close();
|
||||
emit('translateStarted');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 409) {
|
||||
duplicateArticles.value = error.response.data.duplicate_articles;
|
||||
return;
|
||||
}
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
|
||||
);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
if (isConfirmDisabled.value) return;
|
||||
submitTranslation(hasDuplicates.value);
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="dialogTitle"
|
||||
:description="description"
|
||||
:confirm-button-label="confirmLabel"
|
||||
:disable-confirm-button="isConfirmDisabled"
|
||||
:is-loading="isSubmitting"
|
||||
@close="resetForm"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="selectedLocale"
|
||||
:options="localeOptions"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
|
||||
<span class="text-n-slate-10 font-normal">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
|
||||
</span>
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="selectedCategoryId"
|
||||
:options="categoryOptions"
|
||||
:disabled="!selectedLocale || isFetchingCategories"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasDuplicates"
|
||||
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<p class="text-sm text-n-amber-12 m-0">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
|
||||
duplicateArticles.length
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<a
|
||||
v-for="article in duplicateArticles"
|
||||
:key="article.id"
|
||||
:href="articleEditUrl(article.id)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
|
||||
>
|
||||
{{ article.title }}
|
||||
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-xs text-n-amber-11 m-0">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
+3
-6
@@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
@@ -30,6 +28,7 @@ const props = defineProps({
|
||||
isDropdownActive: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
inboxId: { type: Number, default: null },
|
||||
voiceEnabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -82,11 +81,9 @@ const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||
|
||||
const shouldShowSignatureButton = computed(() => {
|
||||
return (
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
|
||||
);
|
||||
});
|
||||
|
||||
@@ -111,7 +108,7 @@ watch(
|
||||
() => props.hasSelectedInbox,
|
||||
newValue => {
|
||||
nextTick(() => {
|
||||
if (newValue && !isVoiceInbox.value) setSignature();
|
||||
if (newValue && !props.voiceEnabled) setSignature();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
+4
-1
@@ -2,7 +2,7 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
@@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
@@ -442,6 +444,7 @@ useKeyboardEvents({
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
:channel-type="inboxChannelType"
|
||||
:voice-enabled="voiceCallEnabled"
|
||||
:is-loading="isCreating"
|
||||
:disable-send-button="isCreating"
|
||||
:has-selected-inbox="!!targetInbox"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
@@ -20,11 +18,13 @@ const emit = defineEmits(['action']);
|
||||
|
||||
const bannerClass = computed(() => {
|
||||
const classMap = {
|
||||
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
|
||||
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
|
||||
slate:
|
||||
'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11',
|
||||
amber:
|
||||
'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11 [&_.link]:text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11 [&_.link]:text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11 [&_.link]:text-n-blue-11',
|
||||
};
|
||||
|
||||
return classMap[props.color];
|
||||
|
||||
@@ -135,6 +135,16 @@ export function useContactFilterContext() {
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME,
|
||||
value: CONTACT_ATTRIBUTES.COMPANY_NAME,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
value: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const CONTACT_ATTRIBUTES = {
|
||||
IDENTIFIER: 'identifier',
|
||||
COUNTRY_CODE: 'country_code',
|
||||
CITY: 'city',
|
||||
COMPANY_NAME: 'company_name',
|
||||
CREATED_AT: 'created_at',
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
REFERER: 'referer',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
|
||||
export function useChannelIcon(inbox) {
|
||||
const channelTypeIconMap = {
|
||||
@@ -14,7 +15,6 @@ export function useChannelIcon(inbox) {
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
'Channel::Voice': 'i-woot-voice',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
@@ -38,6 +38,11 @@ export function useChannelIcon(inbox) {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
|
||||
if (isVoiceCallEnabled(inboxDetails)) {
|
||||
icon = 'i-woot-voice';
|
||||
}
|
||||
|
||||
return icon ?? 'i-ri-global-fill';
|
||||
});
|
||||
|
||||
|
||||
@@ -19,8 +19,11 @@ describe('useChannelIcon', () => {
|
||||
expect(icon).toBe('i-woot-whatsapp');
|
||||
});
|
||||
|
||||
it('returns correct icon for Voice channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Voice' };
|
||||
it('returns correct icon for voice-enabled Twilio channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-voice');
|
||||
});
|
||||
|
||||
@@ -30,6 +30,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
focusOnMount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -82,6 +86,7 @@ onMounted(() => {
|
||||
|
||||
defineExpose({
|
||||
focus: () => inlineInputRef.value?.focus(),
|
||||
blur: () => inlineInputRef.value?.blur(),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -106,6 +111,7 @@ defineExpose({
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:class="customInputClass"
|
||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
@input="handleInput"
|
||||
|
||||
@@ -113,6 +113,7 @@ const props = defineProps({
|
||||
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
||||
},
|
||||
attachments: { type: Array, default: () => [] },
|
||||
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
content: { type: String, default: null },
|
||||
contentAttributes: { type: Object, default: () => ({}) },
|
||||
contentType: {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
|
||||
<div class="p-3 bg-n-alpha-2 rounded-xl">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
|
||||
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="See options" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
|
||||
>
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="No, that will be all" link class="hover:!no-underline">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button
|
||||
label="I want to talk to an agents"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +1,21 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
|
||||
const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const SUBTEXT_MAP = {
|
||||
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
@@ -30,15 +30,65 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { contentAttributes, messageType } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const {
|
||||
call,
|
||||
attachments,
|
||||
contentAttributes,
|
||||
conversationId,
|
||||
currentUserId,
|
||||
inboxId,
|
||||
} = useMessageContext();
|
||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||
useCallSession();
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
|
||||
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
|
||||
// Twilio's call-status webhook flipped the call to in-progress before the
|
||||
// participant-join webhook claimed it).
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
);
|
||||
return conversation?.meta?.assignee || null;
|
||||
});
|
||||
const displayAgentName = computed(() => {
|
||||
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
|
||||
if (acceptedByAgentId.value) {
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
if (agent?.available_name) return agent.available_name;
|
||||
if (agent?.name) return agent.name;
|
||||
}
|
||||
return conversationAssignee.value?.name || null;
|
||||
});
|
||||
|
||||
const audioAttachment = computed(() =>
|
||||
(attachments?.value || []).find(a => a.fileType === 'audio')
|
||||
);
|
||||
|
||||
// Duration may arrive on call.duration_seconds (push_event_data) or
|
||||
// content_attributes.data.duration_seconds — and either may be camelCased
|
||||
// upstream, so check every variant.
|
||||
const durationSeconds = computed(() => {
|
||||
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
|
||||
if (fromCall != null) return fromCall;
|
||||
|
||||
const data = contentAttributes?.value?.data;
|
||||
return data?.durationSeconds || data?.duration_seconds;
|
||||
});
|
||||
|
||||
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
@@ -52,16 +102,28 @@ const labelKey = computed(() => {
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
const subtext = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return formattedDuration.value;
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
if (displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
@@ -70,11 +132,62 @@ const iconName = computed(() => {
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
const callSid = computed(() => call.value?.providerCallId);
|
||||
|
||||
// Show "Join call" when the call is still ringing, no agent has claimed it,
|
||||
// and the conversation is unassigned or assigned to the current user. Mirrors
|
||||
// the eligibility used by FloatingCallWidget so the bubble can act as a
|
||||
// recovery affordance after a refresh or missed widget.
|
||||
const canJoinCall = computed(() => {
|
||||
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
|
||||
if (isOutbound.value) return false;
|
||||
if (acceptedByAgentId.value) return false;
|
||||
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
|
||||
// Suppress the button once this call is the local active session — the
|
||||
// message status webhook may lag behind, so we can't rely on `status` alone
|
||||
// to hide it after a successful join from this client.
|
||||
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
|
||||
return false;
|
||||
const assignee = conversationAssignee.value;
|
||||
if (assignee?.id && assignee.id !== currentUserId.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const recordingAttachment = computed(() => {
|
||||
if (audioAttachment.value) return audioAttachment.value;
|
||||
const url = call.value?.recordingUrl;
|
||||
if (!url) return null;
|
||||
return {
|
||||
dataUrl: url,
|
||||
fileType: 'audio',
|
||||
extension: 'wav',
|
||||
transcribedText: call.value?.transcript || '',
|
||||
};
|
||||
});
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (!canJoinCall.value || isJoining.value) return;
|
||||
|
||||
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
|
||||
await endCall({
|
||||
conversationId: activeCall.value.conversationId,
|
||||
inboxId: activeCall.value.inboxId,
|
||||
callSid: activeCall.value.callSid,
|
||||
});
|
||||
}
|
||||
|
||||
await joinCall({
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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-sm">
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
@@ -94,11 +207,24 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
<span v-if="subtext" class="text-xs text-n-slate-11">
|
||||
{{ subtext }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recordingAttachment" class="px-3 pb-3 w-full">
|
||||
<AudioChip :attachment="recordingAttachment" class="text-n-slate-12" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -41,8 +41,33 @@ const playbackSpeed = ref(1);
|
||||
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
|
||||
// resolves to Infinity until we seek past the end, which forces the engine to
|
||||
// scan the file and compute the real length. Safe no-op for files with a real
|
||||
// duration already (mp3/m4a/etc).
|
||||
const resolveStreamingDuration = () => {
|
||||
const el = audioPlayer.value;
|
||||
if (!el) return;
|
||||
const onTimeUpdate = () => {
|
||||
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||
el.currentTime = 0;
|
||||
duration.value = el.duration;
|
||||
};
|
||||
el.addEventListener('timeupdate', onTimeUpdate);
|
||||
try {
|
||||
el.currentTime = Number.MAX_SAFE_INTEGER;
|
||||
} catch {
|
||||
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
const d = audioPlayer.value?.duration;
|
||||
if (!Number.isFinite(d)) {
|
||||
resolveStreamingDuration();
|
||||
return;
|
||||
}
|
||||
duration.value = d;
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => {
|
||||
@@ -53,7 +78,8 @@ const playbackSpeedLabel = computed(() => {
|
||||
// When the onLoadMetadata is called, so we need to set the duration
|
||||
// value when the component is mounted
|
||||
onMounted(() => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
const d = audioPlayer.value?.duration;
|
||||
if (Number.isFinite(d)) duration.value = d;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
});
|
||||
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
import CallToAction from '../../bubbles/Template/CallToAction.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/CallToAction"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Call To Action">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<CallToAction :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
import Card from '../../bubbles/Template/Card.vue';
|
||||
|
||||
const message = {
|
||||
title: 'Two in one cake (1 pound)',
|
||||
content: 'Customize your order for special occasions',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Card"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Card">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Card :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
import ListPicker from '../../bubbles/Template/ListPicker.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you? `,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/ListPicker"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="ListPicker">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<ListPicker :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
import Media from '../../bubbles/Template/Media.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Media"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Image Media">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Media :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
import QuickReply from '../../bubbles/Template/QuickReply.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you?`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/QuickReply"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Quick Replies">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<QuickReply :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup>
|
||||
import Text from '../../bubbles/Template/Text.vue';
|
||||
|
||||
const message = {
|
||||
content: 'Hello John, how may we assist you?',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Text"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default Text">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Text :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.buttons && message.buttons.length > 0"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<Button
|
||||
v-for="(button, index) in message.buttons"
|
||||
:key="index"
|
||||
:label="button.text || button.title || 'Button'"
|
||||
slate
|
||||
class="!text-n-blue-11 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+6
-6
@@ -10,22 +10,22 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
|
||||
>
|
||||
<div class="px-3 py-2.5">
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<div class="pt-2.5 flex flex-col gap-2">
|
||||
<img :src="message.image_url" class="w-full max-h-44 rounded-lg" />
|
||||
<div class="flex flex-col gap-2 pt-2.5">
|
||||
<h6 class="font-semibold">{{ message.title }}</h6>
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble text-sm"
|
||||
class="text-sm prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<div class="flex justify-center items-center p-3">
|
||||
<Button label="Call us to order" link class="hover:!no-underline" />
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<div class="flex justify-center items-center p-3">
|
||||
<Button label="Visit our store" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import FileIcon from 'dashboard/components-next/icon/FileIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const PDF_EXTENSIONS = ['.pdf', 'pdf'];
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.mov', 'video'];
|
||||
const DOC_EXTENSIONS = ['.doc'];
|
||||
|
||||
const mediaType = computed(() => {
|
||||
if (props.message.mediaType) return props.message.mediaType;
|
||||
|
||||
const format = props.message.header?.format;
|
||||
if (format) return format.toLowerCase();
|
||||
|
||||
const url = props.message.image_url || '';
|
||||
if (PDF_EXTENSIONS.some(ext => url.includes(ext))) return 'document';
|
||||
if (VIDEO_EXTENSIONS.some(ext => url.includes(ext))) return 'video';
|
||||
return 'image';
|
||||
});
|
||||
|
||||
const fileType = computed(() => {
|
||||
const url = props.message.image_url || '';
|
||||
return DOC_EXTENSIONS.some(ext => url.includes(ext)) ? 'doc' : 'pdf';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2.5 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
|
||||
>
|
||||
<img
|
||||
v-if="mediaType === 'image'"
|
||||
:src="message.image_url"
|
||||
class="object-cover w-full max-h-44 rounded-lg"
|
||||
alt="Template media"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="mediaType === 'video'"
|
||||
class="overflow-hidden relative rounded-lg"
|
||||
>
|
||||
<video
|
||||
:src="message.image_url"
|
||||
class="object-cover w-full max-h-44"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mediaType === 'document'" class="flex items-center">
|
||||
<FileIcon :file-type="fileType" class="text-2xl text-n-slate-12" />
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="message.content"
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const actions = computed(() => props.message.actions || []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="(action, index) in actions"
|
||||
:key="index"
|
||||
class="flex justify-center items-center p-3"
|
||||
>
|
||||
<Button
|
||||
:label="action.title || action.text || 'Button'"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-woot-quick-reply" class="size-[15px]" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import TemplatePreview from './TemplatePreview.vue';
|
||||
import {
|
||||
whatsAppTemplates,
|
||||
getWhatsAppVariables,
|
||||
} from './templates/whatsapp-templates.js';
|
||||
import { twilioTemplates } from './templates/twilio-templates.js';
|
||||
|
||||
const findWhatsApp = name => whatsAppTemplates.find(t => t.name === name);
|
||||
const findTwilio = name => twilioTemplates.find(t => t.friendly_name === name);
|
||||
|
||||
const greet = findWhatsApp('greet');
|
||||
const eventInvitation = findWhatsApp('event_invitation_static');
|
||||
const orderConfirmation = findWhatsApp('order_confirmation');
|
||||
const discountCoupon = findWhatsApp('discount_coupon');
|
||||
const trainingVideo = findWhatsApp('training_video');
|
||||
|
||||
const twilioGreet = findTwilio('greet');
|
||||
const shoeLaunch = findTwilio('shoe_launch');
|
||||
const welcomeMessage = findTwilio('welcome_message_new');
|
||||
const courseFeeReminder = findTwilio('course_fee_reminder');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TemplatePreview"
|
||||
:layout="{ type: 'grid', width: 400 }"
|
||||
>
|
||||
<Variant title="WhatsApp - Simple Text">
|
||||
<TemplatePreview
|
||||
:template="greet"
|
||||
:variables="getWhatsAppVariables(greet)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Simple Text (No Variables)">
|
||||
<TemplatePreview :template="greet" :variables="{}" platform="whatsapp" />
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Call to Action Buttons">
|
||||
<TemplatePreview
|
||||
:template="eventInvitation"
|
||||
:variables="getWhatsAppVariables(eventInvitation)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Image Media">
|
||||
<TemplatePreview
|
||||
:template="orderConfirmation"
|
||||
:variables="getWhatsAppVariables(orderConfirmation)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Video Media">
|
||||
<TemplatePreview
|
||||
:template="trainingVideo"
|
||||
:variables="getWhatsAppVariables(trainingVideo)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Copy Code">
|
||||
<TemplatePreview
|
||||
:template="discountCoupon"
|
||||
:variables="getWhatsAppVariables(discountCoupon)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Text">
|
||||
<TemplatePreview
|
||||
:template="twilioGreet"
|
||||
:variables="twilioGreet.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Media">
|
||||
<TemplatePreview
|
||||
:template="shoeLaunch"
|
||||
:variables="shoeLaunch.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Quick Reply">
|
||||
<TemplatePreview
|
||||
:template="welcomeMessage"
|
||||
:variables="welcomeMessage.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Call to Action">
|
||||
<TemplatePreview
|
||||
:template="courseFeeReminder"
|
||||
:variables="courseFeeReminder.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
|
||||
import {
|
||||
PLATFORMS,
|
||||
TEMPLATE_TYPES,
|
||||
WA_HEADER_FORMATS,
|
||||
WA_MEDIA_FORMATS,
|
||||
} from 'dashboard/services/TemplateConstants';
|
||||
|
||||
import CardTemplate from './CardTemplate.vue';
|
||||
import CallToActionTemplate from './CallToActionTemplate.vue';
|
||||
import MediaTemplate from './MediaTemplate.vue';
|
||||
import WhatsAppTextTemplate from './WhatsAppTextTemplate.vue';
|
||||
import QuickReplyTemplate from './QuickReplyTemplate.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
variables: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => Object.values(PLATFORMS).includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const COMPONENT_MAP = {
|
||||
[TEMPLATE_TYPES.WHATSAPP_TEXT]: WhatsAppTextTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER]: WhatsAppTextTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_INTERACTIVE]: CallToActionTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_COPY_CODE]: CallToActionTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_TEXT]: WhatsAppTextTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_MEDIA]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_QUICK_REPLY]: QuickReplyTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION]: CallToActionTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_CARD]: CardTemplate,
|
||||
};
|
||||
|
||||
const substituteVariables = (text, variables) => {
|
||||
if (!text) return '';
|
||||
return text.replace(/\{\{([^}]+)\}\}/g, (match, variable) => {
|
||||
const value = variables[variable];
|
||||
return value !== undefined && value !== '' ? value : `[${variable}]`;
|
||||
});
|
||||
};
|
||||
|
||||
const processedTemplate = computed(() => {
|
||||
const normalized = TemplateNormalizer.normalize(
|
||||
props.template,
|
||||
props.platform
|
||||
);
|
||||
|
||||
let content = '';
|
||||
let imageUrl = '';
|
||||
let title = '';
|
||||
let footer = '';
|
||||
|
||||
if (props.platform === PLATFORMS.WHATSAPP) {
|
||||
content = normalized.body?.text || '';
|
||||
|
||||
if (normalized.header) {
|
||||
if (WA_MEDIA_FORMATS.includes(normalized.header.format)) {
|
||||
imageUrl = normalized.header.example?.header_handle?.[0] || '';
|
||||
}
|
||||
if (normalized.header.format === WA_HEADER_FORMATS.TEXT) {
|
||||
title = normalized.header.text || '';
|
||||
}
|
||||
}
|
||||
|
||||
footer = normalized.footer?.text || '';
|
||||
} else {
|
||||
content = normalized.body || '';
|
||||
|
||||
if (normalized.media && normalized.media.length > 0) {
|
||||
imageUrl = normalized.media[0];
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = normalized.buttons?.length
|
||||
? normalized.buttons
|
||||
: normalized.actions || [];
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
content: substituteVariables(content, props.variables),
|
||||
title: substituteVariables(title, props.variables),
|
||||
footer: substituteVariables(footer, props.variables),
|
||||
image_url: substituteVariables(imageUrl, props.variables),
|
||||
buttons,
|
||||
actions: normalized.actions || [],
|
||||
};
|
||||
});
|
||||
|
||||
const previewComponent = computed(
|
||||
() => COMPONENT_MAP[processedTemplate.value.type] || WhatsAppTextTemplate
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="template-preview">
|
||||
<component :is="previewComponent" :message="processedTemplate" />
|
||||
</div>
|
||||
</template>
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
<script setup>
|
||||
import TemplatePreview from './TemplatePreview.vue';
|
||||
import {
|
||||
whatsAppTemplates,
|
||||
getWhatsAppVariables,
|
||||
} from './templates/whatsapp-templates.js';
|
||||
import { twilioTemplates } from './templates/twilio-templates.js';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TemplatePreviewExamples"
|
||||
:layout="{ type: 'grid', width: 400 }"
|
||||
>
|
||||
<Variant title="WA: Event Invitation (Buttons)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[0]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[0])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Purchase Receipt (Document)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[1]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[1])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Discount Coupon (Copy Code)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[2]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[2])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Support Callback (Phone)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[3]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[3])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Training Video (Video)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[4]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[4])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Order Confirmation (Image)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[5]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[5])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Product Launch (Image + Footer)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[6]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[6])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Technician Visit (Header + Buttons)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[7]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[7])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Greet (Simple Text)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[8]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[8])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Hello World (Header + Footer)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[9]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[9])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Feedback Request (Button)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[10]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[10])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Address Update (Header)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[11]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[11])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Delivery Confirmation">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[12]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[12])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Shoe Launch (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[0]"
|
||||
:variables="twilioTemplates[0].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Product Launch Custom Price (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[1]"
|
||||
:variables="twilioTemplates[1].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Product Launch (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[2]"
|
||||
:variables="twilioTemplates[2].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Greet (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[3]"
|
||||
:variables="twilioTemplates[3].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Order Status (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[4]"
|
||||
:variables="twilioTemplates[4].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Hello World (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[5]"
|
||||
:variables="twilioTemplates[5].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Welcome Message New (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[6]"
|
||||
:variables="twilioTemplates[6].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: SaaS WhatsApp Question (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[7]"
|
||||
:variables="twilioTemplates[7].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Welcome Message (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[8]"
|
||||
:variables="twilioTemplates[8].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Course Fee Reminder (Call to Action)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[9]"
|
||||
:variables="twilioTemplates[9].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+1
-1
@@ -8,7 +8,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80">
|
||||
<span v-dompurify-html="message.content" class="prose prose-bubble" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
|
||||
>
|
||||
<div v-if="message.title" class="text-base font-bold">
|
||||
{{ message.title }}
|
||||
</div>
|
||||
|
||||
<div v-if="message.content" class="text-sm font-medium prose prose-bubble">
|
||||
<span v-dompurify-html="message.content" />
|
||||
</div>
|
||||
|
||||
<div v-if="message.footer" class="text-xs opacity-70 text-n-slate-11">
|
||||
{{ message.footer }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Main Template Preview Component
|
||||
export { default as TemplatePreview } from './TemplatePreview.vue';
|
||||
|
||||
// Core Services
|
||||
export { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
|
||||
export { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
export const twilioTemplates = [
|
||||
{
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '100$',
|
||||
},
|
||||
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
|
||||
friendly_name: 'shoe_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '400$',
|
||||
},
|
||||
content_sid: 'HXd5c1f8f8d68976f841c440d5e4b46c2e',
|
||||
friendly_name: 'product_launch_custom_price',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Introducing our latest release the Nike Air Force! Available now for just $129.99',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: 'Introducing our latest release the Nike Air Force! Available now for just $129.99',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {},
|
||||
content_sid: 'HX25f6e823f2416ca4b34254d98e916fae',
|
||||
friendly_name: 'product_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
},
|
||||
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
|
||||
friendly_name: 'greet',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
3: '12345',
|
||||
},
|
||||
content_sid: 'HX88291ef8d30d7dcd436cbb9b21c236f4',
|
||||
friendly_name: 'order_status',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {},
|
||||
content_sid: 'HXdc6da32d489ee80f67c07d5bb0e7e390',
|
||||
friendly_name: 'hello_world',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
|
||||
friendly_name: 'welcome_message_new',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
|
||||
actions: [
|
||||
{
|
||||
id: 'Chatwoot Cloud_payload',
|
||||
title: 'Chatwoot Cloud',
|
||||
},
|
||||
{
|
||||
id: 'Self-hosted Chatwoot_payload',
|
||||
title: 'Self-hosted Chatwoot',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX3a35e5cd76529fd91d19341deb4ef685',
|
||||
friendly_name: 'saas_whatsapp_question',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Demo_payload',
|
||||
title: 'Demo',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
|
||||
friendly_name: 'welcome_message',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
types: {
|
||||
'twilio/call-to-action': {
|
||||
actions: [
|
||||
{
|
||||
id: null,
|
||||
title: 'Pay now',
|
||||
type: 'URL',
|
||||
url: 'https://payments.example.com/pay',
|
||||
},
|
||||
],
|
||||
body: 'Hello, this is a gentle reminder regarding your course fee.\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
date: '01-Jan-2026',
|
||||
},
|
||||
content_sid: 'HX63e56fc142aad670f320bf400d5bfeb7',
|
||||
friendly_name: 'course_fee_reminder',
|
||||
template_type: 'call_to_action',
|
||||
},
|
||||
];
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
export const whatsAppTemplates = [
|
||||
{
|
||||
id: '1381151706284063',
|
||||
name: 'event_invitation_static',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'F1',
|
||||
param_name: 'event_name',
|
||||
},
|
||||
{
|
||||
example: 'Dubai',
|
||||
param_name: 'location',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://events.example.com/register',
|
||||
text: 'Visit website',
|
||||
type: 'URL',
|
||||
},
|
||||
{
|
||||
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
|
||||
text: 'Get Directions',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '767076159336759',
|
||||
name: 'purchase_receipt',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'DOCUMENT',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['credit', 'CS Mutual', 'receipt']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1469258364071127',
|
||||
name: 'discount_coupon',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: '30',
|
||||
param_name: 'discount_percentage',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Copy offer code',
|
||||
type: 'COPY_CODE',
|
||||
example: ['SAVE1OFF'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1075221534579807',
|
||||
name: 'support_callback',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'muhsin',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: '232323',
|
||||
param_name: 'ticket_id',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Call Support',
|
||||
type: 'PHONE_NUMBER',
|
||||
phone_number: '+2112121212',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1023596726651144',
|
||||
name: 'training_video',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'VIDEO',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'john',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: 'July 31',
|
||||
param_name: 'date',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1106685194739985',
|
||||
name: 'order_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://vite-five-phi.vercel.app/vaporfly.png'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['blue canvas shoes']],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1242180011253003',
|
||||
name: 'product_launch',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://vite-five-phi.vercel.app/coat.png'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'New arrival! Our stunning coat now available in {{color}} color.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'blue',
|
||||
param_name: 'color',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Free shipping on orders over $100. Limited time offer.',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1449876326175680',
|
||||
name: 'technician_visit',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Technician visit',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [
|
||||
['John', '123 Maple St', '2025-12-31', '10:00 AM', '2:00 PM'],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Confirm',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
{
|
||||
text: 'Reschedule',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '997298832221901',
|
||||
name: 'greet',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hey {{customer_name}} how may I help you?',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'John',
|
||||
param_name: 'customer_name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '632315222954611',
|
||||
name: 'hello_world',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello World',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
text: 'WhatsApp Business Platform sample message',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '787864066907971',
|
||||
name: 'feedback_request',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'muhsin',
|
||||
param_name: 'name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://feedback.example.com/survey',
|
||||
text: 'Leave Feedback',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1938057163677205',
|
||||
name: 'address_update',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Address update',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['John', '123 Main St', 'support@telco.com']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1644094842949394',
|
||||
name: 'delivery_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: '{{1}}, your order was successfully delivered on {{2}}.\\n\\nThank you for your purchase.\\n',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['John', 'Jan 1, 2024']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to get variable values from examples
|
||||
export const getWhatsAppVariables = template => {
|
||||
const variables = {};
|
||||
template.components?.forEach(component => {
|
||||
if (component.example?.body_text_named_params) {
|
||||
component.example.body_text_named_params.forEach(param => {
|
||||
variables[param.param_name] = param.example;
|
||||
});
|
||||
}
|
||||
if (component.example?.body_text) {
|
||||
component.example.body_text[0]?.forEach((value, index) => {
|
||||
variables[(index + 1).toString()] = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
return variables;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
const BANNER_COLOR_MAP = {
|
||||
info: 'blue',
|
||||
warning: 'amber',
|
||||
error: 'ruby',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const dismissedBannerIds = ref(
|
||||
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS) || []
|
||||
);
|
||||
|
||||
const dismissKey = banner => `${banner.id}-${banner.updated_at}`;
|
||||
|
||||
const visibleBanners = computed(() => {
|
||||
const banners = globalConfig.value?.activePlatformBanners || [];
|
||||
return banners.filter(
|
||||
banner => !dismissedBannerIds.value.includes(dismissKey(banner))
|
||||
);
|
||||
});
|
||||
|
||||
const formattedMessage = message =>
|
||||
new MessageFormatter(message).formattedMessage;
|
||||
|
||||
const bannerColor = bannerType => BANNER_COLOR_MAP[bannerType] || 'slate';
|
||||
|
||||
const dismissBanner = banner => {
|
||||
const key = dismissKey(banner);
|
||||
if (dismissedBannerIds.value.includes(key)) return;
|
||||
|
||||
dismissedBannerIds.value.push(key);
|
||||
LocalStorage.set(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS,
|
||||
dismissedBannerIds.value
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-for="banner in visibleBanners"
|
||||
:key="banner.id"
|
||||
:color="bannerColor(banner.banner_type)"
|
||||
:action-label="t('GENERAL_SETTINGS.DISMISS')"
|
||||
class="!rounded-none !justify-center [&_.link]:underline [&_p]:m-0"
|
||||
@action="dismissBanner(banner)"
|
||||
>
|
||||
<span
|
||||
v-dompurify-html="formattedMessage(banner.banner_message)"
|
||||
class="text-xs"
|
||||
/>
|
||||
</Banner>
|
||||
</template>
|
||||
@@ -1,3 +1,5 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
@@ -21,16 +22,40 @@ const {
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
|
||||
// don't expose a mic track on the browser side.
|
||||
const isMuted = ref(false);
|
||||
const isWhatsappActive = computed(
|
||||
() => activeCall.value?.provider === 'whatsapp'
|
||||
);
|
||||
|
||||
const toggleMute = () => {
|
||||
isMuted.value = !isMuted.value;
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
};
|
||||
|
||||
watch(hasActiveCall, active => {
|
||||
if (!active) isMuted.value = false;
|
||||
});
|
||||
|
||||
const getCallInfo = call => {
|
||||
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||
const sender = conversation?.meta?.sender;
|
||||
// Inbound WhatsApp calls stash caller info on the call record (from the cable
|
||||
// payload) so the widget has something to show before the conversation lands.
|
||||
const caller = call?.caller;
|
||||
return {
|
||||
conversation,
|
||||
inbox,
|
||||
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
|
||||
contactName:
|
||||
sender?.name ||
|
||||
sender?.phone_number ||
|
||||
caller?.name ||
|
||||
caller?.phone ||
|
||||
'Unknown caller',
|
||||
inboxName: inbox?.name || 'Customer support',
|
||||
avatar: sender?.avatar || sender?.thumbnail,
|
||||
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -44,25 +69,27 @@ const handleEndCall = async () => {
|
||||
await endCallSession({
|
||||
conversationId: call.conversationId,
|
||||
inboxId,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
};
|
||||
|
||||
const handleJoinCall = async call => {
|
||||
if (!call || isJoining.value) return;
|
||||
const { conversation } = getCallInfo(call);
|
||||
if (!call || !conversation || isJoining.value) return;
|
||||
|
||||
// End current active call before joining new one
|
||||
if (hasActiveCall.value) {
|
||||
await handleEndCall();
|
||||
}
|
||||
|
||||
// The conversation may not be hydrated yet (post-refresh seeding path);
|
||||
// call.inboxId already carries what joinCall needs.
|
||||
const result = await joinCall({
|
||||
conversationId: call.conversationId,
|
||||
inboxId: conversation.inbox_id,
|
||||
inboxId: call.inboxId || conversation?.inbox_id,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
if (result && conversation) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: call.conversationId },
|
||||
@@ -161,6 +188,30 @@ watch(
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
v-if="hasActiveCall && isWhatsappActive"
|
||||
v-tooltip.top="
|
||||
isMuted
|
||||
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
|
||||
"
|
||||
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
|
||||
:class="
|
||||
isMuted
|
||||
? 'bg-n-amber-9 hover:bg-n-amber-10'
|
||||
: 'bg-n-slate-3 hover:bg-n-slate-4'
|
||||
"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i
|
||||
class="text-lg"
|
||||
:class="
|
||||
isMuted
|
||||
? 'text-white i-ph-microphone-slash-bold'
|
||||
: 'text-n-slate-12 i-ph-microphone-bold'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
|
||||
import {
|
||||
appendSignature,
|
||||
collapseSelection,
|
||||
findNodeToInsertImage,
|
||||
getContentNode,
|
||||
insertAtCursor,
|
||||
@@ -66,6 +67,7 @@ import {
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
hasPressedCommandAndEnter,
|
||||
isEscape,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
@@ -515,7 +517,9 @@ function setMenubarPosition({ selection } = {}) {
|
||||
|
||||
function checkSelection(editorState) {
|
||||
showSelectionMenu.value = false;
|
||||
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
||||
const { selection } = editorState;
|
||||
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
||||
const hasSelection = !selection.empty && !selection.node;
|
||||
if (hasSelection === isTextSelected.value) return;
|
||||
|
||||
isTextSelected.value = hasSelection;
|
||||
@@ -711,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (isEscape(event)) {
|
||||
collapseSelection(editorView);
|
||||
return true;
|
||||
}
|
||||
if (isEnterToSendEnabled()) {
|
||||
handleLineBreakWhenEnterToSendEnabled(event);
|
||||
}
|
||||
if (isCmdPlusEnterToSendEnabled()) {
|
||||
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createEditorView() {
|
||||
@@ -744,6 +753,9 @@ function createEditorView() {
|
||||
blur: () => {
|
||||
if (props.disabled) return;
|
||||
typingIndicator.stop();
|
||||
// PM keeps its selection on blur — clear the menu flags manually.
|
||||
isTextSelected.value = false;
|
||||
editorRoot.value?.classList.remove('has-selection');
|
||||
emit('blur');
|
||||
},
|
||||
paste: (view, event) => {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
import { collapseSelection } from 'dashboard/helper/editorHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
@@ -362,19 +364,33 @@ export default {
|
||||
onKeyup() {
|
||||
this.$emit('keyup');
|
||||
},
|
||||
onKeydown() {
|
||||
onKeydown(view, event) {
|
||||
this.$emit('keydown');
|
||||
if (isEscape(event)) {
|
||||
if (this.showSlashMenu) {
|
||||
this.showSlashMenu = false;
|
||||
this.slashSearchTerm = '';
|
||||
this.slashMenuPosition = null;
|
||||
return true;
|
||||
}
|
||||
collapseSelection(editorView);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onBlur() {
|
||||
// ProseMirror keeps its selection on blur — clear the menu flag manually.
|
||||
this.isTextSelected = false;
|
||||
this.$refs.editor?.classList.remove('has-selection');
|
||||
this.$emit('blur');
|
||||
},
|
||||
onFocus() {
|
||||
this.$emit('focus');
|
||||
},
|
||||
checkSelection(editorState) {
|
||||
const { from, to } = editorState.selection;
|
||||
// Check if there's a selection (from and to are different)
|
||||
const hasSelection = from !== to;
|
||||
const { selection } = editorState;
|
||||
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
||||
const hasSelection = !selection.empty && !selection.node;
|
||||
// If the selection state is the same as the previous state, do nothing
|
||||
if (hasSelection === this.isTextSelected) return;
|
||||
// Update the selection state
|
||||
|
||||
@@ -38,10 +38,16 @@ const unreadCount = computed(() => props.chat.unread_count);
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
|
||||
const showMetaSection = computed(() => {
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import BackButton from '../BackButton.vue';
|
||||
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
@@ -12,6 +13,13 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import {
|
||||
getVoiceCallProvider,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -91,6 +99,45 @@ const hasMultipleInboxes = computed(
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
|
||||
const isWhatsappVoiceInbox = computed(
|
||||
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
);
|
||||
|
||||
const startWhatsappCall = async () => {
|
||||
if (whatsappCallSession.isInitiating.value) return;
|
||||
try {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
currentChat.value.id
|
||||
);
|
||||
|
||||
// Permission template path returns no call id — show banner, no widget yet.
|
||||
if (!response?.id) {
|
||||
const status = response?.status;
|
||||
const messageKey =
|
||||
status === 'permission_pending'
|
||||
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
|
||||
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
|
||||
useAlert(t(messageKey));
|
||||
return;
|
||||
}
|
||||
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId: currentChat.value.id,
|
||||
inboxId: inbox.value?.id,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
callsStore.setCallActive(response.call_id);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -152,6 +199,18 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<ButtonV4
|
||||
v-if="isWhatsappVoiceInbox"
|
||||
v-tooltip.bottom="$t('CONVERSATION.HEADER.WHATSAPP_CALL')"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
icon="i-lucide-phone"
|
||||
:is-loading="whatsappCallSession.isInitiating.value"
|
||||
:disabled="whatsappCallSession.isInitiating.value"
|
||||
class="rounded-md hover:bg-n-alpha-2"
|
||||
@click="startWhatsappCall"
|
||||
/>
|
||||
<MoreActions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,11 @@ const mockStore = createStore({
|
||||
11: { id: 11, channel_type: INBOX_TYPES.API },
|
||||
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
||||
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
||||
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
|
||||
14: {
|
||||
id: 14,
|
||||
channel_type: INBOX_TYPES.TWILIO,
|
||||
voice_enabled: true,
|
||||
},
|
||||
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
|
||||
};
|
||||
return inboxes[id] || null;
|
||||
@@ -211,11 +215,11 @@ describe('useInbox', () => {
|
||||
});
|
||||
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
|
||||
|
||||
// Test Voice
|
||||
// Test Voice (Twilio with voice_enabled)
|
||||
wrapper = mount(createTestComponent(14), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isAVoiceChannel).toBe(true);
|
||||
expect(wrapper.vm.voiceCallEnabled).toBe(true);
|
||||
|
||||
// Test Tiktok
|
||||
wrapper = mount(createTestComponent(15), {
|
||||
@@ -274,7 +278,8 @@ describe('useInbox', () => {
|
||||
'isAnEmailChannel',
|
||||
'isAnInstagramChannel',
|
||||
'isATiktokChannel',
|
||||
'isAVoiceChannel',
|
||||
'voiceCallEnabled',
|
||||
'voiceCallProvider',
|
||||
];
|
||||
|
||||
expectedProperties.forEach(prop => {
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import {
|
||||
useWhatsappCallSession,
|
||||
sendWhatsappTerminateBeacon,
|
||||
cleanupWhatsappSession,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
const isWhatsappCall = call => call?.provider === 'whatsapp';
|
||||
|
||||
export function useCallSession() {
|
||||
const store = useStore();
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappSession = useWhatsappCallSession();
|
||||
const { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
@@ -15,6 +29,7 @@ export function useCallSession() {
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
|
||||
|
||||
watch(
|
||||
hasActiveCall,
|
||||
@@ -29,21 +44,76 @@ export function useCallSession() {
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Warn before a refresh/close drops a live or ringing call. Cable events
|
||||
// aren't replayed on reconnect, so a confirmed refresh during ringing would
|
||||
// leave the agent unable to accept; for active calls the WebRTC session
|
||||
// dies outright (no rejoin path).
|
||||
const handleBeforeUnload = event => {
|
||||
if (!hasActiveCall.value && !hasIncomingCall.value) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
|
||||
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
|
||||
// on a hard refresh they leave the calls store empty. Seed it from any
|
||||
// ringing voice_call message in the conversation cache.
|
||||
const seedCallsFromHydratedMessages = () => {
|
||||
const conversations = store.getters.getAllConversations || [];
|
||||
const currentUserId = store.getters.getCurrentUserID;
|
||||
conversations.forEach(conv => {
|
||||
(conv.messages || []).forEach(msg => {
|
||||
if (msg.content_type !== 'voice_call') return;
|
||||
if (msg.call?.status !== 'ringing') return;
|
||||
handleVoiceCallCreated(msg, currentUserId);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Terminate only the active call — ringing calls stay alive on Meta so the
|
||||
// agent can pick them up after reload (seeded back via the watcher above).
|
||||
const handlePageHide = () => {
|
||||
sendWhatsappTerminateBeacon();
|
||||
};
|
||||
|
||||
const handleTwilioDisconnected = () => callsStore.clearActiveCall();
|
||||
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
TwilioVoiceClient.addEventListener(
|
||||
'call:disconnected',
|
||||
handleTwilioDisconnected
|
||||
);
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
window.addEventListener('pagehide', handlePageHide);
|
||||
seedCallsFromHydratedMessages();
|
||||
});
|
||||
|
||||
// Re-seed when conversations stream in after mount; addCall merges by callSid.
|
||||
watch(
|
||||
() => store.getters.getAllConversations?.length,
|
||||
() => seedCallsFromHydratedMessages()
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
TwilioVoiceClient.removeEventListener(
|
||||
'call:disconnected',
|
||||
handleTwilioDisconnected
|
||||
);
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
});
|
||||
|
||||
const endCall = async ({ conversationId, inboxId }) => {
|
||||
await VoiceAPI.leaveConference(inboxId, conversationId);
|
||||
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
|
||||
|
||||
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
if (isWhatsappCall(findCall(callSid))) {
|
||||
await whatsappSession.endActiveCall();
|
||||
durationTimer.stop();
|
||||
callsStore.clearActiveCall();
|
||||
return;
|
||||
}
|
||||
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
callsStore.clearActiveCall();
|
||||
@@ -54,6 +124,18 @@ export function useCallSession() {
|
||||
|
||||
isJoining.value = true;
|
||||
try {
|
||||
const call = findCall(callSid);
|
||||
if (isWhatsappCall(call)) {
|
||||
await whatsappSession.acceptIncomingCall({
|
||||
callId: call.callId,
|
||||
sdpOffer: call.sdpOffer,
|
||||
iceServers: call.iceServers,
|
||||
});
|
||||
callsStore.setCallActive(callSid);
|
||||
durationTimer.start();
|
||||
return { callId: call.callId };
|
||||
}
|
||||
|
||||
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
||||
if (!device) return null;
|
||||
|
||||
@@ -66,6 +148,7 @@ export function useCallSession() {
|
||||
await TwilioVoiceClient.joinClientCall({
|
||||
to: joinResponse?.conference_sid,
|
||||
conversationId,
|
||||
callSid,
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
@@ -73,8 +156,15 @@ export function useCallSession() {
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
if (error?.response?.status === 409) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
callsStore.dismissCall(callSid);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
// Drop any half-built WebRTC state so the next click starts fresh.
|
||||
cleanupWhatsappSession();
|
||||
return null;
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
@@ -82,7 +172,12 @@ export function useCallSession() {
|
||||
};
|
||||
|
||||
const rejectIncomingCall = callSid => {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
const call = findCall(callSid);
|
||||
if (isWhatsappCall(call) && call?.callId) {
|
||||
whatsappSession.rejectIncomingCall(call.callId);
|
||||
} else {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
callsStore.dismissCall(callSid);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
INBOX_TYPES,
|
||||
isVoiceCallEnabled,
|
||||
getVoiceCallProvider,
|
||||
} from 'dashboard/helper/inbox';
|
||||
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
@@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => {
|
||||
return channelType.value === INBOX_TYPES.TIKTOK;
|
||||
});
|
||||
|
||||
const isAVoiceChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.VOICE;
|
||||
});
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
|
||||
|
||||
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
||||
|
||||
return {
|
||||
inbox,
|
||||
@@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => {
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
isAVoiceChannel,
|
||||
voiceCallEnabled,
|
||||
voiceCallProvider,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { ref } from 'vue';
|
||||
import Cookies from 'js-cookie';
|
||||
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
|
||||
|
||||
// Module-level state lets the cable handlers and unload listeners reach the
|
||||
// live PeerConnection without prop-drilling refs through every composable.
|
||||
let pc = null;
|
||||
let localStream = null;
|
||||
let remoteStream = null;
|
||||
let remoteAudioEl = null;
|
||||
let mediaRecorder = null;
|
||||
let recorderChunks = [];
|
||||
let audioContext = null;
|
||||
let activeCallId = null;
|
||||
let intentionallyClosing = false;
|
||||
|
||||
const ensureRemoteAudioElement = () => {
|
||||
if (remoteAudioEl) return remoteAudioEl;
|
||||
remoteAudioEl = document.createElement('audio');
|
||||
remoteAudioEl.id = 'whatsapp-call-remote-audio';
|
||||
remoteAudioEl.autoplay = true;
|
||||
remoteAudioEl.playsInline = true;
|
||||
remoteAudioEl.style.display = 'none';
|
||||
document.body.appendChild(remoteAudioEl);
|
||||
return remoteAudioEl;
|
||||
};
|
||||
|
||||
const playRemoteStream = stream => {
|
||||
const el = ensureRemoteAudioElement();
|
||||
el.srcObject = stream;
|
||||
el.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[WhatsApp Call] remote audio play() failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// 1s timeslice keeps a recent recording chunk in memory so a remote hangup
|
||||
// that races cleanup still has data to upload.
|
||||
const RECORDING_TIMESLICE_MS = 1000;
|
||||
const ICE_GATHER_TIMEOUT_MS = 10000;
|
||||
const RECORDER_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
];
|
||||
|
||||
// Outbound calls have no backend-supplied ice_servers (the call doesn't exist
|
||||
// at offer time). Without STUN the browser only sends host candidates and
|
||||
// browser→Meta media silently drops through any non-trivial NAT.
|
||||
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
const waitForIceGatheringComplete = peer =>
|
||||
new Promise(resolve => {
|
||||
if (peer.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
|
||||
peer.addEventListener('icegatheringstatechange', () => {
|
||||
if (peer.iceGatheringState === 'complete') {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close().catch(() => {});
|
||||
}
|
||||
if (localStream) localStream.getTracks().forEach(t => t.stop());
|
||||
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
|
||||
if (pc) pc.close();
|
||||
if (remoteAudioEl) remoteAudioEl.srcObject = null;
|
||||
|
||||
pc = null;
|
||||
localStream = null;
|
||||
remoteStream = null;
|
||||
mediaRecorder = null;
|
||||
recorderChunks = [];
|
||||
audioContext = null;
|
||||
activeCallId = null;
|
||||
intentionallyClosing = false;
|
||||
};
|
||||
|
||||
const setupRecorder = () => {
|
||||
if (!localStream || !remoteStream || mediaRecorder) return;
|
||||
// createMediaStreamSource on a stream with no audio tracks wires up to
|
||||
// nothing — the recorded mix would be silence. Wait until ontrack fires.
|
||||
if (remoteStream.getAudioTracks().length === 0) return;
|
||||
|
||||
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
// AudioContext starts suspended under most autoplay policies; without
|
||||
// resume() the destination stream produces silence.
|
||||
audioContext.resume().catch(() => {});
|
||||
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
audioContext.createMediaStreamSource(localStream).connect(destination);
|
||||
audioContext.createMediaStreamSource(remoteStream).connect(destination);
|
||||
|
||||
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
|
||||
MediaRecorder.isTypeSupported(t)
|
||||
);
|
||||
if (!mimeType) return;
|
||||
|
||||
recorderChunks = [];
|
||||
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
|
||||
};
|
||||
mediaRecorder.start(RECORDING_TIMESLICE_MS);
|
||||
};
|
||||
|
||||
const buildPeerConnection = iceServers => {
|
||||
const config = iceServers && iceServers.length ? { iceServers } : {};
|
||||
pc = new RTCPeerConnection(config);
|
||||
remoteStream = new MediaStream();
|
||||
pc.ontrack = event => {
|
||||
// Reuse the same MediaStream object — the recorder's audioContext source
|
||||
// taps it once, so reassigning would orphan the recorder.
|
||||
const tracks =
|
||||
event.streams && event.streams[0]
|
||||
? event.streams[0].getTracks()
|
||||
: [event.track];
|
||||
tracks.forEach(track => {
|
||||
if (!remoteStream.getTracks().includes(track))
|
||||
remoteStream.addTrack(track);
|
||||
});
|
||||
playRemoteStream(remoteStream);
|
||||
setupRecorder();
|
||||
};
|
||||
return pc;
|
||||
};
|
||||
|
||||
const stopRecorderAndUpload = async callId => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
await new Promise(resolve => {
|
||||
mediaRecorder.addEventListener('stop', resolve, { once: true });
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch (_) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!recorderChunks.length || !callId) return;
|
||||
|
||||
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
|
||||
// Best-effort — the controller's idempotency guard handles a retry.
|
||||
try {
|
||||
await WhatsappCallsAPI.uploadRecording(callId, blob);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
// devise-token-auth requires access-token / client / uid headers on every
|
||||
// request — navigator.sendBeacon can't set custom headers, so we rehydrate
|
||||
// the auth payload from the cw_d_session_info cookie that the dashboard sets
|
||||
// at login. Used by the page-close terminate path below.
|
||||
const getDeviseAuthHeaders = () => {
|
||||
try {
|
||||
const raw = Cookies.get('cw_d_session_info');
|
||||
if (!raw) return null;
|
||||
const session = JSON.parse(raw);
|
||||
return {
|
||||
'access-token': session['access-token'] || '',
|
||||
client: session.client || '',
|
||||
uid: session.uid || '',
|
||||
expiry: session.expiry || '',
|
||||
'token-type': session['token-type'] || 'Bearer',
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const beaconTerminate = callId => {
|
||||
if (!callId) return;
|
||||
const accountId = window.location.pathname.split('/')[3];
|
||||
if (!accountId) return;
|
||||
const headers = getDeviseAuthHeaders();
|
||||
if (!headers) return;
|
||||
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
|
||||
// fetch+keepalive (instead of navigator.sendBeacon) so we can attach auth
|
||||
// headers — without them devise-token-auth 401s and the call stays open on
|
||||
// Meta until its carrier-side timeout (~60s).
|
||||
try {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: '{}',
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
export function useWhatsappCallSession() {
|
||||
const isInitiating = ref(false);
|
||||
|
||||
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
|
||||
cleanup();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
buildPeerConnection(iceServers);
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
const prepareOutboundOffer = async () => {
|
||||
cleanup();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
|
||||
// The store may not have sdpOffer yet (the cable broadcast can race the
|
||||
// click). Fall back to GET /whatsapp_calls/:id which exposes it.
|
||||
let offer = sdpOffer;
|
||||
let ice = iceServers;
|
||||
if (!offer && callId) {
|
||||
try {
|
||||
const fresh = await WhatsappCallsAPI.show(callId);
|
||||
offer = fresh?.sdp_offer || fresh?.sdpOffer;
|
||||
ice = ice || fresh?.ice_servers || fresh?.iceServers;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'[WhatsApp Call] failed to fetch call data for accept:',
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!offer) {
|
||||
throw new Error('Missing sdp_offer for accept — call may have ended.');
|
||||
}
|
||||
|
||||
const sdpAnswer = await prepareInboundAnswer(offer, ice);
|
||||
activeCallId = callId;
|
||||
await WhatsappCallsAPI.accept(callId, sdpAnswer);
|
||||
};
|
||||
|
||||
const rejectIncomingCall = async callId => {
|
||||
intentionallyClosing = true;
|
||||
try {
|
||||
await WhatsappCallsAPI.reject(callId);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
const initiateOutboundCall = async conversationId => {
|
||||
if (isInitiating.value) return null;
|
||||
isInitiating.value = true;
|
||||
try {
|
||||
const sdpOffer = await prepareOutboundOffer();
|
||||
const response = await WhatsappCallsAPI.initiate(
|
||||
conversationId,
|
||||
sdpOffer
|
||||
);
|
||||
// The permission-request branch returns no call id; let the caller render the banner.
|
||||
activeCallId = response?.id || null;
|
||||
return response;
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
throw e;
|
||||
} finally {
|
||||
isInitiating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const endActiveCall = async () => {
|
||||
if (!activeCallId) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
intentionallyClosing = true;
|
||||
const callIdSnapshot = activeCallId;
|
||||
try {
|
||||
await stopRecorderAndUpload(callIdSnapshot);
|
||||
await WhatsappCallsAPI.terminate(callIdSnapshot).catch(() => {});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isInitiating,
|
||||
prepareInboundAnswer,
|
||||
prepareOutboundOffer,
|
||||
acceptIncomingCall,
|
||||
rejectIncomingCall,
|
||||
initiateOutboundCall,
|
||||
endActiveCall,
|
||||
};
|
||||
}
|
||||
|
||||
// Cable handlers fire outside any composable instance, so the shared session
|
||||
// surface is exposed as module-level functions for them.
|
||||
|
||||
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
|
||||
if (!pc) return;
|
||||
activeCallId = callId;
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
};
|
||||
|
||||
export const cleanupWhatsappSession = () => cleanup();
|
||||
|
||||
export const handleWhatsappRemoteEnd = async callId => {
|
||||
// Snapshot before cleanup nulls activeCallId.
|
||||
const id = callId || activeCallId;
|
||||
if (!id) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await stopRecorderAndUpload(id);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
export const setWhatsappCallMuted = muted => {
|
||||
if (!localStream) return false;
|
||||
localStream.getAudioTracks().forEach(track => {
|
||||
track.enabled = !muted;
|
||||
});
|
||||
return muted;
|
||||
};
|
||||
|
||||
export const sendWhatsappTerminateBeacon = () => {
|
||||
if (!activeCallId || intentionallyClosing) return;
|
||||
beaconTerminate(activeCallId);
|
||||
};
|
||||
@@ -109,11 +109,6 @@ export const FORMATTING = {
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Voice': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::Tiktok': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_UPDATES: 'dismissedUpdates',
|
||||
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
|
||||
WIDGET_BUILDER: 'widgetBubble_',
|
||||
DRAFT_MESSAGES: 'draftMessages',
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
|
||||
@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
|
||||
CHATWOOT_V4: 'chatwoot_v4',
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CHANNEL_VOICE: 'channel_voice',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
|
||||
@@ -153,3 +153,8 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
NEXT_CLICKED: 'Year in Review: Next clicked',
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import {
|
||||
applyOutboundAnswer,
|
||||
handleWhatsappRemoteEnd,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
@@ -33,7 +38,11 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
'voice_call.incoming': this.onVoiceCallIncoming,
|
||||
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
|
||||
'voice_call.ended': this.onVoiceCallEnded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,12 +203,51 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||
};
|
||||
|
||||
onEnrichmentCompleted = () => {
|
||||
this.app.$store.dispatch('accounts/get', { silent: true });
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallIncoming = data => {
|
||||
if (data?.provider !== 'whatsapp') return;
|
||||
useCallsStore().addCall({
|
||||
callSid: data.call_id,
|
||||
callId: data.id,
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
callDirection: 'inbound',
|
||||
provider: 'whatsapp',
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
caller: data.caller,
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallOutboundConnected = data => {
|
||||
if (data?.provider !== 'whatsapp' || !data.sdp_answer) return;
|
||||
applyOutboundAnswer(data.id, data.sdp_answer).catch(() => {});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallEnded = async data => {
|
||||
if (data?.provider !== 'whatsapp') return;
|
||||
// Await upload before removeCall — the store's sync teardown would otherwise
|
||||
// wipe the recorder chunks before they reach the server.
|
||||
try {
|
||||
await handleWhatsappRemoteEnd(data.id);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
useCallsStore().removeCall(data.call_id);
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
messageSchema,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
@@ -273,6 +274,18 @@ export const scrollCursorIntoView = view => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse the current selection to a cursor near its head. Used to override
|
||||
* the default Escape -> selectParentNode behavior which would otherwise keep
|
||||
* the text highlight visible.
|
||||
*
|
||||
* @param {EditorView} view - The ProseMirror EditorView
|
||||
*/
|
||||
export const collapseSelection = view => {
|
||||
const { tr, selection } = view.state;
|
||||
view.dispatch(tr.setSelection(Selection.near(selection.$head)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a transaction that inserts a node into editor at the given position
|
||||
* Has an optional param 'content' to check if the
|
||||
|
||||
@@ -11,9 +11,32 @@ export const INBOX_TYPES = {
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
TIKTOK: 'Channel::Tiktok',
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
export const VOICE_CALL_PROVIDERS = {
|
||||
TWILIO: 'twilio',
|
||||
WHATSAPP: 'whatsapp',
|
||||
};
|
||||
|
||||
export const getVoiceCallProvider = inbox => {
|
||||
if (!inbox) return null;
|
||||
|
||||
// Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes.
|
||||
const channelType = inbox.channel_type || inbox.channelType;
|
||||
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
|
||||
|
||||
if (!voiceEnabled) return null;
|
||||
|
||||
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
|
||||
if (channelType === INBOX_TYPES.WHATSAPP)
|
||||
return VOICE_CALL_PROVIDERS.WHATSAPP;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
@@ -30,7 +53,6 @@ const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
|
||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||
@@ -45,7 +67,6 @@ const INBOX_ICON_MAP_LINE = {
|
||||
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
|
||||
[INBOX_TYPES.LINE]: 'i-woot-line',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
||||
[INBOX_TYPES.VOICE]: 'i-woot-voice',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
@@ -58,7 +79,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
|
||||
|
||||
case INBOX_TYPES.TWILIO:
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
case INBOX_TYPES.VOICE:
|
||||
return phoneNumber || '';
|
||||
|
||||
case INBOX_TYPES.EMAIL:
|
||||
@@ -97,9 +117,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
@@ -142,9 +159,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.TIKTOK:
|
||||
return 'brand-tiktok';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
|
||||
action: 'archive',
|
||||
icon: 'i-lucide-archive-restore',
|
||||
},
|
||||
translate: {
|
||||
label:
|
||||
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
|
||||
value: 'translate',
|
||||
action: 'translate',
|
||||
icon: 'i-lucide-languages',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
|
||||
value: 'delete',
|
||||
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
|
||||
};
|
||||
|
||||
export const ARTICLE_MENU_OPTIONS = {
|
||||
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
|
||||
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
|
||||
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
|
||||
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
|
||||
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
|
||||
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
|
||||
};
|
||||
|
||||
export const ARTICLE_TABS = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
stripInlineBase64Images,
|
||||
collapseSelection,
|
||||
} from '../editorHelper';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
@@ -454,6 +455,37 @@ describe('stripInlineBase64Images', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapseSelection', () => {
|
||||
it('collapses a text range to a cursor at its head', () => {
|
||||
const editorView = new EditorView(document.body, {
|
||||
state: createEditorState('Hello world'),
|
||||
});
|
||||
|
||||
// Build a TextSelection via the initial selection's constructor (avoids
|
||||
// importing prosemirror-state, which isn't a direct dep).
|
||||
const { doc, selection } = editorView.state;
|
||||
editorView.dispatch(
|
||||
editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6))
|
||||
);
|
||||
expect(editorView.state.selection.empty).toBe(false);
|
||||
|
||||
collapseSelection(editorView);
|
||||
|
||||
expect(editorView.state.selection.empty).toBe(true);
|
||||
expect(editorView.state.selection.head).toBe(6);
|
||||
});
|
||||
|
||||
it('leaves an already-collapsed selection as a cursor', () => {
|
||||
const editorView = new EditorView(document.body, {
|
||||
state: createEditorState('Hi'),
|
||||
});
|
||||
|
||||
collapseSelection(editorView);
|
||||
|
||||
expect(editorView.state.selection.empty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAtCursor', () => {
|
||||
it('should return undefined if editorView is not provided', () => {
|
||||
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
||||
|
||||
@@ -22,13 +22,39 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||
};
|
||||
|
||||
const extractAssigneeId = conversation => {
|
||||
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
|
||||
};
|
||||
|
||||
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
|
||||
if (currentUserId == null) return false;
|
||||
return !!assigneeId && assigneeId !== currentUserId;
|
||||
};
|
||||
|
||||
const shouldShowCall = ({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
}) => {
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
|
||||
// Outbound calls are scoped to the initiator via shouldSkipCall; the
|
||||
// conversation may be auto-assigned to a different agent on creation, so
|
||||
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
|
||||
if (callDirection === 'outbound') return true;
|
||||
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
|
||||
};
|
||||
|
||||
function extractCallData(message) {
|
||||
const contentData = message?.content_attributes?.data || {};
|
||||
const call = message?.call || {};
|
||||
return {
|
||||
callSid: contentData.call_sid,
|
||||
status: contentData.status,
|
||||
callDirection: contentData.call_direction,
|
||||
callSid: call.provider_call_id,
|
||||
callId: call.id,
|
||||
provider: call.provider,
|
||||
status: call.status,
|
||||
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
conversationId: message?.conversation_id,
|
||||
assigneeId: extractAssigneeId(message?.conversation),
|
||||
senderId: message?.sender?.id,
|
||||
};
|
||||
}
|
||||
@@ -36,14 +62,32 @@ function extractCallData(message) {
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
const {
|
||||
callSid,
|
||||
callId,
|
||||
provider,
|
||||
callDirection,
|
||||
conversationId,
|
||||
assigneeId,
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
callId,
|
||||
provider,
|
||||
conversationId,
|
||||
callDirection,
|
||||
senderId,
|
||||
@@ -53,22 +97,38 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
||||
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, status, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
const {
|
||||
callSid,
|
||||
status,
|
||||
callDirection,
|
||||
conversationId,
|
||||
assigneeId,
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||
|
||||
const callInfo = { conversationId, callStatus: status };
|
||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
|
||||
conversationId,
|
||||
callStatus: status,
|
||||
callSid,
|
||||
});
|
||||
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
callsStore.removeCall(callSid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewCall) {
|
||||
if (status === 'ringing') {
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
@@ -77,3 +137,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncConversationCallVisibility(conversation, currentUserId) {
|
||||
const assigneeId = extractAssigneeId(conversation);
|
||||
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.removeCallsForConversation(conversation.id);
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"BROWSER_LANGUAGE": "Browser Language",
|
||||
"MAIL_SUBJECT": "Email Subject",
|
||||
"COUNTRY_NAME": "Country",
|
||||
"COMPANY_NAME": "Company",
|
||||
"REFERER_LINK": "Referrer Link",
|
||||
"ASSIGNEE_NAME": "Assignee",
|
||||
"TEAM_NAME": "Team",
|
||||
|
||||
@@ -387,6 +387,7 @@
|
||||
"IDENTIFIER": "Identifier",
|
||||
"COUNTRY": "Country",
|
||||
"CITY": "City",
|
||||
"COMPANY": "Company",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY": "Last activity",
|
||||
"REFERER_LINK": "Referer link",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user