Compare commits

..
Author SHA1 Message Date
Shivam Mishra a0438a8465 fix: help center perms 2025-05-29 13:30:59 +05:30
1596 changed files with 13773 additions and 34514 deletions
+2 -2
View File
@@ -77,8 +77,8 @@ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmi
WORKDIR /workspace
RUN chown vscode:vscode /workspace
# set up node js, pnpm and claude code in single layer
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \
# set up node js and pnpm in single layer
RUN npm install -g pnpm@${PNPM_VERSION} \
&& npm cache clean --force
# Switch to vscode user
+4 -13
View File
@@ -4,26 +4,17 @@
"dockerComposeFile": "docker-compose.yml",
"settings": {
"terminal.integrated.shell.linux": "/bin/zsh",
"extensions.showRecommendationsOnlyOnDemand": true,
"editor.formatOnSave": true,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"search.exclude": {
"**/node_modules": true,
"**/tmp": true,
"**/log": true,
"**/coverage": true,
"**/public/packs": true
}
"terminal.integrated.shell.linux": "/bin/zsh"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"Shopify.ruby-lsp",
"rebornix.Ruby",
"misogi.ruby-rubocop",
"wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot",
"mrmlnc.vscode-duplicate"
],
-8
View File
@@ -4,13 +4,5 @@ sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
# Setup Claude Code API key if available
if [ -n "$CLAUDE_CODE_API_KEY" ]; then
mkdir -p ~/.claude
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
chmod +x ~/.claude/anthropic_key.sh
fi
# codespaces make the ports public
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
-1
View File
@@ -103,7 +103,6 @@ module.exports = {
'⌘',
'📄',
'🎉',
'🚀',
'💬',
'👥',
'📥',
-5
View File
@@ -6,11 +6,6 @@ name: Deploy Check
on:
pull_request:
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
deployment_check:
name: Check Deployment
@@ -5,11 +5,6 @@ on:
branches:
- develop
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
log_lines_check:
runs-on: ubuntu-latest
-5
View File
@@ -5,11 +5,6 @@ on:
branches:
- develop
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-22.04
+1 -1
View File
@@ -89,7 +89,7 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---##
gem 'facebook-messenger'
gem 'line-bot-api'
gem 'twilio-ruby'
gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty', '~> 0.1.5'
+12 -18
View File
@@ -172,8 +172,6 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -246,10 +244,8 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (0.1.1)
@@ -257,8 +253,8 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http (3.1.0)
net-http
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
@@ -416,7 +412,7 @@ GEM
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.10.1)
jwt (2.8.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -437,12 +433,10 @@ GEM
json (>= 1.8)
rexml
language_server-protocol (3.17.0.5)
launchy (3.1.1)
launchy (2.5.2)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
@@ -488,7 +482,7 @@ GEM
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.6.0)
net-http (0.4.1)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
@@ -569,7 +563,7 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.2)
public_suffix (6.0.0)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
@@ -821,7 +815,7 @@ GEM
i18n
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (7.6.0)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
nokogiri (>= 1.6, < 2.0)
@@ -1010,7 +1004,7 @@ DEPENDENCIES
telephone_number
test-prof
time_diff
twilio-ruby
twilio-ruby (~> 5.66)
twitty (~> 0.1.5)
tzinfo-data
uglifier
+1 -8
View File
@@ -41,15 +41,8 @@ run:
force_run:
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug:
overmind connect backend
@@ -59,4 +52,4 @@ debug_worker:
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
-4
View File
@@ -1,4 +0,0 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
+1 -12
View File
@@ -10,8 +10,7 @@ function toggleSecretField(e) {
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.textContent = '•'.repeat(10);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -33,13 +32,3 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});
@@ -46,25 +46,17 @@
.cell-data__secret-field {
align-items: center;
color: $hint-grey;
display: flex;
span {
flex: 0 0 auto;
flex: 1;
}
[data-secret-toggler],
[data-secret-copier] {
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
button {
margin-left: 5px;
svg {
fill: currentColor;
height: 1.25rem;
width: 1.25rem;
}
}
}
+2 -9
View File
@@ -21,8 +21,6 @@ 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
@@ -37,12 +35,7 @@ class ContactInboxBuilder
end
def phone_source_id
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
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
@contact.phone_number
end
@@ -107,6 +100,6 @@ class ContactInboxBuilder
end
def allowed_channels?
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? || @inbox.channel_type == 'Channel::Voice'
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end
end
+10 -13
View File
@@ -7,7 +7,7 @@ class Messages::MessageBuilder
@private = params[:private] || false
@conversation = conversation
@user = user
@message_type = params[:message_type].to_s || 'outgoing'
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@@ -33,6 +33,11 @@ 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.
@@ -100,9 +105,8 @@ class Messages::MessageBuilder
end
def message_type
# 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'
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
end
@message_type
@@ -135,7 +139,7 @@ class Messages::MessageBuilder
end
def message_params
message_attrs = {
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: message_type,
@@ -148,12 +152,5 @@ 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
@@ -1,103 +0,0 @@
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :account, :params
# rubocop:disable Lint/MissingSuper
# the parent class has no initialize
def initialize(account:, params:)
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
end
# rubocop:enable Lint/MissingSuper
def build
labels = account.labels.to_a
return [] if labels.empty?
report_data = collect_report_data
labels.map { |label| build_label_report(label, report_data) }
end
private
def collect_report_data
conversation_filter = build_conversation_filter
use_business_hours = use_business_hours?
{
conversation_counts: fetch_conversation_counts(conversation_filter),
resolved_counts: fetch_resolved_counts(conversation_filter),
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
}
end
def build_label_report(label, report_data)
{
id: label.id,
name: label.title,
conversations_count: report_data[:conversation_counts][label.title] || 0,
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
}
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
def build_conversation_filter
conversation_filter = { account_id: account.id }
conversation_filter[:created_at] = range if range.present?
conversation_filter
end
def fetch_conversation_counts(conversation_filter)
fetch_counts(conversation_filter)
end
def fetch_resolved_counts(conversation_filter)
# since the base query is ActsAsTaggableOn,
# the status :resolved won't automatically be converted to integer status
fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
end
def fetch_counts(conversation_filter)
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
taggable_type: 'Conversation',
context: 'labels',
conversations: conversation_filter
)
.select('tags.name, COUNT(taggings.*) AS count')
.group('tags.name')
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
end
def fetch_metrics(conversation_filter, event_name, use_business_hours)
ReportingEvent
.joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
.joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
conversations: conversation_filter,
name: event_name,
taggings: { taggable_type: 'Conversation', context: 'labels' }
)
.group('tags.name')
.order('tags.name')
.select(
'tags.name',
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
)
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
end
end
@@ -29,11 +29,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
head :ok
end
def reset_access_token
@agent_bot.access_token.regenerate_token
@agent_bot.reload
end
private
def agent_bot
@@ -1,152 +0,0 @@
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
@@ -1,39 +0,0 @@
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
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
@@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
@contacts = contacts.page(@current_page)
end
def show; end
@@ -124,12 +124,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save!
end
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
private
def permitted_update_params
@@ -1,23 +1,32 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/google/callback",
scope: scope,
scope: 'email profile https://mail.google.com/',
response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
}
)
if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -81,15 +81,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms]
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
@@ -163,8 +159,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
'line' => Channel::Line,
'telegram' => Channel::Telegram,
'whatsapp' => Channel::Whatsapp,
'sms' => Channel::Sms,
'voice' => Channel::Voice
'sms' => Channel::Sms
}[permitted_params[:channel][:type]]
end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern
include Instagram::IntegrationHelper
before_action :check_authorization
def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -20,4 +21,10 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -1,9 +1,8 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
revoke_linear_token
@hook.destroy!
head :ok
end
@@ -28,16 +27,10 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params, Current.user)
issue = linear_processor_service.create_issue(permitted_params)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_created,
issue_data: { id: issue[:data][:identifier] },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
@@ -45,34 +38,21 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def link_issue
issue_id = permitted_params[:issue_id]
title = permitted_params[:title]
issue = linear_processor_service.link_issue(conversation_link, issue_id, title, Current.user)
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_linked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue_id = permitted_params[:issue_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_unlinked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
@@ -121,15 +101,4 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
end
def revoke_linear_token
return unless @hook&.access_token
begin
linear_client = Linear.new(@hook.access_token)
linear_client.revoke_token
rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
end
end
end
@@ -1,14 +0,0 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
@@ -1,19 +1,28 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
include MicrosoftConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
prompt: 'consent'
}
)
if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -1,21 +0,0 @@
class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include NotionConcern
def create
redirect_url = notion_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/notion/callback",
response_type: 'code',
owner: 'user',
state: state,
client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
end
@@ -1,23 +0,0 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -1,80 +0,0 @@
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
@@ -1,230 +0,0 @@
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 dialin
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
@@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting)
end
def check_signup_enabled
@@ -38,11 +38,6 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok
end
def reset_access_token
@user.access_token.regenerate_token
@user.reload
end
private
def set_user
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -14,10 +14,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::InboxSummaryBuilder)
end
def label
render_report_with(V2::Reports::LabelSummaryBuilder)
end
private
def check_authorization
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private
def scope
'email profile https://mail.google.com/'
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private
def scope
'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email'
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -1,21 +0,0 @@
module NotionConcern
extend ActiveSupport::Concern
def notion_client
app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret, {
site: 'https://api.notion.com',
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
token_url: 'https://api.notion.com/v1/oauth/token',
auth_scheme: :basic_auth
})
end
private
def scope
''
end
end
+1 -1
View File
@@ -15,7 +15,7 @@ class DashboardController < ActionController::Base
private
def ensure_html_format
render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json?
head :not_acceptable unless request.format.html?
end
def set_global_config
@@ -1,36 +0,0 @@
class Notion::CallbacksController < OauthCallbackController
include NotionConcern
private
def provider_name
'notion'
end
def oauth_client
notion_client
end
def handle_response
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'notion',
settings: {
token_type: parsed_body['token_type'],
workspace_name: parsed_body['workspace_name'],
workspace_id: parsed_body['workspace_id'],
workspace_icon: parsed_body['workspace_icon'],
bot_id: parsed_body['bot_id'],
owner: parsed_body['owner']
}
)
hook.save!
redirect_to notion_redirect_uri
end
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
+8 -9
View File
@@ -6,6 +6,7 @@ class OauthCallbackController < ApplicationController
)
handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
@@ -63,10 +64,13 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError
end
def cache_key
"#{provider_name}::#{users_data['email'].downcase}"
end
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
@@ -81,17 +85,12 @@ class OauthCallbackController < ApplicationController
decoded_token[0]
end
def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
def account_id
::Redis::Alfred.get(cache_key)
end
def account
@account ||= account_from_signed_id
@account ||= Account.find(account_id)
end
# Fallback name, for when name field is missing from users_data
@@ -1,40 +1,19 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_category, except: [:index, :show]
before_action :set_article, only: [:show]
layout 'portal'
def index
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@articles_count = @articles.count
search_articles
order_by_sort_param
limit_results
end
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
return head :not_found unless @article
@article.increment_view_count if @article.published?
# Serve the 1x1 tracking pixel with 24-hour private cache
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
expires_in 24.hours, public: false
response.headers['Content-Type'] = 'image/png'
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
send_file pixel_path, type: 'image/png', disposition: 'inline'
end
def show; end
private
@@ -60,6 +39,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content)
end
@@ -8,9 +8,7 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
@categories = @portal.categories.order(position: :asc)
end
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @category.name)
end
def show; end
private
@@ -4,9 +4,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
def show
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
end
def show; end
def sitemap
@help_center_url = @portal.custom_domain || ChatwootApp.help_center_root
@@ -2,13 +2,6 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create
resource = resource_class.new(resource_params)
authorize_resource(resource)
@@ -39,7 +39,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
}
@@ -27,10 +27,7 @@ class Twilio::CallbackController < ApplicationController
*Array.new(10) { |i| :"MediaUrl#{i}" },
*Array.new(10) { |i| :"MediaContentType#{i}" },
:MessagingServiceSid,
:NumMedia,
:Latitude,
:Longitude,
:MessageType
:NumMedia
)
end
end
@@ -1,81 +0,0 @@
# 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
@@ -1,62 +0,0 @@
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
-189
View File
@@ -1,189 +0,0 @@
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
-1
View File
@@ -15,7 +15,6 @@ class AsyncDispatcher < BaseDispatcher
CsatSurveyListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
MessageListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,
+1 -2
View File
@@ -107,8 +107,7 @@ module Api::V1::InboxesHelper
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels,
'voice' => Current.account.voice_channels
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
@@ -36,13 +36,9 @@ module Api::V2::Accounts::ReportsHelper
end
def generate_labels_report
reports = V2::Reports::LabelSummaryBuilder.new(
account: Current.account,
params: build_params({})
).build
reports.map do |report|
[report[:name]] + generate_readable_report_metrics(report)
Current.account.labels.map do |label|
label_report = report_builder({ type: :label, id: label.id }).short_summary
[label.title] + generate_readable_report_metrics(label_report)
end
end
+3 -7
View File
@@ -1,13 +1,9 @@
module MessageFormatHelper
include RegexHelper
def transform_user_mention_content(message_content)
# attachment message without content, message_content is nil
return '' unless message_content.presence
# Use CommonMarker to convert markdown to plain text for notifications
# This handles all markdown formatting (links, bold, italic, etc.) not just mentions
# Converts: [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support)
# To: @👍 customer support
CommonMarker.render_doc(message_content).to_plaintext.strip
message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : ''
end
def render_message_content(message_content)
-17
View File
@@ -1,21 +1,4 @@
module PortalHelper
def set_og_image_url(portal_name, title)
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
return if cdn_url.blank?
client_ref = GlobalConfig.get('OG_IMAGE_CLIENT_REF')['OG_IMAGE_CLIENT_REF']
uri = URI.parse(cdn_url)
uri.path = '/og'
uri.query = URI.encode_www_form(
clientRef: client_ref,
title: title,
portalName: portal_name
)
uri.to_s
end
def generate_portal_bg_color(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
+1 -123
View File
@@ -1,12 +1,11 @@
<script>
import { mapGetters } from 'vuex';
import AddAccountModal from './components/app/AddAccountModal.vue';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue';
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';
@@ -15,8 +14,6 @@ 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,
@@ -28,7 +25,6 @@ export default {
components: {
AddAccountModal,
FloatingCallWidget,
LoadingState,
NetworkNotification,
UpdateBanner,
@@ -55,7 +51,6 @@ export default {
showAddAccountModal: false,
latestChatwootVersion: null,
reconnectService: null,
showCallWidget: false, // Will be set to true when calls are active
};
},
computed: {
@@ -65,10 +60,6 @@ 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 || {};
@@ -93,34 +84,11 @@ 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) {
@@ -138,77 +106,6 @@ 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', {
@@ -256,25 +153,6 @@ 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>
@@ -21,10 +21,6 @@ class AgentBotsAPI extends ApiClient {
deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`);
}
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
}
export default new AgentBotsAPI();
+11 -15
View File
@@ -38,7 +38,13 @@ export default {
}
return false;
},
profileUpdate({ displayName, avatar, ...profileAttributes }) {
profileUpdate({
password,
password_confirmation,
displayName,
avatar,
...profileAttributes
}) {
const formData = new FormData();
Object.keys(profileAttributes).forEach(key => {
const hasValue = profileAttributes[key] === undefined;
@@ -47,22 +53,16 @@ export default {
}
});
formData.append('profile[display_name]', displayName || '');
if (password && password_confirmation) {
formData.append('profile[password]', password);
formData.append('profile[password_confirmation]', password_confirmation);
}
if (avatar) {
formData.append('profile[avatar]', avatar);
}
return axios.put(endPoints('profileUpdate').url, formData);
},
profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) {
return axios.put(endPoints('profileUpdate').url, {
profile: {
current_password: currentPassword,
password,
password_confirmation: passwordConfirmation,
},
});
},
updateUISettings({ uiSettings }) {
return axios.put(endPoints('profileUpdate').url, {
profile: { ui_settings: uiSettings },
@@ -102,8 +102,4 @@ export default {
const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url);
},
resetAccessToken() {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
};
@@ -1,856 +0,0 @@
/* 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();
-5
View File
@@ -61,11 +61,6 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL);
}
active(page = 1, sortAttr = 'name') {
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
@@ -51,10 +51,6 @@ const endPoints = {
resendConfirmation: {
url: '/api/v1/profile/resend_confirmation',
},
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
};
export default page => {
@@ -134,12 +134,12 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
delete(conversationId) {
return axios.delete(`${this.url}/${conversationId}`);
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
}
@@ -33,11 +33,9 @@ class LinearAPI extends ApiClient {
);
}
unlinkIssue(linkId, issueIdentifier, conversationId) {
unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
issue_id: issueIdentifier,
conversation_id: conversationId,
});
}
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class NotionOAuthClient extends ApiClient {
constructor() {
super('notion', { accountScoped: true });
}
generateAuthorization() {
return axios.post(`${this.url}/authorization`);
}
}
export default new NotionOAuthClient();
@@ -9,6 +9,5 @@ describe('#AgentBotsAPI', () => {
expect(AgentBotsAPI).toHaveProperty('create');
expect(AgentBotsAPI).toHaveProperty('update');
expect(AgentBotsAPI).toHaveProperty('delete');
expect(AgentBotsAPI).toHaveProperty('resetAccessToken');
});
});
@@ -91,19 +91,6 @@ describe('#linearAPI', () => {
issueData
);
});
it('creates a valid request with conversation_id', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
conversation_id: 123,
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
@@ -133,18 +120,6 @@ describe('#linearAPI', () => {
}
);
});
it('creates a valid request with title', () => {
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 'ENG-123',
conversation_id: 1,
title: 'Sample Issue',
}
);
});
});
describe('getLinkedIssue', () => {
@@ -189,26 +164,12 @@ describe('#linearAPI', () => {
window.axios = originalAxios;
});
it('creates a valid request with link_id only', () => {
LinearAPIClient.unlinkIssue('link123');
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: undefined,
conversation_id: undefined,
}
);
});
it('creates a valid request with all parameters', () => {
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: 'ENG-456',
conversation_id: 789,
link_id: 1,
}
);
});
@@ -35,16 +35,6 @@ class SummaryReportsAPI extends ApiClient {
},
});
}
getLabelReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/label`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();
@@ -0,0 +1,101 @@
.slide-fade-enter-active {
transition: all 0.3s var(--ease-in-cubic);
}
.slide-fade-leave-active {
transition: all 0.3s var(--ease-out-cubic);
}
.slide-fade-enter,
.slide-fade-leave-to {
opacity: 0;
transform: translateX(10px);
}
.slide-fade-enter {
transform: translateX($space-micro);
}
.slide-fade-leave-to {
transform: translateX($space-medium);
}
.conversations-list-enter-active,
.conversations-list-leave-active {
transition: all 0.25s var(--ease-out-cubic);
}
.conversations-list-enter,
.conversations-list-leave-to {
opacity: 0;
transform: translateX($space-medium);
}
.slide-up-enter-active {
transition: all 0.3s var(--ease-in-cubic);
}
.slide-up-leave-active {
transition: all 0.3s var(--ease-out-cubic);
}
.slide-up-enter,
.slide-up-leave-to {
opacity: 0;
transform: translateY(-$space-medium);
}
.menu-slide-enter-active,
.menu-slide-leave-active {
transform: translateY(0);
transition:
transform 0.25s var(--ease-in-cubic),
opacity 0.15s var(--ease-in-cubic);
}
.menu-slide-enter,
.menu-slide-leave-to {
opacity: 0;
transform: translateY($space-small);
}
.toast-fade-enter-active {
transition: all 0.3s var(--ease-in-sine);
}
.toast-fade-leave-active {
transition: all 0.1s var(--ease-out-sine);
}
.toast-fade-enter,
.toast-fade-leave-to {
opacity: 0;
transform: translateY(-$space-small);
}
.modal-fade-enter-active {
transition: all 0.3s var(--ease-in-sine);
}
.modal-fade-leave-active {
transition: all 0.1s var(--ease-out-sine);
}
.modal-fade-enter,
.modal-fade-leave-to {
opacity: 0;
}
.network-notification-fade-enter-active {
transition: all 0.1s var(--ease-in-sine);
}
.network-notification-fade-leave-active {
transition: all 0.1s var(--ease-out-sine);
}
.network-notification-fade-enter,
.network-notification-fade-leave-to {
opacity: 0;
transform: translateY(-$space-small);
}
@@ -30,11 +30,11 @@
.mx-input:disabled,
.mx-input[readonly] {
@apply bg-n-background cursor-pointer;
@apply bg-white dark:bg-slate-900 cursor-pointer;
}
.mx-icon-calendar {
@apply text-n-slate-10;
@apply dark:text-slate-500;
}
}
@@ -43,17 +43,17 @@
.cell {
&.disabled {
@apply bg-n-slate-2 dark:bg-n-background text-n-slate-10;
@apply bg-slate-25 dark:bg-slate-900 text-slate-200 dark:text-slate-300;
}
&:hover,
&.hover-in-range,
&.in-range {
@apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12;
@apply bg-slate-75 dark:bg-slate-700 text-slate-900 dark:text-slate-100;
}
}
.mx-calendar + .mx-calendar {
.mx-calendar+.mx-calendar {
@apply border-l border-n-weak;
}
@@ -62,7 +62,7 @@
}
.mx-time {
@apply border-0 bg-n-background dark:bg-n-solid-2;
@apply border-0 bg-white dark:bg-slate-800;
.mx-time-header {
@apply border-0;
@@ -70,11 +70,11 @@
.mx-time-item {
&.disabled {
@apply bg-n-slate-2 dark:bg-n-background;
@apply bg-slate-25 dark:bg-slate-900;
}
&:hover {
@apply bg-n-slate-3 dark:bg-n-solid-3;
@apply bg-slate-75 dark:bg-slate-700;
}
}
}
@@ -0,0 +1,38 @@
@import 'dashboard/assets/scss/variables';
.formulate-input {
.formulate-input-errors {
list-style-type: none;
margin: 0;
padding: 0;
}
.formulate-input-error {
color: var(--r-400);
display: block;
font-size: var(--font-size-small);
font-weight: $font-weight-normal;
margin-bottom: $space-one;
width: 100%;
}
}
.integration-hooks {
.formulate-input[data-type='checkbox'] {
.formulate-input-wrapper {
@apply flex;
.formulate-input-element {
@apply pr-2;
input {
@apply mb-0;
}
}
}
.formulate-input-element-decorator {
@apply hidden;
}
}
}
@@ -0,0 +1,21 @@
// loader class
.spinner {
@include color-spinner();
@apply inline-block h-6 py-0 px-6 relative align-middle w-6;
&.message {
@apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0;
&::before {
@apply -ml-3 -mt-3;
}
}
&.small {
@apply h-4 w-4;
&::before {
@apply h-4 -mt-2 w-4;
}
}
}
@@ -0,0 +1,48 @@
// scss-lint:disable SpaceAfterPropertyColon
@import 'shared/assets/fonts/inter';
// Inter,
html,
body {
font-family:
'Inter',
-apple-system,
system-ui,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Tahoma,
Arial,
sans-serif !important;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
margin: 0;
padding: 0;
width: 100%;
}
.app-wrapper {
@apply h-screen flex-grow-0 min-h-0 w-full;
.button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
}
}
.banner + .app-wrapper {
// Reduce the height of the dashboard to make room for the banner.
// And causing the top right green-action button to be pushed down when scrolling.
@apply h-[calc(100%-48px)];
.button--fixed-top {
@apply top-14;
}
.off-canvas-content {
.button--fixed-top {
@apply top-2;
}
}
}
@@ -0,0 +1,101 @@
@import 'dashboard/assets/scss/variables';
$spinner-before-border-color: rgba(255, 255, 255, 0.7);
// input form
@mixin ghost-input() {
box-shadow: none;
border-color: transparent;
&:active,
&:hover,
&:focus {
border-color: transparent;
box-shadow: none;
}
}
@mixin color-spinner() {
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
&::before {
animation: spinner .9s linear infinite;
border: 2px solid $spinner-before-border-color;
border-radius: 50%;
border-top-color: lighten($color-woot, 10%);
box-sizing: border-box;
content: '';
height: $space-medium;
left: 50%;
margin-left: -$space-one;
margin-top: -$space-one;
position: absolute;
top: 50%;
width: $space-medium;
}
}
// --------------------------------------------------------
// arrows
// --------------------------------------------------------
// $direction: top, left, right, bottom, top-left, top-right, bottom-left, bottom-right
// $color: hex, rgb or rbga
// $size: px or em
// @example
// .element{
// @include arrow(top, #000, 50px);
// }
@mixin arrow($direction, $color, $size) {
display: block;
height: 0;
width: 0;
content: '';
@if $direction == 'top' {
border-bottom: $size solid $color;
border-left: $size solid transparent;
border-right: $size solid transparent;
}
@else if $direction == 'right' {
border-bottom: $size solid transparent;
border-left: $size solid $color;
border-top: $size solid transparent;
}
@else if $direction == 'bottom' {
border-left: $size solid transparent;
border-right: $size solid transparent;
border-top: $size solid $color;
}
@else if $direction == 'left' {
border-bottom: $size solid transparent;
border-right: $size solid $color;
border-top: $size solid transparent;
}
@else if $direction == 'top-left' {
border-right: $size solid transparent;
border-top: $size solid $color;
}
@else if $direction == 'top-right' {
border-left: $size solid transparent;
border-top: $size solid $color;
}
@else if $direction == 'bottom-left' {
border-bottom: $size solid $color;
border-right: $size solid transparent;
}
@else if $direction == 'bottom-right' {
border-bottom: $size solid $color;
border-left: $size solid transparent;
}
}
@@ -0,0 +1,204 @@
.app-rtl--wrapper {
direction: rtl;
// Woot Tabs
.tabs-title {
&:first-child {
margin-left: var(--space-small);
margin-right: unset;
}
&:last-child {
margin-left: unset;
margin-right: var(--space-small);
}
}
// woot tables
table,
thead,
th {
text-align: right;
}
// Table footer
.footer {
.page-meta {
direction: initial;
}
}
// Wizard box
.wizard-box {
direction: initial;
}
// Conversation details
.conversation-details-wrap {
.conversation-panel {
// Message text
.text-content {
p {
unicode-bidi: plaintext;
}
ul {
padding-left: unset;
padding-right: var(--space-two);
}
li {
text-align: right;
}
}
// Message items and actions
li {
&.right {
.sender--info {
padding: var(--space-small) var(--space-smaller)
var(--space-smaller) 0;
}
.context-menu-wrap {
margin-left: 0;
margin-right: auto;
}
}
}
}
// Conversation footer
.conversation-footer {
.preview-item {
direction: initial;
}
}
// Custom attributes section in conversation sidebar
.conversation-sidebar-wrap .checkbox-wrap {
.checkbox {
margin-left: var(--space-small);
}
}
}
// Conversation list
.conversations-list-wrap {
border-right: 0;
.conversation {
.conversation--meta {
left: $space-normal;
right: unset;
.unread {
margin-left: unset;
margin-right: auto;
}
}
.assignee-label {
margin-left: 0;
margin-right: var(--space-one);
}
.show-more--button {
margin: unset;
transform: rotate(180deg);
}
}
// Basic filter dropdown
.basic-filter {
left: 0;
right: unset;
}
// Bulk actions
.bulk-action__container {
.triangle {
left: var(--triangle-position);
right: unset;
}
.bulk-action__agents {
left: var(--space-small);
right: unset;
}
.labels-container {
left: var(--space-small);
right: unset;
.label-checkbox {
margin: 0 0 0 var(--space-one);
}
}
.actions-container {
left: var(--space-small);
right: unset;
}
.bulk-action__teams {
left: var(--space-small);
right: unset;
}
}
}
// Contact notes
.card.note-wrap {
.time-stamp {
unicode-bidi: plaintext;
}
}
// Toggle switch
.toggle-button {
&.small {
span {
&.active {
transform: translate(var(--space-minus-small), var(--space-zero));
}
}
}
span {
--minus-space-one-point-five: -0.9375rem;
&.active {
transform: translate(
var(--minus-space-one-point-five),
var(--space-zero)
);
}
}
}
// Modal
.modal-container {
text-align: right;
.modal-footer {
button {
margin-left: 0;
margin-right: var(--space-small);
}
}
}
// Other changes
.colorpicker--chrome {
direction: initial;
}
.mention--box {
direction: initial;
}
.contact--form .input-group {
direction: initial;
}
}
@@ -0,0 +1,97 @@
// Font sizes
$font-size-nano: 0.5rem;
$font-size-micro: 0.675rem;
$font-size-mini: 0.75rem;
$font-size-small: 0.875rem;
$font-size-default: 1rem;
$font-size-medium: 1.125rem;
$font-size-large: 1.375rem;
$font-size-big: 1.5rem;
$font-size-bigger: 1.75rem;
$font-size-mega: 2.125rem;
$font-size-giga: 2.5rem;
// spaces
$zero: 0;
$space-micro: 0.125rem;
$space-smaller: 0.25rem;
$space-small: 0.5rem;
$space-one: 0.675rem;
$space-slab: 0.75rem;
$space-normal: 1rem;
$space-two: 1.25rem;
$space-medium: 1.5rem;
$space-large: 2rem;
$space-larger: 3rem;
$space-jumbo: 4rem;
$space-mega: 6.25rem;
// font-weight
$font-weight-feather: 100;
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-bold: 600;
$font-weight-black: 700;
//Navbar
$nav-bar-width: 14.375rem;
$header-height: 3.5rem;
$woot-logo-padding: $space-large $space-two;
// Colors
$color-woot: #1f93ff;
$color-gray: #6e6f73;
$color-light-gray: #999a9b;
$color-border: var(--s-75);
$color-border-light: var(--s-50);
$color-border-dark: var(--s-100);
$color-background: var(--s-50);
$color-background-light: var(--s-25);
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot;
$secondary-color: #5d7592;
$success-color: #44ce4b;
$warning-color: #ffc532;
$alert-color: #ff382d;
$masked-bg: rgba(0, 0, 0, .4);
// Color-palettes
$color-primary-light: #c7e3ff;
$color-primary-dark: darken($color-woot, 20%);
// Thumbnail
$thumbnail-radius: 2.5rem;
// chat-header
$conv-header-height: 2.5rem;
// Inbox List
$inbox-thumb-size: 3rem;
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
$swift-ease-out-duration: .4s !default;
$swift-ease-out-function: cubic-bezier(0.37, 0, 0.63, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-function !default;
// Transitions
$transition-ease-in: all 0.250s ease-in;
:root {
--dashboard-app-tabs-height: 2.4375rem;
}
+383 -51
View File
@@ -1,4 +1,3 @@
// scss-lint:disable SpaceAfterPropertyColon
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@@ -9,66 +8,54 @@
// Next Colors
@import 'next-colors';
// Base styles for elements
@import 'base';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@import 'shared/assets/stylesheets/font-weights';
@import 'shared/assets/stylesheets/shadows';
@import 'shared/assets/stylesheets/border-radius';
@import 'shared/assets/stylesheets/z-index';
@import 'variables';
@import 'mixins';
@import 'helper-classes';
@import 'formulate';
@import 'date-picker';
@import 'layout';
@import 'animations';
@import 'rtl';
@import 'widgets/base';
@import 'widgets/conversation-view';
@import 'widgets/tabs';
@import 'widgets/woot-tables';
// Plugins
@import 'plugins/multiselect';
@import 'plugins/date-picker';
html,
body {
font-family:
'Inter',
-apple-system,
system-ui,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Tahoma,
Arial,
sans-serif !important;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
margin: 0;
padding: 0;
width: 100%;
}
.app-wrapper {
@apply h-screen flex-grow-0 min-h-0 w-full;
.button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
}
}
.banner + .app-wrapper {
// Reduce the height of the dashboard to make room for the banner.
// And causing the top right green-action button to be pushed down when scrolling.
@apply h-[calc(100%-48px)];
.button--fixed-top {
@apply top-14;
}
.off-canvas-content {
.button--fixed-top {
@apply top-2;
}
}
}
@import 'plugins/dropdown';
.tooltip {
@apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96;
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900 max-w-96;
}
#app {
@apply h-full w-full;
}
.hide {
@apply hidden;
}
.n-blue-border {
@apply border-n-blue-border;
}
.n-blue-text {
@apply text-n-blue-text;
}
.custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
background-position: center;
@@ -80,6 +67,351 @@ body {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
}
// scss-lint:disable PropertySortOrder
@layer base {
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
--color-amber-75: 255 243 208;
--color-amber-100: 255 236 183;
--color-amber-200: 255 224 161;
--color-amber-300: 245 208 140;
--color-amber-400: 228 187 120;
--color-amber-500: 214 163 92;
--color-amber-600: 214 163 92;
--color-amber-700: 255 186 26;
--color-amber-800: 145 89 48;
--color-amber-900: 79 52 34;
--color-ash-100: 235 235 239;
--color-ash-200: 228 228 233;
--color-ash-25: 252 252 253;
--color-ash-300: 221 221 227;
--color-ash-400: 211 212 219;
--color-ash-50: 249 249 251;
--color-ash-500: 185 187 198;
--color-ash-600: 139 141 152;
--color-ash-700: 126 128 138;
--color-ash-75: 242 242 245;
--color-ash-800: 96 100 108;
--color-ash-900: 28 32 36;
--color-primary-25: 251 253 255;
--color-primary-50: 245 249 255;
--color-primary-75: 233 243 255;
--color-primary-100: 218 236 255;
--color-primary-200: 201 226 255;
--color-primary-300: 181 213 255;
--color-primary-400: 155 195 252;
--color-primary-500: 117 171 247;
--color-primary-600: 39 129 246;
--color-primary-700: 16 115 233;
--color-primary-800: 8 109 224;
--color-primary-900: 11 50 101;
--color-ruby-100: 255 220 225;
--color-ruby-200: 255 206 214;
--color-ruby-25: 255 252 253;
--color-ruby-300: 248 191 200;
--color-ruby-400: 239 172 184;
--color-ruby-50: 255 247 248;
--color-ruby-500: 229 146 163;
--color-ruby-600: 229 70 102;
--color-ruby-700: 220 59 93;
--color-ruby-75: 254 234 237;
--color-ruby-800: 202 36 77;
--color-ruby-900: 100 23 43;
--color-teal-100: 224 248 243;
--color-teal-200: 204 243 234;
--color-teal-25: 250 254 253;
--color-teal-300: 184 234 224;
--color-teal-400: 161 222 210;
--color-teal-50: 243 251 249;
--color-teal-500: 83 185 171;
--color-teal-600: 18 165 148;
--color-teal-700: 13 155 138;
--color-teal-75: 236 249 255;
--color-teal-800: 0 133 115;
--color-teal-900: 13 61 56;
--color-green-25: 251 254 252;
--color-green-50: 244 251 246;
--color-green-75: 230 246 235;
--color-green-100: 214 241 223;
--color-green-200: 196 232 209;
--color-green-300: 173 221 192;
--color-green-400: 142 206 170;
--color-green-500: 91 185 139;
--color-green-600: 48 164 108;
--color-green-700: 43 154 102;
--color-green-800: 33 131 88;
--color-green-900: 25 59 45;
--color-mint-25: 249 254 253;
--color-mint-50: 242 251 249;
--color-mint-75: 221 249 242;
--color-mint-100: 200 244 233;
--color-mint-200: 179 236 222;
--color-mint-300: 156 224 208;
--color-mint-400: 126 207 189;
--color-mint-500: 76 187 165;
--color-mint-600: 134 234 212;
--color-mint-700: 125 224 203;
--color-mint-800: 2 120 100;
--color-mint-900: 22 67 60;
--color-sky-25: 249 254 255;
--color-sky-50: 241 250 253;
--color-sky-75: 225 246 253;
--color-sky-100: 209 240 250;
--color-sky-200: 190 231 245;
--color-sky-300: 169 218 237;
--color-sky-400: 141 202 227;
--color-sky-500: 96 179 215;
--color-sky-600: 124 226 254;
--color-sky-700: 116 218 248;
--color-sky-800: 0 116 158;
--color-sky-900: 29 62 86;
--color-indigo-25: 253 253 254;
--color-indigo-50: 247 249 255;
--color-indigo-75: 237 242 254;
--color-indigo-100: 225 233 255;
--color-indigo-200: 210 222 255;
--color-indigo-300: 193 208 255;
--color-indigo-400: 171 189 249;
--color-indigo-500: 141 164 239;
--color-indigo-600: 62 99 221;
--color-indigo-700: 51 88 212;
--color-indigo-800: 58 91 199;
--color-indigo-900: 31 45 92;
--color-iris-25: 253 253 255;
--color-iris-50: 248 248 255;
--color-iris-75: 240 241 254;
--color-iris-100: 230 231 255;
--color-iris-200: 218 220 255;
--color-iris-300: 203 205 255;
--color-iris-400: 184 186 248;
--color-iris-500: 155 158 240;
--color-iris-600: 91 91 214;
--color-iris-700: 81 81 205;
--color-iris-800: 87 83 198;
--color-iris-900: 39 41 98;
--color-violet-25: 253 252 254;
--color-violet-50: 250 248 255;
--color-violet-75: 244 240 254;
--color-violet-100: 235 228 255;
--color-violet-200: 225 217 255;
--color-violet-300: 212 202 254;
--color-violet-400: 194 181 245;
--color-violet-500: 170 153 236;
--color-violet-600: 110 86 207;
--color-violet-700: 101 77 196;
--color-violet-800: 101 80 185;
--color-violet-900: 47 38 95;
--color-pink-25: 255 252 254;
--color-pink-50: 254 247 251;
--color-pink-75: 254 233 245;
--color-pink-100: 251 220 239;
--color-pink-200: 246 206 231;
--color-pink-300: 239 191 221;
--color-pink-400: 231 172 208;
--color-pink-500: 221 147 194;
--color-pink-600: 214 64 159;
--color-pink-700: 207 56 151;
--color-pink-800: 194 41 138;
--color-pink-900: 101 18 73;
--color-orange-25: 254 252 251;
--color-orange-50: 255 247 237;
--color-orange-75: 255 239 214;
--color-orange-100: 255 223 181;
--color-orange-200: 255 209 154;
--color-orange-300: 255 193 130;
--color-orange-400: 245 174 115;
--color-orange-500: 236 148 85;
--color-orange-600: 247 107 21;
--color-orange-700: 239 95 0;
--color-orange-800: 204 78 0;
--color-orange-900: 88 45 29;
}
// scss-lint:disable QualifyingElement
body.dark {
--color-amber-25: 31 19 0;
--color-amber-50: 37 24 4;
--color-amber-75: 48 32 11;
--color-amber-100: 57 39 15;
--color-amber-200: 67 46 18;
--color-amber-300: 83 57 22;
--color-amber-400: 111 77 29;
--color-amber-500: 169 118 42;
--color-amber-600: 169 118 42;
--color-amber-700: 255 203 71;
--color-amber-800: 255 204 77;
--color-amber-900: 255 231 179;
--color-ash-100: 46 48 53;
--color-ash-200: 53 55 60;
--color-ash-25: 24 24 26;
--color-ash-300: 60 63 68;
--color-ash-400: 70 75 80;
--color-ash-50: 27 27 31;
--color-ash-500: 90 97 101;
--color-ash-600: 105 110 119;
--color-ash-700: 120 127 133;
--color-ash-75: 39 40 45;
--color-ash-800: 173 177 184;
--color-ash-900: 237 238 240;
--color-primary-25: 10 17 28;
--color-primary-50: 15 24 38;
--color-primary-75: 15 39 72;
--color-primary-100: 10 49 99;
--color-primary-200: 18 61 117;
--color-primary-300: 29 74 134;
--color-primary-400: 40 89 156;
--color-primary-500: 48 106 186;
--color-primary-600: 39 129 246;
--color-primary-700: 21 116 231;
--color-primary-800: 126 182 255;
--color-primary-900: 205 227 255;
--color-ruby-100: 78 19 37;
--color-ruby-200: 94 26 46;
--color-ruby-25: 25 17 19;
--color-ruby-300: 111 37 57;
--color-ruby-400: 136 52 71;
--color-ruby-50: 30 21 23;
--color-ruby-500: 179 68 90;
--color-ruby-600: 229 70 102;
--color-ruby-700: 236 90 114;
--color-ruby-75: 58 20 30;
--color-ruby-800: 255 148 157;
--color-ruby-900: 254 210 225;
--color-teal-100: 2 59 55;
--color-teal-200: 8 72 67;
--color-teal-25: 13 21 20;
--color-teal-300: 28 105 97;
--color-teal-400: 28 105 97;
--color-teal-50: 17 28 27;
--color-teal-500: 32 126 115;
--color-teal-600: 41 163 131;
--color-teal-700: 14 179 158;
--color-teal-75: 13 45 42;
--color-teal-800: 11 216 182;
--color-teal-900: 173 240 221;
--color-green-25: 14 21 18;
--color-green-50: 18 27 23;
--color-green-75: 19 45 33;
--color-green-100: 17 59 41;
--color-green-200: 23 73 51;
--color-green-300: 32 87 62;
--color-green-400: 40 104 74;
--color-green-500: 47 124 87;
--color-green-600: 48 164 108;
--color-green-700: 51 176 116;
--color-green-800: 61 214 140;
--color-green-900: 177 241 203;
--color-mint-25: 14 21 21;
--color-mint-50: 15 27 27;
--color-mint-75: 9 44 43;
--color-mint-100: 0 58 56;
--color-mint-200: 0 71 68;
--color-mint-300: 16 86 80;
--color-mint-400: 30 104 95;
--color-mint-500: 39 127 112;
--color-mint-600: 134 234 212;
--color-mint-700: 168 245 229;
--color-mint-800: 88 213 186;
--color-mint-900: 196 245 225;
--color-sky-25: 14 21 21;
--color-sky-50: 15 27 27;
--color-sky-75: 9 44 43;
--color-sky-100: 0 58 56;
--color-sky-200: 0 71 68;
--color-sky-300: 16 86 80;
--color-sky-400: 30 104 95;
--color-sky-500: 39 127 112;
--color-sky-600: 134 234 212;
--color-sky-700: 168 245 229;
--color-sky-800: 88 213 186;
--color-sky-900: 196 245 225;
--color-indigo-25: 17 19 31;
--color-indigo-50: 20 23 38;
--color-indigo-75: 24 36 73;
--color-indigo-100: 29 46 98;
--color-indigo-200: 37 57 116;
--color-indigo-300: 48 67 132;
--color-indigo-400: 58 79 151;
--color-indigo-500: 67 93 177;
--color-indigo-600: 62 99 221;
--color-indigo-700: 84 114 228;
--color-indigo-800: 158 177 255;
--color-indigo-900: 214 225 255;
--color-iris-25: 19 19 30;
--color-iris-50: 23 22 37;
--color-iris-75: 32 34 72;
--color-iris-100: 38 42 101;
--color-iris-200: 48 51 116;
--color-iris-300: 61 62 130;
--color-iris-400: 74 74 149;
--color-iris-500: 89 88 177;
--color-iris-600: 91 91 214;
--color-iris-700: 110 106 222;
--color-iris-800: 177 169 255;
--color-iris-900: 224 223 254;
--color-violet-25: 20 18 31;
--color-violet-50: 27 21 37;
--color-violet-75: 41 31 67;
--color-violet-100: 51 37 91;
--color-violet-200: 60 46 105;
--color-violet-300: 71 56 118;
--color-violet-400: 86 70 139;
--color-violet-500: 105 88 173;
--color-violet-600: 110 86 207;
--color-violet-700: 125 102 217;
--color-violet-800: 186 167 255;
--color-violet-900: 226 221 254;
--color-pink-25: 25 17 23;
--color-pink-50: 33 18 29;
--color-pink-75: 55 23 47;
--color-pink-100: 75 20 61;
--color-pink-200: 89 28 71;
--color-pink-300: 105 41 85;
--color-pink-400: 131 56 105;
--color-pink-500: 168 72 133;
--color-pink-600: 214 64 159;
--color-pink-700: 222 81 168;
--color-pink-800: 255 141 204;
--color-pink-900: 253 209 234;
--color-orange-25: 23 18 14;
--color-orange-50: 30 22 15;
--color-orange-75: 51 30 11;
--color-orange-100: 70 33 0;
--color-orange-200: 86 40 0;
--color-orange-300: 102 53 12;
--color-orange-400: 126 69 29;
--color-orange-500: 163 88 41;
--color-orange-600: 247 107 21;
--color-orange-700: 255 128 31;
--color-orange-800: 255 160 87;
--color-orange-900: 255 224 194;
}
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
@@ -0,0 +1,7 @@
.dropdown-pane {
@apply border rounded-lg hidden relative invisible shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-[9999];
&.dropdown-pane--open {
@apply bg-n-alpha-3 backdrop-blur-[100px] absolute block visible;
}
}
@@ -17,10 +17,6 @@
@apply mb-4;
}
&.invalid .multiselect__tags {
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
&.multiselect--disabled {
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
@@ -51,7 +47,7 @@
@apply max-w-full;
.multiselect__option {
@apply text-sm font-normal flex justify-between items-center;
@apply text-sm font-normal;
span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
@@ -62,7 +58,7 @@
}
&::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
@apply bottom-0 flex items-center justify-center text-center;
}
&.multiselect__option--highlight {
@@ -78,7 +74,7 @@
}
&.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12;
@apply bg-transparent;
}
&.multiselect__option--selected {
@@ -128,7 +124,8 @@
}
.multiselect__input {
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
@include ghost-input;
@apply text-sm h-[2.875rem] mb-0 p-0;
}
.multiselect__single {
@@ -0,0 +1 @@
// to be removed
@@ -0,0 +1 @@
// to be removed
@@ -101,7 +101,7 @@ select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-size: 9px 6px;
@apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
@apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
&[disabled] {
@apply field-disabled;
@@ -201,8 +201,3 @@ code {
}
}
}
// Table
table {
@apply border-spacing-0 text-sm w-full;
}
@@ -0,0 +1,261 @@
// scss-lint:disable MergeableSelector
@tailwind utilities;
@layer utilities {
.custom-gradient {
background-image: linear-gradient(
-180deg,
transparent 3%,
rgb(76 81 85) 130%
);
}
.bubble-with-types {
@apply py-2 text-sm font-normal bg-woot-500 dark:bg-woot-500 relative px-4 m-0 text-white dark:text-white;
.message-text__wrap {
@apply relative;
.link {
@apply text-white dark:text-white underline;
}
}
.image,
.video {
@apply cursor-pointer relative;
.modal-container {
@apply text-center;
}
.modal-image {
@apply max-h-[76vh] max-w-[76vw];
}
.modal-video {
@apply max-h-[76vh] max-w-[76vw];
}
&::before {
@apply custom-gradient bottom-0 h-[20%] content-[''] left-0 absolute w-full opacity-80;
}
}
}
}
.conversation-panel {
@apply flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4;
}
.conversation-panel > li {
@apply flex flex-shrink-0 flex-grow-0 flex-auto max-w-full mt-0 mr-0 mb-1 ml-0 relative first:mt-auto last:mb-0;
&.unread--toast {
+ .right {
@apply mb-1;
}
+ .left {
@apply mb-0;
}
span {
@apply shadow-lg rounded-full bg-woot-500 dark:bg-woot-500 text-white dark:text-white text-xs font-medium my-2.5 mx-auto px-2.5 py-1.5;
}
}
.bubble {
@apply bubble-with-types text-left break-words;
.aplayer {
@apply shadow-none;
font-family: inherit;
}
}
&.left {
.bubble {
@apply rounded-r-lg rounded-l mr-auto break-words;
&:not(.is-unsupported) {
@apply border border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-700 text-black-900 dark:text-slate-50;
}
&.is-image {
@apply rounded-lg;
}
.link {
@apply text-woot-600 dark:text-woot-600;
}
.file {
.attachment-name {
@apply text-slate-700 dark:text-woot-300;
}
.icon-wrap {
@apply text-woot-600 dark:text-woot-600;
}
.download {
@apply text-woot-600 dark:text-woot-600;
}
}
}
+ .right {
@apply mt-2.5;
.bubble {
@apply rounded-tr-lg;
}
}
+ .unread--toast {
+ .right {
@apply mt-2.5;
.bubble {
@apply rounded-tr-lg;
}
}
+ .left {
@apply mt-0;
}
}
}
&.right {
@apply justify-end;
.wrap {
@apply flex items-end mr-4 text-right;
.sender--info {
@apply pt-2 pb-1 pr-0 pl-2;
}
}
.bubble {
@apply ml-auto break-words rounded-l-lg rounded-r;
&.is-private {
@apply text-black-900 dark:text-white relative border border-solid bg-yellow-100 dark:bg-yellow-700 border-yellow-200 dark:border-yellow-600/25;
blockquote {
@apply border-slate-400 dark:border-slate-400 text-slate-800 dark:text-slate-300;
p {
@apply text-slate-600 dark:text-slate-300;
}
}
}
&.is-image {
@apply rounded-lg;
.message__mail-head {
@apply px-4 py-2;
}
}
}
+ .left {
@apply mt-2.5;
.bubble {
@apply rounded-tl-lg;
}
}
+ .unread--toast {
+ .left {
@apply rounded-lg;
.bubble {
@apply rounded-tl-lg;
}
}
+ .right {
@apply mt-0;
}
}
}
&.center {
@apply items-center justify-center;
}
.wrap {
max-width: Min(31rem, 84%);
@apply my-0 mx-4;
.sender--name {
@apply text-xs mb-1;
}
}
.sender--thumbnail {
@apply h-3 mr-3 mt-0.5 w-3 rounded-full;
}
.activity-wrap {
@apply flex justify-center text-sm my-1 mx-0 py-1 pr-0.5 pl-2.5 bg-slate-50 dark:bg-slate-600 text-slate-800 dark:text-slate-100 rounded-md border border-slate-100 dark:border-slate-600 border-solid;
.is-text {
@apply inline-flex items-center text-start 2xl:flex;
}
}
}
.activity-wrap .message-text__wrap {
.text-content p {
@apply mb-0;
}
}
.conversation-footer {
@apply flex relative flex-col;
}
.left .bubble .text-content {
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-slate-800 dark:text-slate-100;
}
a {
@apply text-woot-500 dark:text-woot-500 underline;
}
p:last-child {
@apply mb-0;
}
}
.right .bubble .text-content {
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-white dark:text-white;
}
a {
@apply text-white dark:text-white underline;
}
p:last-child {
@apply mb-0;
}
}
@@ -0,0 +1,77 @@
.tabs--container {
@apply flex;
}
.tabs--container--with-border {
@apply border-b border-b-n-weak;
}
.tabs--container--compact.tab--chat-type {
.tabs-title {
a {
@apply py-2 text-sm;
}
}
}
.tabs {
@apply border-r-0 border-l-0 border-t-0 flex min-w-[6.25rem] py-0 px-4 list-none mb-0;
}
.tabs--with-scroll {
@apply overflow-hidden py-0 px-1;
max-width: calc(100% - 64px);
}
.tabs--scroll-button {
@apply items-center rounded-none cursor-pointer flex h-auto justify-center min-w-[2rem];
}
// Tab chat type
.tab--chat-type {
@apply flex;
.tabs-title {
a {
@apply text-base font-medium py-3;
}
}
}
.tabs-title {
@apply flex-shrink-0 my-0 mx-2;
.badge {
@apply bg-n-alpha-black2 dark:bg-n-solid-3 rounded-md text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
}
&:first-child {
@apply ml-0;
}
&:last-child {
@apply mr-0;
}
&:hover,
&:focus {
a {
@apply text-n-slate-12;
}
}
a {
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-11 text-sm top-[1px] relative;
transition: border-color 0.15s $swift-ease-out-function;
}
&.is-active {
a {
@apply border-b border-n-brand text-n-blue-text;
}
.badge {
@apply bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text;
}
}
}
@@ -0,0 +1,90 @@
table {
@apply border-spacing-0 text-sm w-full;
}
.woot-table {
thead {
th {
@apply font-semibold tracking-[1px] text-left px-2.5 uppercase text-slate-900 dark:text-slate-200;
}
}
tbody {
tr {
@apply border-b border-slate-50 dark:border-slate-800/30;
}
td {
@apply p-2.5 text-slate-700 dark:text-slate-100;
}
}
tr {
.show-if-hover {
transition: opacity 0.2s $swift-ease-out-function;
@apply opacity-0;
}
&:hover {
.show-if-hover {
@apply opacity-100;
}
}
}
.agent-name {
@apply block font-medium capitalize;
}
.woot-thumbnail {
@apply rounded-full h-[3.125rem] w-[3.125rem];
}
.button-wrapper {
@apply flex justify-start flex-row min-w-[12.5rem] gap-1;
}
.button {
margin: 0;
}
}
.ve-table {
.ve-table-container.ve-table-border-around {
@apply border-slate-200 dark:border-slate-700;
}
.ve-table-content {
.ve-table-header .ve-table-header-tr .ve-table-header-th {
@apply bg-slate-50 dark:bg-slate-800 text-slate-800 dark:text-slate-100 border-slate-100 dark:border-slate-700/50;
}
.ve-table-body .ve-table-body-tr .ve-table-body-td {
@apply bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-slate-75 dark:border-slate-800;
}
.ve-table-body.ve-table-row-hover .ve-table-body-tr:hover td {
@apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100;
}
}
}
.table-pagination {
.ve-pagination-total {
@apply text-slate-600 dark:text-slate-200;
}
.ve-pagination-goto {
@apply text-slate-600 dark:text-slate-200;
.ve-pagination-goto-input {
@apply bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200;
}
}
.ve-pagination-li {
@apply bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200 border-slate-75 dark:border-slate-700;
}
}
@@ -1,27 +0,0 @@
<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>
@@ -1,306 +0,0 @@
<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,7 +8,6 @@ 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: {
@@ -23,10 +22,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
isVoiceType: {
type: Boolean,
default: false,
},
isEnabled: {
type: Boolean,
default: false,
@@ -72,12 +67,6 @@ 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')
@@ -119,12 +108,6 @@ 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"
@@ -135,7 +118,7 @@ const inboxIcon = computed(() => {
</div>
<div class="flex items-center justify-end w-20 gap-2">
<Button
v-if="isLiveChatType || isVoiceType"
v-if="isLiveChatType"
variant="faded"
size="sm"
color="slate"
@@ -1,63 +0,0 @@
<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>
@@ -37,7 +37,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
id: 1,
name: 'Jamie Lee',
},
message: 'Hello! 👋 Any questions on pricing? I am here to help!',
message: 'Hello! 👋 Any questions on pricing? Im here to help!',
campaign_status: 'active',
enabled: false,
campaign_type: 'ongoing',
@@ -60,8 +60,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
},
sender: {
id: 1,
name: 'Alexa Rivera',
thumbnail: 'AR',
name: 'Chatwoot',
},
message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
campaign_status: 'active',
@@ -89,7 +88,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
name: 'Chris Barlow',
},
message:
'Hi there! 👋 I am here for any questions you may have. Let us chat!',
'Hi there! 👋 Im here for any questions you may have. Lets chat!',
campaign_status: 'active',
enabled: true,
campaign_type: 'ongoing',
@@ -167,7 +166,7 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Hello! We are excited to have your business with us!',
message: 'Hello! Were excited to have your business with us!',
campaign_status: 'active',
enabled: true,
campaign_type: 'one_off',
@@ -211,114 +210,3 @@ 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',
}
];
@@ -1,55 +0,0 @@
<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>
@@ -1,63 +0,0 @@
<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,10 +10,6 @@ defineProps({
type: Boolean,
default: false,
},
isVoiceType: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['edit', 'delete']);
@@ -35,7 +31,6 @@ 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)"
/>
@@ -40,9 +40,9 @@ const handleSubmit = campaignDetails => {
<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-n-weak shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
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-n-slate-12">
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
</h3>
<LiveChatCampaignForm
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -39,9 +39,9 @@ const handleClose = () => emit('close');
<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-n-weak shadow-md flex flex-col gap-6"
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"
>
<h3 class="text-base font-medium text-n-slate-12">
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
</h3>
<SMSCampaignForm @submit="handleSubmit" @cancel="handleClose" />
@@ -174,7 +174,7 @@ const handleSubmit = async () => {
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -1,60 +0,0 @@
<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>
@@ -1,246 +0,0 @@
<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>
@@ -17,7 +17,6 @@ const props = defineProps({
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
});
@@ -93,13 +92,7 @@ const onClickViewDetails = () => emit('showContact', props.id);
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
rounded-full
/>
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
@@ -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>
@@ -7,16 +7,42 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
headerTitle: { type: String, required: true },
buttonLabel: { type: String, default: '' },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
isSegmentsView: { type: Boolean, default: false },
hasActiveFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
showSearch: {
type: Boolean,
default: true,
},
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -59,7 +85,7 @@ const emit = defineEmits([
</Input>
</div>
<div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative">
<div v-if="!isLabelView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
@@ -79,12 +105,7 @@ const emit = defineEmits([
<slot name="filter" />
</div>
<Button
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
icon="i-lucide-save"
color="slate"
size="sm"
@@ -92,7 +113,7 @@ const emit = defineEmits([
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView && !isActiveView"
v-if="isSegmentsView && !isLabelView"
icon="i-lucide-trash"
color="slate"
size="sm"

Some files were not shown because too many files have changed in this diff Show More