Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64df27d8c9 | ||
|
|
bf4a596726 | ||
|
|
3e5ca9bca9 | ||
|
|
5ebe8c71ec | ||
|
|
8c78573d9d | ||
|
|
c553997af8 | ||
|
|
30a3a35281 | ||
|
|
e4ba80e2f0 | ||
|
|
b72848513f | ||
|
|
bb1a4fc466 | ||
|
|
4343ebde59 | ||
|
|
9d95576f21 | ||
|
|
3c7c460fb9 | ||
|
|
bd84d1b17e | ||
|
|
bca048fc69 | ||
|
|
43b952d486 | ||
|
|
e58f60c27b | ||
|
|
759fe0d3f6 | ||
|
|
ce6489b485 | ||
|
|
b8187ed8a7 | ||
|
|
b3af194894 | ||
|
|
1e9180d3cd | ||
|
|
e06525181b | ||
|
|
3d29962969 | ||
|
|
7328e636ac | ||
|
|
e2e68868d5 | ||
|
|
670cf689f5 | ||
|
|
ccdbc2c7f9 | ||
|
|
aa4ef28e0e | ||
|
|
2879a0cd42 | ||
|
|
ce468bac01 | ||
|
|
2b0c154bc5 | ||
|
|
ebabb69048 | ||
|
|
5b77618a43 | ||
|
|
d6dd8efe46 | ||
|
|
74cd639574 | ||
|
|
414daff4f1 | ||
|
|
4e8a39f358 | ||
|
|
5dc1735f69 | ||
|
|
2f7c8f6cfc | ||
|
|
e8d3679aba | ||
|
|
c9daf70655 | ||
|
|
4ea22f7f36 | ||
|
|
4348c4ab87 | ||
|
|
196d7afccf | ||
|
|
4c48a565f6 | ||
|
|
3692cde1a9 | ||
|
|
3f0c01e166 | ||
|
|
4c579bc71e | ||
|
|
a7ff808d01 | ||
|
|
8d660df4c4 |
@@ -89,7 +89,7 @@ gem 'wisper', '2.0.0'
|
||||
##--- gems for channels ---##
|
||||
gem 'facebook-messenger'
|
||||
gem 'line-bot-api'
|
||||
gem 'twilio-ruby', '~> 5.66'
|
||||
gem 'twilio-ruby'
|
||||
# twitty will handle subscription of twitter account events
|
||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
gem 'twitty', '~> 0.1.5'
|
||||
@@ -180,8 +180,6 @@ gem 'ruby-openai'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'ai-agents', '>= 0.2.1'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
+10
-20
@@ -126,8 +126,6 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.2.1)
|
||||
ruby_llm (~> 1.3)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
@@ -248,8 +246,10 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.9.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-mashify (0.1.1)
|
||||
@@ -257,8 +257,8 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
@@ -416,7 +416,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.8.1)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -488,7 +488,7 @@ GEM
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
@@ -719,15 +719,6 @@ GEM
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.3.1)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
faraday-multipart (>= 1)
|
||||
faraday-net_http (>= 1)
|
||||
faraday-retry (>= 1)
|
||||
marcel (~> 1.0)
|
||||
zeitwerk (~> 2)
|
||||
ruby_parser (3.20.0)
|
||||
sexp_processor (~> 4.16)
|
||||
sass (3.7.4)
|
||||
@@ -830,7 +821,7 @@ GEM
|
||||
i18n
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (5.77.0)
|
||||
twilio-ruby (7.6.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
nokogiri (>= 1.6, < 2.0)
|
||||
@@ -906,7 +897,6 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.2.1)
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
@@ -1020,7 +1010,7 @@ DEPENDENCIES
|
||||
telephone_number
|
||||
test-prof
|
||||
time_diff
|
||||
twilio-ruby (~> 5.66)
|
||||
twilio-ruby
|
||||
twitty (~> 0.1.5)
|
||||
tzinfo-data
|
||||
uglifier
|
||||
|
||||
@@ -21,6 +21,8 @@ class ContactInboxBuilder
|
||||
email_source_id
|
||||
when 'Channel::Sms'
|
||||
phone_source_id
|
||||
when 'Channel::Voice'
|
||||
phone_source_id # Voice uses phone number as source ID
|
||||
when 'Channel::Api', 'Channel::WebWidget'
|
||||
SecureRandom.uuid
|
||||
else
|
||||
@@ -35,7 +37,12 @@ class ContactInboxBuilder
|
||||
end
|
||||
|
||||
def phone_source_id
|
||||
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
|
||||
unless @contact.phone_number.present?
|
||||
# For voice channels, we'll create a fallback source ID if phone number is missing
|
||||
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Voice'
|
||||
|
||||
raise ActionController::ParameterMissing, 'contact phone number'
|
||||
end
|
||||
|
||||
@contact.phone_number
|
||||
end
|
||||
@@ -100,6 +107,6 @@ class ContactInboxBuilder
|
||||
end
|
||||
|
||||
def allowed_channels?
|
||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? || @inbox.channel_type == 'Channel::Voice'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ class Messages::MessageBuilder
|
||||
@private = params[:private] || false
|
||||
@conversation = conversation
|
||||
@user = user
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@message_type = params[:message_type].to_s || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
@@ -33,11 +33,6 @@ class Messages::MessageBuilder
|
||||
def content_attributes
|
||||
params = convert_to_hash(@params)
|
||||
content_attributes = params.fetch(:content_attributes, {})
|
||||
|
||||
return parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||
return content_attributes if content_attributes.is_a?(Hash)
|
||||
|
||||
{}
|
||||
end
|
||||
|
||||
# Converts the given object to a hash.
|
||||
@@ -105,8 +100,9 @@ class Messages::MessageBuilder
|
||||
end
|
||||
|
||||
def message_type
|
||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||
# Allow incoming messages in both API and Voice channels
|
||||
if !['Channel::Api', 'Channel::Voice'].include?(@conversation.inbox.channel_type) && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api and Voice inboxes'
|
||||
end
|
||||
|
||||
@message_type
|
||||
@@ -139,7 +135,7 @@ class Messages::MessageBuilder
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
message_attrs = {
|
||||
account_id: @conversation.account_id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
@@ -152,5 +148,12 @@ class Messages::MessageBuilder
|
||||
echo_id: @params[:echo_id],
|
||||
source_id: @params[:source_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
|
||||
# Directly add content_attributes from params if present
|
||||
if @params[:content_attributes].present?
|
||||
message_attrs[:content_attributes] = content_attributes
|
||||
end
|
||||
|
||||
message_attrs
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,152 @@
|
||||
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
|
||||
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
|
||||
protect_from_forgery with: :null_session, only: [:incoming, :conference_status]
|
||||
before_action :validate_twilio_signature, only: [:incoming]
|
||||
before_action :handle_options_request, only: [:incoming, :conference_status]
|
||||
|
||||
# Handle CORS preflight OPTIONS requests
|
||||
def handle_options_request
|
||||
if request.method == "OPTIONS"
|
||||
set_cors_headers
|
||||
head :ok
|
||||
return true
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
|
||||
headers['Access-Control-Max-Age'] = '86400' # 24 hours
|
||||
end
|
||||
|
||||
# Handle incoming calls from Twilio
|
||||
def incoming
|
||||
# Set CORS headers first to ensure they're included
|
||||
set_cors_headers
|
||||
|
||||
# Log basic request info
|
||||
Rails.logger.info("🔔 INCOMING CALL WEBHOOK: CallSid=#{params['CallSid']} From=#{params['From']} To=#{params['To']}")
|
||||
|
||||
# Process incoming call using service
|
||||
begin
|
||||
# Ensure account is set properly
|
||||
if !Current.account && params[:account_id].present?
|
||||
Current.account = Account.find(params[:account_id])
|
||||
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
|
||||
end
|
||||
|
||||
# Validate required parameters
|
||||
validate_incoming_params
|
||||
|
||||
# Process the call
|
||||
service = Voice::IncomingCallService.new(
|
||||
account: Current.account,
|
||||
params: params.to_unsafe_h.merge(host_with_port: request.host_with_port)
|
||||
)
|
||||
twiml_response = service.process
|
||||
|
||||
# Return TwiML response
|
||||
Rails.logger.info("✅ INCOMING CALL: Successfully processed")
|
||||
render xml: twiml_response
|
||||
rescue StandardError => e
|
||||
# Log the error with detailed information
|
||||
Rails.logger.error("❌ INCOMING CALL ERROR: #{e.message}")
|
||||
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
|
||||
|
||||
# Return friendly error message to caller
|
||||
render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.")
|
||||
end
|
||||
end
|
||||
|
||||
# Handle conference status updates
|
||||
def conference_status
|
||||
# Set CORS headers first to ensure they're always included
|
||||
set_cors_headers
|
||||
|
||||
# Return immediately for OPTIONS requests
|
||||
if request.method == "OPTIONS"
|
||||
return head :ok
|
||||
end
|
||||
|
||||
# Log basic request info
|
||||
Rails.logger.info("🎧 CONFERENCE STATUS WEBHOOK: ConferenceSid=#{params['ConferenceSid']} Event=#{params['StatusCallbackEvent']}")
|
||||
|
||||
# Process conference status updates using service
|
||||
begin
|
||||
# Set account for local development if needed
|
||||
if !Current.account && params[:account_id].present?
|
||||
Current.account = Account.find(params[:account_id])
|
||||
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
|
||||
end
|
||||
|
||||
# Validate required parameters
|
||||
if params['ConferenceSid'].blank? && params['CallSid'].blank?
|
||||
Rails.logger.error("❌ MISSING REQUIRED PARAMS: Need either ConferenceSid or CallSid")
|
||||
end
|
||||
|
||||
# Use service to process conference status
|
||||
service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
|
||||
service.process
|
||||
|
||||
Rails.logger.info("✅ CONFERENCE STATUS: Successfully processed")
|
||||
rescue StandardError => e
|
||||
# Log errors but don't affect the response
|
||||
Rails.logger.error("❌ CONFERENCE STATUS ERROR: #{e.message}")
|
||||
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
|
||||
end
|
||||
|
||||
# Always return a successful response for Twilio
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_incoming_params
|
||||
if params['CallSid'].blank?
|
||||
raise "Missing required parameter: CallSid"
|
||||
end
|
||||
|
||||
if params['From'].blank?
|
||||
raise "Missing required parameter: From"
|
||||
end
|
||||
|
||||
if params['To'].blank?
|
||||
raise "Missing required parameter: To"
|
||||
end
|
||||
|
||||
if Current.account.nil?
|
||||
raise "Current account not set"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_twilio_signature
|
||||
begin
|
||||
validator = Voice::TwilioValidatorService.new(
|
||||
account: Current.account,
|
||||
params: params,
|
||||
request: request
|
||||
)
|
||||
|
||||
if !validator.valid?
|
||||
Rails.logger.error("❌ INVALID TWILIO SIGNATURE")
|
||||
render_error('Invalid Twilio signature')
|
||||
return false
|
||||
end
|
||||
|
||||
return true
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
|
||||
render_error('Error validating Twilio request')
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
def render_error(message)
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
response.say(message: message)
|
||||
response.hangup
|
||||
render xml: response.to_s
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_contact
|
||||
|
||||
def create
|
||||
# Validate that contact has a phone number
|
||||
if @contact.phone_number.blank?
|
||||
render json: { error: 'Contact has no phone number' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Use the outgoing call service to handle the entire process
|
||||
service = Voice::OutgoingCallService.new(
|
||||
account: Current.account,
|
||||
contact: @contact,
|
||||
user: Current.user
|
||||
)
|
||||
|
||||
# Process the call - this handles all the steps
|
||||
conversation = service.process
|
||||
|
||||
# Assign to @conversation so jbuilder template can access it
|
||||
@conversation = conversation
|
||||
|
||||
# Use the conversation jbuilder template to ensure consistent representation
|
||||
# This will ensure only display_id is used as the id, not the internal database id
|
||||
render 'api/v1/accounts/conversations/show'
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error initiating call: #{e.message}")
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_contact
|
||||
@contact = Current.account.contacts.find(params[:contact_id])
|
||||
end
|
||||
end
|
||||
@@ -163,7 +163,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
'line' => Channel::Line,
|
||||
'telegram' => Channel::Telegram,
|
||||
'whatsapp' => Channel::Whatsapp,
|
||||
'sms' => Channel::Sms
|
||||
'sms' => Channel::Sms,
|
||||
'voice' => Channel::Voice
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
class Api::V1::Accounts::Voice::TokensController < Api::V1::Accounts::BaseController
|
||||
before_action :set_voice_inbox
|
||||
|
||||
def create
|
||||
render json: build_response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Voice::TokensController#create: #{e.class} - #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
||||
render json: { error: 'Failed to generate token', details: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_response
|
||||
{
|
||||
token: twilio_token.to_jwt,
|
||||
identity: client_identity,
|
||||
voice_enabled: true,
|
||||
account_sid: twilio_config[:account_sid],
|
||||
agent_id: Current.user.id,
|
||||
account_id: Current.account.id,
|
||||
inbox_id: @voice_inbox.id,
|
||||
phone_number: twilio_config[:phone_number],
|
||||
twiml_endpoint: twilio_config[:twiml_url],
|
||||
has_twiml_app: twilio_config[:outgoing_app_sid].present?
|
||||
}
|
||||
end
|
||||
|
||||
def twilio_token
|
||||
Twilio::JWT::AccessToken.new(
|
||||
*twilio_credentials,
|
||||
identity: client_identity,
|
||||
ttl: 1.hour.to_i
|
||||
).tap { |t| t.add_grant(voice_grant) }
|
||||
end
|
||||
|
||||
def twilio_credentials
|
||||
twilio_config.values_at(:account_sid, :api_key_sid, :api_key_secret)
|
||||
end
|
||||
|
||||
def voice_grant
|
||||
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
|
||||
grant.incoming_allow = true
|
||||
grant.outgoing_application_sid = twilio_config[:outgoing_app_sid]
|
||||
grant.outgoing_application_params = outgoing_params
|
||||
end
|
||||
end
|
||||
|
||||
def outgoing_params
|
||||
{
|
||||
account_id: Current.account.id,
|
||||
agent_id: Current.user.id,
|
||||
identity: client_identity,
|
||||
client_name: client_identity,
|
||||
accountSid: twilio_config[:account_sid],
|
||||
is_agent: 'true'
|
||||
}
|
||||
end
|
||||
|
||||
def twilio_config
|
||||
@twilio_config ||= begin
|
||||
cfg = @voice_inbox.channel.provider_config_hash || {}
|
||||
{
|
||||
account_sid: cfg['account_sid'],
|
||||
api_key_sid: cfg['api_key_sid'],
|
||||
api_key_secret: cfg['api_key_secret'],
|
||||
outgoing_app_sid: cfg['outgoing_application_sid'],
|
||||
phone_number: @voice_inbox.channel.phone_number,
|
||||
twiml_url: "#{ENV.fetch('FRONTEND_URL', '')}/api/v1/accounts/#{Current.account.id}/voice/twiml_for_client"
|
||||
}.with_indifferent_access.merge(client_identity:)
|
||||
end
|
||||
end
|
||||
|
||||
def client_identity
|
||||
@client_identity ||= "agent-#{Current.user.id}-account-#{Current.account.id}"
|
||||
end
|
||||
|
||||
def set_voice_inbox
|
||||
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,230 @@
|
||||
require 'twilio-ruby'
|
||||
|
||||
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: %i[end_call join_call reject_call]
|
||||
skip_before_action :authenticate_user!, only: :twiml_for_client
|
||||
protect_from_forgery with: :null_session, only: :twiml_for_client
|
||||
|
||||
before_action :render_options, if: -> { request.options? }
|
||||
after_action :set_cors_headers, if: -> { action_name == 'twiml_for_client' }
|
||||
|
||||
# ---------- PUBLIC ACTIONS --------------------------------------------------
|
||||
|
||||
def end_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
return render_not_found('active call') unless call_sid
|
||||
|
||||
twilio_client.calls(call_sid).update(status: 'completed') if in_progress?(call_sid)
|
||||
|
||||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||||
call_sid: call_sid,
|
||||
provider: :twilio)
|
||||
.process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
|
||||
|
||||
broadcast_status(call_sid, 'completed')
|
||||
render_success('Call successfully ended')
|
||||
rescue StandardError => e
|
||||
render_error("Failed to end call: #{e.message}")
|
||||
end
|
||||
|
||||
def join_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
outbound = convo_attr('requires_agent_join') == true
|
||||
|
||||
return render_not_found('active call') unless call_sid || outbound
|
||||
|
||||
conference_sid = convo_attr('conference_sid') || create_conference_sid!
|
||||
update_join_metadata!(call_sid)
|
||||
broadcast_status(call_sid, 'in-progress')
|
||||
|
||||
render json: {
|
||||
status: 'success',
|
||||
message: 'Agent joining call via WebRTC',
|
||||
conference_sid: conference_sid,
|
||||
using_webrtc: true,
|
||||
conversation_id: @conversation.display_id,
|
||||
account_id: Current.account.id
|
||||
}
|
||||
rescue StandardError => e
|
||||
render_error("Failed to join call: #{e.message}")
|
||||
end
|
||||
|
||||
def reject_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
return render_not_found('active call') unless call_sid
|
||||
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge(
|
||||
'agent_rejected' => true,
|
||||
'rejected_at' => Time.current.to_i,
|
||||
'rejected_by' => user_meta
|
||||
))
|
||||
|
||||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||||
call_sid: call_sid,
|
||||
provider: :twilio)
|
||||
.create_activity_message("#{current_user.name} declined to answer",
|
||||
rejected_by: current_user.name,
|
||||
rejected_at: Time.current.to_i)
|
||||
|
||||
render_success('Call rejected by agent')
|
||||
end
|
||||
|
||||
def call_status
|
||||
call_sid = params[:call_sid]
|
||||
return render_not_found('active call') unless call_sid
|
||||
|
||||
call = twilio_client.calls(call_sid).fetch
|
||||
render json: call.slice(:status, :duration, :direction, :from, :to, :start_time, :end_time)
|
||||
rescue StandardError => e
|
||||
render_error("Failed to fetch call status: #{e.message}")
|
||||
end
|
||||
|
||||
# TwiML for agent WebRTC dial‑in
|
||||
def twiml_for_client
|
||||
to = params[:To] || params[:to]
|
||||
return render_twiml_error('Missing conference ID parameter') if to.blank?
|
||||
|
||||
render xml: build_twiml(to), content_type: 'text/xml'
|
||||
rescue StandardError => e
|
||||
render_twiml_error(e.message)
|
||||
end
|
||||
|
||||
# ---------- PRIVATE ---------------------------------------------------------
|
||||
|
||||
private
|
||||
|
||||
# ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
def render_options
|
||||
head :ok
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers['Content-Type'] ||= 'text/xml; charset=utf-8'
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
|
||||
headers['Access-Control-Max-Age'] = '86400'
|
||||
end
|
||||
|
||||
def render_success(msg) = render json: { status: 'success', message: msg }
|
||||
def render_not_found(resource) = render json: { error: "No #{resource} found" }, status: :not_found
|
||||
def render_error(msg) = render json: { error: msg }, status: :internal_server_error
|
||||
|
||||
def fetch_conversation
|
||||
@conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= begin
|
||||
cfg = @conversation.inbox.channel.provider_config_hash
|
||||
Twilio::REST::Client.new(cfg['account_sid'], cfg['auth_token'])
|
||||
end
|
||||
end
|
||||
|
||||
def in_progress?(call_sid)
|
||||
%w[in-progress ringing].include?(twilio_client.calls(call_sid).fetch.status)
|
||||
end
|
||||
|
||||
def convo_attrs
|
||||
@conversation.additional_attributes || {}
|
||||
end
|
||||
|
||||
def convo_attr(key)
|
||||
convo_attrs[key]
|
||||
end
|
||||
|
||||
def user_meta
|
||||
{ id: current_user.id, name: current_user.name }
|
||||
end
|
||||
|
||||
def create_conference_sid!
|
||||
sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}"
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge('conference_sid' => sid))
|
||||
sid
|
||||
end
|
||||
|
||||
def update_join_metadata!(call_sid)
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge(
|
||||
'agent_joined' => true,
|
||||
'joined_at' => Time.current.to_i,
|
||||
'joined_by' => user_meta,
|
||||
'call_status' => 'in-progress'
|
||||
))
|
||||
|
||||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||||
call_sid: call_sid,
|
||||
provider: :twilio)
|
||||
.process_status_update('in-progress', nil, false, "#{current_user.name} joined the call")
|
||||
end
|
||||
|
||||
def broadcast_status(call_sid, status)
|
||||
ActionCable.server.broadcast "account_#{@conversation.account_id}", {
|
||||
event_name: 'call_status_changed',
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: status,
|
||||
conversation_id: @conversation.display_id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
timestamp: Time.current.to_i
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# ---- TwiML -----------------------------------------------------------------
|
||||
|
||||
def build_twiml(conference_name)
|
||||
# For agent legs, we need to add transcription too
|
||||
account_id = params[:account_id] || Current.account&.id
|
||||
agent_id = params[:agent_id] || current_user&.id
|
||||
transcription_url = "#{base_url}/twilio/transcription_callback?account_id=#{account_id}&conference_sid=#{conference_name}&speaker_type=agent&agent_id=#{agent_id}"
|
||||
|
||||
Twilio::TwiML::VoiceResponse.new do |r|
|
||||
# Add transcription for the agent leg too
|
||||
r.start do |start|
|
||||
start.transcription(
|
||||
status_callback_url: transcription_url,
|
||||
status_callback_method: 'POST',
|
||||
track: 'inbound_track', # Use inbound_track consistently for conference calls
|
||||
language_code: 'en-US'
|
||||
)
|
||||
end
|
||||
|
||||
r.dial do |dial|
|
||||
dial.conference(
|
||||
conference_name,
|
||||
startConferenceOnEnter: true,
|
||||
endConferenceOnExit: true,
|
||||
muted: false,
|
||||
beep: false,
|
||||
waitUrl: '',
|
||||
earlyMedia: true,
|
||||
statusCallback: conference_callback_url,
|
||||
statusCallbackEvent: 'start end join leave',
|
||||
statusCallbackMethod: 'POST',
|
||||
participantLabel: "agent-#{params[:agent_id] || current_user&.id}"
|
||||
)
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def conference_callback_url
|
||||
account_id = params[:account_id] || Current.account&.id
|
||||
"#{base_url.chomp('/')}/api/v1/accounts/#{account_id}/channels/voice/webhooks/conference_status"
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', '')
|
||||
end
|
||||
|
||||
# ---- TwiML Error -----------------------------------------------------------
|
||||
|
||||
def render_twiml_error(message)
|
||||
response = Twilio::TwiML::VoiceResponse.new do |r|
|
||||
r.say(message: "Error: #{message}")
|
||||
r.hangup
|
||||
end
|
||||
set_cors_headers
|
||||
render xml: response.to_s, content_type: 'text/xml'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Twilio::RecordingController < ActionController::Base
|
||||
skip_forgery_protection
|
||||
|
||||
# POST /twilio/recording_callback
|
||||
# This endpoint is called by Twilio when a call recording is available
|
||||
def recording_callback
|
||||
conference_sid = params['conference_sid']
|
||||
call_sid = params['CallSid']
|
||||
recording_url = params['RecordingUrl']
|
||||
recording_sid = params['RecordingSid']
|
||||
account_id = params['account_id']
|
||||
Rails.logger.info("[Twilio::RecordingController] Incoming recording_callback with params: #{params.inspect}")
|
||||
unless recording_url && account_id && (conference_sid || call_sid)
|
||||
Rails.logger.warn("[Twilio::RecordingController] Missing required params. recording_url: #{recording_url}, account_id: #{account_id}, conference_sid: #{conference_sid}, call_sid: #{call_sid}")
|
||||
return head :bad_request
|
||||
end
|
||||
|
||||
# Find the account
|
||||
account = Account.find_by(id: account_id)
|
||||
unless account
|
||||
Rails.logger.warn("[Twilio::RecordingController] Account not found for id: #{account_id}")
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
# Prefer lookup by conference_sid (most robust for conference recordings)
|
||||
conversation = if conference_sid
|
||||
account.conversations.find_by("additional_attributes ->> 'conference_sid' = ?", conference_sid)
|
||||
elsif call_sid
|
||||
account.conversations.find_by("additional_attributes ->> 'call_sid' = ?", call_sid)
|
||||
end
|
||||
unless conversation
|
||||
Rails.logger.warn("[Twilio::RecordingController] Conversation not found for conference_sid: #{conference_sid} or call_sid: #{call_sid}")
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
# Find the original voice call message (should be unique per conference)
|
||||
message = conversation.messages.voice_call.order(:created_at).first
|
||||
unless message
|
||||
Rails.logger.warn("[Twilio::RecordingController] No voice_call message found in conversation_id: #{conversation.id}")
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
# Download the recording from Twilio
|
||||
begin
|
||||
Rails.logger.info("[Twilio::RecordingController] Downloading recording from: #{recording_url}.mp3")
|
||||
file = URI.open(recording_url + '.mp3')
|
||||
rescue => e
|
||||
Rails.logger.error("[Twilio::RecordingController] Failed to download recording: #{e.message}")
|
||||
return head :internal_server_error
|
||||
end
|
||||
|
||||
# Attach the audio file to the message as an audio attachment
|
||||
begin
|
||||
att = message.attachments.create!(
|
||||
account_id: account.id,
|
||||
file: {
|
||||
io: file,
|
||||
filename: "twilio_recording_#{recording_sid}.mp3",
|
||||
content_type: 'audio/mpeg'
|
||||
},
|
||||
file_type: :audio,
|
||||
external_url: recording_url + '.mp3',
|
||||
meta: { recording_sid: recording_sid, conference_sid: conference_sid, call_sid: call_sid }
|
||||
)
|
||||
Rails.logger.info("[Twilio::RecordingController] Successfully attached recording to message_id: #{message.id}, attachment_id: #{att.id}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Twilio::RecordingController] Failed to attach recording: #{e.message}")
|
||||
return head :internal_server_error
|
||||
end
|
||||
|
||||
# Optionally, update message content_attributes to indicate recording is attached
|
||||
content_attributes = message.content_attributes || {}
|
||||
content_attributes['recording_attached'] = true
|
||||
content_attributes['conference_sid'] = conference_sid if conference_sid
|
||||
message.update!(content_attributes: content_attributes)
|
||||
|
||||
head :ok
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
class Twilio::TranscriptionController < ActionController::Base
|
||||
skip_forgery_protection
|
||||
|
||||
# Receives real-time transcription updates from Twilio
|
||||
def transcription_callback
|
||||
# Set Current.account
|
||||
Current.account = Account.find_by(id: params[:account_id])
|
||||
|
||||
# Only process transcription content events
|
||||
if params['TranscriptionEvent'] == 'transcription-content'
|
||||
process_transcription_content
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_transcription_content
|
||||
# Extract transcript content from JSON
|
||||
data = JSON.parse(params['TranscriptionData'])
|
||||
transcript_content = data['transcript']
|
||||
confidence = data['confidence']
|
||||
|
||||
|
||||
# Find conversation by conference_sid from our standard format
|
||||
display_id = params[:conference_sid].match(/^conf_account_\d+_conv_(\d+)$/)[1]
|
||||
conversation = Current.account.conversations.find_by(display_id: display_id)
|
||||
|
||||
# Create message based on speaker_type
|
||||
create_message(conversation, transcript_content, confidence)
|
||||
end
|
||||
|
||||
def create_message(conversation, content, confidence)
|
||||
if params[:speaker_type] == 'contact'
|
||||
# Contact message (incoming)
|
||||
sender = conversation.contact
|
||||
message_type = :incoming
|
||||
else
|
||||
# Agent message (outgoing)
|
||||
sender = User.find_by(id: params[:agent_id])
|
||||
message_type = :outgoing
|
||||
end
|
||||
|
||||
# Create the message
|
||||
Messages::MessageBuilder.new(
|
||||
sender,
|
||||
conversation,
|
||||
content: content,
|
||||
message_type: message_type,
|
||||
private: false,
|
||||
additional_attributes: {
|
||||
transcription: true,
|
||||
call_sid: params['CallSid'],
|
||||
conference_sid: params[:conference_sid],
|
||||
speaker_type: params[:speaker_type],
|
||||
confidence: confidence,
|
||||
track: params['Track']
|
||||
}
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,189 @@
|
||||
class Twilio::VoiceController < ActionController::Base
|
||||
skip_forgery_protection
|
||||
|
||||
before_action :set_call_details, only: %i[status_callback simple_twiml]
|
||||
before_action :set_inbox, only: %i[status_callback simple_twiml]
|
||||
|
||||
|
||||
def status_callback
|
||||
return head :ok unless @inbox
|
||||
|
||||
conversation = Voice::ConversationFinderService.new(
|
||||
account: @inbox.account,
|
||||
call_sid: @call_sid,
|
||||
phone_number: incoming_number,
|
||||
is_outbound: outbound?,
|
||||
inbox: @inbox
|
||||
).perform
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: @call_sid,
|
||||
provider: :twilio
|
||||
).process_status_update(params[:CallStatus], params[:CallDuration]&.to_i, first_status_response?)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
def simple_twiml
|
||||
return fallback_twiml unless @inbox
|
||||
|
||||
conversation = Voice::ConversationFinderService.new(
|
||||
account: @inbox.account,
|
||||
call_sid: @call_sid,
|
||||
phone_number: incoming_number,
|
||||
is_outbound: outbound?,
|
||||
inbox: @inbox
|
||||
).perform
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: @call_sid,
|
||||
provider: :twilio
|
||||
).process_status_update('in-progress', nil, true)
|
||||
|
||||
conference_name = ensure_conference_name(conversation, params[:conference_name])
|
||||
|
||||
conversation.update!(
|
||||
additional_attributes: conversation.additional_attributes.merge(
|
||||
'conference_sid' => conference_name,
|
||||
'call_direction' => outbound? ? 'outbound' : 'inbound',
|
||||
'requires_agent_join' => true
|
||||
)
|
||||
)
|
||||
|
||||
render_twiml do |r|
|
||||
r.say(message: 'Please wait while we connect you to an agent')
|
||||
|
||||
# Enable real-time transcription for this call leg
|
||||
# For outbound calls, we're connecting to the contact, so this track is for the contact
|
||||
contact_id = conversation.contact_id
|
||||
callback_url = "#{base_url}/twilio/transcription_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}&speaker_type=contact&contact_id=#{contact_id}"
|
||||
Rails.logger.info("📞 VoiceController: Setting transcription callback to: #{callback_url}")
|
||||
|
||||
r.start do |start|
|
||||
start.transcription(
|
||||
status_callback_url: callback_url,
|
||||
status_callback_method: 'POST',
|
||||
track: 'inbound_track',
|
||||
language_code: 'en-US'
|
||||
)
|
||||
end
|
||||
|
||||
# Set up the conference
|
||||
conference_callback_url = "#{base_url}/api/v1/accounts/#{@inbox.account_id}/channels/voice/webhooks/conference_status"
|
||||
Rails.logger.info("📞 VoiceController: Setting conference callback to: #{conference_callback_url}")
|
||||
|
||||
r.dial do |d|
|
||||
d.conference(
|
||||
conference_name,
|
||||
startConferenceOnEnter: false,
|
||||
endConferenceOnExit: true,
|
||||
beep: false,
|
||||
muted: false,
|
||||
waitUrl: '',
|
||||
earlyMedia: true,
|
||||
statusCallback: conference_callback_url,
|
||||
statusCallbackMethod: 'POST',
|
||||
statusCallbackEvent: 'start end join leave',
|
||||
participantLabel: "caller-#{@call_sid.last(8)}",
|
||||
record: 'record-from-start',
|
||||
recording_status_callback: "#{base_url}/twilio/recording_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}",
|
||||
recording_status_callback_method: 'POST'
|
||||
)
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error creating voice conversation: #{e.message}")
|
||||
fallback_twiml
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_call_details
|
||||
@call_sid = params[:CallSid]
|
||||
@direction = params[:Direction]
|
||||
end
|
||||
|
||||
def set_inbox
|
||||
@inbox = find_inbox(outbound? ? params[:From] : params[:To])
|
||||
end
|
||||
|
||||
def outbound?
|
||||
@direction == 'outbound-api'
|
||||
end
|
||||
|
||||
def incoming_number
|
||||
outbound? ? params[:To] : params[:From]
|
||||
end
|
||||
|
||||
def first_status_response?
|
||||
params[:IsFirstResponseForStatus] == 'true'
|
||||
end
|
||||
|
||||
def render_twiml(status: :ok)
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
yield response
|
||||
render xml: response.to_s, status: status
|
||||
end
|
||||
|
||||
def build_message(conversation, content)
|
||||
Messages::MessageBuilder.new(
|
||||
nil,
|
||||
conversation,
|
||||
content: content,
|
||||
message_type: :activity,
|
||||
additional_attributes: { call_sid: @call_sid, call_status: 'in-progress', user_input: true }
|
||||
).perform
|
||||
end
|
||||
|
||||
def input_text
|
||||
return "Caller pressed #{params[:Digits]}" if params[:Digits].present?
|
||||
return "Caller said: \"#{params[:SpeechResult]}\"" if params[:SpeechResult].present?
|
||||
|
||||
'Caller responded'
|
||||
end
|
||||
|
||||
def ensure_conference_name(conversation, supplied)
|
||||
name = supplied.presence ||
|
||||
conversation.additional_attributes['conference_sid'] ||
|
||||
conversation.additional_attributes['conference_name']
|
||||
|
||||
return name if name&.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
|
||||
"conf_account_#{@inbox.account_id}_conv_#{conversation.display_id}"
|
||||
end
|
||||
|
||||
def fallback_twiml
|
||||
render_twiml do |r|
|
||||
r.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup.')
|
||||
r.pause(length: 1)
|
||||
r.say(message: 'We will connect you with an agent shortly.')
|
||||
r.hangup
|
||||
end
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
def find_inbox(phone_number)
|
||||
return nil if phone_number.blank?
|
||||
|
||||
Inbox.joins('INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id')
|
||||
.find_by('channel_voice.phone_number = ?', phone_number)
|
||||
end
|
||||
|
||||
def find_or_create_conversation(inbox, phone_number, call_sid)
|
||||
Voice::ConversationFinderService.new(
|
||||
account: inbox.account,
|
||||
call_sid: call_sid,
|
||||
phone_number: phone_number,
|
||||
is_outbound: false,
|
||||
inbox: inbox
|
||||
).perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("find_or_create_conversation error: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher
|
||||
CsatSurveyListener.instance,
|
||||
HookListener.instance,
|
||||
InstallationWebhookListener.instance,
|
||||
MessageListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
ReportingEventListener.instance,
|
||||
|
||||
@@ -107,7 +107,8 @@ module Api::V1::InboxesHelper
|
||||
'line' => Current.account.line_channels,
|
||||
'telegram' => Current.account.telegram_channels,
|
||||
'whatsapp' => Current.account.whatsapp_channels,
|
||||
'sms' => Current.account.sms_channels
|
||||
'sms' => Current.account.sms_channels,
|
||||
'voice' => Current.account.voice_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import FloatingCallWidget from './components/widgets/FloatingCallWidget.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
@@ -14,6 +15,8 @@ import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useFontSize } from 'dashboard/composables/useFontSize';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
@@ -25,6 +28,7 @@ export default {
|
||||
|
||||
components: {
|
||||
AddAccountModal,
|
||||
FloatingCallWidget,
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
@@ -51,6 +55,7 @@ export default {
|
||||
showAddAccountModal: false,
|
||||
latestChatwootVersion: null,
|
||||
reconnectService: null,
|
||||
showCallWidget: false, // Will be set to true when calls are active
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -60,6 +65,10 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
activeCall: 'calls/getActiveCall',
|
||||
hasActiveCall: 'calls/hasActiveCall',
|
||||
incomingCall: 'calls/getIncomingCall',
|
||||
hasIncomingCall: 'calls/hasIncomingCall',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
@@ -84,11 +93,34 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
hasIncomingCall: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.showCallWidget = true;
|
||||
} else {
|
||||
this.showCallWidget = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
hasActiveCall: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.showCallWidget = true;
|
||||
} else {
|
||||
this.showCallWidget = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeColorTheme();
|
||||
this.listenToThemeChanges();
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
|
||||
// Make app instance available globally for direct call widget updates
|
||||
window.app = this;
|
||||
},
|
||||
unmounted() {
|
||||
if (this.reconnectService) {
|
||||
@@ -106,6 +138,77 @@ export default {
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
},
|
||||
handleCallEnded() {
|
||||
this.showCallWidget = false;
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
this.$store.dispatch('calls/clearIncomingCall');
|
||||
|
||||
// Clear the activeCallConversation state in all ContactInfo components
|
||||
this.$nextTick(() => {
|
||||
const clearContactInfoCallState = (components) => {
|
||||
if (!components) return;
|
||||
|
||||
components.forEach(component => {
|
||||
if (component.$options && component.$options.name === 'ContactInfo') {
|
||||
if (component.activeCallConversation) {
|
||||
component.activeCallConversation = null;
|
||||
component.$forceUpdate();
|
||||
}
|
||||
}
|
||||
if (component.$children && component.$children.length) {
|
||||
clearContactInfoCallState(component.$children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
clearContactInfoCallState(this.$children);
|
||||
});
|
||||
},
|
||||
handleCallJoined() {
|
||||
this.showCallWidget = true;
|
||||
},
|
||||
handleCallRejected() {
|
||||
this.showCallWidget = false;
|
||||
this.$store.dispatch('calls/clearIncomingCall');
|
||||
},
|
||||
forceEndCall() {
|
||||
this.showCallWidget = false;
|
||||
if (window.forceEndCallHandlers) {
|
||||
window.forceEndCallHandlers.forEach(handler => {
|
||||
try {
|
||||
handler();
|
||||
} catch (e) {
|
||||
// Optionally log error in production
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.activeCall && this.activeCall.callSid) {
|
||||
const { callSid, conversationId } = this.activeCall;
|
||||
const savedCallSid = callSid;
|
||||
const savedConversationId = conversationId;
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
if (savedConversationId) {
|
||||
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||
.then(() => {
|
||||
useAlert({ message: 'Call ended successfully', type: 'success' });
|
||||
})
|
||||
.catch(() => {
|
||||
setTimeout(() => {
|
||||
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||
.then(() => {
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
}, 1000);
|
||||
useAlert({ message: 'Call UI has been reset', type: 'info' });
|
||||
});
|
||||
} else {
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
}
|
||||
} else {
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
}
|
||||
},
|
||||
async initializeAccount() {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
@@ -153,6 +256,25 @@ export default {
|
||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||
<WootSnackbarBox />
|
||||
<NetworkNotification />
|
||||
<!-- Floating call widget that appears during active calls -->
|
||||
<FloatingCallWidget
|
||||
v-if="showCallWidget || hasActiveCall || hasIncomingCall"
|
||||
:key="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : 'no-call')"
|
||||
:call-sid="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : '')"
|
||||
:inbox-name="activeCall ? (activeCall.inboxName || 'Primary') : (incomingCall ? incomingCall.inboxName : 'Primary')"
|
||||
:conversation-id="activeCall ? activeCall.conversationId : (incomingCall ? incomingCall.conversationId : null)"
|
||||
:contact-name="activeCall ? activeCall.contactName : (incomingCall ? incomingCall.contactName : '')"
|
||||
:contact-id="activeCall ? activeCall.contactId : (incomingCall ? incomingCall.contactId : null)"
|
||||
:inbox-id="activeCall ? activeCall.inboxId : (incomingCall ? incomingCall.inboxId : null)"
|
||||
:inbox-avatar-url="activeCall ? activeCall.inboxAvatarUrl : (incomingCall ? incomingCall.inboxAvatarUrl : '')"
|
||||
:inbox-phone-number="activeCall ? activeCall.inboxPhoneNumber : (incomingCall ? incomingCall.inboxPhoneNumber : '')"
|
||||
:avatar-url="activeCall ? activeCall.avatarUrl : (incomingCall ? incomingCall.avatarUrl : '')"
|
||||
:phone-number="activeCall ? activeCall.phoneNumber : (incomingCall ? incomingCall.phoneNumber : '')"
|
||||
use-web-rtc
|
||||
@callEnded="handleCallEnded"
|
||||
@callJoined="handleCallJoined"
|
||||
@callRejected="handleCallRejected"
|
||||
/>
|
||||
</div>
|
||||
<LoadingState v-else />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class VoiceAPI extends ApiClient {
|
||||
constructor() {
|
||||
// Use 'voice' as the resource with accountScoped: true
|
||||
super('voice', { accountScoped: true });
|
||||
|
||||
// Client-side Twilio device
|
||||
this.device = null;
|
||||
this.activeConnection = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
// Initiate a call to a contact
|
||||
initiateCall(contactId) {
|
||||
if (!contactId) {
|
||||
throw new Error('Contact ID is required to initiate a call');
|
||||
}
|
||||
|
||||
// Based on the route definition, the correct URL path is /api/v1/accounts/{accountId}/contacts/{contactId}/call
|
||||
// The endpoint is defined in the contacts namespace, not voice namespace
|
||||
return axios.post(`${this.baseUrl().replace('/voice', '')}/contacts/${contactId}/call`);
|
||||
}
|
||||
|
||||
// End an active call
|
||||
endCall(callSid, conversationId) {
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to end a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to end a call');
|
||||
}
|
||||
|
||||
// Validate call SID format - Twilio call SID starts with 'CA' or 'TJ'
|
||||
if (!callSid.startsWith('CA') && !callSid.startsWith('TJ')) {
|
||||
throw new Error(
|
||||
'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.'
|
||||
);
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/end_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Get call status
|
||||
getCallStatus(callSid) {
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to get call status');
|
||||
}
|
||||
|
||||
return axios.get(`${this.url}/call_status`, {
|
||||
params: { call_sid: callSid },
|
||||
});
|
||||
}
|
||||
|
||||
// Join an incoming call as an agent (join the conference)
|
||||
// This is used for the WebRTC client-side setup, not for phone calls anymore
|
||||
joinCall(params) {
|
||||
// Check if we have individual parameters or a params object
|
||||
const conversationId = params.conversation_id || params.conversationId;
|
||||
const callSid = params.call_sid || params.callSid;
|
||||
const accountId = params.account_id;
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to join a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to join a call');
|
||||
}
|
||||
|
||||
// Build request payload with proper naming convention
|
||||
const payload = {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
};
|
||||
|
||||
// Add account_id if provided
|
||||
if (accountId) {
|
||||
payload.account_id = accountId;
|
||||
}
|
||||
|
||||
console.log('Calling join_call API endpoint with payload:', payload);
|
||||
|
||||
return axios.post(`${this.url}/join_call`, payload);
|
||||
}
|
||||
|
||||
// Reject an incoming call as an agent (don't join the conference)
|
||||
rejectCall(callSid, conversationId) {
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to reject a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to reject a call');
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/reject_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Client SDK methods
|
||||
|
||||
// Get a capability token for the Twilio Client
|
||||
getToken(inboxId) {
|
||||
console.log(`Requesting token for inbox ID: ${inboxId} at URL: ${this.url}/tokens`);
|
||||
|
||||
// Log the base URL for debugging
|
||||
console.log(`Base URL: ${this.baseUrl()}`);
|
||||
|
||||
// Check if inboxId is valid
|
||||
if (!inboxId) {
|
||||
console.error('No inbox ID provided for token request');
|
||||
return Promise.reject(new Error('Inbox ID is required'));
|
||||
}
|
||||
|
||||
// Add more request details to help debugging
|
||||
return axios.post(`${this.url}/tokens`, { inbox_id: inboxId }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).catch(error => {
|
||||
// Extract useful error details for debugging
|
||||
const errorInfo = {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
url: `${this.url}/tokens`,
|
||||
inboxId,
|
||||
};
|
||||
|
||||
console.error('Token request error details:', errorInfo);
|
||||
|
||||
// Try to extract a more useful error message from the HTML response if it's a 500 error
|
||||
if (error.response?.status === 500 && typeof error.response.data === 'string') {
|
||||
// Look for specific error patterns in the HTML
|
||||
const htmlData = error.response.data;
|
||||
|
||||
// Check for common Ruby/Rails error patterns
|
||||
const nameMatchResult = htmlData.match(/<h2>(.*?)<\/h2>/);
|
||||
const detailsMatchResult = htmlData.match(/<pre>([\s\S]*?)<\/pre>/);
|
||||
|
||||
const errorName = nameMatchResult ? nameMatchResult[1] : null;
|
||||
const errorDetails = detailsMatchResult ? detailsMatchResult[1] : null;
|
||||
|
||||
if (errorName || errorDetails) {
|
||||
const enhancedError = new Error(`Server error: ${errorName || 'Internal Server Error'}`);
|
||||
enhancedError.details = errorDetails;
|
||||
enhancedError.originalError = error;
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the Twilio Device
|
||||
async initializeDevice(inboxId) {
|
||||
// If already initialized, return the existing device after checking its health
|
||||
if (this.initialized && this.device) {
|
||||
const deviceState = this.device.state;
|
||||
console.log('Device already initialized, current state:', deviceState);
|
||||
|
||||
// If the device is in a bad state, destroy and reinitialize
|
||||
if (deviceState === 'error' || deviceState === 'unregistered') {
|
||||
console.log('Device is in a bad state, destroying and reinitializing...');
|
||||
try {
|
||||
this.device.destroy();
|
||||
} catch (e) {
|
||||
console.log('Error destroying device:', e);
|
||||
}
|
||||
this.device = null;
|
||||
this.initialized = false;
|
||||
} else {
|
||||
// Device is in a good state, return it
|
||||
return this.device;
|
||||
}
|
||||
}
|
||||
|
||||
// Device needs to be initialized or reinitialized
|
||||
try {
|
||||
console.log(`Starting Twilio Device initialization for inbox: ${inboxId}`);
|
||||
|
||||
// Import the Twilio Voice SDK
|
||||
let Device;
|
||||
try {
|
||||
// We know the package is installed via package.json
|
||||
const { Device: TwilioDevice } = await import('@twilio/voice-sdk');
|
||||
Device = TwilioDevice;
|
||||
console.log('✓ Twilio Voice SDK imported successfully');
|
||||
} catch (importError) {
|
||||
console.error('✗ Failed to import Twilio Voice SDK:', importError);
|
||||
throw new Error(`Failed to load Twilio Voice SDK: ${importError.message}`);
|
||||
}
|
||||
|
||||
// Validate inbox ID
|
||||
if (!inboxId) {
|
||||
throw new Error('Inbox ID is required to initialize the Twilio Device');
|
||||
}
|
||||
|
||||
// Step 1: Get a token from the server
|
||||
console.log(`Requesting Twilio token for inbox: ${inboxId}`);
|
||||
let response;
|
||||
try {
|
||||
response = await this.getToken(inboxId);
|
||||
console.log(`✓ Token response received with status: ${response.status}`);
|
||||
} catch (tokenError) {
|
||||
console.error('✗ Token request failed:', tokenError);
|
||||
|
||||
// Enhanced error handling for token requests
|
||||
if (tokenError.details) {
|
||||
// If we already have extracted details from the error, include those
|
||||
console.error('Token error details:', tokenError.details);
|
||||
throw new Error(`Failed to get token: ${tokenError.message}`);
|
||||
}
|
||||
|
||||
// Check for specific HTTP error status codes
|
||||
if (tokenError.response) {
|
||||
const status = tokenError.response.status;
|
||||
const data = tokenError.response.data;
|
||||
|
||||
if (status === 401) {
|
||||
throw new Error('Authentication error: Please check your Twilio credentials');
|
||||
} else if (status === 403) {
|
||||
throw new Error('Permission denied: You don\'t have access to this inbox');
|
||||
} else if (status === 404) {
|
||||
throw new Error('Inbox not found or does not have voice capability');
|
||||
} else if (status === 500) {
|
||||
throw new Error('Server error: The server encountered an error processing your request. Check your Twilio configuration.');
|
||||
} else if (data && data.error) {
|
||||
throw new Error(`Server error: ${data.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to get token: ${tokenError.message}`);
|
||||
}
|
||||
|
||||
// Validate token response
|
||||
if (!response.data || !response.data.token) {
|
||||
console.error('✗ Invalid token response data:', response.data);
|
||||
|
||||
// Check if we have an error message in the response
|
||||
if (response.data && response.data.error) {
|
||||
throw new Error(`Server did not return a valid token: ${response.data.error}`);
|
||||
} else {
|
||||
throw new Error('Server did not return a valid token');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for warnings about missing TwiML App SID
|
||||
if (response.data.warning) {
|
||||
console.warn('⚠️ Twilio Voice Warning:', response.data.warning);
|
||||
|
||||
if (!response.data.has_twiml_app) {
|
||||
console.error(
|
||||
'🚨 IMPORTANT: Missing TwiML App SID. Browser-based calling requires a ' +
|
||||
'TwiML App configured in Twilio Console. Set the Voice Request URL to: ' +
|
||||
response.data.twiml_endpoint
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract token data
|
||||
const { token, identity, voice_enabled, account_sid } = response.data;
|
||||
|
||||
// Log diagnostic information
|
||||
console.log(`✓ Token data received for identity: ${identity}`);
|
||||
console.log(`✓ Voice enabled: ${voice_enabled}`);
|
||||
console.log(`✓ Twilio Account SID available: ${!!account_sid}`);
|
||||
|
||||
// Log the TwiML endpoint that will be used
|
||||
if (response.data.twiml_endpoint) {
|
||||
console.log(`✓ TwiML endpoint: ${response.data.twiml_endpoint}`);
|
||||
} else {
|
||||
console.warn('⚠️ No TwiML endpoint found in token response');
|
||||
}
|
||||
|
||||
// Check if voice is enabled
|
||||
if (!voice_enabled) {
|
||||
throw new Error('Voice is not enabled for this inbox. Check your Twilio configuration.');
|
||||
}
|
||||
|
||||
// Store the TwiML endpoint URL for later use
|
||||
this.twimlEndpoint = response.data.twiml_endpoint;
|
||||
|
||||
// Step 2: Create Twilio Device with better options
|
||||
const deviceOptions = {
|
||||
// Use absolute minimal options - less is more for audio compatibility
|
||||
allowIncomingWhileBusy: true, // Allow incoming calls while already on a call
|
||||
debug: true, // Enable debug logging
|
||||
warnings: true, // Show warnings in console
|
||||
disableAudioContextSounds: true, // Disable browser audio context for sounds
|
||||
// Add explicit edge parameter - this helps avoid connectivity issues
|
||||
edge: ['ashburn', 'sydney', 'roaming'],
|
||||
// Explicitly set codec preferences
|
||||
codecPreferences: ['opus', 'pcmu'],
|
||||
// Add the account ID to any calls made by this device
|
||||
appParams: {
|
||||
account_id: response.data.account_id,
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Creating Twilio Device with options:', deviceOptions);
|
||||
|
||||
try {
|
||||
this.device = new Device(token, deviceOptions);
|
||||
console.log('✓ Twilio Device created successfully');
|
||||
} catch (deviceError) {
|
||||
console.error('✗ Failed to create Twilio Device:', deviceError);
|
||||
throw new Error(`Failed to create Twilio Device: ${deviceError.message}`);
|
||||
}
|
||||
|
||||
// Step 3: Set up event listeners with enhanced error handling
|
||||
this._setupDeviceEventListeners(inboxId);
|
||||
|
||||
// Step 4: Register the device with Twilio
|
||||
console.log('Registering Twilio Device...');
|
||||
try {
|
||||
await this.device.register();
|
||||
console.log('✓ Twilio Device registered successfully');
|
||||
this.initialized = true;
|
||||
return this.device;
|
||||
} catch (registerError) {
|
||||
console.error('✗ Failed to register Twilio Device:', registerError);
|
||||
|
||||
// Handle specific registration errors
|
||||
if (registerError.message && registerError.message.includes('token')) {
|
||||
throw new Error('Invalid Twilio token. Check your account credentials.');
|
||||
} else if (registerError.message && registerError.message.includes('permission')) {
|
||||
throw new Error('Missing microphone permission. Please allow microphone access.');
|
||||
}
|
||||
|
||||
throw new Error(`Failed to register device: ${registerError.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Clear device and initialized flag in case of error
|
||||
this.device = null;
|
||||
this.initialized = false;
|
||||
|
||||
console.error('Failed to initialize Twilio Device:', error);
|
||||
|
||||
// Create a detailed error with context for debugging
|
||||
const enhancedError = new Error(`Twilio Device initialization failed: ${error.message}`);
|
||||
enhancedError.originalError = error;
|
||||
enhancedError.inboxId = inboxId;
|
||||
enhancedError.timestamp = new Date().toISOString();
|
||||
enhancedError.browserInfo = {
|
||||
userAgent: navigator.userAgent,
|
||||
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
};
|
||||
|
||||
// Add specific advice for known error cases
|
||||
if (error.message.includes('permission')) {
|
||||
enhancedError.advice = 'Please ensure your browser allows microphone access.';
|
||||
} else if (error.message.includes('token')) {
|
||||
enhancedError.advice = 'Check your Twilio credentials in the Voice channel settings.';
|
||||
} else if (error.message.includes('TwiML')) {
|
||||
enhancedError.advice = 'Set up a valid TwiML app in your Twilio console and configure it in the inbox settings.';
|
||||
} else if (error.message.includes('configuration')) {
|
||||
enhancedError.advice = 'Review your Voice inbox configuration to ensure all required fields are completed.';
|
||||
}
|
||||
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to set up device event listeners
|
||||
_setupDeviceEventListeners(inboxId) {
|
||||
if (!this.device) return;
|
||||
|
||||
// Remove any existing listeners to prevent duplicates
|
||||
this.device.removeAllListeners();
|
||||
|
||||
// Add standard event listeners
|
||||
this.device.on('registered', () => {
|
||||
console.log('✓ Twilio Device registered with Twilio servers');
|
||||
});
|
||||
|
||||
this.device.on('unregistered', () => {
|
||||
console.log('⚠️ Twilio Device unregistered from Twilio servers');
|
||||
});
|
||||
|
||||
this.device.on('tokenWillExpire', () => {
|
||||
console.log('⚠️ Twilio token is about to expire, refreshing...');
|
||||
this.getToken(inboxId)
|
||||
.then(newTokenResponse => {
|
||||
if (newTokenResponse.data && newTokenResponse.data.token) {
|
||||
console.log('✓ Successfully obtained new token');
|
||||
this.device.updateToken(newTokenResponse.data.token);
|
||||
} else {
|
||||
console.error('✗ Failed to get a valid token for renewal');
|
||||
}
|
||||
})
|
||||
.catch(tokenError => {
|
||||
console.error('✗ Error refreshing token:', tokenError);
|
||||
});
|
||||
});
|
||||
|
||||
this.device.on('incoming', connection => {
|
||||
console.log('📞 Incoming call received via Twilio Device');
|
||||
this.activeConnection = connection;
|
||||
|
||||
// Set up connection-specific events
|
||||
this._setupConnectionEventListeners(connection);
|
||||
});
|
||||
|
||||
this.device.on('error', error => {
|
||||
// Enhanced error logging with full details
|
||||
const errorDetails = {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
description: error.description || 'No description',
|
||||
twilioErrorObject: error,
|
||||
connectionInfo: this.activeConnection ? {
|
||||
parameters: this.activeConnection.parameters,
|
||||
status: this.activeConnection.status && this.activeConnection.status(),
|
||||
direction: this.activeConnection.direction,
|
||||
} : 'No active connection',
|
||||
deviceState: this.device.state,
|
||||
browserInfo: {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.error('❌ DETAILED Twilio Device Error:', errorDetails);
|
||||
|
||||
// Provide helpful troubleshooting tips based on error code
|
||||
switch (error.code) {
|
||||
case 31000:
|
||||
console.error('⚠️ Error 31000: General Error. This could be an authentication, configuration, or network issue.');
|
||||
console.error('31000 Error Details:', {
|
||||
sdp: error.sdp || 'No SDP data',
|
||||
callState: error.call ? error.call.state : 'No call state',
|
||||
connectionState: error.connection ? error.connection.state : 'No connection state',
|
||||
peerConnectionState: error.peerConnection ? error.peerConnection.iceConnectionState : 'No ICE state',
|
||||
message: error.message,
|
||||
twilioError: error,
|
||||
info: error.info || 'No additional info',
|
||||
solution: 'Check Twilio account status, SDP negotiations, and network connectivity'
|
||||
});
|
||||
|
||||
// Create a network diagnostic to check connectivity
|
||||
fetch('https://status.twilio.com/api/v2/status.json')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Twilio service status check:', data);
|
||||
})
|
||||
.catch(statusError => {
|
||||
console.error('Failed to check Twilio status:', statusError);
|
||||
});
|
||||
break;
|
||||
case 31002:
|
||||
console.error('⚠️ Error 31002: Permission Denied. Your browser microphone is blocked or unavailable.');
|
||||
break;
|
||||
case 31003:
|
||||
console.error('⚠️ Error 31003: TwiML App Error. Your TwiML application does not exist or is misconfigured.');
|
||||
break;
|
||||
case 31005:
|
||||
console.error('⚠️ Error 31005: Error sent from gateway in HANGUP. This usually means the TwiML endpoint is not reachable or returning invalid TwiML.');
|
||||
console.error('Additional details for 31005:', {
|
||||
activeConnection: this.activeConnection ? 'Yes' : 'No',
|
||||
deviceState: this.device ? this.device.state : 'No device',
|
||||
params: this.activeConnection ? this.activeConnection.parameters : 'No params',
|
||||
twimlEndpoint: this.activeConnection && this.activeConnection.parameters ?
|
||||
this.activeConnection.parameters.To : 'Unknown endpoint',
|
||||
hangupReason: error.hangupReason || 'Unknown', // Capture hangup reason
|
||||
message: error.message,
|
||||
description: error.description,
|
||||
customMessage: error.customMessage,
|
||||
originalError: error.originalError ? JSON.stringify(error.originalError) : 'None'
|
||||
});
|
||||
break;
|
||||
case 31008:
|
||||
console.error('⚠️ Error 31008: Connection Error. The call could not be established.');
|
||||
break;
|
||||
case 31204:
|
||||
console.error('⚠️ Error 31204: ICE Connection Failed. WebRTC connection failure, check firewall settings.');
|
||||
break;
|
||||
default:
|
||||
console.error(`⚠️ Unspecified error with code ${error.code}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.device.on('connect', connection => {
|
||||
console.log('📞 Call connected');
|
||||
this.activeConnection = connection;
|
||||
this._setupConnectionEventListeners(connection);
|
||||
});
|
||||
|
||||
this.device.on('disconnect', () => {
|
||||
console.log('📞 Call disconnected');
|
||||
this.activeConnection = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for the active connection with enhanced audio diagnostic logging
|
||||
_setupConnectionEventListeners(connection) {
|
||||
if (!connection) return;
|
||||
|
||||
// Add advanced audio debug data
|
||||
const getAudioDiagnostics = () => {
|
||||
const audioContext = window.AudioContext || window.webkitAudioContext;
|
||||
let audioInfo = { supported: !!audioContext };
|
||||
|
||||
try {
|
||||
if (audioContext) {
|
||||
const context = new audioContext();
|
||||
audioInfo = {
|
||||
...audioInfo,
|
||||
sampleRate: context.sampleRate,
|
||||
state: context.state,
|
||||
baseLatency: context.baseLatency,
|
||||
outputLatency: context.outputLatency,
|
||||
destination: {
|
||||
maxChannelCount: context.destination.maxChannelCount,
|
||||
numberOfInputs: context.destination.numberOfInputs,
|
||||
numberOfOutputs: context.destination.numberOfOutputs
|
||||
}
|
||||
};
|
||||
context.close();
|
||||
}
|
||||
} catch (e) {
|
||||
audioInfo.error = e.message;
|
||||
}
|
||||
|
||||
// Check if microphone is accessible
|
||||
let microphoneInfo = { detected: false, active: false, tracks: [] };
|
||||
if (window.activeAudioStream) {
|
||||
const tracks = window.activeAudioStream.getAudioTracks();
|
||||
microphoneInfo = {
|
||||
detected: true,
|
||||
active: tracks.some(track => track.enabled && track.readyState === 'live'),
|
||||
tracks: tracks.map(track => ({
|
||||
id: track.id,
|
||||
label: track.label,
|
||||
enabled: track.enabled,
|
||||
muted: track.muted,
|
||||
readyState: track.readyState,
|
||||
constraints: track.getConstraints()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
audioContext: audioInfo,
|
||||
microphone: microphoneInfo,
|
||||
speakersMuted: typeof window.speechSynthesis !== 'undefined' ?
|
||||
window.speechSynthesis.speaking === false : 'unknown'
|
||||
};
|
||||
};
|
||||
|
||||
connection.on('error', error => {
|
||||
// Significantly enhanced connection error logging with audio diagnostics
|
||||
const diagnostics = getAudioDiagnostics();
|
||||
|
||||
const connectionErrorDetails = {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
description: error.description || 'No description',
|
||||
twilioErrorObject: error,
|
||||
connectionInfo: {
|
||||
parameters: connection.parameters,
|
||||
status: connection.status && connection.status(),
|
||||
direction: connection.direction,
|
||||
},
|
||||
deviceState: this.device ? this.device.state : 'No device',
|
||||
timestamp: new Date().toISOString(),
|
||||
// Audio diagnostics for troubleshooting
|
||||
audioDiagnostics: diagnostics,
|
||||
// Browser media permissions
|
||||
mediaPermissions: {
|
||||
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
activeAudioStream: !!window.activeAudioStream,
|
||||
activeAudioTracks: window.activeAudioStream ?
|
||||
window.activeAudioStream.getAudioTracks().length : 0
|
||||
}
|
||||
};
|
||||
|
||||
console.error('❌ DETAILED Connection Error with Audio Diagnostics:', connectionErrorDetails);
|
||||
});
|
||||
|
||||
connection.on('mute', isMuted => {
|
||||
console.log(`📞 Call ${isMuted ? 'muted' : 'unmuted'}`);
|
||||
});
|
||||
|
||||
connection.on('accept', () => {
|
||||
// Enhanced logging for accept event with audio diagnostics
|
||||
const diagnostics = getAudioDiagnostics();
|
||||
|
||||
console.log('📞 Call accepted with audio diagnostics:', {
|
||||
connectionParameters: connection.parameters,
|
||||
status: connection.status && connection.status(),
|
||||
audioDiagnostics: diagnostics,
|
||||
activeAudioStream: window.activeAudioStream ? {
|
||||
active: window.activeAudioStream.active,
|
||||
id: window.activeAudioStream.id,
|
||||
trackCount: window.activeAudioStream.getTracks().length
|
||||
} : 'No active stream'
|
||||
});
|
||||
|
||||
// AUDIO HEALTH CHECK AFTER CONNECTION
|
||||
setTimeout(() => {
|
||||
console.log('🔊 AUDIO HEALTH CHECK:', {
|
||||
connectionActive: this.activeConnection === connection,
|
||||
connectionState: connection.status && connection.status(),
|
||||
audioTracks: window.activeAudioStream ?
|
||||
window.activeAudioStream.getAudioTracks().map(track => ({
|
||||
label: track.label,
|
||||
enabled: track.enabled,
|
||||
readyState: track.readyState,
|
||||
muted: track.muted
|
||||
})) : 'No active stream',
|
||||
// Device state after 5 seconds
|
||||
deviceState: this.device ? this.device.state : 'No device'
|
||||
});
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
connection.on('disconnect', () => {
|
||||
console.log('📞 Call disconnected', {
|
||||
disconnectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
|
||||
finalStatus: connection.status && connection.status(),
|
||||
audioDiagnostics: getAudioDiagnostics()
|
||||
});
|
||||
this.activeConnection = null;
|
||||
});
|
||||
|
||||
connection.on('reject', () => {
|
||||
console.log('📞 Call rejected', {
|
||||
rejectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
|
||||
audioDiagnostics: getAudioDiagnostics()
|
||||
});
|
||||
this.activeConnection = null;
|
||||
});
|
||||
|
||||
// Additional event for warning messages
|
||||
connection.on('warning', warning => {
|
||||
console.warn('⚠️ Connection Warning:', warning);
|
||||
});
|
||||
|
||||
// Listen for TwiML processing events
|
||||
connection.on('twiml-processing', twiml => {
|
||||
console.log('📄 Processing TwiML:', twiml);
|
||||
});
|
||||
|
||||
// Enhanced audio events for debugging
|
||||
if (typeof connection.on === 'function') {
|
||||
try {
|
||||
// Check for volume events
|
||||
connection.on('volume', (inputVolume, outputVolume) => {
|
||||
// Log only significant volume changes to avoid console spam
|
||||
if (Math.abs(inputVolume) > 50 || Math.abs(outputVolume) > 50) {
|
||||
console.log(`🔊 Volume change - Input: ${inputVolume}, Output: ${outputVolume}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for media stream events if supported
|
||||
if (typeof connection.getRemoteStream === 'function') {
|
||||
const remoteStream = connection.getRemoteStream();
|
||||
if (remoteStream) {
|
||||
console.log('✅ Remote audio stream available:', {
|
||||
active: remoteStream.active,
|
||||
id: remoteStream.id,
|
||||
tracks: remoteStream.getTracks().map(t => ({
|
||||
kind: t.kind,
|
||||
enabled: t.enabled,
|
||||
readyState: t.readyState
|
||||
}))
|
||||
});
|
||||
} else {
|
||||
console.warn('⚠️ No remote audio stream available');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error setting up enhanced audio events:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make a call using the Twilio Client
|
||||
makeClientCall(params) {
|
||||
if (!this.device || !this.initialized) {
|
||||
throw new Error('Twilio Device not initialized');
|
||||
}
|
||||
|
||||
this.activeConnection = this.device.connect(params);
|
||||
return this.activeConnection;
|
||||
}
|
||||
|
||||
// Join a conference call using the Twilio Client
|
||||
joinClientCall(conferenceParams) {
|
||||
if (!this.device || !this.initialized) {
|
||||
throw new Error('Twilio Device not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// IMPORTANT: Do NOT try to register if already registered
|
||||
// Only check state is ready
|
||||
if (this.device.state !== 'ready' && this.device.state !== 'registered') {
|
||||
// Don't try to register again if already registered
|
||||
}
|
||||
|
||||
// This is CRITICAL for Twilio - params must be formatted exactly right
|
||||
// and passed directly in the format Twilio expects
|
||||
const params = {
|
||||
// REQUIRED: Twilio Voice JS SDK expects 'To' parameter to be a properly formatted string
|
||||
To: `${conferenceParams.To}`,
|
||||
|
||||
// Additional params for our server
|
||||
account_id: conferenceParams.account_id,
|
||||
is_agent: 'true'
|
||||
};
|
||||
|
||||
// Check To parameter exists - fail if missing
|
||||
if (!params.To) {
|
||||
throw new Error('Missing To parameter for conference');
|
||||
}
|
||||
|
||||
// Make sure 'To' is explicitly a string
|
||||
const stringifiedTo = String(params.To);
|
||||
console.log('🎯 CRITICAL CONFERENCE CONNECTION: Connecting agent to conference with To=', stringifiedTo);
|
||||
|
||||
// Follow Twilio documentation format - params should be nested under 'params' property
|
||||
console.log('🎯 TRYING CONNECTION: Using documented format with params property');
|
||||
|
||||
// Just use the minimal required parameters
|
||||
const connection = this.device.connect({
|
||||
params: {
|
||||
To: stringifiedTo, // Conference ID
|
||||
is_agent: 'true' // Flag to indicate agent is joining
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🎯 CONFERENCE CONNECTION RESULT:', connection ? 'Success' : 'Failed');
|
||||
this.activeConnection = connection;
|
||||
|
||||
if (connection && typeof connection.then === 'function') {
|
||||
// It's a Promise - newer Twilio SDK version
|
||||
connection.then(resolvedConnection => {
|
||||
this.activeConnection = resolvedConnection;
|
||||
try {
|
||||
if (typeof resolvedConnection.on === 'function') {
|
||||
resolvedConnection.on('accept', () => {
|
||||
// Connection accepted
|
||||
});
|
||||
}
|
||||
} catch (listenerError) {
|
||||
// Could not add listeners to Promise connection
|
||||
}
|
||||
}).catch(connError => {
|
||||
// WebRTC Promise connection error
|
||||
});
|
||||
} else {
|
||||
// It's a synchronous connection - older Twilio SDK
|
||||
}
|
||||
return connection;
|
||||
} catch (error) {
|
||||
// Error joining conference
|
||||
}
|
||||
}
|
||||
|
||||
// Get the status of the device with additional diagnostic info
|
||||
getDeviceStatus() {
|
||||
if (!this.device) {
|
||||
return 'not_initialized';
|
||||
}
|
||||
|
||||
const deviceState = this.device.state;
|
||||
|
||||
// Append a recommended action based on the state
|
||||
switch (deviceState) {
|
||||
case 'registered':
|
||||
return 'ready';
|
||||
case 'unregistered':
|
||||
return 'disconnected';
|
||||
case 'destroyed':
|
||||
return 'terminated';
|
||||
case 'busy':
|
||||
return 'busy';
|
||||
case 'error':
|
||||
return 'error';
|
||||
default:
|
||||
return deviceState;
|
||||
}
|
||||
}
|
||||
|
||||
// Get comprehensive diagnostic information about the device and connection
|
||||
getDiagnosticInfo() {
|
||||
const browserInfo = {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
vendor: navigator.vendor,
|
||||
hasMediaDevices: !!navigator.mediaDevices,
|
||||
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
};
|
||||
|
||||
const deviceInfo = this.device ? {
|
||||
state: this.device.state,
|
||||
isInitialized: this.initialized,
|
||||
capabilities: this.device.capabilities || {},
|
||||
isBusy: this.device.isBusy || false,
|
||||
audio: {
|
||||
isAudioSelectionSupported: this.device.isAudioSelectionSupported || false
|
||||
}
|
||||
} : { state: 'not_initialized' };
|
||||
|
||||
const connectionInfo = this.activeConnection ? {
|
||||
status: this.activeConnection.status(),
|
||||
isMuted: this.activeConnection.isMuted(),
|
||||
direction: this.activeConnection.direction,
|
||||
parameters: this.activeConnection.parameters,
|
||||
} : { status: 'no_connection' };
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
browser: browserInfo,
|
||||
device: deviceInfo,
|
||||
connection: connectionInfo
|
||||
};
|
||||
}
|
||||
|
||||
// Get the status of the active connection
|
||||
getConnectionStatus() {
|
||||
if (!this.activeConnection) {
|
||||
return 'no_connection';
|
||||
}
|
||||
|
||||
const status = this.activeConnection.status();
|
||||
|
||||
// Translate connection statuses to more user-friendly terms
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'connecting';
|
||||
case 'open':
|
||||
return 'connected';
|
||||
case 'connecting':
|
||||
return 'connecting';
|
||||
case 'ringing':
|
||||
return 'ringing';
|
||||
case 'closed':
|
||||
return 'ended';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new VoiceAPI();
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div>
|
||||
<Button
|
||||
icon="i-ri-phone-fill"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:tooltip="$t('CALL_BUTTON.TOOLTIP')"
|
||||
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
||||
@click="openCallModal"
|
||||
/>
|
||||
<div v-if="showCallModal" class="fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0">
|
||||
<CallModal @close="showCallModal = false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CallModal from './CallModal.vue';
|
||||
|
||||
const showCallModal = ref(false);
|
||||
|
||||
const openCallModal = () => {
|
||||
showCallModal.value = true;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<div class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl">
|
||||
<div class="px-4 py-3 flex items-center">
|
||||
<h3 class="text-base font-medium">{{ $t('CALL_MODAL.START_CALL') }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Inbox Selector (First) -->
|
||||
<div class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap">
|
||||
{{ $t('CALL_MODAL.VIA') }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="selectedInbox"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
|
||||
>
|
||||
<span class="text-sm truncate text-n-slate-12 flex items-center gap-2">
|
||||
<span class="i-ri-phone-fill text-n-slate-11"></span>
|
||||
{{ selectedInbox.name }} - {{ selectedInbox.phoneNumber }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="flex-shrink-0"
|
||||
@click="selectedInbox = null"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-on-click-outside="() => showInboxDropdown = false"
|
||||
class="relative flex items-center h-7"
|
||||
>
|
||||
<Button
|
||||
:label="$t('CALL_MODAL.SELECT_INBOX')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="slate"
|
||||
class="hover:!no-underline"
|
||||
@click="showInboxDropdown = !showInboxDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="voiceInboxesList.length > 0 && showInboxDropdown"
|
||||
:menu-items="voiceInboxesList"
|
||||
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||
@action="selectInbox($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Selector -->
|
||||
<ContactSelector
|
||||
:contacts="contacts"
|
||||
:selected-contact="selectedContact"
|
||||
:show-contacts-dropdown="showContactsDropdown"
|
||||
:is-loading="isSearching"
|
||||
:is-creating-contact="false"
|
||||
:contact-id="null"
|
||||
:contactable-inboxes-list="[]"
|
||||
:show-inboxes-dropdown="false"
|
||||
:has-errors="false"
|
||||
@search-contacts="handleContactSearch"
|
||||
@set-selected-contact="handleSelectedContact"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@update-dropdown="handleDropdownUpdate"
|
||||
/>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center justify-end w-full h-[3.25rem] gap-2 px-4 py-3">
|
||||
<Button
|
||||
:label="$t('CALL_MODAL.CANCEL')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CALL_MODAL.CALL')"
|
||||
icon="i-ri-phone-fill"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
:disabled="!selectedInbox || !selectedContact || isLoading"
|
||||
:is-loading="isLoading"
|
||||
@click="makeCall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import ContactSelector from 'dashboard/components-next/NewConversation/components/ContactSelector.vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const selectedContact = ref(null);
|
||||
const selectedInbox = ref(null);
|
||||
const showContactsDropdown = ref(false);
|
||||
const showInboxDropdown = ref(false);
|
||||
const contacts = ref([]);
|
||||
const isSearching = ref(false);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const voiceInboxesList = computed(() => {
|
||||
return inboxes.value
|
||||
.filter(inbox => inbox.channel_type === INBOX_TYPES.VOICE)
|
||||
.map(inbox => ({
|
||||
id: inbox.id,
|
||||
title: `${inbox.name}`,
|
||||
subtitle: inbox.phone_number,
|
||||
label: `${inbox.name} - ${inbox.phone_number}`,
|
||||
action: 'select-inbox',
|
||||
value: inbox.id,
|
||||
sourceId: inbox.id,
|
||||
phoneNumber: inbox.phone_number,
|
||||
name: inbox.name,
|
||||
icon: 'i-ri-phone-fill',
|
||||
}));
|
||||
});
|
||||
|
||||
// Auto-select the first available voice inbox
|
||||
watch(voiceInboxesList, (newList) => {
|
||||
if (newList.length > 0 && !selectedInbox.value) {
|
||||
selectedInbox.value = newList[0];
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const selectInbox = item => {
|
||||
const inbox = voiceInboxesList.value.find(i => i.value === item.value);
|
||||
if (inbox) {
|
||||
selectedInbox.value = inbox;
|
||||
showInboxDropdown.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectedContact = ({ value, action, ...rest }) => {
|
||||
// If this is a direct call to a phone number
|
||||
if (action === 'create' && value.match(/^\+?[0-9\s\-()]+$/)) {
|
||||
selectedContact.value = {
|
||||
id: 'direct-call',
|
||||
name: t('CALL_MODAL.CALL_DIRECTLY'),
|
||||
sourceId: 'direct-call',
|
||||
phoneNumber: value,
|
||||
action: 'contact',
|
||||
};
|
||||
} else {
|
||||
// For existing contacts, make sure we're capturing their ID properly
|
||||
console.log('Contact selected from dropdown:', { value, action, ...rest });
|
||||
selectedContact.value = {
|
||||
...rest,
|
||||
sourceId: rest.id || rest.value || value // Make sure we have the ID in sourceId
|
||||
};
|
||||
}
|
||||
showContactsDropdown.value = false;
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
showContactsDropdown.value = value;
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
selectedContact.value = null;
|
||||
};
|
||||
|
||||
// This function gets called from the ContactSelector component
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
// Pass all the needed keys for search when using the value sent directly
|
||||
debouncedSearchContacts(value);
|
||||
};
|
||||
|
||||
const debouncedSearchContacts = debounce(async query => {
|
||||
if (!query || query.length < 2) {
|
||||
contacts.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching.value = true;
|
||||
try {
|
||||
// Use the simple search endpoint since it's more reliable for this use case
|
||||
const { data } = await ContactAPI.search(query);
|
||||
|
||||
console.log('Search response:', data); // Log the search response
|
||||
|
||||
// Ensure contacts.value is an array and convert to camelCase
|
||||
const searchResults = data?.payload ? camelcaseKeys(data.payload, { deep: true }) : [];
|
||||
|
||||
// Filter to only include contacts with phone numbers
|
||||
const contactsWithPhone = searchResults.filter(contact => contact.phoneNumber);
|
||||
|
||||
// Map the contacts to ensure they have sourceId set to ID for consistency
|
||||
contacts.value = contactsWithPhone.map(contact => ({
|
||||
...contact,
|
||||
sourceId: contact.id, // Make sure sourceId is set
|
||||
value: contact.id // Make sure value is set for TagInput
|
||||
}));
|
||||
|
||||
// If it looks like a phone number, add option to call directly
|
||||
if (query.match(/^\+?[0-9\s\-()]+$/) && !contacts.value.some(c => c.phoneNumber === query)) {
|
||||
contacts.value.push({
|
||||
id: 'direct-call',
|
||||
name: t('CALL_MODAL.CALL_DIRECTLY'),
|
||||
phoneNumber: query,
|
||||
sourceId: 'direct-call',
|
||||
value: 'direct-call'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Processed contacts for dropdown:', contacts.value);
|
||||
} catch (error) {
|
||||
console.error('Error searching contacts:', error);
|
||||
contacts.value = []; // Ensure this is always an array
|
||||
useAlert(t('CALL_MODAL.CONTACT_SEARCH_ERROR'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const makeCall = async () => {
|
||||
if (!selectedInbox.value || !selectedContact.value) {
|
||||
useAlert(t('CALL_MODAL.VALIDATION_ERROR'));
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const isDirect = selectedContact.value.sourceId === 'direct-call';
|
||||
const contactId = isDirect ? null : (selectedContact.value.sourceId || selectedContact.value.id);
|
||||
|
||||
if (contactId) {
|
||||
console.log('Making call to contact ID:', contactId, 'with full contact:', selectedContact.value);
|
||||
// Use VoiceAPI.initiateCall instead of direct axios call
|
||||
await VoiceAPI.initiateCall(contactId);
|
||||
} else {
|
||||
// For direct phone number calls
|
||||
const phoneNumber = selectedContact.value.phoneNumber;
|
||||
|
||||
if (!phoneNumber) {
|
||||
throw new Error('Phone number is required for direct calls');
|
||||
}
|
||||
|
||||
// First create a contact with this phone number
|
||||
console.log('Creating new contact with phone number:', phoneNumber);
|
||||
const contactPayload = {
|
||||
phone_number: phoneNumber,
|
||||
inbox_id: selectedInbox.value.sourceId,
|
||||
name: `Phone: ${phoneNumber}`,
|
||||
};
|
||||
|
||||
const contactResponse = await ContactAPI.create(contactPayload);
|
||||
console.log('Created contact:', contactResponse.data);
|
||||
|
||||
// Then initiate call to the newly created contact
|
||||
if (contactResponse.data && contactResponse.data.payload && contactResponse.data.payload.contact) {
|
||||
const newContactId = contactResponse.data.payload.contact.id;
|
||||
console.log('Using new contact ID:', newContactId);
|
||||
await VoiceAPI.initiateCall(newContactId);
|
||||
} else {
|
||||
throw new Error('Failed to create contact for direct call');
|
||||
}
|
||||
}
|
||||
|
||||
useAlert(t('CALL_MODAL.SUCCESS_MESSAGE'));
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
console.error('Error making call:', error);
|
||||
|
||||
let errorMessage = t('CALL_MODAL.ERROR_MESSAGE');
|
||||
|
||||
// Simple error handling - just show server message if available
|
||||
if (error.response && error.response.data && error.response.data.error) {
|
||||
errorMessage = error.response.data.error;
|
||||
}
|
||||
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// The first inbox will be selected automatically via the watch
|
||||
// This ensures it works even if voiceInboxesList is populated after mounting
|
||||
});
|
||||
</script>
|
||||
@@ -8,6 +8,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
|
||||
import SMSCampaignDetails from './SMSCampaignDetails.vue';
|
||||
import VoiceCampaignDetails from './VoiceCampaignDetails.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@@ -22,6 +23,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isVoiceType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -67,6 +72,12 @@ const campaignStatus = computed(() => {
|
||||
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
|
||||
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
|
||||
}
|
||||
|
||||
if (props.isVoiceType) {
|
||||
return props.status === STATUS_COMPLETED
|
||||
? t('CAMPAIGN.VOICE.CARD.STATUS.COMPLETED')
|
||||
: t('CAMPAIGN.VOICE.CARD.STATUS.SCHEDULED');
|
||||
}
|
||||
|
||||
return props.status === STATUS_COMPLETED
|
||||
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
|
||||
@@ -108,6 +119,12 @@ const inboxIcon = computed(() => {
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
/>
|
||||
<VoiceCampaignDetails
|
||||
v-else-if="isVoiceType"
|
||||
:sender="sender"
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
/>
|
||||
<SMSCampaignDetails
|
||||
v-else
|
||||
:inbox-name="inboxName"
|
||||
@@ -118,7 +135,7 @@ const inboxIcon = computed(() => {
|
||||
</div>
|
||||
<div class="flex items-center justify-end w-20 gap-2">
|
||||
<Button
|
||||
v-if="isLiveChatType"
|
||||
v-if="isLiveChatType || isVoiceType"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
color="slate"
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
sender: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
inboxName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inboxIcon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
campaign: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const senderName = computed(() =>
|
||||
props.sender?.name || t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.BOT')
|
||||
);
|
||||
|
||||
const senderThumbnail = computed(() => props.sender?.thumbnail || '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 w-full overflow-hidden">
|
||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Avatar
|
||||
:name="senderName"
|
||||
:src="senderThumbnail"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ senderName }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.FROM') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+116
-4
@@ -37,7 +37,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
id: 1,
|
||||
name: 'Jamie Lee',
|
||||
},
|
||||
message: 'Hello! 👋 Any questions on pricing? I’m here to help!',
|
||||
message: 'Hello! 👋 Any questions on pricing? I am here to help!',
|
||||
campaign_status: 'active',
|
||||
enabled: false,
|
||||
campaign_type: 'ongoing',
|
||||
@@ -60,7 +60,8 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Chatwoot',
|
||||
name: 'Alexa Rivera',
|
||||
thumbnail: 'AR',
|
||||
},
|
||||
message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
|
||||
campaign_status: 'active',
|
||||
@@ -88,7 +89,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
name: 'Chris Barlow',
|
||||
},
|
||||
message:
|
||||
'Hi there! 👋 I’m here for any questions you may have. Let’s chat!',
|
||||
'Hi there! 👋 I am here for any questions you may have. Let us chat!',
|
||||
campaign_status: 'active',
|
||||
enabled: true,
|
||||
campaign_type: 'ongoing',
|
||||
@@ -166,7 +167,7 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
phone_number: '+29818373149903',
|
||||
provider: 'default',
|
||||
},
|
||||
message: 'Hello! We’re excited to have your business with us!',
|
||||
message: 'Hello! We are excited to have your business with us!',
|
||||
campaign_status: 'active',
|
||||
enabled: true,
|
||||
campaign_type: 'one_off',
|
||||
@@ -210,3 +211,114 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
updated_at: '2024-10-30T16:15:03.157Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const VOICE_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Signup Confirmation Call',
|
||||
inbox: {
|
||||
id: 10,
|
||||
name: 'PaperLayer Phone Support',
|
||||
channel_type: 'Channel::Voice',
|
||||
avatar_url: '',
|
||||
phone_number: '+14155552671',
|
||||
},
|
||||
message: 'Hello! 👋 Thanks for signing up with PaperLayer. I am calling to confirm your account setup and see if you have any questions about getting started.',
|
||||
campaign_status: 'scheduled',
|
||||
enabled: true,
|
||||
campaign_type: 'voice',
|
||||
scheduled_at: new Date('2024-11-16T20:43:08.000Z').getTime(),
|
||||
audience: [
|
||||
{ id: 4, type: 'Label', title: 'Support Customers' },
|
||||
{ id: 5, type: 'Label', title: 'Active Users' },
|
||||
],
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Chris Barlow',
|
||||
thumbnail: 'CB'
|
||||
},
|
||||
created_at: '2024-11-15T13:13:08.496Z',
|
||||
updated_at: '2024-11-15T13:15:38.698Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Support Ticket Follow-Up',
|
||||
inbox: {
|
||||
id: 10,
|
||||
name: 'PaperLayer Phone Support',
|
||||
channel_type: 'Channel::Voice',
|
||||
avatar_url: '',
|
||||
phone_number: '+14155552671',
|
||||
},
|
||||
message: 'Hi, this is PaperLayer support calling to follow up on your recent ticket #12345. Has your issue been resolved to your satisfaction? If not, I can connect you with a specialist right away.',
|
||||
campaign_status: 'completed',
|
||||
enabled: true,
|
||||
campaign_type: 'voice',
|
||||
scheduled_at: new Date('2024-11-10T15:30:00.000Z').getTime(),
|
||||
audience: [
|
||||
{ id: 1, type: 'Label', title: 'Enterprise' },
|
||||
{ id: 6, type: 'Label', title: 'Premium' },
|
||||
],
|
||||
sender: {
|
||||
id: 2,
|
||||
name: 'Sarah Wilson',
|
||||
thumbnail: 'SW'
|
||||
},
|
||||
created_at: '2024-11-10T13:14:00.168Z',
|
||||
updated_at: '2024-11-10T13:15:38.707Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Appointment Reminder',
|
||||
inbox: {
|
||||
id: 10,
|
||||
name: 'PaperLayer Phone Support',
|
||||
channel_type: 'Channel::Voice',
|
||||
avatar_url: '',
|
||||
phone_number: '+14155552671',
|
||||
},
|
||||
message: 'Hello, this is a reminder about your upcoming consultation scheduled for tomorrow at 2:00 PM. Would you like to confirm this appointment or would you prefer to reschedule?',
|
||||
campaign_status: 'scheduled',
|
||||
enabled: true,
|
||||
campaign_type: 'voice',
|
||||
scheduled_at: new Date('2024-11-20T18:00:00.000Z').getTime(),
|
||||
audience: [
|
||||
{ id: 7, type: 'Label', title: 'Consultation Clients' },
|
||||
{ id: 8, type: 'Label', title: 'New Customers' },
|
||||
],
|
||||
sender: {
|
||||
id: 3,
|
||||
name: 'Michael Thompson',
|
||||
thumbnail: 'MT'
|
||||
},
|
||||
created_at: '2024-11-12T09:30:45.123Z',
|
||||
updated_at: '2024-11-12T09:30:45.123Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Customer Feedback Survey',
|
||||
inbox: {
|
||||
id: 10,
|
||||
name: 'PaperLayer Phone Support',
|
||||
channel_type: 'Channel::Voice',
|
||||
avatar_url: '',
|
||||
phone_number: '+14155552671',
|
||||
},
|
||||
message: 'Hello, this is PaperLayer reaching out for your valuable feedback. We noticed you\'ve been using our service for 30 days now. I\'d like to ask a few quick questions about your experience. Your feedback helps us improve our service. Would you have a moment to share your thoughts?',
|
||||
campaign_status: 'scheduled',
|
||||
enabled: true,
|
||||
campaign_type: 'voice',
|
||||
scheduled_at: new Date('2024-11-25T14:15:00.000Z').getTime(),
|
||||
audience: [
|
||||
{ id: 9, type: 'Label', title: 'Active 30+ Days' },
|
||||
{ id: 10, type: 'Label', title: 'Product Users' },
|
||||
],
|
||||
sender: {
|
||||
id: 4,
|
||||
name: 'Jessica Rivera',
|
||||
thumbnail: 'JR'
|
||||
},
|
||||
created_at: '2024-11-18T10:45:23.789Z',
|
||||
updated_at: '2024-11-18T10:45:23.789Z',
|
||||
}
|
||||
];
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
actionPerms: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[36rem]"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
>
|
||||
<slot name="empty-state-item" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full bg-gradient-to-t from-n-background from-0% via-n-background/95 via-25% to-transparent"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-6 w-full max-w-4xl mx-auto px-4">
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
class="max-w-2xl mx-auto text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<Policy :permissions="actionPerms">
|
||||
<slot name="actions" />
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { VOICE_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import EmptyStateLayout from './CustomEmptyStateLayout.vue';
|
||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="flex flex-col gap-4 p-px">
|
||||
<div
|
||||
v-for="(campaign, index) in VOICE_CAMPAIGN_EMPTY_STATE_CONTENT"
|
||||
:key="campaign.id"
|
||||
:style="{
|
||||
opacity: index === 0 ? 1 : index === 1 ? 0.7 : index === 2 ? 0.4 : 0.2
|
||||
}"
|
||||
>
|
||||
<CampaignCard
|
||||
:title="campaign.title"
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
:is-voice-type="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="mt-10 py-5 px-8 rounded-lg bg-slate-800/5 border border-slate-300/10 max-w-[32rem] mx-auto text-center shadow-sm">
|
||||
<p class="mb-4 text-sm text-slate-700 dark:text-slate-300 font-medium">
|
||||
{{ t('CAMPAIGN.VOICE.EMPTY_STATE.JS_API_DESCRIPTION') }}
|
||||
</p>
|
||||
<code class="block px-5 py-4 overflow-auto text-xs rounded bg-slate-800 text-slate-200 text-left">
|
||||
chatwoot.triggerVoiceCampaign({
|
||||
campaignId: 'CAMPAIGN_ID',
|
||||
user: {
|
||||
name: 'John Doe',
|
||||
phone: '+1234567890'
|
||||
}
|
||||
});
|
||||
</code>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
@@ -10,6 +10,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isVoiceType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
@@ -31,6 +35,7 @@ const handleDelete = campaign => emit('delete', campaign);
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
:is-live-chat-type="isLiveChatType"
|
||||
:is-voice-type="isVoiceType"
|
||||
@edit="handleEdit(campaign)"
|
||||
@delete="handleDelete(campaign)"
|
||||
/>
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
|
||||
|
||||
import VoiceCampaignForm from './VoiceCampaignForm.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
store.dispatch('labels/get');
|
||||
});
|
||||
|
||||
const addCampaign = async campaignDetails => {
|
||||
try {
|
||||
await store.dispatch('campaigns/create', {
|
||||
...campaignDetails,
|
||||
campaign_type: CAMPAIGN_TYPES.VOICE,
|
||||
});
|
||||
|
||||
// tracking this here instead of the store to track the type of campaign
|
||||
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
|
||||
type: CAMPAIGN_TYPES.VOICE,
|
||||
});
|
||||
|
||||
useAlert(t('CAMPAIGN.VOICE.CREATE.FORM.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage = error?.response?.message || t('CAMPAIGN.VOICE.CREATE.FORM.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => emit('close');
|
||||
|
||||
const handleSubmit = campaignDetails => {
|
||||
addCampaign(campaignDetails);
|
||||
handleClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
|
||||
>
|
||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||
{{ t(`CAMPAIGN.VOICE.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<VoiceCampaignForm
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
import { reactive, computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||
labels: useMapGetter('labels/getLabels'),
|
||||
inboxes: useMapGetter('inboxes/getVoiceInboxes'),
|
||||
};
|
||||
|
||||
const senderList = ref([]);
|
||||
|
||||
const initialState = {
|
||||
title: '',
|
||||
message: '',
|
||||
inboxId: null,
|
||||
senderId: null,
|
||||
scheduledAt: null,
|
||||
selectedAudience: [],
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
const rules = {
|
||||
title: { required, minLength: minLength(1) },
|
||||
message: { required, minLength: minLength(1) },
|
||||
inboxId: { required },
|
||||
senderId: { required },
|
||||
scheduledAt: { required },
|
||||
selectedAudience: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
const isCreating = computed(() => formState.uiFlags.value.isCreating);
|
||||
|
||||
const currentDateTime = computed(() => {
|
||||
// Added to disable the scheduled at field from being set to the current time
|
||||
const now = new Date();
|
||||
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
return localTime.toISOString().slice(0, 16);
|
||||
});
|
||||
|
||||
const mapToOptions = (items, valueKey, labelKey) =>
|
||||
items?.map(item => ({
|
||||
value: item[valueKey],
|
||||
label: item[labelKey],
|
||||
})) ?? [];
|
||||
|
||||
const audienceList = computed(() =>
|
||||
mapToOptions(formState.labels.value, 'id', 'title')
|
||||
);
|
||||
|
||||
const inboxOptions = computed(() =>
|
||||
mapToOptions(formState.inboxes.value, 'id', 'name')
|
||||
);
|
||||
|
||||
const sendersAndBotList = computed(() => [
|
||||
{ value: 0, label: t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.BOT') },
|
||||
...mapToOptions(senderList.value, 'id', 'name'),
|
||||
]);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
const baseKey = 'CAMPAIGN.VOICE.CREATE.FORM';
|
||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
title: getErrorMessage('title', 'TITLE'),
|
||||
message: getErrorMessage('message', 'MESSAGE'),
|
||||
inbox: getErrorMessage('inboxId', 'INBOX'),
|
||||
sender: getErrorMessage('senderId', 'SENT_BY'),
|
||||
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
|
||||
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
|
||||
}));
|
||||
|
||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||
|
||||
const formatToUTCString = localDateTime =>
|
||||
localDateTime ? new Date(localDateTime).toISOString() : null;
|
||||
|
||||
const handleInboxChange = async inboxId => {
|
||||
if (!inboxId) {
|
||||
senderList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('inboxMembers/get', { inboxId });
|
||||
senderList.value = response?.data?.payload ?? [];
|
||||
} catch (error) {
|
||||
senderList.value = [];
|
||||
useAlert(
|
||||
error?.response?.message ??
|
||||
t('CAMPAIGN.VOICE.CREATE.FORM.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => state.inboxId,
|
||||
newInboxId => {
|
||||
if (newInboxId) {
|
||||
handleInboxChange(newInboxId);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
Object.assign(state, initialState);
|
||||
};
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const prepareCampaignDetails = () => ({
|
||||
title: state.title,
|
||||
message: state.message,
|
||||
inbox_id: state.inboxId,
|
||||
sender_id: state.senderId || null,
|
||||
scheduled_at: formatToUTCString(state.scheduledAt),
|
||||
audience: state.selectedAudience?.map(id => ({
|
||||
id,
|
||||
type: 'Label',
|
||||
})),
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
emit('submit', prepareCampaignDetails());
|
||||
resetState();
|
||||
handleCancel();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<Input
|
||||
v-model="state.title"
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.TITLE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.TITLE.PLACEHOLDER')"
|
||||
:message="formErrors.title"
|
||||
:message-type="formErrors.title ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="state.message"
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.MESSAGE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.MESSAGE.PLACEHOLDER')"
|
||||
show-character-count
|
||||
:message="formErrors.message"
|
||||
:message-type="formErrors.message ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.VOICE.CREATE.FORM.INBOX.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="inbox"
|
||||
v-model="state.inboxId"
|
||||
:options="inboxOptions"
|
||||
:has-error="!!formErrors.inbox"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.INBOX.PLACEHOLDER')"
|
||||
:message="formErrors.inbox"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="sender" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.VOICE.CREATE.FORM.SENT_BY.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="sender"
|
||||
v-model="state.senderId"
|
||||
:options="sendersAndBotList"
|
||||
:has-error="!!formErrors.sender"
|
||||
:disabled="!state.inboxId"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.SENT_BY.PLACEHOLDER')"
|
||||
:message="formErrors.sender"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.LABEL') }}
|
||||
</label>
|
||||
<TagMultiSelectComboBox
|
||||
v-model="state.selectedAudience"
|
||||
:options="audienceList"
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
|
||||
:has-error="!!formErrors.audience"
|
||||
:message="formErrors.audience"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="state.scheduledAt"
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.SCHEDULED_AT.LABEL')"
|
||||
type="datetime-local"
|
||||
:min="currentDateTime"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
|
||||
:message="formErrors.scheduledAt"
|
||||
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
type="button"
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.BUTTONS.CREATE')"
|
||||
class="w-full"
|
||||
type="submit"
|
||||
:is-loading="isCreating"
|
||||
:disabled="isCreating || isSubmitDisabled"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -272,11 +272,11 @@ defineExpose({
|
||||
class="w-full"
|
||||
@input="
|
||||
isValidationField(item.key) &&
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
"
|
||||
@blur="
|
||||
isValidationField(item.key) &&
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+155
-2
@@ -13,13 +13,99 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
// Simple check: Is this a voice channel conversation?
|
||||
const isVoiceChannel = computed(() => {
|
||||
return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice';
|
||||
});
|
||||
|
||||
// Get call direction: inbound or outbound
|
||||
const isIncomingCall = computed(() => {
|
||||
if (!isVoiceChannel.value) return false;
|
||||
|
||||
const direction = props.conversation?.additional_attributes?.call_direction;
|
||||
return direction === 'inbound';
|
||||
});
|
||||
|
||||
// Simple function to normalize call status
|
||||
const normalizedCallStatus = computed(() => {
|
||||
if (!isVoiceChannel.value) return null;
|
||||
|
||||
// Get the raw status directly from conversation
|
||||
const status = props.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Simple mapping of call statuses
|
||||
if (status === 'in-progress') return 'active';
|
||||
if (status === 'completed') return 'ended';
|
||||
if (status === 'canceled') return 'ended';
|
||||
if (status === 'failed') return 'ended';
|
||||
if (status === 'busy') return 'no-answer';
|
||||
if (status === 'no-answer') return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
|
||||
// Return the status as is for explicit values
|
||||
if (status === 'active') return 'active';
|
||||
if (status === 'missed') return 'missed';
|
||||
if (status === 'ended') return 'ended';
|
||||
if (status === 'ringing') return 'ringing';
|
||||
|
||||
// If no status is set, default to 'ended'
|
||||
return 'ended';
|
||||
});
|
||||
|
||||
// Get formatted call status text for voice channel conversations
|
||||
const callStatusText = computed(() => {
|
||||
if (!isVoiceChannel.value) return '';
|
||||
|
||||
const status = normalizedCallStatus.value;
|
||||
const isIncoming = isIncomingCall.value;
|
||||
|
||||
if (status === 'active') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'missed') {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
} else {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'no-answer') {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
}
|
||||
|
||||
return isIncoming
|
||||
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
});
|
||||
|
||||
// Return proper message content based on message type
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {}, customAttributes = {} } =
|
||||
props.conversation;
|
||||
const { email: { subject } = {} } = customAttributes;
|
||||
|
||||
// Return special formatting for voice calls
|
||||
if (isVoiceChannel.value) {
|
||||
return callStatusText.value;
|
||||
}
|
||||
|
||||
return getPlainText(
|
||||
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
|
||||
);
|
||||
@@ -42,9 +128,57 @@ const unreadMessagesCount = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex items-end w-full gap-2 pb-1">
|
||||
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
<!-- Voice Call Message -->
|
||||
<div
|
||||
v-if="isVoiceChannel"
|
||||
class="w-full mb-0 text-sm flex items-center gap-1 pt-0.5"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
|
||||
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
|
||||
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Explicit icon based on call status -->
|
||||
<!-- Missed call or no answer -->
|
||||
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
|
||||
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
|
||||
|
||||
<!-- Active call -->
|
||||
<i v-else-if="normalizedCallStatus === 'active'"
|
||||
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
|
||||
|
||||
<!-- Ended incoming call -->
|
||||
<i v-else-if="normalizedCallStatus === 'ended' && isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<!-- Ended outgoing call -->
|
||||
<i v-else-if="normalizedCallStatus === 'ended'"
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<!-- Ringing incoming call -->
|
||||
<i v-else-if="isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<!-- Ringing outgoing call -->
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
<span class="text-current truncate">{{ callStatusText }}</span>
|
||||
<span
|
||||
v-if="normalizedCallStatus === 'ringing'"
|
||||
class="flex-shrink-0 text-xs font-medium text-green-600 dark:text-green-400"
|
||||
>
|
||||
({{ t('CONVERSATION.VOICE_CALL.JOIN_CALL') }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Regular Message -->
|
||||
<p v-else class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
@@ -64,3 +198,22 @@ const unreadMessagesCount = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Animation for ringing calls */
|
||||
.pulse-animation {
|
||||
animation: icon-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+148
-1
@@ -24,7 +24,93 @@ const slaCardLabelRef = ref(null);
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
// Simple check: Is this a voice channel conversation?
|
||||
const isVoiceChannel = computed(() => {
|
||||
return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice';
|
||||
});
|
||||
|
||||
// Get call direction: inbound or outbound
|
||||
const isIncomingCall = computed(() => {
|
||||
if (!isVoiceChannel.value) return false;
|
||||
|
||||
const direction = props.conversation?.additional_attributes?.call_direction;
|
||||
return direction === 'inbound';
|
||||
});
|
||||
|
||||
// Simple function to normalize call status
|
||||
const normalizedCallStatus = computed(() => {
|
||||
if (!isVoiceChannel.value) return null;
|
||||
|
||||
// Get the raw status directly from conversation
|
||||
const status = props.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Simple mapping of call statuses
|
||||
if (status === 'in-progress') return 'active';
|
||||
if (status === 'completed') return 'ended';
|
||||
if (status === 'canceled') return 'ended';
|
||||
if (status === 'failed') return 'ended';
|
||||
if (status === 'busy') return 'no-answer';
|
||||
if (status === 'no-answer') return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
|
||||
// Return the status as is for explicit values
|
||||
if (status === 'active') return 'active';
|
||||
if (status === 'missed') return 'missed';
|
||||
if (status === 'ended') return 'ended';
|
||||
if (status === 'ringing') return 'ringing';
|
||||
|
||||
// If no status is set, default to 'ended'
|
||||
return 'ended';
|
||||
});
|
||||
|
||||
// Get formatted call status text for voice channel conversations
|
||||
const callStatusText = computed(() => {
|
||||
if (!isVoiceChannel.value) return '';
|
||||
|
||||
const status = normalizedCallStatus.value;
|
||||
const isIncoming = isIncomingCall.value;
|
||||
|
||||
if (status === 'active') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'missed') {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
} else {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'no-answer') {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
}
|
||||
|
||||
return isIncoming
|
||||
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
});
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
// If it's a voice call, use the voice call text with icon
|
||||
if (isVoiceChannel.value) {
|
||||
return callStatusText.value;
|
||||
}
|
||||
|
||||
// Otherwise use the regular message content
|
||||
const { lastNonActivityMessage = {}, customAttributes = {} } =
|
||||
props.conversation;
|
||||
const { email: { subject } = {} } = customAttributes;
|
||||
@@ -61,7 +147,49 @@ defineExpose({
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
|
||||
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
<!-- Voice Call Message display with icon -->
|
||||
<div
|
||||
v-if="isVoiceChannel"
|
||||
class="flex items-center gap-1 mb-0 text-sm line-clamp-1"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
|
||||
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
|
||||
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Explicit icon based on call status -->
|
||||
<!-- Missed call or no answer -->
|
||||
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
|
||||
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
|
||||
|
||||
<!-- Active call -->
|
||||
<i v-else-if="normalizedCallStatus === 'active'"
|
||||
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
|
||||
|
||||
<!-- Ended incoming call -->
|
||||
<i v-else-if="normalizedCallStatus === 'ended' && isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<!-- Ended outgoing call -->
|
||||
<i v-else-if="normalizedCallStatus === 'ended'"
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<!-- Ringing incoming call -->
|
||||
<i v-else-if="isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<!-- Ringing outgoing call -->
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<span class="text-current truncate">{{ callStatusText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Regular Message display -->
|
||||
<p v-else class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
@@ -105,3 +233,22 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Animation for ringing calls */
|
||||
.pulse-animation {
|
||||
animation: icon-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+248
-11
@@ -4,6 +4,7 @@ import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
@@ -32,6 +33,7 @@ const props = defineProps({
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const cardMessagePreviewWithMetaRef = ref(null);
|
||||
|
||||
@@ -57,6 +59,123 @@ const lastActivityAt = computed(() => {
|
||||
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
|
||||
});
|
||||
|
||||
const lastNonActivityMessage = computed(() => {
|
||||
return props.conversation?.lastNonActivityMessage || {};
|
||||
});
|
||||
|
||||
// Simple check: Is this a voice channel conversation?
|
||||
const isVoiceChannel = computed(() => {
|
||||
return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice';
|
||||
});
|
||||
|
||||
// Get call direction: inbound or outbound
|
||||
const isIncomingCall = computed(() => {
|
||||
if (!isVoiceChannel.value) return false;
|
||||
|
||||
const direction = props.conversation?.additional_attributes?.call_direction;
|
||||
return direction === 'inbound';
|
||||
});
|
||||
|
||||
// Simple function to normalize call status
|
||||
const normalizedCallStatus = computed(() => {
|
||||
if (!isVoiceChannel.value) return null;
|
||||
|
||||
// Get the raw status directly from conversation
|
||||
const status = props.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Simple mapping of call statuses
|
||||
if (status === 'in-progress') return 'active';
|
||||
if (status === 'completed') return 'ended';
|
||||
if (status === 'canceled') return 'ended';
|
||||
if (status === 'failed') return 'ended';
|
||||
if (status === 'busy') return 'no-answer';
|
||||
if (status === 'no-answer') return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
|
||||
// Return the status as is for explicit values
|
||||
if (status === 'active') return 'active';
|
||||
if (status === 'missed') return 'missed';
|
||||
if (status === 'ended') return 'ended';
|
||||
if (status === 'ringing') return 'ringing';
|
||||
|
||||
// If no status is set, default to 'ended'
|
||||
return 'ended';
|
||||
});
|
||||
|
||||
const isRingingCall = computed(() => {
|
||||
return normalizedCallStatus.value === 'ringing';
|
||||
});
|
||||
|
||||
const isActiveCall = computed(() => {
|
||||
return normalizedCallStatus.value === 'active';
|
||||
});
|
||||
|
||||
// Get formatted call status text for voice channel conversations
|
||||
const callStatusText = computed(() => {
|
||||
if (!isVoiceChannel.value) return '';
|
||||
|
||||
const status = normalizedCallStatus.value;
|
||||
const isIncoming = isIncomingCall.value;
|
||||
|
||||
if (status === 'active') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'missed') {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
} else {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'no-answer') {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
}
|
||||
|
||||
return isIncoming
|
||||
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
});
|
||||
|
||||
// Get icon class based on call status
|
||||
const callIconClass = computed(() => {
|
||||
if (!isVoiceChannel.value) return '';
|
||||
|
||||
const status = normalizedCallStatus.value;
|
||||
const isIncoming = isIncomingCall.value;
|
||||
|
||||
if (status === 'missed' || status === 'no-answer') {
|
||||
return 'i-ph-phone-x-fill';
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
return 'i-ph-phone-call-fill';
|
||||
}
|
||||
|
||||
if (status === 'ended' || status === 'completed') {
|
||||
return 'i-ph-phone-fill';
|
||||
}
|
||||
|
||||
// Default phone icon for ringing state
|
||||
return isIncoming
|
||||
? 'i-ph-phone-incoming-fill'
|
||||
: 'i-ph-phone-outgoing-fill';
|
||||
});
|
||||
|
||||
const showMessagePreviewWithoutMeta = computed(() => {
|
||||
const { labels = [] } = props.conversation;
|
||||
return (
|
||||
@@ -87,9 +206,21 @@ const onCardClick = e => {
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer relative"
|
||||
:class="{
|
||||
'border-l-2 border-green-500 dark:border-green-400': isRingingCall,
|
||||
'border-l-2 border-woot-500 dark:border-woot-400': isActiveCall,
|
||||
'border-l-2 border-red-500 dark:border-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'conversation-ringing': isRingingCall
|
||||
}"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<!-- Ringing call indicator (pulse effect) -->
|
||||
<div
|
||||
v-if="isRingingCall"
|
||||
class="absolute left-0 top-0 bottom-0 w-0.5 bg-green-500 dark:bg-green-400 animate-pulse"
|
||||
></div>
|
||||
|
||||
<Avatar
|
||||
:name="currentContactName"
|
||||
:src="currentContactThumbnail"
|
||||
@@ -108,7 +239,35 @@ const onCardClick = e => {
|
||||
v-tooltip.left="inboxName"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
|
||||
>
|
||||
<!-- Special handling for voice channel with specific icons based on call status -->
|
||||
<span
|
||||
v-if="isVoiceChannel && normalizedCallStatus === 'missed'"
|
||||
class="i-ph-phone-x-fill text-red-600 dark:text-red-400 size-3 inline-block"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isVoiceChannel && normalizedCallStatus === 'active'"
|
||||
class="i-ph-phone-call-fill text-woot-600 dark:text-woot-400 size-3 inline-block"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isVoiceChannel && normalizedCallStatus === 'ended' && isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-n-slate-11 size-3 inline-block"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isVoiceChannel && normalizedCallStatus === 'ended'"
|
||||
class="i-ph-phone-outgoing-fill text-n-slate-11 size-3 inline-block"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isVoiceChannel && isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-green-600 dark:text-green-400 size-3 inline-block"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isVoiceChannel"
|
||||
class="i-ph-phone-outgoing-fill text-green-600 dark:text-green-400 size-3 inline-block"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"
|
||||
></span>
|
||||
<Icon
|
||||
v-else
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-3"
|
||||
/>
|
||||
@@ -118,16 +277,94 @@ const onCardClick = e => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardMessagePreview
|
||||
v-show="showMessagePreviewWithoutMeta"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<CardMessagePreviewWithMeta
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
|
||||
<!-- Special preview for voice channel conversations -->
|
||||
<div
|
||||
v-if="isVoiceChannel"
|
||||
class="flex items-center py-1 h-7 gap-1 mb-0 text-sm line-clamp-1"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
|
||||
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
|
||||
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Icon based on call status -->
|
||||
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
|
||||
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="normalizedCallStatus === 'active'"
|
||||
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="normalizedCallStatus === 'ended'"
|
||||
class="i-ph-phone-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<span class="text-current truncate">{{ callStatusText }}</span>
|
||||
|
||||
<!-- Join now prompt for ringing calls -->
|
||||
<span
|
||||
v-if="normalizedCallStatus === 'ringing'"
|
||||
class="flex-shrink-0 text-xs font-medium text-green-600 dark:text-green-400"
|
||||
>
|
||||
({{ t('CONVERSATION.VOICE_CALL.JOIN_CALL') }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Regular message previews for non-voice channel conversations -->
|
||||
<template v-else>
|
||||
<CardMessagePreview
|
||||
v-show="showMessagePreviewWithoutMeta"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<CardMessagePreviewWithMeta
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conversation-ringing {
|
||||
animation: border-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.pulse-animation {
|
||||
animation: icon-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes border-pulse {
|
||||
0% {
|
||||
border-color: rgba(34, 197, 94, 0.8); /* Green for ringing */
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+10
-2
@@ -15,6 +15,7 @@ import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isVoiceInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
@@ -113,6 +114,12 @@ const { onFileUpload } = useFileUpload({
|
||||
});
|
||||
|
||||
const sendButtonLabel = computed(() => {
|
||||
// For voice inboxes, show "Make a Call" instead of "Send"
|
||||
if (props.isVoiceInbox) {
|
||||
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.MAKE_CALL');
|
||||
}
|
||||
|
||||
// For all other inboxes, show the standard "Send" label
|
||||
const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵';
|
||||
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', {
|
||||
keyCode,
|
||||
@@ -157,7 +164,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="!isWhatsappInbox && !hasNoInbox"
|
||||
v-if="!isWhatsappInbox && !isVoiceInbox && !hasNoInbox"
|
||||
v-on-click-outside="() => (isEmojiPickerOpen = false)"
|
||||
class="relative"
|
||||
>
|
||||
@@ -197,7 +204,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox"
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox && !isVoiceInbox"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@@ -217,6 +224,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
<Button
|
||||
v-if="!isWhatsappInbox"
|
||||
:icon="isVoiceInbox ? 'i-ri-phone-fill' : undefined"
|
||||
:label="sendButtonLabel"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
|
||||
+5
-3
@@ -68,6 +68,7 @@ const inboxTypes = computed(() => ({
|
||||
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
|
||||
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
|
||||
isVoice: props.targetInbox?.channelType === INBOX_TYPES.VOICE,
|
||||
isEmailOrWebWidget:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.EMAIL ||
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
@@ -87,7 +88,7 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
message: { required: requiredIf(!inboxTypes.value.isWhatsapp) },
|
||||
message: { required: requiredIf(!inboxTypes.value.isWhatsapp && !inboxTypes.value.isVoice) },
|
||||
subject: { required: requiredIf(inboxTypes.value.isEmail) },
|
||||
}));
|
||||
|
||||
@@ -311,7 +312,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
/>
|
||||
|
||||
<MessageEditor
|
||||
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
|
||||
v-if="!inboxTypes.isWhatsapp && !inboxTypes.isVoice && !showNoInboxAlert"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@@ -321,7 +322,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
v-if="state.attachedFiles.length > 0"
|
||||
v-if="state.attachedFiles.length > 0 && !inboxTypes.isVoice"
|
||||
:attachments="state.attachedFiles"
|
||||
@update:attachments="state.attachedFiles = $event"
|
||||
/>
|
||||
@@ -329,6 +330,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
<ActionButtons
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-voice-inbox="inboxTypes.isVoice"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
|
||||
+5
-3
@@ -8,8 +8,9 @@ const CHANNEL_PRIORITY = {
|
||||
'Channel::Whatsapp': 2,
|
||||
'Channel::Sms': 3,
|
||||
'Channel::TwilioSms': 4,
|
||||
'Channel::WebWidget': 5,
|
||||
'Channel::Api': 6,
|
||||
'Channel::Voice': 5,
|
||||
'Channel::WebWidget': 6,
|
||||
'Channel::Api': 7,
|
||||
};
|
||||
|
||||
export const generateLabelForContactableInboxesList = ({
|
||||
@@ -23,7 +24,8 @@ export const generateLabelForContactableInboxesList = ({
|
||||
}
|
||||
if (
|
||||
channelType === INBOX_TYPES.TWILIO ||
|
||||
channelType === INBOX_TYPES.WHATSAPP
|
||||
channelType === INBOX_TYPES.WHATSAPP ||
|
||||
channelType === INBOX_TYPES.VOICE
|
||||
) {
|
||||
return `${name} (${phoneNumber})`;
|
||||
}
|
||||
|
||||
@@ -60,38 +60,47 @@ const filteredAttrs = computed(() => {
|
||||
const computedVariant = computed(() => {
|
||||
if (props.variant) return props.variant;
|
||||
// The useAttrs method returns attributes values an empty string (not boolean value as in props).
|
||||
if (attrs.solid || attrs.solid === '') return 'solid';
|
||||
if (attrs.outline || attrs.outline === '') return 'outline';
|
||||
if (attrs.faded || attrs.faded === '') return 'faded';
|
||||
if (attrs.link || attrs.link === '') return 'link';
|
||||
if (attrs.ghost || attrs.ghost === '') return 'ghost';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.solid || attrObj.solid === '') return 'solid';
|
||||
if (attrObj.outline || attrObj.outline === '') return 'outline';
|
||||
if (attrObj.faded || attrObj.faded === '') return 'faded';
|
||||
if (attrObj.link || attrObj.link === '') return 'link';
|
||||
if (attrObj.ghost || attrObj.ghost === '') return 'ghost';
|
||||
return 'solid'; // Default variant
|
||||
});
|
||||
|
||||
const computedColor = computed(() => {
|
||||
if (props.color) return props.color;
|
||||
if (attrs.blue || attrs.blue === '') return 'blue';
|
||||
if (attrs.ruby || attrs.ruby === '') return 'ruby';
|
||||
if (attrs.amber || attrs.amber === '') return 'amber';
|
||||
if (attrs.slate || attrs.slate === '') return 'slate';
|
||||
if (attrs.teal || attrs.teal === '') return 'teal';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.blue || attrObj.blue === '') return 'blue';
|
||||
if (attrObj.ruby || attrObj.ruby === '') return 'ruby';
|
||||
if (attrObj.amber || attrObj.amber === '') return 'amber';
|
||||
if (attrObj.slate || attrObj.slate === '') return 'slate';
|
||||
if (attrObj.green || attrObj.green === '') return 'green';
|
||||
if (attrObj.teal || attrObj.teal === '') return 'teal';
|
||||
return 'blue'; // Default color
|
||||
});
|
||||
|
||||
const computedSize = computed(() => {
|
||||
if (props.size) return props.size;
|
||||
if (attrs.xs || attrs.xs === '') return 'xs';
|
||||
if (attrs.sm || attrs.sm === '') return 'sm';
|
||||
if (attrs.md || attrs.md === '') return 'md';
|
||||
if (attrs.lg || attrs.lg === '') return 'lg';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.xs || attrObj.xs === '') return 'xs';
|
||||
if (attrObj.sm || attrObj.sm === '') return 'sm';
|
||||
if (attrObj.md || attrObj.md === '') return 'md';
|
||||
if (attrObj.lg || attrObj.lg === '') return 'lg';
|
||||
return 'md';
|
||||
});
|
||||
|
||||
const computedJustify = computed(() => {
|
||||
if (props.justify) return props.justify;
|
||||
if (attrs.start || attrs.start === '') return 'start';
|
||||
if (attrs.center || attrs.center === '') return 'center';
|
||||
if (attrs.end || attrs.end === '') return 'end';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.start || attrObj.start === '') return 'start';
|
||||
if (attrObj.center || attrObj.center === '') return 'center';
|
||||
if (attrObj.end || attrObj.end === '') return 'end';
|
||||
|
||||
return 'center';
|
||||
});
|
||||
@@ -141,6 +150,17 @@ const STYLE_CONFIG = {
|
||||
ghost:
|
||||
'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
green: {
|
||||
solid:
|
||||
'bg-green-600 text-white hover:enabled:bg-green-700 focus-visible:bg-green-700 outline-transparent',
|
||||
faded:
|
||||
'bg-green-600/10 text-green-700 hover:enabled:bg-green-600/20 focus-visible:bg-green-600/20 outline-transparent',
|
||||
outline:
|
||||
'text-green-700 hover:enabled:bg-green-600/10 focus-visible:bg-green-600/10 outline-green-600',
|
||||
ghost:
|
||||
'text-green-700 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-green-700 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
},
|
||||
teal: {
|
||||
solid:
|
||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||
|
||||
@@ -36,6 +36,7 @@ import DyteBubble from './bubbles/Dyte.vue';
|
||||
import LocationBubble from './bubbles/Location.vue';
|
||||
import CSATBubble from './bubbles/CSAT.vue';
|
||||
import FormBubble from './bubbles/Form.vue';
|
||||
import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
@@ -296,6 +297,15 @@ const componentToRender = computed(() => {
|
||||
return InstagramStoryBubble;
|
||||
}
|
||||
|
||||
// Handle voice call bubble
|
||||
if (
|
||||
props.contentType === 'voice_call' ||
|
||||
props.contentAttributes?.type === 'voice_call' ||
|
||||
props.contentAttributes?.data?.callType === 'voice_call'
|
||||
) {
|
||||
return VoiceCallBubble;
|
||||
}
|
||||
|
||||
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||
const fileType = props.attachments[0].fileType;
|
||||
|
||||
@@ -509,10 +519,11 @@ provideMessageContext({
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
'min-w-0 max-w-full': componentToRender === VoiceCallBubble,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<Component :is="componentToRender" />
|
||||
<Component :is="componentToRender" :message="props" />
|
||||
</div>
|
||||
<MessageError
|
||||
v-if="contentAttributes.externalError"
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
isAWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isAVoiceChannel,
|
||||
} = useInbox();
|
||||
|
||||
const {
|
||||
@@ -41,6 +42,8 @@ const showStatusIndicator = computed(() => {
|
||||
if (status.value === MESSAGE_STATUS.FAILED) return false;
|
||||
// Don't show status for deleted messages
|
||||
if (contentAttributes.value?.deleted) return false;
|
||||
// Don't show status for transcription messages
|
||||
if (isAVoiceChannel.value) return false;
|
||||
|
||||
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
|
||||
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex-col border border-slate-100 dark:border-slate-700 rounded-lg overflow-hidden w-full max-w-xs"
|
||||
:class="statusClass"
|
||||
>
|
||||
<div class="flex items-center p-3 gap-3 w-full">
|
||||
<!-- Call Icon -->
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center size-10 rounded-full"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<span
|
||||
:class="[iconName, 'text-white text-xl']"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- Call Info -->
|
||||
<div class="flex flex-col flex-grow overflow-hidden">
|
||||
<span class="text-base font-medium" :class="labelTextClass">
|
||||
{{ labelText }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">
|
||||
{{ subtextWithDuration }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="hasAudioAttachment && recordingUrl">
|
||||
<div class="w-full m-0 p-1 min-w-[260px]">
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
:src="recordingUrl"
|
||||
preload="metadata"
|
||||
@ended="handlePlaybackEnd"
|
||||
controls
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useVoiceCallHelpers } from 'dashboard/composables/useVoiceCallHelpers';
|
||||
|
||||
export default {
|
||||
name: 'VoiceCallBubble',
|
||||
components: {
|
||||
},
|
||||
inject: ['$emit'],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
internalStatus: '',
|
||||
refreshInterval: null,
|
||||
statusCheckInterval: null,
|
||||
isAnimating: false,
|
||||
recordingUrl: '',
|
||||
isPlaying: false,
|
||||
hasAudioAttachment: false,
|
||||
};
|
||||
},
|
||||
setup(props) {
|
||||
// Initialize our composable for use in methods
|
||||
const {
|
||||
normalizeCallStatus,
|
||||
isIncomingCall,
|
||||
getCallIconName,
|
||||
getStatusText
|
||||
} = useVoiceCallHelpers({ conversation: props.message?.conversation }, {
|
||||
t: (key) => {
|
||||
// This is a simple passthrough for the t function since we're in options API
|
||||
// In setup() we can't access this.$t directly
|
||||
return key;
|
||||
}
|
||||
});
|
||||
|
||||
// Expose these helpers to the component instance
|
||||
return {
|
||||
normalizeCallHelper: normalizeCallStatus,
|
||||
checkIsIncoming: isIncomingCall,
|
||||
getCallIconHelper: getCallIconName,
|
||||
getStatusTextHelper: getStatusText
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
callData() {
|
||||
return this.message?.contentAttributes?.data || {};
|
||||
},
|
||||
|
||||
directionalStatus() {
|
||||
const direction = this.callData?.call_direction;
|
||||
if (direction) {
|
||||
return direction === 'inbound' ? 'inbound' : 'outbound';
|
||||
}
|
||||
return this.message?.messageType === 0 ? 'inbound' : 'outbound';
|
||||
},
|
||||
|
||||
isIncoming() {
|
||||
return this.directionalStatus === 'inbound';
|
||||
},
|
||||
|
||||
isOutgoing() {
|
||||
return this.directionalStatus === 'outbound';
|
||||
},
|
||||
|
||||
status() {
|
||||
// Use internal status if we have one (from UI updates)
|
||||
if (this.internalStatus) {
|
||||
return this.internalStatus;
|
||||
}
|
||||
|
||||
// First check for direct call_status in the conversation additional_attributes
|
||||
// This is the most authoritative source for call status
|
||||
const conversationCallStatus = this.message?.conversation?.additional_attributes?.call_status;
|
||||
if (conversationCallStatus) {
|
||||
// Use our composable helper for status normalization
|
||||
return this.normalizeCallHelper(conversationCallStatus, this.isIncoming);
|
||||
}
|
||||
|
||||
// Use the status from call data if present
|
||||
const callStatus = this.callData?.status;
|
||||
if (callStatus) {
|
||||
// Use our composable helper for status normalization
|
||||
return this.normalizeCallHelper(callStatus, this.isIncoming);
|
||||
}
|
||||
|
||||
// Determine status from timestamps
|
||||
if (this.callData?.ended_at) {
|
||||
return 'ended';
|
||||
}
|
||||
if (this.callData?.missed) {
|
||||
return this.isIncoming ? 'missed' : 'no-answer';
|
||||
}
|
||||
|
||||
// Check both message data and conversation data for started_at
|
||||
if (this.callData?.started_at ||
|
||||
this.message?.conversation?.additional_attributes?.call_started_at) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// Default to ringing
|
||||
return 'ringing';
|
||||
},
|
||||
|
||||
formattedDuration() {
|
||||
if (
|
||||
this.callData?.started_at &&
|
||||
(this.status === 'active' || this.status === 'ended')
|
||||
) {
|
||||
const startTime = new Date(this.callData.started_at);
|
||||
const endTime = this.callData?.ended_at
|
||||
? new Date(this.callData.ended_at)
|
||||
: new Date();
|
||||
|
||||
const durationMs = endTime - startTime;
|
||||
return this.formatDuration(durationMs);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
statusClass() {
|
||||
return {
|
||||
'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100': !this.isInbox,
|
||||
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100': this.isInbox,
|
||||
'call-ringing': this.status === 'ringing',
|
||||
};
|
||||
},
|
||||
|
||||
iconName() {
|
||||
// Use our composable helper for icon selection
|
||||
return this.getCallIconHelper(this.status, this.isIncoming);
|
||||
},
|
||||
|
||||
iconBgClass() {
|
||||
// Icon background colors based on status
|
||||
if (this.status === 'active') {
|
||||
return 'bg-green-500'; // Green for calls in progress
|
||||
}
|
||||
|
||||
if (this.status === 'missed' || this.status === 'no-answer') {
|
||||
return 'bg-red-500'; // Red for missed calls
|
||||
}
|
||||
|
||||
if (this.status === 'ended') {
|
||||
return 'bg-purple-500'; // Purple for ended calls
|
||||
}
|
||||
|
||||
// Default green for ringing
|
||||
return 'bg-green-500 pulse-animation';
|
||||
},
|
||||
|
||||
labelText() {
|
||||
// Use our composable helper to get status text
|
||||
// We need to convert the key to the actual text since we're in options API
|
||||
const key = this.getStatusTextHelper(this.status, this.isIncoming);
|
||||
|
||||
// Special cases for floating widget compatibility
|
||||
if (this.status === 'ringing') {
|
||||
if (this.isIncoming) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING');
|
||||
} else {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING');
|
||||
}
|
||||
}
|
||||
|
||||
// Map the key to the translated text
|
||||
return this.$t(key);
|
||||
},
|
||||
|
||||
labelTextClass() {
|
||||
if (this.status === 'missed' || this.status === 'no-answer') {
|
||||
return 'text-red-500';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
subtext() {
|
||||
// Checking call direction and status
|
||||
const direction = this.isIncoming ? 'incoming' : 'outgoing';
|
||||
|
||||
// Check if we have agent_joined flag to determine if agent answered
|
||||
const agentJoined = this.message?.conversation?.additional_attributes?.agent_joined === true;
|
||||
const callStarted = !!this.message?.conversation?.additional_attributes?.call_started_at;
|
||||
|
||||
// Special handling for incoming calls that were previously joined but now ended
|
||||
// This avoids showing "You didn't answer" when agent actually did answer
|
||||
if (this.isIncoming && this.status === 'missed' && (agentJoined || callStarted)) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
|
||||
// Common subtext for all statuses
|
||||
const subtextMap = {
|
||||
incoming: {
|
||||
ringing: this.$t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'),
|
||||
active: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'),
|
||||
missed: this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER'),
|
||||
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED')
|
||||
},
|
||||
outgoing: {
|
||||
ringing: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
active: this.$t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'),
|
||||
'no-answer': this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
completed: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
canceled: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
failed: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
busy: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED')
|
||||
}
|
||||
};
|
||||
|
||||
// First check if we have a specific message for this status
|
||||
if (subtextMap[direction] && subtextMap[direction][this.status]) {
|
||||
return subtextMap[direction][this.status];
|
||||
}
|
||||
|
||||
// Default for missing statuses
|
||||
if (this.isIncoming) {
|
||||
if (this.status === 'ringing') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
} else if (agentJoined || callStarted) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
} else {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER');
|
||||
}
|
||||
} else {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED');
|
||||
}
|
||||
},
|
||||
|
||||
subtextWithDuration() {
|
||||
// Checking if we have start and end timestamps for duration calculation
|
||||
let durationToShow = this.formattedDuration;
|
||||
|
||||
// Check if we have explicit call duration from the content attributes
|
||||
if (!durationToShow && this.callData?.duration) {
|
||||
const durationSeconds = parseInt(this.callData.duration, 10);
|
||||
if (!isNaN(durationSeconds) && durationSeconds > 0) {
|
||||
const minutes = Math.floor(durationSeconds / 60);
|
||||
const seconds = durationSeconds % 60;
|
||||
durationToShow = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// For completed calls, always show the duration if we have it
|
||||
const shouldShowDuration =
|
||||
(this.status === 'ended' || this.status === 'completed') &&
|
||||
durationToShow;
|
||||
|
||||
if (shouldShowDuration) {
|
||||
return `${this.subtext} · ${durationToShow}`;
|
||||
}
|
||||
|
||||
return this.subtext;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.setupVoiceCall();
|
||||
this.setAudioAttachment();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setupVoiceCall();
|
||||
this.setAudioAttachment();
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up all intervals to prevent memory leaks
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
|
||||
if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval);
|
||||
this.statusCheckInterval = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDuration(milliseconds) {
|
||||
// Convert milliseconds to seconds
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
|
||||
// Calculate minutes and remaining seconds
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
// Format as MM:SS with leading zeros
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
setupVoiceCall() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
|
||||
// Create refresh interval for active calls to update duration
|
||||
if (this.status === 'active') {
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.$forceUpdate();
|
||||
}, 1000);
|
||||
|
||||
// Set animation flag
|
||||
this.isAnimating = true;
|
||||
} else {
|
||||
this.isAnimating = false;
|
||||
}
|
||||
|
||||
// Always check for call status changes, not just when ringing
|
||||
if (true) { // Always run status checks
|
||||
// Create a separate interval to check if the call status has changed
|
||||
this.statusCheckInterval = setInterval(() => {
|
||||
// Check if content_attributes has been updated with new status
|
||||
const updatedStatus = this.callData?.status;
|
||||
const statusUpdatedAt = this.callData?.status_updated;
|
||||
|
||||
// Also check the conversation's call status (which might be more authoritative)
|
||||
const conversationStatus = this.message?.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Check for any status changes from either source
|
||||
const hasMessageStatusChanged = updatedStatus &&
|
||||
updatedStatus !== this.internalStatus &&
|
||||
statusUpdatedAt;
|
||||
|
||||
const hasConversationStatusChanged = conversationStatus &&
|
||||
conversationStatus !== this.internalStatus;
|
||||
|
||||
// If either status has changed, update UI
|
||||
if (hasMessageStatusChanged || hasConversationStatusChanged) {
|
||||
// Prefer the conversation status if available (more reliable)
|
||||
const newStatus = conversationStatus || updatedStatus;
|
||||
|
||||
// Status has changed, update UI
|
||||
this.updateStatus(newStatus);
|
||||
this.$forceUpdate();
|
||||
|
||||
// If call is now active or ended, update UI
|
||||
if (newStatus === 'active' || newStatus === 'in-progress') {
|
||||
this.setupVoiceCall();
|
||||
}
|
||||
}
|
||||
}, 1000); // Check more frequently - every 1 second
|
||||
} else if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval);
|
||||
this.statusCheckInterval = null;
|
||||
}
|
||||
},
|
||||
updateStatus(newStatus) {
|
||||
if (
|
||||
newStatus &&
|
||||
newStatus !== this.status &&
|
||||
newStatus !== this.internalStatus
|
||||
) {
|
||||
this.internalStatus = newStatus;
|
||||
}
|
||||
},
|
||||
handlePlaybackEnd() {
|
||||
this.isPlaying = false;
|
||||
},
|
||||
setAudioAttachment() {
|
||||
// Look for audio attachment in message.attachments or message.contentAttributes.attachments
|
||||
let attachments = [];
|
||||
if (this.message?.attachments && Array.isArray(this.message.attachments)) {
|
||||
attachments = this.message.attachments;
|
||||
} else if (this.message?.contentAttributes?.attachments && Array.isArray(this.message.contentAttributes.attachments)) {
|
||||
attachments = this.message.contentAttributes.attachments;
|
||||
}
|
||||
// Find the first audio attachment, supporting both camelCase and snake_case fields
|
||||
const audio = attachments.find(att => {
|
||||
if (!att) return false;
|
||||
// Check file_type or fileType
|
||||
if ((att.file_type && att.file_type.startsWith('audio')) ||
|
||||
(att.fileType && att.fileType.startsWith('audio'))) return true;
|
||||
// Check content_type or contentType
|
||||
if ((att.content_type && att.content_type.startsWith('audio')) ||
|
||||
(att.contentType && att.contentType.startsWith('audio'))) return true;
|
||||
// Check data_url or dataUrl
|
||||
if ((att.data_url && att.data_url.match(/\.(mp3|wav|ogg|m4a)$/i)) ||
|
||||
(att.dataUrl && att.dataUrl.match(/\.(mp3|wav|ogg|m4a)$/i))) return true;
|
||||
// Check file_url or fileUrl
|
||||
if ((att.file_url && att.file_url.match(/\.(mp3|wav|ogg|m4a)$/i)) ||
|
||||
(att.fileUrl && att.fileUrl.match(/\.(mp3|wav|ogg|m4a)$/i))) return true;
|
||||
return false;
|
||||
});
|
||||
if (audio) {
|
||||
this.recordingUrl = audio.data_url || audio.file_url || audio.dataUrl || audio.fileUrl || '';
|
||||
this.hasAudioAttachment = true;
|
||||
} else {
|
||||
this.recordingUrl = '';
|
||||
this.hasAudioAttachment = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Voice call styling */
|
||||
.pulse-animation {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.call-ringing {
|
||||
animation: border-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); /* Green for ringing */
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes border-pulse {
|
||||
0% {
|
||||
border-color: rgba(34, 197, 94, 0.8); /* Green for ringing */
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,9 @@ defineOptions({
|
||||
});
|
||||
|
||||
const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
// Safely access the URL, providing a fallback if not available
|
||||
const url = attachment?.dataUrl || attachment?.data_url || '';
|
||||
return timeStampAppendedURL(url);
|
||||
});
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
@@ -91,8 +93,16 @@ const changePlaybackSpeed = () => {
|
||||
};
|
||||
|
||||
const downloadAudio = async () => {
|
||||
const { fileType, dataUrl, extension } = attachment;
|
||||
downloadFile({ url: dataUrl, type: fileType, extension });
|
||||
// Get the URL with fallback options
|
||||
const url = attachment?.dataUrl || attachment?.data_url || '';
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = attachment?.fileType || attachment?.file_type || 'file';
|
||||
const extension = attachment?.extension || 'mp3';
|
||||
|
||||
downloadFile({ url, type: fileType, extension });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
@@ -16,6 +17,7 @@ import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import CallButton from 'dashboard/components-next/CallModal/CallButton.vue';
|
||||
|
||||
const emit = defineEmits([
|
||||
'closeKeyShortcutModal',
|
||||
@@ -63,6 +65,11 @@ const conversationCustomViews = useMapGetter(
|
||||
'customViews/getConversationCustomViews'
|
||||
);
|
||||
|
||||
// Check if there are any voice inboxes
|
||||
const hasVoiceInbox = computed(() =>
|
||||
inboxes.value.some(inbox => inbox.channel_type === INBOX_TYPES.VOICE)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('labels/get');
|
||||
store.dispatch('inboxes/get');
|
||||
@@ -331,6 +338,11 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.SMS'),
|
||||
to: accountScopedRoute('campaigns_sms_index'),
|
||||
},
|
||||
{
|
||||
name: 'Voice',
|
||||
label: t('SIDEBAR.VOICE'),
|
||||
to: accountScopedRoute('campaigns_voice_index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -511,6 +523,7 @@ const menuItems = computed(() => {
|
||||
{{ searchShortcut }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<CallButton v-if="hasVoiceInbox" class="flex-shrink-0" />
|
||||
<ComposeConversation align-position="right">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
@@ -541,4 +554,4 @@ const menuItems = computed(() => {
|
||||
/>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
</template>
|
||||
@@ -133,7 +133,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="inputType === 'multi_select'"
|
||||
@@ -152,7 +156,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<input
|
||||
v-else-if="inputType === 'email'"
|
||||
|
||||
@@ -48,13 +48,13 @@ export default {
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
'voice',
|
||||
'api',
|
||||
'whatsapp',
|
||||
'sms',
|
||||
'telegram',
|
||||
'line',
|
||||
'instagram',
|
||||
'voice',
|
||||
].includes(key);
|
||||
},
|
||||
isComingSoon() {
|
||||
|
||||
@@ -203,7 +203,7 @@ export default {
|
||||
v-model="values"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Select"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
multiple
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
@@ -211,7 +211,11 @@ export default {
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="inputType === 'search_select'"
|
||||
@@ -221,7 +225,7 @@ export default {
|
||||
v-model="values"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Select"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
@@ -229,7 +233,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
|
||||
<input
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,13 @@ export default {
|
||||
<div
|
||||
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
|
||||
>
|
||||
<!-- Use i-ph- icons for phone specifically, and FluentIcon for others -->
|
||||
<span
|
||||
v-if="inbox.channel_type === 'Channel::Voice'"
|
||||
class="mr-0.5 rtl:ml-0.5 rtl:mr-0 i-ph-phone text-sm"
|
||||
></span>
|
||||
<fluent-icon
|
||||
v-else
|
||||
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
|
||||
:icon="computedInboxClass"
|
||||
size="12"
|
||||
|
||||
@@ -311,6 +311,7 @@ export default {
|
||||
<MessagePreview
|
||||
v-if="lastMessageInChat"
|
||||
:message="lastMessageInChat"
|
||||
:conversation="chat"
|
||||
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
|
||||
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,10 @@ export default {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
conversation: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showMessageType: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -26,6 +30,13 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
shouldShowCallStatus() {
|
||||
// Always show call status for voice channels if present
|
||||
return (
|
||||
this.conversation?.meta?.channel === 'Channel::Voice' &&
|
||||
!!this.conversation?.additional_attributes?.call_status
|
||||
);
|
||||
},
|
||||
messageByAgent() {
|
||||
const { message_type: messageType } = this.message;
|
||||
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||
@@ -38,7 +49,125 @@ export default {
|
||||
const { private: isPrivate } = this.message;
|
||||
return isPrivate;
|
||||
},
|
||||
// Simple check: Is this a voice channel conversation?
|
||||
isVoiceChannel() {
|
||||
return this.conversation?.meta?.channel === 'Channel::Voice';
|
||||
},
|
||||
// Check if this is a voice call message
|
||||
isVoiceCall() {
|
||||
return (
|
||||
this.message?.content_type === 'voice_call'
|
||||
);
|
||||
},
|
||||
// Get call direction for voice calls
|
||||
isIncomingCall() {
|
||||
if (!this.isVoiceChannel) return false;
|
||||
|
||||
// First check conversation attributes
|
||||
const direction = this.conversation?.additional_attributes?.call_direction;
|
||||
if (direction) {
|
||||
return direction === 'inbound';
|
||||
}
|
||||
},
|
||||
// Get normalized call status
|
||||
callStatus() {
|
||||
if (!this.isVoiceChannel) return null;
|
||||
|
||||
// Get raw status from conversation
|
||||
const status = this.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Map status to normalized values
|
||||
if (status === 'in-progress') return 'active';
|
||||
if (status === 'completed') return 'ended';
|
||||
if (status === 'canceled') return 'ended';
|
||||
if (status === 'failed') return 'ended';
|
||||
if (status === 'busy') return 'no-answer';
|
||||
if (status === 'no-answer') return this.isIncomingCall ? 'missed' : 'no-answer';
|
||||
|
||||
// Return explicit status values as-is
|
||||
if (status === 'active') return 'active';
|
||||
if (status === 'missed') return 'missed';
|
||||
if (status === 'ended') return 'ended';
|
||||
if (status === 'ringing') return 'ringing';
|
||||
|
||||
// Default status
|
||||
return 'active';
|
||||
},
|
||||
// Voice call icon based on status
|
||||
voiceCallIcon() {
|
||||
if (!this.isVoiceChannel) return null;
|
||||
|
||||
const status = this.callStatus;
|
||||
const isIncoming = this.isIncomingCall;
|
||||
|
||||
if (status === 'missed' || status === 'no-answer') {
|
||||
return 'phone-missed-call';
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
return 'phone-in-talk';
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
|
||||
}
|
||||
|
||||
if (status === 'ringing') {
|
||||
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
|
||||
}
|
||||
|
||||
// Default based on direction
|
||||
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
|
||||
},
|
||||
parsedLastMessage() {
|
||||
// For voice calls, return status text
|
||||
if (this.isVoiceChannel) {
|
||||
// Get status-based text
|
||||
const status = this.callStatus;
|
||||
const isIncoming = this.isIncomingCall;
|
||||
|
||||
// Return appropriate status text based on call status and direction
|
||||
if (status === 'active') {
|
||||
// return last message content if message is not activity and not voice call
|
||||
if (!this.isMessageAnActivity && !this.isVoiceCall) {
|
||||
return this.getPlainText(this.message.content);
|
||||
}
|
||||
return this.$t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
if (status === 'ringing') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'missed') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
} else {
|
||||
if (status === 'ringing') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'no-answer') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback based on direction
|
||||
return isIncoming
|
||||
? this.$t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
||||
: this.$t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
// Default behavior for non-voice calls
|
||||
const { content_attributes: contentAttributes } = this.message;
|
||||
const { email: { subject } = {} } = contentAttributes || {};
|
||||
return this.getPlainText(subject || this.message.content);
|
||||
@@ -62,48 +191,99 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<template v-if="showMessageType">
|
||||
<fluent-icon
|
||||
v-if="isMessagePrivate"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="lock-closed"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-else-if="messageByAgent"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="arrow-reply"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="info"
|
||||
/>
|
||||
<!-- Always show call status for voice channels if present -->
|
||||
<template v-if="shouldShowCallStatus">
|
||||
<span
|
||||
class="-mt-0.5 align-middle inline-block mr-1"
|
||||
:class="{
|
||||
'text-red-600 dark:text-red-400': callStatus === 'missed' || callStatus === 'no-answer',
|
||||
'text-green-600 dark:text-green-400': callStatus === 'active' || callStatus === 'ringing',
|
||||
'text-n-slate-11': callStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Missed call icon -->
|
||||
<i v-if="callStatus === 'missed' || callStatus === 'no-answer'"
|
||||
class="i-ph-phone-x text-base"></i>
|
||||
<!-- Active call icon -->
|
||||
<i v-else-if="callStatus === 'active'"
|
||||
class="i-ph-phone-call text-base"></i>
|
||||
<!-- Incoming call icon -->
|
||||
<i v-else-if="(callStatus === 'ended' && isIncomingCall) || (isIncomingCall)"
|
||||
class="i-ph-phone-incoming text-base"></i>
|
||||
<!-- Outgoing call icon -->
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing text-base"></i>
|
||||
</span>
|
||||
<span>{{ parsedLastMessage }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="showMessageType">
|
||||
<fluent-icon
|
||||
v-if="isMessagePrivate"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="lock-closed"
|
||||
/>
|
||||
<!-- Voice calls with phosphor icons (non-filled variants) -->
|
||||
<span
|
||||
v-else-if="isVoiceCall"
|
||||
class="-mt-0.5 align-middle inline-block mr-1"
|
||||
:class="{
|
||||
'text-red-600 dark:text-red-400': callStatus === 'missed' || callStatus === 'no-answer',
|
||||
'text-green-600 dark:text-green-400': callStatus === 'active' || callStatus === 'ringing',
|
||||
'text-n-slate-11': callStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Missed call icon -->
|
||||
<i v-if="callStatus === 'missed' || callStatus === 'no-answer'"
|
||||
class="i-ph-phone-x text-base"></i>
|
||||
<!-- Active call icon -->
|
||||
<i v-else-if="callStatus === 'active'"
|
||||
class="i-ph-phone-call text-base"></i>
|
||||
<!-- Incoming call icon -->
|
||||
<i v-else-if="(callStatus === 'ended' && isIncomingCall) || (isIncomingCall)"
|
||||
class="i-ph-phone-incoming text-base"></i>
|
||||
<!-- Outgoing call icon -->
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing text-base"></i>
|
||||
</span>
|
||||
<fluent-icon
|
||||
v-else-if="messageByAgent"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="arrow-reply"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="info"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="message.content && isMessageSticker">
|
||||
<fluent-icon
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-11"
|
||||
icon="image"
|
||||
/>
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
</span>
|
||||
<span v-else-if="message.content || isVoiceCall">
|
||||
{{ parsedLastMessage }}
|
||||
</span>
|
||||
<span v-else-if="message.attachments">
|
||||
<fluent-icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-11"
|
||||
:icon="attachmentIcon"
|
||||
/>
|
||||
{{ $t(`${attachmentMessageContent}`) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="message.content && isMessageSticker">
|
||||
<fluent-icon
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-11"
|
||||
icon="image"
|
||||
/>
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
</span>
|
||||
<span v-else-if="message.content">
|
||||
{{ parsedLastMessage }}
|
||||
</span>
|
||||
<span v-else-if="message.attachments">
|
||||
<fluent-icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-11"
|
||||
:icon="attachmentIcon"
|
||||
/>
|
||||
{{ $t(`${attachmentMessageContent}`) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import ReplyBox from './ReplyBox.vue';
|
||||
import MessageList from 'next/message/MessageList.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import VoiceTimelineView from './VoiceTimelineView.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
// stores and apis
|
||||
@@ -42,6 +43,7 @@ export default {
|
||||
ReplyBox,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
VoiceTimelineView,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
@@ -115,6 +117,10 @@ export default {
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
// Use inbox.channel_type to match backend conventions
|
||||
return this.inbox?.channel_type === 'Channel::Voice';
|
||||
},
|
||||
typingUsersList() {
|
||||
const userList = this.$store.getters[
|
||||
'conversationTypingStatus/getUserList'
|
||||
@@ -519,9 +525,15 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<VoiceTimelineView
|
||||
v-if="isAVoiceChannel"
|
||||
:conversation-id="currentChat.id"
|
||||
class="mb-4"
|
||||
/>
|
||||
<ReplyBox
|
||||
:pop-out-reply-box="isPopOutReplyBox"
|
||||
@update:pop-out-reply-box="isPopOutReplyBox = $event"
|
||||
:is-private-note-only="isAVoiceChannel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@ export default {
|
||||
recordingAudioState: '',
|
||||
recordingAudioDurationText: '',
|
||||
isUploading: false,
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
replyType: REPLY_EDITOR_MODES.NOTE,
|
||||
mentionSearchKey: '',
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
<script>
|
||||
import { defineComponent, ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
name: 'VoiceTimelineView',
|
||||
props: {
|
||||
conversationId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isCallEnded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
// Use 01:34 (94 seconds) as our fallback/demo duration throughout the component
|
||||
const defaultDuration = '01:34';
|
||||
const defaultSeconds = 94;
|
||||
|
||||
return {
|
||||
currentTime: '00:00',
|
||||
duration: defaultDuration, // Default duration that will be shown even if API returns nothing
|
||||
isPlaying: false,
|
||||
// Generate more realistic waveform data with varied heights
|
||||
waveformData: this.generateRealisticWaveform(180),
|
||||
// Define different waveform segments for caller and agent with speaking patterns
|
||||
waveformSegments: [
|
||||
{ start: '00:00', end: '00:20', type: 'caller', data: this.generateRealisticWaveform(40) },
|
||||
{ start: '00:21', end: '00:30', type: 'agent', data: this.generateRealisticWaveform(20) },
|
||||
{ start: '00:31', end: '00:45', type: 'caller', data: this.generateRealisticWaveform(30) },
|
||||
{ start: '00:46', end: '00:58', type: 'agent', data: this.generateRealisticWaveform(26) },
|
||||
{ start: '00:59', end: '01:10', type: 'caller', data: this.generateRealisticWaveform(24) },
|
||||
{ start: '01:11', end: '01:23', type: 'agent', data: this.generateRealisticWaveform(24) },
|
||||
{ start: '01:24', end: '01:34', type: 'caller', data: this.generateRealisticWaveform(20) },
|
||||
],
|
||||
timeLabels: ['00:00', '00:12', '00:24', '00:36', '00:48', '01:00', '01:12', '01:24'],
|
||||
markers: [
|
||||
{ time: '00:15', label: 'systemEvent', text: 'Tool call: Customer identity verified (method: Voice)', icon: 'i-ph-check-circle-fill', ms: '4598ms' },
|
||||
{ time: '00:32', label: 'systemEvent', text: 'Tool call: Order details retrieved (order_id: #38291)', icon: 'i-ph-database-fill', ms: '7436ms' },
|
||||
{ time: '00:48', label: 'systemEvent', text: 'Tool call: Customer account verified (id: 57482)', icon: 'i-ph-user-circle-fill', ms: '22060ms' },
|
||||
{ time: '01:05', label: 'systemEvent', text: 'Tool call: Address updated (1234 Main St, NY)', icon: 'i-ph-database-fill', ms: '84521ms' },
|
||||
{ time: '01:20', label: 'systemEvent', text: 'Tool call: Email sent (template: address_update)', icon: 'i-ph-envelope-fill', ms: '3250ms' },
|
||||
],
|
||||
timer: null,
|
||||
currentSeconds: 0,
|
||||
totalSeconds: defaultSeconds, // Default duration in seconds
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
}),
|
||||
playButtonIcon() {
|
||||
return this.isPlaying ? 'pause' : 'play_arrow';
|
||||
},
|
||||
playButtonLabel() {
|
||||
return this.isPlaying ? 'Pause' : 'Play';
|
||||
},
|
||||
progressPercentage() {
|
||||
return this.totalSeconds > 0 ? (this.currentSeconds / this.totalSeconds) * 100 : 0;
|
||||
},
|
||||
callData() {
|
||||
return this.currentChat?.additional_attributes || {};
|
||||
},
|
||||
callDuration() {
|
||||
// Get call duration in seconds from call data
|
||||
const duration = this.callData?.duration || 94; // Default to 94 seconds (01:34) if not available
|
||||
return parseInt(duration, 10);
|
||||
},
|
||||
formattedCallDuration() {
|
||||
// Always return a formatted duration, never 00:00
|
||||
const formattedTime = this.formatTime(this.callDuration);
|
||||
return formattedTime === '00:00' ? '01:34' : formattedTime;
|
||||
},
|
||||
shouldShowTimeline() {
|
||||
// Always show the timeline (all conditions removed for demo/testing)
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
conversationId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.loadCallData();
|
||||
},
|
||||
},
|
||||
currentSeconds(newVal) {
|
||||
this.currentTime = this.formatTime(newVal);
|
||||
},
|
||||
callDuration: {
|
||||
handler(newVal) {
|
||||
if (newVal && newVal !== this.totalSeconds) {
|
||||
this.totalSeconds = newVal;
|
||||
this.duration = this.formattedCallDuration;
|
||||
// Ensure duration is never empty
|
||||
if (!this.duration || this.duration === '00:00') {
|
||||
this.duration = '01:34'; // Fallback default
|
||||
}
|
||||
console.log('Call duration updated to:', this.duration);
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
generateRealisticWaveform(length) {
|
||||
// Create a waveform pattern that matches the marked style in the reference image
|
||||
// with vertical bars grouped together in sections
|
||||
const baseAmplitude = 40;
|
||||
const minAmplitude = 10;
|
||||
const waveform = [];
|
||||
|
||||
// Generate segments of active speech with groups of bars
|
||||
let i = 0;
|
||||
while (i < length) {
|
||||
// Create a speech segment (cluster of vertical bars)
|
||||
const segmentSize = Math.floor(Math.random() * 8) + 5; // 5-12 bars per segment
|
||||
|
||||
// For each bar in the segment
|
||||
for (let j = 0; j < segmentSize && i < length; j++, i++) {
|
||||
// Create bars of varying heights in a pattern
|
||||
const barHeight = j % 2 === 0
|
||||
? Math.random() * baseAmplitude + minAmplitude // Taller bars
|
||||
: Math.random() * (baseAmplitude / 2) + minAmplitude; // Shorter bars
|
||||
|
||||
waveform.push(barHeight);
|
||||
}
|
||||
|
||||
// Add a gap between segments (spaces between bar clusters)
|
||||
const gapSize = Math.floor(Math.random() * 4) + 2; // 2-5 bars of space
|
||||
for (let j = 0; j < gapSize && i < length; j++, i++) {
|
||||
waveform.push(Math.random() * (minAmplitude / 5)); // Very low height for gaps
|
||||
}
|
||||
}
|
||||
|
||||
return waveform;
|
||||
},
|
||||
|
||||
togglePlayPause() {
|
||||
this.isPlaying = !this.isPlaying;
|
||||
|
||||
if (this.isPlaying) {
|
||||
this.startTimer();
|
||||
} else {
|
||||
this.stopTimer();
|
||||
}
|
||||
},
|
||||
startTimer() {
|
||||
if (this.timer) return;
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.currentSeconds += 0.1;
|
||||
if (this.currentSeconds >= this.totalSeconds) {
|
||||
this.currentSeconds = this.totalSeconds;
|
||||
this.stopTimer();
|
||||
this.isPlaying = false;
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
stopTimer() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
},
|
||||
formatTime(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
getMarkerPosition(marker) {
|
||||
return this.getTimePercentage(marker.time);
|
||||
},
|
||||
|
||||
getTimePercentage(timeString) {
|
||||
const minutes = parseInt(timeString.split(':')[0], 10);
|
||||
const seconds = parseInt(timeString.split(':')[1], 10);
|
||||
|
||||
const totalSeconds = minutes * 60 + seconds;
|
||||
|
||||
return (totalSeconds / this.totalSeconds) * 100;
|
||||
},
|
||||
|
||||
timeToSeconds(timeString) {
|
||||
const minutes = parseInt(timeString.split(':')[0], 10);
|
||||
const seconds = parseInt(timeString.split(':')[1], 10);
|
||||
|
||||
return minutes * 60 + seconds;
|
||||
},
|
||||
getMarkerClass(marker) {
|
||||
switch (marker.label) {
|
||||
case 'assistantSentSMS':
|
||||
return 'marker--sms';
|
||||
case 'userResponse':
|
||||
return 'marker--user';
|
||||
case 'agentMessage':
|
||||
return 'marker--agent';
|
||||
case 'systemEvent':
|
||||
return 'marker--system';
|
||||
case 'callEnded':
|
||||
return 'marker--end';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
setProgress(event) {
|
||||
const timeline = event.currentTarget;
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const offsetX = event.clientX - rect.left;
|
||||
const percentage = (offsetX / rect.width) * 100;
|
||||
this.currentSeconds = (percentage / 100) * this.totalSeconds;
|
||||
},
|
||||
loadCallData() {
|
||||
// Always set a default value first
|
||||
this.duration = '01:34'; // Default fallback
|
||||
this.totalSeconds = 94; // Default 01:34 in seconds
|
||||
|
||||
// If we have actual call duration, use it
|
||||
if (this.callDuration && this.callDuration > 0) {
|
||||
this.totalSeconds = this.callDuration;
|
||||
this.duration = this.formattedCallDuration;
|
||||
}
|
||||
|
||||
console.log('Call data loaded for conversation:', this.conversationId);
|
||||
console.log('Call duration:', this.duration);
|
||||
console.log('Sample conversation events:', this.markers.map(m => m.text).join(', '));
|
||||
},
|
||||
|
||||
extractCallEvents() {
|
||||
// Simulate real implementation - would parse messages to find call events
|
||||
const messages = this.currentChat?.messages || [];
|
||||
|
||||
// In a real implementation, we would find call events in the messages
|
||||
console.log('Analyzing conversation messages for call events and tool calls...');
|
||||
console.log('Looking through', messages.length, 'messages for activities');
|
||||
|
||||
// This is just for demonstration - actual implementation would process real events
|
||||
const eventTypes = [
|
||||
'call_initiated',
|
||||
'authentication_successful',
|
||||
'customer_verified',
|
||||
'tool_call:database_lookup',
|
||||
'tool_call:payment_processed',
|
||||
'tool_call:email_notification_sent',
|
||||
'call_ended'
|
||||
];
|
||||
|
||||
console.log('Found call events and tool calls:', eventTypes.join(', '));
|
||||
|
||||
// The actual markers we're showing in the UI simulate these events
|
||||
console.log('Processing tool calls:');
|
||||
console.log('- Tool call: Customer account verified (customer_id: 57482)');
|
||||
console.log('- Tool call: Address updated (new_address: 1234 Main St, Apt 5B)');
|
||||
console.log('- Tool call: Confirmation email sent (template: address_update)');
|
||||
},
|
||||
|
||||
// Determine the active speaker at the current time
|
||||
getActiveSpeaker() {
|
||||
// In a real implementation, this would check the actual call data
|
||||
// For now, we'll use the predefined segments to determine who's speaking
|
||||
|
||||
const currentTimeStr = this.formatTime(this.currentSeconds);
|
||||
|
||||
// Find the segment that contains the current time
|
||||
const activeSegment = this.waveformSegments.find(segment => {
|
||||
const startSeconds = this.timeToSeconds(segment.start);
|
||||
const endSeconds = this.timeToSeconds(segment.end);
|
||||
return this.currentSeconds >= startSeconds && this.currentSeconds <= endSeconds;
|
||||
});
|
||||
|
||||
// Return the speaker type, or null if not found
|
||||
return activeSegment ? activeSegment.type : null;
|
||||
},
|
||||
|
||||
// Check if a specific speaker is active
|
||||
isSpeakerActive(speakerType) {
|
||||
return this.getActiveSpeaker() === speakerType;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadCallData();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopTimer();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shouldShowTimeline" class="voice-timeline-view">
|
||||
<div class="timeline-header">
|
||||
<div class="time-display">
|
||||
<span class="current-time">{{ currentTime }}</span> / <span class="total-time">{{ duration || formattedCallDuration }}</span>
|
||||
</div>
|
||||
<div class="control-buttons">
|
||||
<NextButton
|
||||
@click="togglePlayPause"
|
||||
v-tooltip.top-start="playButtonLabel"
|
||||
:icon="isPlaying ? 'i-ph-pause-fill' : 'i-ph-play-fill'"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
round
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="timeline-container"
|
||||
@click="setProgress"
|
||||
>
|
||||
<!-- Progress Indicator -->
|
||||
<div
|
||||
class="progress-indicator"
|
||||
:style="{ left: `${progressPercentage}%` }"
|
||||
></div>
|
||||
|
||||
<!-- Agent Timeline (Top) -->
|
||||
<div class="agent-timeline">
|
||||
<div class="waveform-bars">
|
||||
<div
|
||||
v-for="(value, index) in waveformData.slice(waveformData.length / 2)"
|
||||
:key="'agent-' + index"
|
||||
class="waveform-bar"
|
||||
:class="{
|
||||
'active-agent-bar': isSpeakerActive('agent'),
|
||||
'inactive-agent-bar': !isSpeakerActive('agent'),
|
||||
'progress-passed': index / (waveformData.length / 2) < progressPercentage / 100
|
||||
}"
|
||||
:style="{
|
||||
height: `${value}%`
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Timeline (Middle) -->
|
||||
<div class="contact-timeline">
|
||||
<div class="waveform-bars">
|
||||
<div
|
||||
v-for="(value, index) in waveformData.slice(0, waveformData.length / 2)"
|
||||
:key="'contact-' + index"
|
||||
class="waveform-bar"
|
||||
:class="{
|
||||
'active-contact-bar': isSpeakerActive('caller'),
|
||||
'inactive-contact-bar': !isSpeakerActive('caller'),
|
||||
'progress-passed': index / (waveformData.length / 2) < progressPercentage / 100
|
||||
}"
|
||||
:style="{
|
||||
height: `${value}%`
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Timeline overlaid at bottom -->
|
||||
<div class="events-timeline">
|
||||
<!-- Event Markers -->
|
||||
<div
|
||||
v-for="(marker, index) in markers"
|
||||
:key="index"
|
||||
class="event-marker"
|
||||
:class="getMarkerClass(marker)"
|
||||
:style="{ left: `${getMarkerPosition(marker)}%` }"
|
||||
>
|
||||
<div class="marker-dot">
|
||||
<span v-if="marker.icon" :class="[marker.icon, 'marker-icon']"></span>
|
||||
</div>
|
||||
|
||||
<!-- Tooltips for all events -->
|
||||
<div
|
||||
class="marker-tooltip"
|
||||
:class="[
|
||||
`marker-tooltip-${marker.label}`,
|
||||
{ 'marker-tooltip-visible': isPlaying && currentSeconds >= timeToSeconds(marker.time) && currentSeconds <= timeToSeconds(marker.time) + 2 }
|
||||
]"
|
||||
>
|
||||
<div class="font-semibold mb-1">Tool Call</div>
|
||||
<div class="marker-text">
|
||||
<div class="whitespace-normal">
|
||||
{{ marker.text.replace('Tool call:', '').trim() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.voice-timeline-view {
|
||||
@apply relative flex flex-col px-5 pt-3 pb-5 mb-4 mx-4 h-[220px] border border-n-slate-3 dark:border-n-slate-7 rounded-lg bg-n-solid-2 shadow-sm;
|
||||
font-family: Inter, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
@apply flex justify-between items-center mb-2 px-1;
|
||||
|
||||
.control-buttons {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
@apply text-sm font-medium text-n-slate-11;
|
||||
|
||||
.current-time {
|
||||
@apply font-medium text-n-slate-11;
|
||||
}
|
||||
|
||||
.total-time {
|
||||
@apply font-normal text-n-slate-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
@apply relative flex-1 bg-n-solid-2 rounded-lg overflow-hidden cursor-pointer shadow-sm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 190px;
|
||||
}
|
||||
|
||||
.time-labels {
|
||||
@apply absolute top-0 left-0 right-0 h-7 z-10;
|
||||
|
||||
.time-label {
|
||||
@apply absolute text-sm transform -translate-x-1/2 font-medium text-n-blue-8 dark:text-n-blue-5;
|
||||
top: 4px;
|
||||
|
||||
.h-3 {
|
||||
@apply bg-n-slate-3 dark:bg-n-slate-6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
@apply absolute top-0 bottom-0 w-[1.5px] z-30 bg-n-blue-7 dark:bg-n-blue-5;
|
||||
box-shadow: 0 0 3px rgba(var(--blue-7), 0.5);
|
||||
}
|
||||
|
||||
/* Agent Timeline (Top) */
|
||||
.agent-timeline {
|
||||
@apply relative h-[50px] px-2 mt-1 bg-n-blue-2 dark:bg-n-blue-7/20 rounded-xl;
|
||||
|
||||
.waveform-bars {
|
||||
@apply flex items-end h-full relative z-10;
|
||||
}
|
||||
|
||||
.waveform-bar {
|
||||
@apply flex-1 mx-[0.5px] transition-all duration-300 rounded-sm;
|
||||
min-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Contact Timeline (Middle) */
|
||||
.contact-timeline {
|
||||
@apply relative h-[55px] px-2 mt-3 bg-n-slate-2 dark:bg-n-slate-7/20 rounded-xl; /* Added background */
|
||||
|
||||
.waveform-bars {
|
||||
@apply flex items-end h-full relative z-10;
|
||||
}
|
||||
|
||||
.waveform-bar {
|
||||
@apply flex-1 mx-[0.5px] transition-all duration-300 rounded-sm;
|
||||
min-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Waveform bar states */
|
||||
.waveform-bar {
|
||||
@apply rounded-sm mx-[0.5px] transition-all duration-300 min-w-[2.5px];
|
||||
}
|
||||
|
||||
.active-agent-bar {
|
||||
@apply bg-n-blue-7 dark:bg-n-blue-6;
|
||||
}
|
||||
|
||||
.inactive-agent-bar {
|
||||
@apply bg-n-blue-5 dark:bg-n-blue-5;
|
||||
}
|
||||
|
||||
.active-contact-bar {
|
||||
@apply bg-n-slate-7 dark:bg-n-slate-6;
|
||||
}
|
||||
|
||||
.inactive-contact-bar {
|
||||
@apply bg-n-slate-4 dark:bg-n-slate-5;
|
||||
}
|
||||
|
||||
.progress-passed {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.waveform-bar:not(.progress-passed) {
|
||||
@apply opacity-40;
|
||||
}
|
||||
|
||||
/* Events Timeline (Bottom) */
|
||||
.events-timeline {
|
||||
@apply absolute bottom-0 left-0 right-0 h-[60px] px-2 z-20;
|
||||
}
|
||||
|
||||
.event-marker {
|
||||
@apply absolute w-0 z-30;
|
||||
/* All event markers are positioned at the bottom in the events timeline */
|
||||
bottom: 15px;
|
||||
|
||||
.marker-dot {
|
||||
@apply absolute -translate-x-1/2 size-8 rounded-full flex items-center justify-center shadow-lg;
|
||||
top: -16px;
|
||||
}
|
||||
|
||||
.marker-icon {
|
||||
@apply text-[16px] font-bold;
|
||||
}
|
||||
|
||||
/* Apply bright Tailwind color classes to each marker icon type */
|
||||
.marker--systemEvent .marker-icon {
|
||||
@apply text-n-slate-1;
|
||||
}
|
||||
|
||||
.marker--agentMessage .marker-icon {
|
||||
@apply text-n-slate-1;
|
||||
}
|
||||
|
||||
.marker--userResponse .marker-icon {
|
||||
@apply text-n-slate-12;
|
||||
}
|
||||
|
||||
.marker--callEnded .marker-icon {
|
||||
@apply text-n-slate-1;
|
||||
}
|
||||
|
||||
.marker-tooltip {
|
||||
@apply absolute -translate-x-1/2 p-3 rounded-md shadow-xl text-n-slate-1 text-sm min-w-[150px] max-w-[250px] transition-all duration-200 z-50 opacity-0 invisible bg-n-slate-12;
|
||||
bottom: 40px;
|
||||
|
||||
&.marker-tooltip-visible {
|
||||
@apply opacity-100 visible;
|
||||
}
|
||||
|
||||
.marker-text {
|
||||
@apply text-sm whitespace-normal font-normal text-n-slate-1;
|
||||
}
|
||||
|
||||
.marker-time {
|
||||
@apply text-xs text-n-slate-2 mt-1 font-medium;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
@apply absolute w-0 h-0 border-l-[6px] border-l-transparent border-t-[6px] left-1/2 -translate-x-1/2;
|
||||
border-top-color: #1E293B; /* Consistent color for both light/dark modes */
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
/* Improve positioning when tooltip is near the edge */
|
||||
&.marker-tooltip-left {
|
||||
@apply -left-[50px];
|
||||
&:after {
|
||||
@apply left-[calc(50%+50px)];
|
||||
}
|
||||
}
|
||||
|
||||
&.marker-tooltip-right {
|
||||
@apply -right-[50px];
|
||||
&:after {
|
||||
@apply right-[calc(50%+50px)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.marker-tooltip {
|
||||
@apply z-50 opacity-100 visible;
|
||||
}
|
||||
|
||||
.marker-dot {
|
||||
@apply transform scale-125;
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
/* Marker type styling with bright Tailwind color classes */
|
||||
&.marker--systemEvent .marker-dot {
|
||||
@apply bg-n-iris-11;
|
||||
}
|
||||
|
||||
&.marker--agentMessage .marker-dot {
|
||||
@apply bg-n-blue-11;
|
||||
}
|
||||
|
||||
&.marker--userResponse .marker-dot {
|
||||
@apply bg-n-amber-11;
|
||||
}
|
||||
|
||||
&.marker--callEnded .marker-dot {
|
||||
@apply bg-n-ruby-11;
|
||||
}
|
||||
|
||||
/* Tooltip type styling with bright Tailwind color classes */
|
||||
&.marker-tooltip-systemEvent {
|
||||
@apply bg-n-iris-11;
|
||||
|
||||
&:after {
|
||||
@apply border-t-n-iris-11;
|
||||
}
|
||||
}
|
||||
|
||||
&.marker-tooltip-agentMessage {
|
||||
@apply bg-n-blue-11;
|
||||
|
||||
&:after {
|
||||
@apply border-t-n-blue-11;
|
||||
}
|
||||
}
|
||||
|
||||
&.marker-tooltip-userResponse {
|
||||
@apply bg-n-amber-11;
|
||||
|
||||
&:after {
|
||||
@apply border-t-n-amber-11;
|
||||
}
|
||||
}
|
||||
|
||||
&.marker-tooltip-callEnded {
|
||||
@apply bg-n-ruby-11;
|
||||
|
||||
&:after {
|
||||
@apply border-t-n-ruby-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
teams,
|
||||
labels,
|
||||
statusFilterOptions,
|
||||
messageTypeOptions,
|
||||
priorityOptions,
|
||||
campaigns,
|
||||
contacts,
|
||||
inboxes,
|
||||
@@ -16,7 +18,6 @@ import {
|
||||
countries,
|
||||
slaPolicies,
|
||||
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
|
||||
import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables');
|
||||
@@ -71,7 +72,9 @@ describe('useAutomation', () => {
|
||||
case 'country_code':
|
||||
return countries;
|
||||
case 'message_type':
|
||||
return MESSAGE_CONDITION_VALUES;
|
||||
return messageTypeOptions;
|
||||
case 'priority':
|
||||
return priorityOptions;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -93,6 +96,8 @@ describe('useAutomation', () => {
|
||||
return [];
|
||||
case 'add_sla':
|
||||
return slaPolicies;
|
||||
case 'change_priority':
|
||||
return priorityOptions;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -218,8 +223,9 @@ describe('useAutomation', () => {
|
||||
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
|
||||
expect(getConditionDropdownValues('country_code')).toEqual(countries);
|
||||
expect(getConditionDropdownValues('message_type')).toEqual(
|
||||
MESSAGE_CONDITION_VALUES
|
||||
messageTypeOptions
|
||||
);
|
||||
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
|
||||
});
|
||||
|
||||
it('gets action dropdown values correctly', () => {
|
||||
@@ -231,6 +237,7 @@ describe('useAutomation', () => {
|
||||
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
|
||||
expect(getActionDropdownValues('send_message')).toEqual([]);
|
||||
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
|
||||
expect(getActionDropdownValues('change_priority')).toEqual(priorityOptions);
|
||||
});
|
||||
|
||||
it('handles event change correctly', () => {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/helper/automationHelper.js');
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
describe('useMacros', () => {
|
||||
const mockLabels = [
|
||||
@@ -148,9 +151,11 @@ describe('useMacros', () => {
|
||||
|
||||
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('change_priority')).toEqual(
|
||||
PRIORITY_CONDITION_VALUES
|
||||
);
|
||||
const expectedPriority = PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: `MACROS.PRIORITY_TYPES.${item.i18nKey}`,
|
||||
}));
|
||||
expect(getMacroDropdownValues('change_priority')).toEqual(expectedPriority);
|
||||
});
|
||||
|
||||
it('returns an empty array for unknown types', () => {
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
getActionOptions,
|
||||
getConditionOptions,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import {
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
|
||||
/**
|
||||
* This is a shared composables that holds utilites used to build dropdown and file options
|
||||
@@ -60,6 +64,20 @@ export default function useAutomationValues() {
|
||||
];
|
||||
});
|
||||
|
||||
const messageTypeOptions = computed(() =>
|
||||
MESSAGE_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: t(`AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: t(`AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`),
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* Adds a translated "None" option to the beginning of a list
|
||||
* @param {Array} list - The list to add "None" to
|
||||
@@ -87,6 +105,8 @@ export default function useAutomationValues() {
|
||||
customAttributes: getters['attributes/getAttributes'].value,
|
||||
inboxes: inboxes.value,
|
||||
statusFilterOptions: statusFilterOptions.value,
|
||||
priorityOptions: priorityOptions.value,
|
||||
messageTypeOptions: messageTypeOptions.value,
|
||||
teams: teams.value,
|
||||
languages,
|
||||
countries,
|
||||
@@ -108,6 +128,7 @@ export default function useAutomationValues() {
|
||||
languages,
|
||||
type,
|
||||
addNoneToListFn: addNoneToList,
|
||||
priorityOptions: priorityOptions.value,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -115,6 +136,8 @@ export default function useAutomationValues() {
|
||||
booleanFilterOptions,
|
||||
statusFilterItems,
|
||||
statusFilterOptions,
|
||||
priorityOptions,
|
||||
messageTypeOptions,
|
||||
getConditionDropdownValues,
|
||||
getActionDropdownValues,
|
||||
agents,
|
||||
|
||||
@@ -78,6 +78,10 @@ export const useInbox = () => {
|
||||
return inbox.value.provider || '';
|
||||
});
|
||||
|
||||
const isAVoiceChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.VOICE;
|
||||
});
|
||||
|
||||
const isAMicrosoftInbox = computed(() => {
|
||||
return isAnEmailChannel.value && inbox.value.provider === 'microsoft';
|
||||
});
|
||||
@@ -125,10 +129,6 @@ export const useInbox = () => {
|
||||
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
||||
});
|
||||
|
||||
const isAVoiceChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.VOICE;
|
||||
});
|
||||
|
||||
return {
|
||||
inbox,
|
||||
isAFacebookInbox,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
* @returns {Object} An object containing the getMacroDropdownValues function
|
||||
*/
|
||||
export const useMacros = () => {
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
@@ -32,7 +34,10 @@ export const useMacros = () => {
|
||||
name: i.title,
|
||||
}));
|
||||
case 'change_priority':
|
||||
return PRIORITY_CONDITION_VALUES;
|
||||
return PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: t(`MACROS.PRIORITY_TYPES.${item.i18nKey}`),
|
||||
}));
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
export const useVoiceCallHelpers = (props, { t }) => {
|
||||
// Check if the conversation is from a voice channel
|
||||
const isVoiceChannelConversation = computed(() => {
|
||||
// First check the meta.inbox.channel_type
|
||||
if (props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check the inbox_id to find the channel type
|
||||
// This is useful when the meta.inbox is not fully populated
|
||||
if (props.conversation?.inbox_id && props.conversation?.meta?.channel_type === 'Channel::Voice') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Function to check if a conversation is a voice channel conversation (non-computed version)
|
||||
const isConversationFromVoiceChannel = (conversation) => {
|
||||
if (!conversation) return false;
|
||||
|
||||
// Check meta inbox channel type
|
||||
if (conversation.meta?.inbox?.channel_type === 'Channel::Voice') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check meta channel type
|
||||
if (conversation.meta?.channel_type === 'Channel::Voice') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Helper function to find call information from various sources
|
||||
const getCallData = (conversation) => {
|
||||
if (!conversation) return {};
|
||||
|
||||
// First check for data directly in conversation attributes
|
||||
const conversationAttributes = conversation.custom_attributes || conversation.additional_attributes || {};
|
||||
if (conversationAttributes.call_data) {
|
||||
return conversationAttributes.call_data;
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
// Check if a message has an arrow prefix
|
||||
const hasArrow = (message) => {
|
||||
if (!message?.content) return false;
|
||||
|
||||
return (
|
||||
typeof message.content === 'string' &&
|
||||
(message.content.startsWith('←') ||
|
||||
message.content.startsWith('→') ||
|
||||
message.content.startsWith('↔️'))
|
||||
);
|
||||
};
|
||||
|
||||
// Determine if it's an incoming call
|
||||
const isIncomingCall = (callData, message) => {
|
||||
if (!message) return null;
|
||||
|
||||
// Check for arrow in content
|
||||
if (hasArrow(message)) {
|
||||
return message.content.startsWith('←');
|
||||
}
|
||||
|
||||
// Try to use the direction stored in the call data
|
||||
if (callData?.call_direction) {
|
||||
return callData.call_direction === 'inbound';
|
||||
}
|
||||
|
||||
// Fall back to message_type
|
||||
return message.message_type === 0;
|
||||
};
|
||||
|
||||
// Get normalized call status from multiple sources
|
||||
const normalizeCallStatus = (status, isIncoming) => {
|
||||
// Map from Twilio status to our UI status
|
||||
const statusMap = {
|
||||
'in-progress': 'active',
|
||||
'completed': 'ended',
|
||||
'canceled': 'ended',
|
||||
'failed': 'ended',
|
||||
'busy': 'no-answer',
|
||||
'no-answer': isIncoming ? 'missed' : 'no-answer',
|
||||
'active': 'active',
|
||||
'missed': 'missed',
|
||||
'ended': 'ended',
|
||||
'ringing': 'ringing'
|
||||
};
|
||||
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
// Get the appropriate icon for a call status
|
||||
const getCallIconName = (status, isIncoming) => {
|
||||
if (status === 'missed' || status === 'no-answer') {
|
||||
return 'i-ph-phone-x-fill';
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
return 'i-ph-phone-call-fill';
|
||||
}
|
||||
|
||||
if (status === 'ended' || status === 'completed') {
|
||||
return isIncoming ? 'i-ph-phone-incoming-fill' : 'i-ph-phone-outgoing-fill';
|
||||
}
|
||||
|
||||
// Default phone icon for ringing state
|
||||
return isIncoming
|
||||
? 'i-ph-phone-incoming-fill'
|
||||
: 'i-ph-phone-outgoing-fill';
|
||||
};
|
||||
|
||||
// Get the appropriate text for a call status
|
||||
const getStatusText = (status, isIncoming) => {
|
||||
if (status === 'active') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'missed') {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
} else {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'no-answer') {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
}
|
||||
|
||||
return isIncoming
|
||||
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
};
|
||||
|
||||
// Process message content with arrow prefix
|
||||
const processArrowContent = (content, isIncoming, normalizedStatus) => {
|
||||
// Remove arrows and clean up the text
|
||||
let text = content.replace(/^[←→↔️]/, '').trim();
|
||||
|
||||
// If it only says "Voice Call" or "jo", add more descriptive status info
|
||||
if (text === 'Voice Call' || text === 'jo' || text === '') {
|
||||
return getStatusText(normalizedStatus, isIncoming);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
return {
|
||||
isVoiceChannelConversation,
|
||||
isConversationFromVoiceChannel,
|
||||
getCallData,
|
||||
hasArrow,
|
||||
isIncomingCall,
|
||||
normalizeCallStatus,
|
||||
getCallIconName,
|
||||
getStatusText,
|
||||
processArrowContent,
|
||||
};
|
||||
};
|
||||
@@ -38,11 +38,13 @@ export const DEFAULT_ACTIONS = [
|
||||
export const MESSAGE_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'incoming',
|
||||
name: 'Incoming Message',
|
||||
name: 'Incoming',
|
||||
i18nKey: 'INCOMING',
|
||||
},
|
||||
{
|
||||
id: 'outgoing',
|
||||
name: 'Outgoing Message',
|
||||
name: 'Outgoing',
|
||||
i18nKey: 'OUTGOING',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,21 +52,26 @@ export const PRIORITY_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'nil',
|
||||
name: 'None',
|
||||
i18nKey: 'NONE',
|
||||
},
|
||||
{
|
||||
id: 'low',
|
||||
name: 'Low',
|
||||
i18nKey: 'LOW',
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
name: 'Medium',
|
||||
i18nKey: 'MEDIUM',
|
||||
},
|
||||
{
|
||||
id: 'high',
|
||||
name: 'High',
|
||||
i18nKey: 'HIGH',
|
||||
},
|
||||
{
|
||||
id: 'urgent',
|
||||
name: 'Urgent',
|
||||
i18nKey: 'URGENT',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -212,6 +212,33 @@ export class DashboardAudioNotificationHelper {
|
||||
showBadgeOnFavicon();
|
||||
this.playAudioEvery30Seconds();
|
||||
};
|
||||
|
||||
onIncomingCall = () => {
|
||||
// Always play audio alerts for incoming calls, regardless of other settings
|
||||
// This ensures users never miss a call notification
|
||||
|
||||
// Use a different tone for calls if available, otherwise use regular tone
|
||||
const originalTone = this.audioConfig.tone;
|
||||
try {
|
||||
// Temporarily set a call-specific tone if it exists
|
||||
this.audioConfig.tone = 'call-ring';
|
||||
this.intializeAudio();
|
||||
this.playAudioAlert();
|
||||
} catch (error) {
|
||||
console.error('Error playing call notification:', error);
|
||||
// Fallback to regular tone
|
||||
this.audioConfig.tone = originalTone;
|
||||
this.intializeAudio();
|
||||
this.playAudioAlert();
|
||||
} finally {
|
||||
// Restore original tone for messages
|
||||
this.audioConfig.tone = originalTone;
|
||||
this.intializeAudio();
|
||||
}
|
||||
|
||||
// Also show badge on favicon
|
||||
showBadgeOnFavicon();
|
||||
};
|
||||
}
|
||||
|
||||
export default new DashboardAudioNotificationHelper(GlobalStore);
|
||||
|
||||
@@ -110,12 +110,23 @@ export const hasValidAvatarUrl = avatarUrl => {
|
||||
};
|
||||
|
||||
export const timeStampAppendedURL = dataUrl => {
|
||||
const url = new URL(dataUrl);
|
||||
if (!url.searchParams.has('t')) {
|
||||
url.searchParams.append('t', Date.now());
|
||||
}
|
||||
try {
|
||||
// Make sure the URL is valid before trying to construct it
|
||||
if (!dataUrl || typeof dataUrl !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
const url = new URL(dataUrl);
|
||||
if (!url.searchParams.has('t')) {
|
||||
url.searchParams.append('t', Date.now());
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
} catch (error) {
|
||||
// If URL construction fails, just return the original URL
|
||||
console.error('Invalid URL in timeStampAppendedURL:', error);
|
||||
return dataUrl || '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getHostNameFromURL = url => {
|
||||
|
||||
@@ -34,6 +34,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
|
||||
// Call events
|
||||
'incoming_call': this.onIncomingCall,
|
||||
'call_status_changed': this.onCallStatusChanged
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +204,52 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
};
|
||||
|
||||
onIncomingCall = data => {
|
||||
// Normalize snake_case to camelCase for consistency with frontend code
|
||||
const normalizedPayload = {
|
||||
callSid: data.call_sid,
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
inboxName: data.inbox_name,
|
||||
inboxAvatarUrl: data.inbox_avatar_url,
|
||||
inboxPhoneNumber: data.inbox_phone_number,
|
||||
contactName: data.contact_name || 'Unknown Caller',
|
||||
contactId: data.contact_id,
|
||||
accountId: data.account_id,
|
||||
isOutbound: data.is_outbound || false,
|
||||
// CRITICAL: Use 'conference_sid' in camelCase format to match field names
|
||||
conference_sid: data.conference_sid,
|
||||
conferenceId: data.conference_sid, // Add aliases for consistency
|
||||
conferenceSid: data.conference_sid, // Add aliases for consistency
|
||||
requiresAgentJoin: data.requires_agent_join || false,
|
||||
callDirection: data.call_direction,
|
||||
phoneNumber: data.phone_number,
|
||||
avatarUrl: data.avatar_url
|
||||
};
|
||||
|
||||
// Update store
|
||||
this.app.$store.dispatch('calls/setIncomingCall', normalizedPayload);
|
||||
|
||||
// Also update App.vue showCallWidget directly for immediate UI feedback
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
};
|
||||
|
||||
onCallStatusChanged = data => {
|
||||
// Normalize snake_case to camelCase for consistency with frontend code
|
||||
const normalizedPayload = {
|
||||
callSid: data.call_sid,
|
||||
status: data.status,
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
timestamp: data.timestamp || Date.now()
|
||||
};
|
||||
// Only dispatch to Vuex; Vuex handles widget and call state
|
||||
this.app.$store.dispatch('calls/handleCallStatusChanged', normalizedPayload);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
DEFAULT_CONVERSATION_OPENED_CONDITION,
|
||||
DEFAULT_OTHER_CONDITION,
|
||||
DEFAULT_ACTIONS,
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
import filterQueryGenerator from './filterQueryGenerator';
|
||||
import actionQueryGenerator from './actionQueryGenerator';
|
||||
@@ -103,6 +101,7 @@ export const getActionOptions = ({
|
||||
slaPolicies,
|
||||
type,
|
||||
addNoneToListFn,
|
||||
priorityOptions,
|
||||
}) => {
|
||||
const actionsMap = {
|
||||
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
|
||||
@@ -110,7 +109,7 @@ export const getActionOptions = ({
|
||||
send_email_to_team: teams,
|
||||
add_label: generateConditionOptions(labels, 'title'),
|
||||
remove_label: generateConditionOptions(labels, 'title'),
|
||||
change_priority: PRIORITY_CONDITION_VALUES,
|
||||
change_priority: priorityOptions,
|
||||
add_sla: slaPolicies,
|
||||
};
|
||||
return actionsMap[type];
|
||||
@@ -128,6 +127,8 @@ export const getConditionOptions = ({
|
||||
statusFilterOptions,
|
||||
teams,
|
||||
type,
|
||||
priorityOptions,
|
||||
messageTypeOptions,
|
||||
}) => {
|
||||
if (isCustomAttributeCheckbox(customAttributes, type)) {
|
||||
return booleanFilterOptions;
|
||||
@@ -147,8 +148,8 @@ export const getConditionOptions = ({
|
||||
browser_language: languages,
|
||||
conversation_language: languages,
|
||||
country_code: countries,
|
||||
message_type: MESSAGE_CONDITION_VALUES,
|
||||
priority: PRIORITY_CONDITION_VALUES,
|
||||
message_type: messageTypeOptions,
|
||||
priority: priorityOptions,
|
||||
};
|
||||
|
||||
return conditionFilterMaps[type];
|
||||
|
||||
@@ -3,6 +3,7 @@ export const INBOX_TYPES = {
|
||||
FB: 'Channel::FacebookPage',
|
||||
TWITTER: 'Channel::TwitterProfile',
|
||||
TWILIO: 'Channel::TwilioSms',
|
||||
VOICE: 'Channel::Voice',
|
||||
WHATSAPP: 'Channel::Whatsapp',
|
||||
API: 'Channel::Api',
|
||||
EMAIL: 'Channel::Email',
|
||||
@@ -10,7 +11,6 @@ export const INBOX_TYPES = {
|
||||
LINE: 'Channel::Line',
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
@@ -50,7 +50,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:
|
||||
@@ -74,6 +73,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.TWILIO:
|
||||
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
return 'whatsapp';
|
||||
|
||||
@@ -89,9 +91,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
@@ -113,6 +112,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
? 'brand-whatsapp'
|
||||
: 'brand-sms';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
return 'brand-whatsapp';
|
||||
|
||||
@@ -131,9 +133,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.INSTAGRAM:
|
||||
return 'brand-instagram';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import allLanguages from 'dashboard/components/widgets/conversation/advancedFilt
|
||||
|
||||
import allCountries from 'shared/constants/countries.js';
|
||||
|
||||
import {
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
|
||||
export const customAttributes = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -468,7 +473,7 @@ export const inboxes = [
|
||||
welcome_title: '',
|
||||
welcome_tagline: '',
|
||||
web_widget_script:
|
||||
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.defer = true;\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
|
||||
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
|
||||
website_token: 'yZ7USzaEs7hrwUAHLGwjbxJ1',
|
||||
selected_feature_flags: ['attachments', 'emoji_picker', 'end_conversation'],
|
||||
reply_time: 'in_a_few_minutes',
|
||||
@@ -636,16 +641,16 @@ export const statusFilterOptions = [
|
||||
];
|
||||
export const languages = allLanguages;
|
||||
export const countries = allCountries;
|
||||
export const MESSAGE_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'incoming',
|
||||
name: 'Incoming Message',
|
||||
},
|
||||
{
|
||||
id: 'outgoing',
|
||||
name: 'Outgoing Message',
|
||||
},
|
||||
];
|
||||
|
||||
export const messageTypeOptions = MESSAGE_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: `AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`,
|
||||
}));
|
||||
|
||||
export const priorityOptions = PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: `AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`,
|
||||
}));
|
||||
|
||||
export const automationToSubmit = {
|
||||
name: 'Fayaz',
|
||||
|
||||
@@ -147,7 +147,8 @@
|
||||
"SEND_ATTACHMENT": "Send Attachment",
|
||||
"SEND_MESSAGE": "Send a Message",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA"
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Message Type",
|
||||
|
||||
@@ -49,5 +49,8 @@
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days",
|
||||
"PLACEHOLDER": "Enter duration"
|
||||
},
|
||||
"CHANNEL_SELECTOR": {
|
||||
"COMING_SOON": "Coming Soon!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
"NEXT_WEEK": "Next week"
|
||||
}
|
||||
},
|
||||
"MENTION": {
|
||||
"AGENTS": "Agents",
|
||||
"TEAMS": "Teams"
|
||||
},
|
||||
"CUSTOM_SNOOZE": {
|
||||
"TITLE": "Snooze until",
|
||||
"APPLY": "Snooze",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LIMIT_MESSAGES": {
|
||||
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
|
||||
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
|
||||
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
|
||||
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
|
||||
},
|
||||
"TITLE": "Account settings",
|
||||
|
||||
@@ -268,6 +268,46 @@
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
}
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice Channel",
|
||||
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||
},
|
||||
"TWILIO": {
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "Account SID",
|
||||
"PLACEHOLDER": "Enter your Twilio Account SID",
|
||||
"REQUIRED": "Account SID is required"
|
||||
},
|
||||
"AUTH_TOKEN": {
|
||||
"LABEL": "Auth Token",
|
||||
"PLACEHOLDER": "Enter your Twilio Auth Token",
|
||||
"REQUIRED": "Auth Token is required"
|
||||
},
|
||||
"API_KEY_SID": {
|
||||
"LABEL": "API Key SID",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key SID",
|
||||
"REQUIRED": "API Key SID is required"
|
||||
},
|
||||
"API_KEY_SECRET": {
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
}
|
||||
},
|
||||
"API_CHANNEL": {
|
||||
"TITLE": "API Channel",
|
||||
"DESC": "Integrate with API channel and start supporting your customers.",
|
||||
@@ -789,7 +829,8 @@
|
||||
"TELEGRAM": "Telegram",
|
||||
"LINE": "Line",
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram"
|
||||
"INSTAGRAM": "Instagram",
|
||||
"VOICE": "Voice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,14 @@
|
||||
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
|
||||
"BUTTON_TEXT": "Connect Linear workspace"
|
||||
}
|
||||
},
|
||||
"NOTION": {
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
@@ -539,6 +547,8 @@
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_APPROVE": {
|
||||
|
||||
@@ -147,7 +147,8 @@
|
||||
"SEND_ATTACHMENT": "Send Attachment",
|
||||
"SEND_MESSAGE": "Send a Message",
|
||||
"CHANGE_PRIORITY": "تغيير الأولوية",
|
||||
"ADD_SLA": "Add SLA"
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "فتح المحادثة"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Message Type",
|
||||
|
||||
@@ -49,5 +49,8 @@
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days",
|
||||
"PLACEHOLDER": "Enter duration"
|
||||
},
|
||||
"CHANNEL_SELECTOR": {
|
||||
"COMING_SOON": "Coming Soon!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
"NEXT_WEEK": "الأسبوع القادم"
|
||||
}
|
||||
},
|
||||
"MENTION": {
|
||||
"AGENTS": "الوكلاء",
|
||||
"TEAMS": "الفرق"
|
||||
},
|
||||
"CUSTOM_SNOOZE": {
|
||||
"TITLE": "تأجيل حتى",
|
||||
"APPLY": "تأجيل",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LIMIT_MESSAGES": {
|
||||
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
|
||||
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
|
||||
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
|
||||
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
|
||||
},
|
||||
"TITLE": "إعدادات الحساب",
|
||||
|
||||
@@ -268,6 +268,46 @@
|
||||
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة واتساب"
|
||||
}
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice Channel",
|
||||
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "رقم الهاتف",
|
||||
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||
},
|
||||
"TWILIO": {
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "معرف حساب Twilio (يعرف أيضاً بـ Account SID)",
|
||||
"PLACEHOLDER": "Enter your Twilio Account SID",
|
||||
"REQUIRED": "Account SID is required"
|
||||
},
|
||||
"AUTH_TOKEN": {
|
||||
"LABEL": "رمز المصادقة Auth Token",
|
||||
"PLACEHOLDER": "Enter your Twilio Auth Token",
|
||||
"REQUIRED": "Auth Token is required"
|
||||
},
|
||||
"API_KEY_SID": {
|
||||
"LABEL": "مفتاح API SID",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key SID",
|
||||
"REQUIRED": "API Key SID is required"
|
||||
},
|
||||
"API_KEY_SECRET": {
|
||||
"LABEL": "مفتاح سر API",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
}
|
||||
},
|
||||
"API_CHANNEL": {
|
||||
"TITLE": "قناة API",
|
||||
"DESC": "اربط مع قناة API وابدأ في دعم عملائك.",
|
||||
@@ -789,7 +829,8 @@
|
||||
"TELEGRAM": "تيليجرام",
|
||||
"LINE": "Line",
|
||||
"API": "قناة API",
|
||||
"INSTAGRAM": "Instagram"
|
||||
"INSTAGRAM": "Instagram",
|
||||
"VOICE": "Voice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,14 @@
|
||||
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
|
||||
"BUTTON_TEXT": "Connect Linear workspace"
|
||||
}
|
||||
},
|
||||
"NOTION": {
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
"CONFIRM": "نعم، احذف",
|
||||
"CANCEL": "إلغاء"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
@@ -539,6 +547,8 @@
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "حذف",
|
||||
"BULK_APPROVE": {
|
||||
|
||||
@@ -147,7 +147,8 @@
|
||||
"SEND_ATTACHMENT": "Send Attachment",
|
||||
"SEND_MESSAGE": "Send a Message",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA"
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Message Type",
|
||||
|
||||
@@ -49,5 +49,8 @@
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days",
|
||||
"PLACEHOLDER": "Enter duration"
|
||||
},
|
||||
"CHANNEL_SELECTOR": {
|
||||
"COMING_SOON": "Coming Soon!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
"NEXT_WEEK": "Next week"
|
||||
}
|
||||
},
|
||||
"MENTION": {
|
||||
"AGENTS": "Agents",
|
||||
"TEAMS": "Teams"
|
||||
},
|
||||
"CUSTOM_SNOOZE": {
|
||||
"TITLE": "Snooze until",
|
||||
"APPLY": "Snooze",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LIMIT_MESSAGES": {
|
||||
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
|
||||
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
|
||||
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
|
||||
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
|
||||
},
|
||||
"TITLE": "Account settings",
|
||||
|
||||
@@ -268,6 +268,46 @@
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
}
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice Channel",
|
||||
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||
},
|
||||
"TWILIO": {
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "Account SID",
|
||||
"PLACEHOLDER": "Enter your Twilio Account SID",
|
||||
"REQUIRED": "Account SID is required"
|
||||
},
|
||||
"AUTH_TOKEN": {
|
||||
"LABEL": "Auth Token",
|
||||
"PLACEHOLDER": "Enter your Twilio Auth Token",
|
||||
"REQUIRED": "Auth Token is required"
|
||||
},
|
||||
"API_KEY_SID": {
|
||||
"LABEL": "API Key SID",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key SID",
|
||||
"REQUIRED": "API Key SID is required"
|
||||
},
|
||||
"API_KEY_SECRET": {
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
}
|
||||
},
|
||||
"API_CHANNEL": {
|
||||
"TITLE": "API Channel",
|
||||
"DESC": "Integrate with API channel and start supporting your customers.",
|
||||
@@ -789,7 +829,8 @@
|
||||
"TELEGRAM": "Telegram",
|
||||
"LINE": "Line",
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram"
|
||||
"INSTAGRAM": "Instagram",
|
||||
"VOICE": "Voice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,14 @@
|
||||
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
|
||||
"BUTTON_TEXT": "Connect Linear workspace"
|
||||
}
|
||||
},
|
||||
"NOTION": {
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
@@ -539,6 +547,8 @@
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_APPROVE": {
|
||||
|
||||
@@ -147,7 +147,8 @@
|
||||
"SEND_ATTACHMENT": "Send Attachment",
|
||||
"SEND_MESSAGE": "Send a Message",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA"
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Message Type",
|
||||
|
||||
@@ -49,5 +49,8 @@
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days",
|
||||
"PLACEHOLDER": "Enter duration"
|
||||
},
|
||||
"CHANNEL_SELECTOR": {
|
||||
"COMING_SOON": "Coming Soon!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
"NEXT_WEEK": "Next week"
|
||||
}
|
||||
},
|
||||
"MENTION": {
|
||||
"AGENTS": "Агенти",
|
||||
"TEAMS": "Teams"
|
||||
},
|
||||
"CUSTOM_SNOOZE": {
|
||||
"TITLE": "Snooze until",
|
||||
"APPLY": "Snooze",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LIMIT_MESSAGES": {
|
||||
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
|
||||
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
|
||||
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
|
||||
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
|
||||
},
|
||||
"TITLE": "Account settings",
|
||||
|
||||
@@ -268,6 +268,46 @@
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
}
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice Channel",
|
||||
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||
},
|
||||
"TWILIO": {
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "Account SID",
|
||||
"PLACEHOLDER": "Enter your Twilio Account SID",
|
||||
"REQUIRED": "Account SID is required"
|
||||
},
|
||||
"AUTH_TOKEN": {
|
||||
"LABEL": "Auth Token",
|
||||
"PLACEHOLDER": "Enter your Twilio Auth Token",
|
||||
"REQUIRED": "Auth Token is required"
|
||||
},
|
||||
"API_KEY_SID": {
|
||||
"LABEL": "API Key SID",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key SID",
|
||||
"REQUIRED": "API Key SID is required"
|
||||
},
|
||||
"API_KEY_SECRET": {
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
}
|
||||
},
|
||||
"API_CHANNEL": {
|
||||
"TITLE": "API Channel",
|
||||
"DESC": "Integrate with API channel and start supporting your customers.",
|
||||
@@ -789,7 +829,8 @@
|
||||
"TELEGRAM": "Telegram",
|
||||
"LINE": "Line",
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram"
|
||||
"INSTAGRAM": "Instagram",
|
||||
"VOICE": "Voice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,14 @@
|
||||
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
|
||||
"BUTTON_TEXT": "Connect Linear workspace"
|
||||
}
|
||||
},
|
||||
"NOTION": {
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Отмени"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
@@ -539,6 +547,8 @@
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "Изтрий",
|
||||
"BULK_APPROVE": {
|
||||
|
||||
@@ -147,7 +147,8 @@
|
||||
"SEND_ATTACHMENT": "Send Attachment",
|
||||
"SEND_MESSAGE": "Send a Message",
|
||||
"CHANGE_PRIORITY": "Canvia la prioritat",
|
||||
"ADD_SLA": "Afegeix SLA"
|
||||
"ADD_SLA": "Afegeix SLA",
|
||||
"OPEN_CONVERSATION": "Obrir conversa"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Message Type",
|
||||
|
||||
@@ -49,5 +49,8 @@
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days",
|
||||
"PLACEHOLDER": "Enter duration"
|
||||
},
|
||||
"CHANNEL_SELECTOR": {
|
||||
"COMING_SOON": "Coming Soon!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
"NEXT_WEEK": "Pròxima setmana"
|
||||
}
|
||||
},
|
||||
"MENTION": {
|
||||
"AGENTS": "Agents",
|
||||
"TEAMS": "Equips"
|
||||
},
|
||||
"CUSTOM_SNOOZE": {
|
||||
"TITLE": "Posposat fins a",
|
||||
"APPLY": "Posposat",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LIMIT_MESSAGES": {
|
||||
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
|
||||
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
|
||||
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
|
||||
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
|
||||
},
|
||||
"TITLE": "Configuració del compte",
|
||||
|
||||
@@ -268,6 +268,46 @@
|
||||
"ERROR_MESSAGE": "No hem pogut desar el canal WhatsApp"
|
||||
}
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice Channel",
|
||||
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Número de telèfon",
|
||||
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||
},
|
||||
"TWILIO": {
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "Compte SID",
|
||||
"PLACEHOLDER": "Enter your Twilio Account SID",
|
||||
"REQUIRED": "Account SID is required"
|
||||
},
|
||||
"AUTH_TOKEN": {
|
||||
"LABEL": "Token d'autenticació",
|
||||
"PLACEHOLDER": "Enter your Twilio Auth Token",
|
||||
"REQUIRED": "Auth Token is required"
|
||||
},
|
||||
"API_KEY_SID": {
|
||||
"LABEL": "API Key SID",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key SID",
|
||||
"REQUIRED": "API Key SID is required"
|
||||
},
|
||||
"API_KEY_SECRET": {
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
}
|
||||
},
|
||||
"API_CHANNEL": {
|
||||
"TITLE": "Canal de l'API",
|
||||
"DESC": "Integra’t amb el canal API i comença a donar suport als teus clients.",
|
||||
@@ -789,7 +829,8 @@
|
||||
"TELEGRAM": "Telegram",
|
||||
"LINE": "Line",
|
||||
"API": "Canal de l'API",
|
||||
"INSTAGRAM": "Instagram"
|
||||
"INSTAGRAM": "Instagram",
|
||||
"VOICE": "Voice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,14 @@
|
||||
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
|
||||
"BUTTON_TEXT": "Connect Linear workspace"
|
||||
}
|
||||
},
|
||||
"NOTION": {
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
"CONFIRM": "Sí, esborra",
|
||||
"CANCEL": "Cancel·la"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
@@ -539,6 +547,8 @@
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "Esborrar",
|
||||
"BULK_APPROVE": {
|
||||
|
||||
@@ -147,7 +147,8 @@
|
||||
"SEND_ATTACHMENT": "Odeslat přílohu",
|
||||
"SEND_MESSAGE": "Odeslat zprávu",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA"
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Typ zprávy",
|
||||
|
||||
@@ -49,5 +49,8 @@
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days",
|
||||
"PLACEHOLDER": "Enter duration"
|
||||
},
|
||||
"CHANNEL_SELECTOR": {
|
||||
"COMING_SOON": "Coming Soon!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
"NEXT_WEEK": "Příští týden"
|
||||
}
|
||||
},
|
||||
"MENTION": {
|
||||
"AGENTS": "Agenti",
|
||||
"TEAMS": "Týmy"
|
||||
},
|
||||
"CUSTOM_SNOOZE": {
|
||||
"TITLE": "Odložit do",
|
||||
"APPLY": "Odložit",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LIMIT_MESSAGES": {
|
||||
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
|
||||
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
|
||||
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
|
||||
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
|
||||
},
|
||||
"TITLE": "Nastavení účtu",
|
||||
|
||||
@@ -268,6 +268,46 @@
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
}
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice Channel",
|
||||
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Telefonní číslo",
|
||||
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||
},
|
||||
"TWILIO": {
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "SID účtu",
|
||||
"PLACEHOLDER": "Enter your Twilio Account SID",
|
||||
"REQUIRED": "Account SID is required"
|
||||
},
|
||||
"AUTH_TOKEN": {
|
||||
"LABEL": "Auth Token",
|
||||
"PLACEHOLDER": "Enter your Twilio Auth Token",
|
||||
"REQUIRED": "Auth Token is required"
|
||||
},
|
||||
"API_KEY_SID": {
|
||||
"LABEL": "API Key SID",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key SID",
|
||||
"REQUIRED": "API Key SID is required"
|
||||
},
|
||||
"API_KEY_SECRET": {
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
}
|
||||
},
|
||||
"API_CHANNEL": {
|
||||
"TITLE": "API Channel",
|
||||
"DESC": "Integrate with API channel and start supporting your customers.",
|
||||
@@ -789,7 +829,8 @@
|
||||
"TELEGRAM": "Telegram",
|
||||
"LINE": "Line",
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram"
|
||||
"INSTAGRAM": "Instagram",
|
||||
"VOICE": "Voice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,14 @@
|
||||
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
|
||||
"BUTTON_TEXT": "Connect Linear workspace"
|
||||
}
|
||||
},
|
||||
"NOTION": {
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Zrušit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
@@ -539,6 +547,8 @@
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "Vymazat",
|
||||
"BULK_APPROVE": {
|
||||
|
||||
@@ -147,7 +147,8 @@
|
||||
"SEND_ATTACHMENT": "Send Attachment",
|
||||
"SEND_MESSAGE": "Send a Message",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA"
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Åbn samtale"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Message Type",
|
||||
|
||||
@@ -49,5 +49,8 @@
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days",
|
||||
"PLACEHOLDER": "Enter duration"
|
||||
},
|
||||
"CHANNEL_SELECTOR": {
|
||||
"COMING_SOON": "Coming Soon!"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user