Compare commits

..
Author SHA1 Message Date
Shivam Mishra 0267c5a6dc style: comments 2025-03-26 22:26:37 +05:30
Shivam Mishra 92a04369e2 chore: add alternative 2025-03-26 22:24:53 +05:30
Shivam Mishra 2c91577419 feat: start service 2025-03-26 22:20:58 +05:30
395 changed files with 5138 additions and 10440 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+1 -1
View File
@@ -2,7 +2,7 @@
# # # #
# # Linux nightly installer action # # Linux nightly installer action
# # This action will try to install and setup # # This action will try to install and setup
# # chatwoot on an Ubuntu 22.04 machine using # # chatwoot on an Ubuntu 20.04 machine using
# # the linux installer script. # # the linux installer script.
# # # #
# # This is set to run daily at midnight. # # This is set to run daily at midnight.
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
services: services:
postgres: postgres:
image: pgvector/pgvector:pg15 image: pgvector/pgvector:pg15
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
-3
View File
@@ -91,6 +91,3 @@ yarn-debug.log*
# Vite uses dotenv and suggests to ignore local-only env files. See # Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files # https://vitejs.dev/guide/env-and-mode.html#env-files
*.local *.local
# Claude.ai config file
CLAUDE.md
@@ -9,7 +9,7 @@ class Campaigns::CampaignConversationBuilder
@contact_inbox.lock! @contact_inbox.lock!
# We won't send campaigns if a conversation is already present # We won't send campaigns if a conversation is already present
raise 'Conversation already present' if @contact_inbox.reload.conversations.present? raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
@conversation = ::Conversation.create!(conversation_params) @conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
@@ -63,33 +63,9 @@ class ContactInboxWithContactBuilder
contact = find_contact_by_identifier(contact_attributes[:identifier]) contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact contact
end end
def instagram_channel?
inbox.channel_type == 'Channel::Instagram'
end
# There might be existing contact_inboxes created through Channel::FacebookPage
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
# while still reusing contacts if found in Facebook channels so that we can create
# new conversations with the same contact.
def find_contact_by_instagram_source_id(instagram_id)
return if instagram_id.blank?
existing_contact_inbox = ContactInbox.joins(:inbox)
.where(source_id: instagram_id)
.where(
'inboxes.channel_type = ? AND inboxes.account_id = ?',
'Channel::FacebookPage',
account.id
).first
existing_contact_inbox&.contact
end
def find_contact_by_identifier(identifier) def find_contact_by_identifier(identifier)
return if identifier.blank? return if identifier.blank?
@@ -1,178 +0,0 @@
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue StandardError => e
handle_error(e)
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
find_conversation_scope.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_conversation_scope
Conversation.where(conversation_params)
end
def find_or_build_for_multiple_conversations
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
# Duplicate webhook events may be sent for the same message
# when a user is connected to the Instagram account through both Messenger and Instagram login.
# There is chance for echo events to be sent for the same message.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_conversation_attributes
))
end
def additional_conversation_attributes
{}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
true
end
# Abstract methods to be implemented by subclasses
def get_story_object_from_source_id(source_id)
raise NotImplementedError
end
end
@@ -1,42 +1,200 @@
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder # This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false) def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo) super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end end
private private
def get_story_object_from_source_id(source_id) def attachments
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}" @messaging[:message][:attachments] || {}
response = HTTParty.get(url)
return JSON.parse(response.body).with_indifferent_access if response.success?
# Create message first if it doesn't exist
@message ||= conversation.messages.create!(message_params)
handle_error_response(response)
nil
end end
def handle_error_response(response) def message_type
parsed_response = JSON.parse(response.body) @outgoing_echo ? :outgoing : :incoming
error_code = parsed_response.dig('error', 'code') end
# https://developers.facebook.com/docs/messenger-platform/error-codes def message_identifier
# Access token has expired or become invalid. message[:mid]
channel.authorization_error! if error_code == 190 end
# There was a problem scraping data from the provided link. def message_source_id
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005 @outgoing_echo ? recipient_id : sender_id
if error_code == 1_609_005 end
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def instagram_direct_message_conversation
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end end
def base_uri def find_or_build_for_multiple_conversations
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}" last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end end
@@ -1,33 +0,0 @@
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
def find_conversation_scope
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def additional_conversation_attributes
{ type: 'instagram_direct_message' }
end
end
@@ -68,8 +68,20 @@ class Messages::Messenger::MessageBuilder
message.save! message.save!
end end
# This is a placeholder method to be overridden by child classes def get_story_object_from_source_id(source_id)
def get_story_object_from_source_id(_source_id) k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{} {}
end end
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end end
def permitted_params def permitted_params
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {}) params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
end end
def process_avatar_from_url def process_avatar_from_url
@@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end end
def allowed_agent_params def allowed_agent_params
[:name, :email, :role, :availability, :auto_offline] [:name, :email, :name, :role, :availability, :auto_offline]
end end
def agent_params def agent_params
@@ -1,21 +1,17 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
def index def index
# Start with all conversations for this contact @conversations = Current.account.conversations.includes(
conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings :assignee, :contact, :inbox, :taggings
).where(contact_id: @contact.id) ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
end
# Apply permission-based filtering using the existing service private
conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
# Only allow conversations from inboxes the user has access to def inbox_ids
inbox_ids = Current.user.assigned_inboxes.pluck(:id) if Current.user.administrator? || Current.user.agent?
conversations = conversations.where(inbox_id: inbox_ids) Current.user.assigned_inboxes.pluck(:id)
else
@conversations = conversations.order(last_activity_at: :desc).limit(20) []
end
end end
end end
@@ -48,7 +48,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def filter def filter
result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations] @conversations = result[:conversations]
@conversations_count = result[:count] @conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute, rescue CustomExceptions::CustomFilter::InvalidAttribute,
@@ -1,30 +0,0 @@
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
redirect_url = instagram_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/instagram/callback",
scope: REQUIRED_SCOPES.join(','),
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
state: generate_instagram_token(Current.account.id)
}
)
if redirect_url
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
@@ -9,6 +9,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portals = Current.account.portals @portals = Current.account.portals
end end
def add_members
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
@portal.members << agents
end
def show def show
@all_articles = @portal.articles @all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale]) @articles = @all_articles.search(locale: params[:locale])
@@ -80,6 +85,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
{ channel_web_widget_id: inbox.channel.id } { channel_web_widget_id: inbox.channel.id }
end end
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
def set_current_page def set_current_page
@current_page = params[:page] || 1 @current_page = params[:page] || 1
end end
@@ -66,7 +66,9 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end end
def check_authorization def check_authorization
authorize :report, :view? return if Current.account_user.administrator?
raise Pundit::NotAuthorizedError
end end
def common_params def common_params
@@ -135,3 +137,5 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end end
end end
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
@@ -1,74 +0,0 @@
module InstagramConcern
extend ActiveSupport::Concern
def instagram_client
::OAuth2::Client.new(
client_id,
client_secret,
{
site: 'https://api.instagram.com',
authorize_url: 'https://api.instagram.com/oauth/authorize',
token_url: 'https://api.instagram.com/oauth/access_token',
auth_scheme: :request_body,
token_method: :post
}
)
end
private
def client_id
GlobalConfigService.load('INSTAGRAM_APP_ID', nil)
end
def client_secret
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
end
def exchange_for_long_lived_token(short_lived_token)
endpoint = 'https://graph.instagram.com/access_token'
params = {
grant_type: 'ig_exchange_token',
client_secret: client_secret,
access_token: short_lived_token,
client_id: client_id
}
make_api_request(endpoint, params, 'Failed to exchange token')
end
def fetch_instagram_user_details(access_token)
endpoint = 'https://graph.instagram.com/v22.0/me'
params = {
fields: 'id,username,user_id,name,profile_picture_url,account_type',
access_token: access_token
}
make_api_request(endpoint, params, 'Failed to fetch Instagram user details')
end
def make_api_request(endpoint, params, error_prefix)
response = HTTParty.get(
endpoint,
query: params,
headers: { 'Accept' => 'application/json' }
)
unless response.success?
Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
raise "#{error_prefix}: #{response.body}"
end
begin
JSON.parse(response.body)
rescue JSON::ParserError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Invalid JSON response: #{response.body}"
raise e
end
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
+1 -2
View File
@@ -36,7 +36,7 @@ class DashboardController < ActionController::Base
'LOGOUT_REDIRECT_LINK', 'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE', 'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV', 'DEPLOYMENT_ENV',
'INSTALLATION_PRICING_PLAN' 'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
).merge(app_config) ).merge(app_config)
end end
@@ -65,7 +65,6 @@ class DashboardController < ActionController::Base
VAPID_PUBLIC_KEY: VapidService.public_key, VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
@@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
def validate_business_account? def validate_business_account?
# return true if the user is a business account, false if it is a gmail account # return true if the user is a business account, false if it is a gmail account
auth_hash['info']['email'].downcase.exclude?('@gmail.com') auth_hash['info']['email'].exclude?('@gmail.com')
end end
def create_account_for_user def create_account_for_user
@@ -1,163 +0,0 @@
class Instagram::CallbacksController < ApplicationController
include InstagramConcern
include Instagram::IntegrationHelper
def show
# Check if Instagram redirected with an error (user canceled authorization)
# See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
if params[:error].present?
handle_authorization_error
return
end
process_successful_authorization
rescue StandardError => e
handle_error(e)
end
private
# Process the authorization code and create inbox
def process_successful_authorization
@response = instagram_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/#{provider_name}/callback",
grant_type: 'authorization_code'
)
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
end
end
# Handle all errors that might occur during authorization
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response
def handle_error(error)
Rails.logger.error("Instagram Channel creation Error: #{error.message}")
ChatwootExceptionTracker.new(error).capture_exception
error_info = extract_error_info(error)
redirect_to_error_page(error_info)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
begin
# Instagram returns JSON error response which we parse to extract error details
JSON.parse(error.message)
rescue JSON::ParseError
# Fall back to a generic OAuth error if JSON parsing fails
{ 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message }
end
else
# For other unexpected errors
{ 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message }
end
end
# Handles the case when a user denies permissions or cancels the authorization flow
# Error parameters are documented at:
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
def handle_authorization_error
error_info = {
'error_type' => params[:error] || 'authorization_error',
'code' => 400,
'error_message' => params[:error_description] || 'Authorization was denied'
}
Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}")
redirect_to_error_page(error_info)
end
# Centralized method to redirect to error page with appropriate parameters
# This ensures consistent error handling across different error scenarios
# Frontend will handle the error page based on the error_type
def redirect_to_error_page(error_info)
redirect_to app_new_instagram_inbox_url(
account_id: account_id,
error_type: error_info['error_type'],
code: error_info['code'],
error_message: error_info['error_message']
)
end
def find_or_create_inbox
user_details = fetch_instagram_user_details(@long_lived_token_response['access_token'])
channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s)
channel_exists = channel_instagram.present?
if channel_instagram
update_channel(channel_instagram, user_details)
else
channel_instagram = create_channel_with_inbox(user_details)
end
# reauthorize channel, this code path only triggers when instagram auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_instagram.reauthorized!
[channel_instagram.inbox, channel_exists]
end
def find_channel_by_instagram_id(instagram_id)
Channel::Instagram.find_by(instagram_id: instagram_id, account: account)
end
def update_channel(channel_instagram, user_details)
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram.update!(
access_token: @long_lived_token_response['access_token'],
expires_at: expires_at
)
# Update inbox name if username changed
channel_instagram.inbox.update!(name: user_details['username'])
channel_instagram
end
def create_channel_with_inbox(user_details)
ActiveRecord::Base.transaction do
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram = Channel::Instagram.create!(
access_token: @long_lived_token_response['access_token'],
instagram_id: user_details['user_id'].to_s,
account: account,
expires_at: expires_at
)
account.inboxes.create!(
account: account,
channel: channel_instagram,
name: user_details['username']
)
channel_instagram
end
end
def account_id
return unless params[:state]
verify_instagram_token(params[:state])
end
def oauth_code
params[:code]
end
def account
@account ||= Account.find(account_id)
end
def provider_name
'instagram'
end
end
@@ -43,8 +43,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
['MAILER_INBOUND_EMAIL_DOMAIN'] ['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear' when 'linear'
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET] %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
when 'instagram'
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
else else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS] %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end end
@@ -15,9 +15,6 @@ class Webhooks::InstagramController < ActionController::API
private private
def valid_token?(token) def valid_token?(token)
# Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
# INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') ||
token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '')
end end
end end
+1 -2
View File
@@ -81,8 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard
COLLECTION_FILTERS = { COLLECTION_FILTERS = {
active: ->(resources) { resources.where(status: :active) }, active: ->(resources) { resources.where(status: :active) },
suspended: ->(resources) { resources.where(status: :suspended) }, suspended: ->(resources) { resources.where(status: :suspended) },
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }, recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
marked_for_deletion: ->(resources) { resources.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") }
}.freeze }.freeze
# Overwrite this method to customize how accounts are displayed # Overwrite this method to customize how accounts are displayed
+1 -13
View File
@@ -32,7 +32,6 @@ class ConversationFinder
def initialize(current_user, params) def initialize(current_user, params)
@current_user = current_user @current_user = current_user
@current_account = current_user.account @current_account = current_user.account
@is_admin = current_account.account_users.find_by(user_id: current_user.id)&.administrator?
@params = params @params = params
end end
@@ -86,19 +85,8 @@ class ConversationFinder
@team = current_account.teams.find(params[:team_id]) if params[:team_id] @team = current_account.teams.find(params[:team_id]) if params[:team_id]
end end
def find_conversation_by_inbox
@conversations = current_account.conversations
@conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin
end
def find_all_conversations def find_all_conversations
find_conversation_by_inbox @conversations = current_account.conversations.where(inbox_id: @inbox_ids)
# Apply permission-based filtering
@conversations = Conversations::PermissionFilterService.new(
@conversations,
current_user,
current_account
).perform
filter_by_conversation_type if params[:conversation_type] filter_by_conversation_type if params[:conversation_type]
@conversations @conversations
end end
-4
View File
@@ -18,8 +18,4 @@ module BillingHelper
def non_web_inboxes(account) def non_web_inboxes(account)
account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count
end end
def agents(account)
account.users.count
end
end end
@@ -1,49 +0,0 @@
module Instagram::IntegrationHelper
REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze
# Generates a signed JWT token for Instagram integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_instagram_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
nil
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Instagram JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_instagram_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
end
private
def client_secret
@client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
nil
end
end
+3
View File
@@ -4,6 +4,7 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad
import LoadingState from './components/widgets/LoadingState.vue'; import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue'; import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable'; import vueActionCable from './helper/actionCable';
@@ -30,6 +31,7 @@ export default {
UpdateBanner, UpdateBanner,
PaymentPendingBanner, PaymentPendingBanner,
WootSnackbarBox, WootSnackbarBox,
UpgradeBanner,
PendingEmailVerificationBanner, PendingEmailVerificationBanner,
}, },
setup() { setup() {
@@ -144,6 +146,7 @@ export default {
<template v-if="currentAccountId"> <template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" /> <PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" /> <PaymentPendingBanner v-if="hideOnOnboardingView" />
<UpgradeBanner />
</template> </template>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
-17
View File
@@ -1,26 +1,9 @@
/* global axios */
import ApiClient from './ApiClient'; import ApiClient from './ApiClient';
class AgentBotsAPI extends ApiClient { class AgentBotsAPI extends ApiClient {
constructor() { constructor() {
super('agent_bots', { accountScoped: true }); super('agent_bots', { accountScoped: true });
} }
create(data) {
return axios.post(this.url, data, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
update(id, data) {
return axios.patch(`${this.url}/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`);
}
} }
export default new AgentBotsAPI(); export default new AgentBotsAPI();
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class InstagramChannel extends ApiClient {
constructor() {
super('instagram', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new InstagramChannel();
@@ -17,12 +17,6 @@ class EnterpriseAccountAPI extends ApiClient {
getLimits() { getLimits() {
return axios.get(`${this.url}limits`); return axios.get(`${this.url}limits`);
} }
toggleDeletion(action) {
return axios.post(`${this.url}toggle_deletion`, {
action_type: action,
});
}
} }
export default new EnterpriseAccountAPI(); export default new EnterpriseAccountAPI();
@@ -10,7 +10,6 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('update'); expect(accountAPI).toHaveProperty('update');
expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout'); expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
}); });
describe('API calls', () => { describe('API calls', () => {
@@ -43,21 +42,5 @@ describe('#enterpriseAccountAPI', () => {
'/enterprise/api/v1/subscription' '/enterprise/api/v1/subscription'
); );
}); });
it('#toggleDeletion with delete action', () => {
accountAPI.toggleDeletion('delete');
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/toggle_deletion',
{ action_type: 'delete' }
);
});
it('#toggleDeletion with undelete action', () => {
accountAPI.toggleDeletion('undelete');
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/toggle_deletion',
{ action_type: 'undelete' }
);
});
}); });
}); });
@@ -29,19 +29,6 @@
--iris-11: 87 83 198; --iris-11: 87 83 198;
--iris-12: 39 41 98; --iris-12: 39 41 98;
--blue-1: 251 253 255;
--blue-2: 245 249 255;
--blue-3: 233 243 255;
--blue-4: 218 236 255;
--blue-5: 201 226 255;
--blue-6: 181 213 255;
--blue-7: 155 195 252;
--blue-8: 117 171 247;
--blue-9: 39 129 246;
--blue-10: 16 115 233;
--blue-11: 8 109 224;
--blue-12: 11 50 101;
--ruby-1: 255 252 253; --ruby-1: 255 252 253;
--ruby-2: 255 247 248; --ruby-2: 255 247 248;
--ruby-3: 254 234 237; --ruby-3: 254 234 237;
@@ -144,19 +131,6 @@
--iris-11: 158 177 255; --iris-11: 158 177 255;
--iris-12: 224 223 254; --iris-12: 224 223 254;
--blue-1: 10 17 28;
--blue-2: 15 24 38;
--blue-3: 15 39 72;
--blue-4: 10 49 99;
--blue-5: 18 61 117;
--blue-6: 29 84 134;
--blue-7: 40 89 156;
--blue-8: 48 106 186;
--blue-9: 39 129 246;
--blue-10: 21 116 231;
--blue-11: 126 182 255;
--blue-12: 205 227 255;
--ruby-1: 25 17 19; --ruby-1: 25 17 19;
--ruby-2: 30 21 23; --ruby-2: 30 21 23;
--ruby-3: 58 20 30; --ruby-3: 58 20 30;
@@ -81,6 +81,28 @@
margin-left: var(--space-small); margin-left: var(--space-small);
} }
} }
// Conversation sidebar close button
.close-button--rtl {
transform: rotate(180deg);
}
// Resolve actions button
.resolve-actions {
.button-group .button:first-child {
border-bottom-left-radius: 0;
border-bottom-right-radius: var(--border-radius-normal);
border-top-left-radius: 0;
border-top-right-radius: var(--border-radius-normal);
}
.button-group .button:last-child {
border-bottom-left-radius: var(--border-radius-normal);
border-bottom-right-radius: 0;
border-top-left-radius: var(--border-radius-normal);
border-top-right-radius: 0;
}
}
} }
// Conversation list // Conversation list
@@ -155,6 +177,71 @@
} }
} }
// Help center
.article-container .row--article-block {
td:last-child {
direction: initial;
}
}
.portal-popover__container .portal {
.actions-container {
margin-left: unset;
margin-right: var(--space-one);
}
}
.edit-article--container {
.header-right--wrap {
.button-group .button:first-child {
border-bottom-left-radius: 0;
border-bottom-right-radius: var(--border-radius-normal);
border-top-left-radius: 0;
border-top-right-radius: var(--border-radius-normal);
}
.button-group .button:last-child {
border-bottom-left-radius: var(--border-radius-normal);
border-bottom-right-radius: 0;
border-top-left-radius: var(--border-radius-normal);
border-top-right-radius: 0;
}
}
.header-left--wrap {
.back-button {
direction: initial;
}
}
.article--buttons {
.dropdown-pane {
left: 0;
position: absolute;
right: unset;
}
}
.sidebar-button {
transform: rotate(180deg);
}
}
.article-settings--container {
border-left: 0;
border-right: 1px solid var(--color-border-light);
flex-direction: row-reverse;
margin-left: 0;
margin-right: var(--space-normal);
padding-left: 0;
padding-right: var(--space-normal);
}
.category-list--container .header-left--wrap {
direction: initial;
justify-content: flex-end;
}
// Toggle switch // Toggle switch
.toggle-button { .toggle-button {
&.small { &.small {
@@ -177,6 +264,11 @@
} }
} }
// Widget builder
.widget-builder-container .widget-preview {
direction: initial;
}
// Modal // Modal
.modal-container { .modal-container {
text-align: right; text-align: right;
@@ -190,6 +282,7 @@
} }
// Other changes // Other changes
.colorpicker--chrome { .colorpicker--chrome {
direction: initial; direction: initial;
} }
@@ -198,6 +291,14 @@
direction: initial; direction: initial;
} }
.contact--details .contact--bio {
direction: ltr;
}
.merge-contacts .child-contact-wrap {
direction: ltr;
}
.contact--form .input-group { .contact--form .input-group {
direction: initial; direction: initial;
} }
@@ -29,6 +29,7 @@
@import 'rtl'; @import 'rtl';
@import 'widgets/base'; @import 'widgets/base';
@import 'widgets/buttons';
@import 'widgets/conversation-view'; @import 'widgets/conversation-view';
@import 'widgets/tabs'; @import 'widgets/tabs';
@import 'widgets/woot-tables'; @import 'widgets/woot-tables';
@@ -40,12 +40,6 @@ dl:not(.reset-base) {
@apply mb-0; @apply mb-0;
} }
// Button base
button {
font-family: inherit;
@apply inline-block text-center align-middle cursor-pointer text-sm m-0 py-1 px-2.5 transition-all duration-200 ease-in-out border-0 border-none rounded-lg disabled:opacity-50;
}
// Form elements // Form elements
// ------------------------- // -------------------------
label { label {
@@ -0,0 +1,228 @@
// scss-lint:disable SpaceAfterPropertyColon
// scss-lint:disable MergeableSelector
button {
font-family: inherit;
transition:
background-color 0.25s ease-out,
color 0.25s ease-out;
@apply inline-block items-center mb-0 text-center align-middle cursor-pointer text-sm mt-0 mx-0 py-1 px-2.5 border border-solid border-transparent dark:border-transparent rounded-[0.3125rem];
&:disabled,
&.disabled {
@apply opacity-40 cursor-not-allowed;
}
}
.button-group {
@apply mb-0 flex flex-nowrap items-stretch;
.button {
flex: 0 0 auto;
@apply m-0 text-sm rounded-none first:rounded-tl-[0.3125rem] first:rounded-bl-[0.3125rem] last:rounded-tr-[0.3125rem] last:rounded-br-[0.3125rem] rtl:space-x-reverse;
}
.button--only-icon {
@apply w-10 justify-center pl-0 pr-0;
}
}
.back-button {
@apply m-0;
}
.button {
@apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
.button__content {
@apply w-full whitespace-nowrap overflow-hidden text-ellipsis;
img,
svg {
@apply inline-block;
}
}
&:hover:not(:disabled):not(.success):not(.alert):not(.warning):not(
.clear
):not(.smooth):not(.hollow) {
@apply bg-n-brand/80 dark:bg-n-brand/80;
}
&:disabled,
&.disabled {
@apply opacity-40 cursor-not-allowed;
}
&.success {
@apply bg-n-teal-9 text-white dark:text-white;
}
&.secondary {
@apply bg-n-solid-3 text-white dark:text-white;
}
&.primary {
@apply bg-n-brand text-white dark:text-white;
}
&.clear {
@apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent;
}
&.alert {
@apply bg-n-ruby-9 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
}
}
&.warning {
@apply bg-n-amber-9 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
}
}
&.tiny {
@apply h-6 text-[10px];
}
&.small {
@apply h-8 text-xs;
}
.spinner {
@apply px-2 py-0;
}
// @TODDO - Remove after moving all buttons to woot-button
.icon + .button__content {
@apply w-auto;
}
&.expanded {
@apply flex justify-center text-center;
}
&.round {
@apply rounded-full;
}
// @TODO Use with link
&.compact {
@apply pb-0 pt-0;
}
&.hollow {
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:enabled:bg-n-brand/20;
&.secondary {
@apply text-n-slate-12 border-n-slate-5 hover:enabled:bg-n-slate-5;
}
&.success {
@apply text-n-teal-9 border-n-teal-8 hover:enabled:bg-n-teal-5;
}
&.alert {
@apply text-n-ruby-9 border-n-ruby-8 hover:enabled:bg-n-ruby-5;
}
&.warning {
@apply text-n-amber-9 border-n-amber-8 hover:enabled:bg-n-amber-5;
}
}
// Smooth style
&.smooth {
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:enabled:bg-n-brand/20 dark:hover:enabled:bg-n-brand/40;
&.secondary {
@apply bg-n-slate-4 text-n-slate-11 hover:enabled:text-n-slate-11 hover:enabled:bg-n-slate-5;
}
&.success {
@apply bg-n-teal-4 text-n-teal-11 hover:enabled:text-n-teal-11 hover:enabled:bg-n-teal-5;
}
&.alert {
@apply bg-n-ruby-4 text-n-ruby-11 hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-5;
}
&.warning {
@apply bg-n-amber-4 text-n-amber-11 hover:enabled:text-n-amber-11 hover:enabled:bg-n-amber-5;
}
}
&.clear {
@apply text-n-blue-text hover:enabled:bg-n-brand/10 dark:hover:enabled:bg-n-brand/30;
&.secondary {
@apply text-n-slate-12 hover:enabled:bg-n-slate-4;
}
&.success {
@apply text-n-teal-10 hover:enabled:bg-n-teal-4;
}
&.alert {
@apply text-n-ruby-11 hover:enabled:bg-n-ruby-4;
}
&.warning {
@apply text-n-amber-11 hover:enabled:bg-n-amber-4;
}
&:active {
&.secondary {
@apply active:bg-n-slate-3 dark:active:bg-n-slate-7;
}
}
&:focus {
&.secondary {
@apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6;
}
}
}
// Sizes
&.tiny {
@apply h-6;
}
&.small {
@apply h-8 pb-1 pt-1;
}
&.large {
@apply h-12;
}
&.button--only-icon {
@apply justify-center pl-0 pr-0 w-10;
&.tiny {
@apply w-6;
}
&.small {
@apply w-8;
}
&.large {
@apply w-12;
}
}
&.link {
@apply h-auto m-0 p-0;
&:hover {
@apply underline;
}
}
}
@@ -51,7 +51,6 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
<Button <Button
:label="t('DIALOG.BUTTONS.CANCEL')" :label="t('DIALOG.BUTTONS.CANCEL')"
variant="link" variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand" class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog" @click="closeDialog"
/> />
@@ -31,6 +31,10 @@ const sortMenus = [
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'), label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
value: 'email', value: 'email',
}, },
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
value: 'phone_number',
},
{ {
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'), label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
value: 'company_name', value: 'company_name',
@@ -1,5 +1,3 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
@@ -33,8 +33,6 @@ const insertIntoRichEditor = computed(() => {
); );
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => { const useCopilotResponse = () => {
if (insertIntoRichEditor.value) { if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content); emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -55,17 +53,9 @@ const useCopilotResponse = () => {
/> />
<div class="flex flex-col gap-1 text-n-slate-12"> <div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div> <div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11"> <div v-dompurify-html="messageContent" class="prose-sm break-words" />
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
</span>
<div
v-else
v-dompurify-html="messageContent"
class="prose-sm break-words"
/>
<div class="flex flex-row mt-1"> <div class="flex flex-row mt-1">
<Button <Button
v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')" :label="$t('CAPTAIN.COPILOT.USE')"
faded faded
sm sm
@@ -125,10 +125,7 @@ defineExpose({ open, close });
<slot /> <slot />
<!-- Dialog content will be injected here --> <!-- Dialog content will be injected here -->
<slot name="footer"> <slot name="footer">
<div <div class="flex items-center justify-between w-full gap-3">
v-if="showCancelButton || showConfirmButton"
class="flex items-center justify-between w-full gap-3"
>
<Button <Button
v-if="showCancelButton" v-if="showCancelButton"
variant="faded" variant="faded"
@@ -103,7 +103,7 @@ export default {
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }} {{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
</h3> </h3>
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews"> <form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
<label :class="{ error: v$.name.$error }"> <div>
<input <input
v-model="name" v-model="name"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full" class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
@@ -116,14 +116,14 @@ export default {
> >
{{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }} {{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }}
</span> </span>
</label> </div>
<div class="flex flex-row justify-end w-full gap-2"> <div class="flex flex-row justify-end w-full gap-2">
<NextButton sm solid blue :disabled="isButtonDisabled">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON') }}
</NextButton>
<NextButton faded slate sm @click.prevent="onClose"> <NextButton faded slate sm @click.prevent="onClose">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }} {{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
</NextButton> </NextButton>
<NextButton solid blue sm :disabled="isButtonDisabled">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON') }}
</NextButton>
</div> </div>
</form> </form>
</div> </div>
@@ -12,7 +12,6 @@ export function useChannelIcon(inbox) {
'Channel::TwitterProfile': 'i-ri-twitter-x-fill', 'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill', 'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill', 'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
}; };
const providerIconMap = { const providerIconMap = {
@@ -19,17 +19,10 @@ const {
isAWebWidgetInbox, isAWebWidgetInbox,
isAWhatsAppChannel, isAWhatsAppChannel,
isAnEmailChannel, isAnEmailChannel,
isAnInstagramChannel,
} = useInbox(); } = useInbox();
const { const { status, isPrivate, createdAt, sourceId, messageType } =
status, useMessageContext();
isPrivate,
createdAt,
sourceId,
messageType,
contentAttributes,
} = useMessageContext();
const readableTime = computed(() => const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a') messageTimestamp(createdAt.value, 'LLL d, h:mm a')
@@ -37,11 +30,6 @@ const readableTime = computed(() =>
const showStatusIndicator = computed(() => { const showStatusIndicator = computed(() => {
if (isPrivate.value) return false; if (isPrivate.value) return false;
// Don't show status for failed messages, we already show error message
if (status.value === MESSAGE_STATUS.FAILED) return false;
// Don't show status for deleted messages
if (contentAttributes.value?.deleted) return false;
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true; if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true; if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
@@ -59,8 +47,7 @@ const isSent = computed(() => {
isATwilioChannel.value || isATwilioChannel.value ||
isAFacebookInbox.value || isAFacebookInbox.value ||
isASmsInbox.value || isASmsInbox.value ||
isATelegramChannel.value || isATelegramChannel.value
isAnInstagramChannel.value
) { ) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT; return sourceId.value && status.value === MESSAGE_STATUS.SENT;
} }
@@ -99,8 +86,7 @@ const isRead = computed(() => {
if ( if (
isAWhatsAppChannel.value || isAWhatsAppChannel.value ||
isATwilioChannel.value || isATwilioChannel.value ||
isAFacebookInbox.value || isAFacebookInbox.value
isAnInstagramChannel.value
) { ) {
return sourceId.value && status.value === MESSAGE_STATUS.READ; return sourceId.value && status.value === MESSAGE_STATUS.READ;
} }
@@ -116,6 +102,7 @@ const statusToShow = computed(() => {
if (isRead.value) return MESSAGE_STATUS.READ; if (isRead.value) return MESSAGE_STATUS.READ;
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED; if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
if (isSent.value) return MESSAGE_STATUS.SENT; if (isSent.value) return MESSAGE_STATUS.SENT;
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
return MESSAGE_STATUS.PROGRESS; return MESSAGE_STATUS.PROGRESS;
}); });
@@ -1,24 +0,0 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
showingOriginal: Boolean,
});
defineEmits(['toggle']);
</script>
<template>
<span>
<span
class="text-xs text-n-slate-11 cursor-pointer hover:underline select-none"
@click="$emit('toggle')"
>
{{
showingOriginal
? $t('CONVERSATION.VIEW_TRANSLATED')
: $t('CONVERSATION.VIEW_ORIGINAL')
}}
</span>
</span>
</template>
@@ -9,11 +9,9 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue'; import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue'; import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { useMessageContext } from '../../provider.js'; import { useMessageContext } from '../../provider.js';
import { MESSAGE_TYPES } from 'next/message/constants.js'; import { MESSAGE_TYPES } from 'next/message/constants.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, contentAttributes, attachments, messageType } = const { content, contentAttributes, attachments, messageType } =
useMessageContext(); useMessageContext();
@@ -21,77 +19,35 @@ const { content, contentAttributes, attachments, messageType } =
const isExpandable = ref(false); const isExpandable = ref(false);
const isExpanded = ref(false); const isExpanded = ref(false);
const showQuotedMessage = ref(false); const showQuotedMessage = ref(false);
const renderOriginal = ref(false);
const contentContainer = useTemplateRef('contentContainer'); const contentContainer = useTemplateRef('contentContainer');
onMounted(() => { onMounted(() => {
isExpandable.value = contentContainer.value?.scrollHeight > 400; isExpandable.value = contentContainer.value?.scrollHeight > 400;
}); });
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING); const isOutgoing = computed(() => {
return messageType.value === MESSAGE_TYPES.OUTGOING;
});
const isIncoming = computed(() => !isOutgoing.value); const isIncoming = computed(() => !isOutgoing.value);
const { hasTranslations, translationContent } = const textToShow = computed(() => {
useTranslations(contentAttributes);
const originalEmailText = computed(() => {
const text = const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value; contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>'); return text?.replace(/\n/g, '<br>');
}); });
const originalEmailHtml = computed( // Use TextContent as the default to fullHTML
() =>
contentAttributes?.value?.email?.htmlContent?.full ??
originalEmailText.value
);
const messageContent = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original content
return content.value;
});
const textToShow = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original text
return originalEmailText.value;
});
const fullHTML = computed(() => { const fullHTML = computed(() => {
// If translations exist and we're showing translations (not original) return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original HTML
return originalEmailHtml.value;
}); });
const unquotedHTML = computed(() => const unquotedHTML = computed(() => {
EmailQuoteExtractor.extractQuotes(fullHTML.value) return EmailQuoteExtractor.extractQuotes(fullHTML.value);
);
const hasQuotedMessage = computed(() =>
EmailQuoteExtractor.hasQuotes(fullHTML.value)
);
// Ensure unique keys for <Letter> when toggling between original and translated views.
// This forces Vue to re-render the component and update content correctly.
const translationKeySuffix = computed(() => {
if (renderOriginal.value) return 'original';
if (hasTranslations.value) return 'translated';
return 'original';
}); });
const handleSeeOriginal = () => { const hasQuotedMessage = computed(() => {
renderOriginal.value = !renderOriginal.value; return EmailQuoteExtractor.hasQuotes(fullHTML.value);
}; });
</script> </script>
<template> <template>
@@ -119,7 +75,7 @@ const handleSeeOriginal = () => {
> >
<div <div
v-if="isExpandable && !isExpanded" v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent" class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-gray-3 via-n-gray-3 via-20% to-transparent"
> >
<button <button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2" class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@@ -132,12 +88,11 @@ const handleSeeOriginal = () => {
<FormattedContent <FormattedContent
v-if="isOutgoing && content" v-if="isOutgoing && content"
class="text-n-slate-12" class="text-n-slate-12"
:content="messageContent" :content="content"
/> />
<template v-else> <template v-else>
<Letter <Letter
v-if="showQuotedMessage" v-if="showQuotedMessage"
:key="`letter-quoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render" class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[ :allowed-css-properties="[
...allowedCssProperties, ...allowedCssProperties,
@@ -149,7 +104,6 @@ const handleSeeOriginal = () => {
/> />
<Letter <Letter
v-else v-else
:key="`letter-unquoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render" class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHTML" :html="unquotedHTML"
:allowed-css-properties="[ :allowed-css-properties="[
@@ -181,12 +135,6 @@ const handleSeeOriginal = () => {
</button> </button>
</div> </div>
</section> </section>
<TranslationToggle
v-if="hasTranslations"
class="py-2 px-3"
:showing-original="renderOriginal"
@toggle="handleSeeOriginal"
/>
<section <section
v-if="Array.isArray(attachments) && attachments.length" v-if="Array.isArray(attachments) && attachments.length"
class="px-4 pb-4 space-y-2" class="px-4 pb-4 space-y-2"
@@ -3,16 +3,16 @@ import { computed, ref } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue'; import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue'; import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { MESSAGE_TYPES } from '../../constants'; import { MESSAGE_TYPES } from '../../constants';
import { useMessageContext } from '../../provider.js'; import { useMessageContext } from '../../provider.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, attachments, contentAttributes, messageType } = const { content, attachments, contentAttributes, messageType } =
useMessageContext(); useMessageContext();
const { hasTranslations, translationContent } = const hasTranslations = computed(() => {
useTranslations(contentAttributes); const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const renderOriginal = ref(false); const renderOriginal = ref(false);
@@ -22,7 +22,8 @@ const renderContent = computed(() => {
} }
if (hasTranslations.value) { if (hasTranslations.value) {
return translationContent.value; const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
} }
return content.value; return content.value;
@@ -36,6 +37,12 @@ const isEmpty = computed(() => {
return !content.value && !attachments.value?.length; return !content.value && !attachments.value?.length;
}); });
const viewToggleKey = computed(() => {
return renderOriginal.value
? 'CONVERSATION.VIEW_TRANSLATED'
: 'CONVERSATION.VIEW_ORIGINAL';
});
const handleSeeOriginal = () => { const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value; renderOriginal.value = !renderOriginal.value;
}; };
@@ -48,12 +55,15 @@ const handleSeeOriginal = () => {
{{ $t('CONVERSATION.NO_CONTENT') }} {{ $t('CONVERSATION.NO_CONTENT') }}
</span> </span>
<FormattedContent v-if="renderContent" :content="renderContent" /> <FormattedContent v-if="renderContent" :content="renderContent" />
<TranslationToggle <span class="-mt-3">
v-if="hasTranslations" <span
class="-mt-3" v-if="hasTranslations"
:showing-original="renderOriginal" class="text-xs text-n-slate-11 cursor-pointer hover:underline"
@toggle="handleSeeOriginal" @click="handleSeeOriginal"
/> >
{{ $t(viewToggleKey) }}
</span>
</span>
<AttachmentChips :attachments="attachments" class="gap-2" /> <AttachmentChips :attachments="attachments" class="gap-2" />
<template v-if="isTemplate"> <template v-if="isTemplate">
<div <div
@@ -39,7 +39,7 @@ const textColorClass = computed(() => {
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12 docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
json: 'text-n-slate-12', json: 'text-n-slate-12',
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12 odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
pdf: 'text-n-slate-12', pdf: 'text-n-ruby-12',
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]', ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]', pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
rar: 'dark:text-[#EDEEF0] text-[#2F265F]', rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts'; import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue'; import SidebarGroup from './SidebarGroup.vue';
@@ -36,6 +37,18 @@ const toggleShortcutModalFn = show => {
} }
}; };
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showV4Routes = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.REPORT_V4
);
});
useSidebarKeyboardShortcuts(toggleShortcutModalFn); useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar // We're using localStorage to store the expanded item in the sidebar
@@ -103,7 +116,32 @@ const newReportRoutes = () => [
}, },
]; ];
const reportRoutes = computed(() => newReportRoutes()); const oldReportRoutes = () => [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
];
const reportRoutes = computed(() =>
showV4Routes.value ? newReportRoutes() : oldReportRoutes()
);
const menuItems = computed(() => { const menuItems = computed(() => {
return [ return [
@@ -26,12 +26,6 @@ const showAccountSwitcher = computed(
() => userAccounts.value.length > 1 && currentAccount.value.name () => userAccounts.value.length > 1 && currentAccount.value.name
); );
const sortedCurrentUserAccounts = computed(() => {
return [...(currentUser.value.accounts || [])].sort((a, b) =>
a.name.localeCompare(b.name)
);
});
const onChangeAccount = newId => { const onChangeAccount = newId => {
const accountUrl = `/app/accounts/${newId}/dashboard`; const accountUrl = `/app/accounts/${newId}/dashboard`;
window.location.href = accountUrl; window.location.href = accountUrl;
@@ -76,7 +70,7 @@ const emitNewAccount = () => {
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50"> <DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')"> <DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
<DropdownItem <DropdownItem
v-for="account in sortedCurrentUserAccounts" v-for="account in currentUser.accounts"
:id="`account-${account.id}`" :id="`account-${account.id}`"
:key="account.id" :key="account.id"
class="cursor-pointer" class="cursor-pointer"
@@ -35,7 +35,7 @@ const onToggle = () => {
<template> <template>
<div class="text-sm"> <div class="text-sm">
<button <button
class="flex items-center select-none w-full rounded-lg bg-n-slate-2 outline outline-1 outline-n-weak m-0 cursor-grab justify-between py-2 px-4 drag-handle" class="flex items-center select-none w-full rounded-lg bg-n-slate-2 border border-n-weak m-0 cursor-grab justify-between py-2 px-4 drag-handle"
:class="{ 'rounded-bl-none rounded-br-none': isOpen }" :class="{ 'rounded-bl-none rounded-br-none': isOpen }"
@click.stop="onToggle" @click.stop="onToggle"
> >
@@ -55,7 +55,7 @@ const onToggle = () => {
</button> </button>
<div <div
v-if="isOpen" v-if="isOpen"
class="bg-n-background outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg" class="bg-n-background border border-n-weak dark:border-n-slate-2 border-t-0 rounded-br-lg rounded-bl-lg"
:class="compact ? 'p-0' : 'px-2 py-4'" :class="compact ? 'p-0' : 'px-2 py-4'"
> >
<slot /> <slot />
@@ -0,0 +1,64 @@
<script>
import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
export default {
props: {
value: {
type: String,
default: '',
},
},
data() {
return {
masked: true,
};
},
methods: {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
toggleMasked() {
this.masked = !this.masked;
},
},
};
</script>
<template>
<div class="text--container">
<woot-button size="small" class="button--text" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</woot-button>
<woot-button
variant="clear"
size="small"
class="button--visibility"
color-scheme="secondary"
:icon="masked ? 'eye-show' : 'eye-hide'"
@click.prevent="toggleMasked"
/>
<highlightjs v-if="value" :code="masked ? '•'.repeat(10) : value" />
</div>
</template>
<style lang="scss" scoped>
.text--container {
position: relative;
text-align: left;
.button--text,
.button--visibility {
margin-top: 0;
position: absolute;
right: 0;
}
.button--visibility {
right: 60px;
}
}
</style>
@@ -3,16 +3,12 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: {
NextButton,
},
props: { props: {
size: { size: {
type: String, type: String,
default: 'sm', default: 'small',
}, },
}, },
computed: { computed: {
@@ -37,13 +33,13 @@ export default {
<!-- eslint-disable-next-line vue/no-root-v-if --> <!-- eslint-disable-next-line vue/no-root-v-if -->
<template> <template>
<NextButton <woot-button
v-if="!hasNextSidebar" v-if="!hasNextSidebar"
ghost
slate
:size="size" :size="size"
icon="i-lucide-menu" variant="clear"
class="-ml-3" color-scheme="secondary"
class="-ml-3 text-black-900 dark:text-slate-300"
icon="list"
@click="onMenuItemClick" @click="onMenuItemClick"
/> />
</template> </template>
@@ -35,7 +35,7 @@ export default {
color-scheme="alert" color-scheme="alert"
:banner-message="bannerMessage" :banner-message="bannerMessage"
:action-button-label="actionButtonMessage" :action-button-label="actionButtonMessage"
action-button-icon="i-lucide-mail" action-button-icon="mail"
has-action-button has-action-button
@primary-action="resendVerificationEmail" @primary-action="resendVerificationEmail"
/> />
@@ -0,0 +1,52 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
Spinner,
},
props: {
isLoading: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
buttonIconClass: {
type: String,
default: '',
},
type: {
type: String,
default: 'button',
},
variant: {
type: String,
default: 'primary',
},
},
created() {
if (import.meta.env.DEV) {
// eslint-disable-next-line
console.warn(
'[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead'
);
}
},
};
</script>
<template>
<button :type="type" class="button nice" :class="variant">
<fluent-icon
v-if="!isLoading && icon"
class="icon"
:class="buttonIconClass"
:icon="icon"
/>
<Spinner v-if="isLoading" />
<slot />
</button>
</template>
@@ -0,0 +1,66 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
Spinner,
},
props: {
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
buttonText: {
type: String,
default: '',
},
buttonClass: {
type: String,
default: '',
},
iconClass: {
type: String,
default: '',
},
spinnerClass: {
type: String,
default: '',
},
type: {
type: String,
default: 'submit',
},
},
computed: {
computedClass() {
return `button nice gap-2 ${this.buttonClass || ' '}`;
},
},
};
</script>
<template>
<button
:type="type"
data-testid="submit_button"
:disabled="disabled"
:class="computedClass"
>
<fluent-icon v-if="!!iconClass" :icon="iconClass" class="icon" />
<span>{{ buttonText }}</span>
<Spinner v-if="loading" class="ml-2" :color-scheme="spinnerClass" />
</button>
</template>
<style lang="scss" scoped>
button:disabled {
@apply bg-woot-100 dark:bg-woot-500/25 dark:text-slate-500 opacity-100;
&:hover {
@apply bg-woot-100 dark:bg-woot-500/25;
}
}
</style>
@@ -134,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template> <template>
<div class="relative flex items-center justify-end resolve-actions"> <div class="relative flex items-center justify-end resolve-actions">
<div <div
class="rounded-lg shadow outline-1 outline" class="rounded-lg shadow button-group outline-1 outline"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'" :class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
> >
<Button <Button
@@ -1,6 +1,7 @@
// [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file. // [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file.
/* eslint no-plusplus: 0 */ /* eslint no-plusplus: 0 */
import AvatarUploader from './widgets/forms/AvatarUploader.vue'; import AvatarUploader from './widgets/forms/AvatarUploader.vue';
import Button from './ui/WootButton.vue';
import Code from './Code.vue'; import Code from './Code.vue';
import ColorPicker from './widgets/ColorPicker.vue'; import ColorPicker from './widgets/ColorPicker.vue';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue'; import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
@@ -17,6 +18,7 @@ import ModalHeader from './ModalHeader.vue';
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import SidemenuIcon from './SidemenuIcon.vue'; import SidemenuIcon from './SidemenuIcon.vue';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import SubmitButton from './buttons/FormSubmitButton.vue';
import Tabs from './ui/Tabs/Tabs.vue'; import Tabs from './ui/Tabs/Tabs.vue';
import TabsItem from './ui/Tabs/TabsItem.vue'; import TabsItem from './ui/Tabs/TabsItem.vue';
import Thumbnail from './widgets/Thumbnail.vue'; import Thumbnail from './widgets/Thumbnail.vue';
@@ -24,6 +26,7 @@ import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = { const WootUIKit = {
AvatarUploader, AvatarUploader,
Button,
Code, Code,
ColorPicker, ColorPicker,
ConfirmDeleteModal, ConfirmDeleteModal,
@@ -40,6 +43,7 @@ const WootUIKit = {
ModalHeader, ModalHeader,
SidemenuIcon, SidemenuIcon,
Spinner, Spinner,
SubmitButton,
Tabs, Tabs,
TabsItem, TabsItem,
Thumbnail, Thumbnail,
@@ -7,7 +7,6 @@ import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue'; import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge.vue'; import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { AVAILABILITY_STATUS_KEYS } = wootConstants; const { AVAILABILITY_STATUS_KEYS } = wootConstants;
@@ -18,7 +17,6 @@ export default {
WootDropdownMenu, WootDropdownMenu,
WootDropdownItem, WootDropdownItem,
AvailabilityStatusBadge, AvailabilityStatusBadge,
NextButton,
}, },
data() { data() {
return { return {
@@ -103,21 +101,19 @@ export default {
:key="status.value" :key="status.value"
class="flex items-baseline" class="flex items-baseline"
> >
<NextButton <woot-button
sm size="small"
:color="status.disabled ? 'blue' : 'slate'" :color-scheme="status.disabled ? '' : 'secondary'"
:variant="status.disabled ? 'faded' : 'ghost'" :variant="status.disabled ? 'smooth' : 'clear'"
class="status-change--dropdown-button !w-full !justify-start" class="status-change--dropdown-button"
@click="changeAvailabilityStatus(status.value)" @click="changeAvailabilityStatus(status.value)"
> >
<AvailabilityStatusBadge :status="status.value" /> <AvailabilityStatusBadge :status="status.value" />
<span class="min-w-0 truncate font-medium text-xs"> {{ status.label }}
{{ status.label }} </woot-button>
</span>
</NextButton>
</WootDropdownItem> </WootDropdownItem>
<WootDropdownDivider /> <WootDropdownDivider />
<WootDropdownItem class="flex items-center justify-between px-3 py-2 m-0"> <WootDropdownItem class="flex items-center justify-between p-2 m-0">
<div class="flex items-center"> <div class="flex items-center">
<fluent-icon <fluent-icon
v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')" v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')"
@@ -127,7 +123,7 @@ export default {
/> />
<span <span
class="mx-2 my-0 text-xs font-medium text-slate-600 dark:text-slate-100" class="mx-1 my-0 text-xs font-medium text-slate-600 dark:text-slate-100"
> >
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }} {{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
</span> </span>
@@ -127,6 +127,7 @@ const settings = accountId => ({
meta: { meta: {
permissions: ['administrator'], permissions: ['administrator'],
}, },
globalConfigFlag: 'csmlEditorHost',
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`), toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots', toStateName: 'agent_bots',
featureFlag: FEATURE_FLAGS.AGENT_BOTS, featureFlag: FEATURE_FLAGS.AGENT_BOTS,
@@ -1,11 +1,7 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: {
NextButton,
},
emits: ['toggleAccounts'], emits: ['toggleAccounts'],
data() { data() {
return { showSwitchButton: false }; return { showSwitchButton: false };
@@ -50,13 +46,14 @@ export default {
class="absolute top-0 right-0 flex items-center justify-end w-full h-full rounded-md ltr:overlay-shadow ltr:dark:overlay-shadow-dark rtl:rtl-overlay-shadow rtl:dark:rtl-overlay-shadow-dark" class="absolute top-0 right-0 flex items-center justify-end w-full h-full rounded-md ltr:overlay-shadow ltr:dark:overlay-shadow-dark rtl:rtl-overlay-shadow rtl:dark:rtl-overlay-shadow-dark"
> >
<div class="mx-2 my-0"> <div class="mx-2 my-0">
<NextButton <woot-button
ghost variant="clear"
xs size="tiny"
icon="i-lucide-arrow-right-left" icon="arrow-swap"
:label="$t('SIDEBAR.SWITCH')"
@click="$emit('toggleAccounts')" @click="$emit('toggleAccounts')"
/> >
{{ $t('SIDEBAR.SWITCH') }}
</woot-button>
</div> </div>
</div> </div>
</transition> </transition>
@@ -3,12 +3,8 @@ import { required, minLength } from '@vuelidate/validators';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: {
NextButton,
},
props: { props: {
show: { show: {
type: Boolean, type: Boolean,
@@ -90,24 +86,19 @@ export default {
/> />
</label> </label>
</div> </div>
<div class="w-full flex justify-end gap-2 items-center"> <div class="w-full">
<NextButton <div class="w-full">
faded <woot-submit-button
slate :disabled="
type="reset" v$.accountName.$invalid ||
:label="$t('CREATE_ACCOUNT.FORM.CANCEL')" v$.accountName.$invalid ||
@click.prevent="() => $emit('closeAccountCreateModal')" uiFlags.isCreating
/> "
<NextButton :button-text="$t('CREATE_ACCOUNT.FORM.SUBMIT')"
type="submit" :loading="uiFlags.isCreating"
:label="$t('CREATE_ACCOUNT.FORM.SUBMIT')" button-class="large expanded"
:is-loading="uiFlags.isCreating" />
:disabled=" </div>
v$.accountName.$invalid ||
v$.accountName.$invalid ||
uiFlags.isCreating
"
/>
</div> </div>
</form> </form>
</div> </div>
@@ -1,12 +1,10 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Thumbnail from '../../widgets/Thumbnail.vue'; import Thumbnail from '../../widgets/Thumbnail.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
Thumbnail, Thumbnail,
NextButton,
}, },
emits: ['toggleMenu'], emits: ['toggleMenu'],
computed: { computed: {
@@ -27,10 +25,10 @@ export default {
</script> </script>
<template> <template>
<NextButton <woot-button
v-tooltip.right="$t(`SIDEBAR.PROFILE_SETTINGS`)" v-tooltip.right="$t(`SIDEBAR.PROFILE_SETTINGS`)"
link variant="link"
class="rounded-full" class="flex items-center rounded-full"
@click="handleClick" @click="handleClick"
> >
<Thumbnail <Thumbnail
@@ -39,7 +37,6 @@ export default {
:status="statusOfAgent" :status="statusOfAgent"
should-show-status-always should-show-status-always
size="32px" size="32px"
class="flex-shrink-0"
/> />
</NextButton> </woot-button>
</template> </template>
@@ -5,14 +5,12 @@ import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus.vue'; import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus.vue';
import { FEATURE_FLAGS } from '../../../featureFlags'; import { FEATURE_FLAGS } from '../../../featureFlags';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
WootDropdownMenu, WootDropdownMenu,
WootDropdownItem, WootDropdownItem,
AvailabilityStatus, AvailabilityStatus,
NextButton,
}, },
props: { props: {
show: { show: {
@@ -84,46 +82,37 @@ export default {
<AvailabilityStatus /> <AvailabilityStatus />
<WootDropdownMenu> <WootDropdownMenu>
<WootDropdownItem v-if="showChangeAccountOption"> <WootDropdownItem v-if="showChangeAccountOption">
<NextButton <woot-button
ghost variant="clear"
sm color-scheme="secondary"
slate size="small"
icon="i-lucide-arrow-right-left" icon="arrow-swap"
class="!w-full !justify-start"
@click="$emit('toggleAccounts')" @click="$emit('toggleAccounts')"
> >
<span class="min-w-0 truncate font-medium text-xs"> {{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }} </woot-button>
</span>
</NextButton>
</WootDropdownItem> </WootDropdownItem>
<WootDropdownItem v-if="showChatSupport"> <WootDropdownItem v-if="showChatSupport">
<NextButton <woot-button
ghost variant="clear"
sm color-scheme="secondary"
slate size="small"
icon="i-lucide-message-circle-question" icon="chat-help"
class="!w-full !justify-start"
@click="$emit('showSupportChatWindow')" @click="$emit('showSupportChatWindow')"
> >
<span class="min-w-0 truncate font-medium text-xs"> {{ $t('SIDEBAR_ITEMS.CONTACT_SUPPORT') }}
{{ $t('SIDEBAR_ITEMS.CONTACT_SUPPORT') }} </woot-button>
</span>
</NextButton>
</WootDropdownItem> </WootDropdownItem>
<WootDropdownItem> <WootDropdownItem>
<NextButton <woot-button
ghost variant="clear"
sm color-scheme="secondary"
slate size="small"
icon="i-lucide-keyboard" icon="keyboard"
class="!w-full !justify-start"
@click="handleKeyboardHelpClick" @click="handleKeyboardHelpClick"
> >
<span class="min-w-0 truncate font-medium text-xs"> {{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }} </woot-button>
</span>
</NextButton>
</WootDropdownItem> </WootDropdownItem>
<WootDropdownItem> <WootDropdownItem>
<router-link <router-link
@@ -133,70 +122,56 @@ export default {
> >
<a <a
:href="href" :href="href"
class="h-8 bg-white button small clear secondary dark:bg-slate-800"
:class="{ 'is-active': isActive }" :class="{ 'is-active': isActive }"
@click="e => handleProfileSettingClick(e, navigate)" @click="e => handleProfileSettingClick(e, navigate)"
> >
<NextButton <fluent-icon icon="person" size="14" class="icon icon--font" />
ghost <span class="button__content">
sm {{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
slate </span>
icon="i-lucide-circle-user"
class="!w-full !justify-start"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
</span>
</NextButton>
</a> </a>
</router-link> </router-link>
</WootDropdownItem> </WootDropdownItem>
<WootDropdownItem> <WootDropdownItem>
<NextButton <woot-button
ghost variant="clear"
sm color-scheme="secondary"
slate size="small"
icon="i-lucide-sun-moon" icon="appearance"
class="!w-full !justify-start"
@click="openAppearanceOptions" @click="openAppearanceOptions"
> >
<span class="min-w-0 truncate font-medium text-xs"> {{ $t('SIDEBAR_ITEMS.APPEARANCE') }}
{{ $t('SIDEBAR_ITEMS.APPEARANCE') }} </woot-button>
</span>
</NextButton>
</WootDropdownItem> </WootDropdownItem>
<WootDropdownItem v-if="currentUser.type === 'SuperAdmin'"> <WootDropdownItem v-if="currentUser.type === 'SuperAdmin'">
<a <a
href="/super_admin" href="/super_admin"
class="h-8 bg-white button small clear secondary dark:bg-slate-800"
target="_blank" target="_blank"
rel="noopener nofollow noreferrer" rel="noopener nofollow noreferrer"
@click="$emit('close')" @click="$emit('close')"
> >
<NextButton <fluent-icon
ghost icon="content-settings"
sm size="14"
slate class="icon icon--font"
icon="i-lucide-layout-dashboard" />
class="!w-full !justify-start" <span class="button__content">
> {{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
<span class="min-w-0 truncate font-medium text-xs"> </span>
{{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
</span>
</NextButton>
</a> </a>
</WootDropdownItem> </WootDropdownItem>
<WootDropdownItem> <WootDropdownItem>
<NextButton <woot-button
ghost variant="clear"
sm color-scheme="secondary"
slate size="small"
icon="i-lucide-circle-power" icon="power"
class="!w-full !justify-start"
@click="logout" @click="logout"
> >
<span class="min-w-0 truncate font-medium text-xs"> {{ $t('SIDEBAR_ITEMS.LOGOUT') }}
{{ $t('SIDEBAR_ITEMS.LOGOUT') }} </woot-button>
</span>
</NextButton>
</WootDropdownItem> </WootDropdownItem>
</WootDropdownMenu> </WootDropdownMenu>
</div> </div>
@@ -13,10 +13,9 @@ import {
isOnUnattendedView, isOnUnattendedView,
} from '../../../store/modules/conversations/helpers/actionHelpers'; } from '../../../store/modules/conversations/helpers/actionHelpers';
import Policy from '../../policy.vue'; import Policy from '../../policy.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { SecondaryChildNavItem, Policy, NextButton }, components: { SecondaryChildNavItem, Policy },
props: { props: {
menuItem: { menuItem: {
type: Object, type: Object,
@@ -49,6 +48,13 @@ export default {
return !!this.menuItem.children; return !!this.menuItem.children;
}, },
isMenuItemVisible() { isMenuItemVisible() {
if (this.menuItem.globalConfigFlag) {
// this checks for the `csmlEditorHost` flag in the global config
// if this is present, we toggle the CSML editor menu item
// TODO: This is very specific, and can be handled better, fix it
return !!this.globalConfig[this.menuItem.globalConfigFlag];
}
let isFeatureEnabled = true; let isFeatureEnabled = true;
if (this.menuItem.featureFlag) { if (this.menuItem.featureFlag) {
isFeatureEnabled = this.isFeatureEnabledonAccount( isFeatureEnabled = this.isFeatureEnabledonAccount(
@@ -199,7 +205,14 @@ export default {
{{ $t(`SIDEBAR.${menuItem.label}`) }} {{ $t(`SIDEBAR.${menuItem.label}`) }}
</span> </span>
<div v-if="menuItem.showNewButton" class="flex items-center"> <div v-if="menuItem.showNewButton" class="flex items-center">
<NextButton ghost xs slate icon="i-lucide-plus" @click="onClickOpen" /> <woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
class="p-0 ml-2"
@click="onClickOpen"
/>
</div> </div>
</div> </div>
<router-link <router-link
@@ -259,15 +272,16 @@ export default {
> >
<li class="pl-1"> <li class="pl-1">
<a :href="href"> <a :href="href">
<NextButton <woot-button
ghost size="tiny"
xs variant="clear"
slate color-scheme="secondary"
icon="i-lucide-plus" icon="add"
:label="$t(`SIDEBAR.${menuItem.newLinkTag}`)"
:data-testid="menuItem.dataTestid" :data-testid="menuItem.dataTestid"
@click="e => newLinkClick(e, navigate)" @click="e => newLinkClick(e, navigate)"
/> >
{{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }}
</woot-button>
</a> </a>
</li> </li>
</router-link> </router-link>
@@ -41,6 +41,7 @@ describe('AccountSelector', () => {
'fluent-icon': FluentIcon, 'fluent-icon': FluentIcon,
}, },
stubs: { stubs: {
WootButton: { template: '<button />' },
// override global stub // override global stub
WootModalHeader: false, WootModalHeader: false,
}, },
@@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex'; import { createStore } from 'vuex';
import AgentDetails from '../AgentDetails.vue'; import AgentDetails from '../AgentDetails.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import WootButton from 'dashboard/components/ui/WootButton.vue';
describe('AgentDetails', () => { describe('AgentDetails', () => {
const currentUser = { const currentUser = {
@@ -40,12 +40,12 @@ describe('AgentDetails', () => {
plugins: [store], plugins: [store],
components: { components: {
Thumbnail, Thumbnail,
NextButton, WootButton,
}, },
directives: { directives: {
tooltip: mockTooltipDirective, // Mocking the tooltip directive tooltip: mockTooltipDirective, // Mocking the tooltip directive
}, },
stubs: { NextButton: { template: '<button><slot /></button>' } }, stubs: { WootButton: { template: '<button><slot /></button>' } },
}, },
}); });
}); });
@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { createStore } from 'vuex'; import { createStore } from 'vuex';
import AvailabilityStatus from '../AvailabilityStatus.vue'; import AvailabilityStatus from '../AvailabilityStatus.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import WootButton from 'dashboard/components/ui/WootButton.vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue'; import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
@@ -40,7 +40,7 @@ describe('AvailabilityStatus', () => {
global: { global: {
plugins: [store], plugins: [store],
components: { components: {
NextButton, WootButton,
WootDropdownItem, WootDropdownItem,
WootDropdownMenu, WootDropdownMenu,
WootDropdownHeader, WootDropdownHeader,
@@ -22,7 +22,7 @@ const store = createStore({
describe('SidemenuIcon', () => { describe('SidemenuIcon', () => {
test('matches snapshot', () => { test('matches snapshot', () => {
const wrapper = shallowMount(SidemenuIcon, { const wrapper = shallowMount(SidemenuIcon, {
stubs: { NextButton: { template: '<button><slot /></button>' } }, stubs: { WootButton: { template: '<button><slot /></button>' } },
global: { plugins: [store] }, global: { plugins: [store] },
}); });
expect(wrapper.vm).toBeTruthy(); expect(wrapper.vm).toBeTruthy();
@@ -2,11 +2,11 @@
exports[`SidemenuIcon > matches snapshot 1`] = ` exports[`SidemenuIcon > matches snapshot 1`] = `
<button <button
class="-ml-3" class="-ml-3 text-black-900 dark:text-slate-300"
ghost="" color-scheme="secondary"
icon="i-lucide-menu" icon="list"
size="sm" size="small"
slate="" variant="clear"
> >
@@ -1,10 +1,5 @@
<script> <script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: {
NextButton,
},
props: { props: {
bannerMessage: { bannerMessage: {
type: String, type: String,
@@ -24,7 +19,7 @@ export default {
}, },
actionButtonVariant: { actionButtonVariant: {
type: String, type: String,
default: 'faded', default: '',
}, },
actionButtonLabel: { actionButtonLabel: {
type: String, type: String,
@@ -32,7 +27,7 @@ export default {
}, },
actionButtonIcon: { actionButtonIcon: {
type: String, type: String,
default: 'i-lucide-arrow-right', default: 'arrow-right',
}, },
colorScheme: { colorScheme: {
type: String, type: String,
@@ -53,18 +48,6 @@ export default {
} }
return classList; return classList;
}, },
// TODO - Remove this method when we standardize
// the button color and variant names
getButtonColor() {
const colorMap = {
primary: 'blue',
secondary: 'blue',
alert: 'ruby',
warning: 'amber',
};
return colorMap[this.colorScheme] || 'blue';
},
}, },
methods: { methods: {
onClick(e) { onClick(e) {
@@ -94,23 +77,27 @@ export default {
</a> </a>
</span> </span>
<div class="actions"> <div class="actions">
<NextButton <woot-button
v-if="hasActionButton" v-if="hasActionButton"
xs size="tiny"
:icon="actionButtonIcon" :icon="actionButtonIcon"
:variant="actionButtonVariant" :variant="actionButtonVariant"
:color="getButtonColor" color-scheme="primary"
:label="actionButtonLabel" class-names="banner-action__button"
@click="onClick" @click="onClick"
/> >
<NextButton {{ actionButtonLabel }}
</woot-button>
<woot-button
v-if="hasCloseButton" v-if="hasCloseButton"
xs size="tiny"
icon="i-lucide-circle-x" :color-scheme="colorScheme"
:color="getButtonColor" icon="dismiss-circle"
:label="$t('GENERAL_SETTINGS.DISMISS')" class-names="banner-action__button"
@click="onClickClose" @click="onClickClose"
/> >
{{ $t('GENERAL_SETTINGS.DISMISS') }}
</woot-button>
</div> </div>
</div> </div>
</template> </template>
@@ -119,6 +106,13 @@ export default {
.banner { .banner {
&.primary { &.primary {
@apply bg-woot-500 dark:bg-woot-500; @apply bg-woot-500 dark:bg-woot-500;
.banner-action__button {
@apply bg-woot-600 dark:bg-woot-600 border-none text-white;
&:hover {
@apply bg-woot-700 dark:bg-woot-700;
}
}
} }
&.secondary { &.secondary {
@@ -130,6 +124,13 @@ export default {
&.alert { &.alert {
@apply bg-n-ruby-3 text-n-ruby-12; @apply bg-n-ruby-3 text-n-ruby-12;
.banner-action__button {
@apply border-none text-n-ruby-12 bg-n-ruby-5;
&:hover {
@apply bg-n-ruby-4;
}
}
a { a {
@apply text-n-ruby-12; @apply text-n-ruby-12;
@@ -145,12 +146,21 @@ export default {
&.gray { &.gray {
@apply text-black-500 dark:text-black-500; @apply text-black-500 dark:text-black-500;
.banner-action__button {
@apply text-white dark:text-white;
}
} }
a { a {
@apply ml-1 underline text-white dark:text-white text-xs; @apply ml-1 underline text-white dark:text-white text-xs;
} }
.banner-action__button {
::v-deep .button__content {
@apply whitespace-nowrap;
}
}
.banner-message { .banner-message {
@apply flex items-center; @apply flex items-center;
} }
@@ -218,14 +218,14 @@ const emitDateRange = () => {
/> />
<div <div
v-if="showDatePicker" v-if="showDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container" class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
> >
<CalendarDateRange <CalendarDateRange
:selected-range="selectedRange" :selected-range="selectedRange"
@set-range="setDateRange" @set-range="setDateRange"
/> />
<div <div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-n-strong" class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
> >
<div class="flex justify-around h-fit"> <div class="flex justify-around h-fit">
<!-- Calendars for Start and End Dates --> <!-- Calendars for Start and End Dates -->
@@ -251,12 +251,12 @@ const emitDateRange = () => {
@validate="updateManualInput($event, calendar)" @validate="updateManualInput($event, calendar)"
@error="handleManualInputError($event)" @error="handleManualInputError($event)"
/> />
<div class="py-5 border-b border-n-strong"> <div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
<div <div
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]" class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
:class=" :class="
calendar === START_CALENDAR && calendar === START_CALENDAR &&
'ltr:border-r rtl:border-l border-n-strong' 'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
" "
> >
<CalendarYear <CalendarYear
@@ -1,8 +1,6 @@
<script setup> <script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper'; import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({ defineProps({
calendarType: { calendarType: {
type: String, type: String,
@@ -40,38 +38,42 @@ const onClickSetView = (type, mode) => {
<template> <template>
<div class="flex items-start justify-between w-full h-9"> <div class="flex items-start justify-between w-full h-9">
<NextButton <button
slate class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
ghost
xs
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click="onClickPrev(calendarType)" @click="onClickPrev(calendarType)"
/> >
<fluent-icon
icon="chevron-left"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button <button
v-if="firstButtonLabel" v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand" class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50 hover:text-woot-600 dark:hover:text-woot-600"
@click="onClickSetView(calendarType, viewMode)" @click="onClickSetView(calendarType, viewMode)"
> >
{{ firstButtonLabel }} {{ firstButtonLabel }}
</button> </button>
<button <button
v-if="buttonLabel" v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12" class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
:class="{ 'hover:text-n-brand': viewMode }" :class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
@click="onClickSetView(calendarType, YEAR)" @click="onClickSetView(calendarType, YEAR)"
> >
{{ buttonLabel }} {{ buttonLabel }}
</button> </button>
</div> </div>
<NextButton <button
slate class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
ghost
xs
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
@click="onClickNext(calendarType)" @click="onClickNext(calendarType)"
/> >
<fluent-icon
icon="chevron-right"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
</div> </div>
</template> </template>
@@ -65,7 +65,7 @@ const validateDate = () => {
<input <input
v-model="localDateValue" v-model="localDateValue"
type="text" type="text"
class="!text-sm !mb-0 disabled:!outline-n-strong" class="reset-base border bg-slate-25 dark:bg-slate-900 ring-offset-ash-900 border-slate-50 dark:border-slate-700/50 w-full disabled:text-slate-200 dark:disabled:text-slate-700 disabled:cursor-not-allowed text-slate-800 dark:text-slate-50 px-1.5 py-1 text-sm rounded-xl h-10"
:placeholder="dateFormat" :placeholder="dateFormat"
:disabled="isDisabled" :disabled="isDisabled"
@keypress.enter="validateDate" @keypress.enter="validateDate"
@@ -18,7 +18,7 @@ const setDateRange = range => {
<template> <template>
<div class="w-[200px] flex flex-col items-start"> <div class="w-[200px] flex flex-col items-start">
<h4 <h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12" class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
> >
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }} {{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4> </h4>
@@ -26,11 +26,11 @@ const setDateRange = range => {
<button <button
v-for="range in dateRanges" v-for="range in dateRanges"
:key="range.label" :key="range.label"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3" class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-slate-50 dark:hover:bg-slate-700"
:class=" :class="
range.value === selectedRange range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active' ? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
: 'text-n-slate-12' : 'text-slate-600 dark:text-slate-200'
" "
@click="setDateRange(range)" @click="setDateRange(range)"
> >
@@ -1,6 +1,4 @@
<script setup> <script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['clear', 'change']); const emit = defineEmits(['clear', 'change']);
const onClickClear = () => { const onClickClear = () => {
@@ -13,19 +11,18 @@ const onClickApply = () => {
</script> </script>
<template> <template>
<div class="h-[56px] flex justify-between gap-2 px-2 py-3 items-center"> <div class="h-[56px] flex justify-between px-5 py-3 items-center">
<NextButton <button
slate class="p-1.5 rounded-lg w-fit text-sm font-medium text-slate-600 dark:text-slate-200 hover:text-slate-800 dark:hover:text-slate-100"
ghost
sm
:label="$t('DATE_PICKER.CLEAR_BUTTON')"
@click="onClickClear" @click="onClickClear"
/> >
<NextButton {{ $t('DATE_PICKER.CLEAR_BUTTON') }}
sm </button>
ghost <button
:label="$t('DATE_PICKER.APPLY_BUTTON')" class="p-1.5 rounded-lg w-fit text-sm font-medium text-woot-500 dark:text-woot-300 hover:text-woot-700 dark:hover:text-woot-500"
@click="onClickApply" @click="onClickApply"
/> >
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
</button>
</div> </div>
</template> </template>
@@ -71,12 +71,10 @@ const selectMonth = index => {
<button <button
v-for="(month, index) in months" v-for="(month, index) in months"
:key="index" :key="index"
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[92px] h-10 rounded-lg py-2.5 px-2" class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[92px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{ :class="{
'bg-n-brand text-white hover:bg-n-blue-10': 'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
index === activeMonthIndex, index === activeMonthIndex,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex,
}" }"
@click="selectMonth(index)" @click="selectMonth(index)"
> >
@@ -107,16 +107,17 @@ const isNextDayInRange = day => {
}; };
const dayClasses = day => ({ const dayClasses = day => ({
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day), 'text-slate-500 dark:text-slate-400 pointer-events-none':
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7': !isInCurrentMonth(day),
'text-slate-800 dark:text-slate-50 hover:text-slate-800 dark:hover:text-white hover:bg-woot-100 dark:hover:bg-woot-700':
isInCurrentMonth(day), isInCurrentMonth(day),
'bg-n-brand text-white': 'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day), isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-n-blue-4 dark:bg-n-blue-5': 'bg-woot-50 dark:bg-woot-800':
(isInRange(day) || isHoveringInRange(day)) && (isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) && !isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day), isInCurrentMonth(day),
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-text': 'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day), isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
}); });
</script> </script>
@@ -163,7 +164,7 @@ const dayClasses = day => ({
!isLastDayOfMonth(day) && !isLastDayOfMonth(day) &&
isInCurrentMonth(day) isInCurrentMonth(day)
" "
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-n-blue-4 dark:bg-n-blue-5 -z-10" class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
/> />
</div> </div>
</div> </div>
@@ -72,10 +72,10 @@ const selectYear = year => {
<button <button
v-for="year in years" v-for="year in years"
:key="year" :key="year"
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[144px] h-10 rounded-lg py-2.5 px-2" class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[144px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{ :class="{
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear, 'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear, year === activeYear,
}" }"
@click="selectYear(year)" @click="selectYear(year)"
> >
@@ -48,7 +48,7 @@ const openDatePicker = () => {
<template> <template>
<button <button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1" class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-slate-50 dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="openDatePicker" @click="openDatePicker"
> >
<fluent-icon <fluent-icon
@@ -1,16 +1,14 @@
<script setup> <script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({ defineProps({
buttonText: { buttonText: {
type: String, type: String,
default: '', default: '',
}, },
trailingIcon: { rightIcon: {
type: Boolean, type: String,
default: false, default: '',
}, },
icon: { leftIcon: {
type: String, type: String,
default: '', default: '',
}, },
@@ -18,15 +16,32 @@ defineProps({
</script> </script>
<template> <template>
<Button <button
ghost class="inline-flex relative items-center p-1.5 w-fit h-8 gap-1.5 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
slate
sm
class="relative"
:icon="icon"
:trailing-icon="trailingIcon"
> >
<span class="min-w-0 truncate">{{ buttonText }}</span> <slot name="leftIcon">
<fluent-icon
v-if="leftIcon"
:icon="leftIcon"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</slot>
<span
v-if="buttonText"
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
{{ buttonText }}
</span>
<slot name="rightIcon">
<fluent-icon
v-if="rightIcon"
:icon="rightIcon"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</slot>
<slot name="dropdown" /> <slot name="dropdown" />
</Button> </button>
</template> </template>
@@ -8,7 +8,9 @@ defineProps({
</script> </script>
<template> <template>
<div class="flex items-center justify-center h-10 text-sm text-n-slate-11"> <div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
{{ message }} {{ message }}
</div> </div>
</template> </template>
@@ -78,7 +78,7 @@ const shouldShowEmptyState = computed(() => {
<template> <template>
<div <div
class="absolute z-20 w-40 bg-n-solid-2 border-0 outline outline-1 outline-n-weak shadow rounded-xl max-h-[400px]" class="absolute z-20 w-40 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-50 dark:border-slate-700/50 max-h-[400px]"
@click.stop @click.stop
> >
<slot name="search"> <slot name="search">
@@ -21,7 +21,7 @@ defineProps({
<template> <template>
<button <button
class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:enabled:bg-n-alpha-2" class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:bg-slate-50 dark:hover:bg-slate-700 active:bg-slate-75 dark:active:bg-slate-800"
> >
<div class="inline-flex items-center gap-3 overflow-hidden"> <div class="inline-flex items-center gap-3 overflow-hidden">
<fluent-icon <fluent-icon
@@ -30,14 +30,16 @@ defineProps({
size="18" size="18"
:style="{ color: iconColor }" :style="{ color: iconColor }"
/> />
<span class="text-sm font-medium truncate text-n-slate-12"> <span
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
{{ buttonText }} {{ buttonText }}
</span> </span>
<fluent-icon <fluent-icon
v-if="isActive" v-if="isActive"
icon="checkmark" icon="checkmark"
size="18" size="18"
class="flex-shrink-0 text-n-slate-12" class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/> />
</div> </div>
<slot name="dropdown" /> <slot name="dropdown" />
@@ -8,7 +8,9 @@ defineProps({
</script> </script>
<template> <template>
<div class="flex items-center justify-center h-10 text-sm text-n-slate-11"> <div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
{{ message }} {{ message }}
</div> </div>
</template> </template>
@@ -1,7 +1,5 @@
<script setup> <script setup>
import { defineEmits, defineModel } from 'vue'; import { defineEmits, defineModel } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({ defineProps({
inputPlaceholder: { inputPlaceholder: {
type: String, type: String,
@@ -23,29 +21,31 @@ const value = defineModel({
<template> <template>
<div <div
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-n-solid-2 dark:bg-n-solid-2 z-10 gap-2 px-3 border-b rounded-t-xl border-n-weak" class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-white z-10 dark:bg-slate-800 gap-2 px-3 border-b rounded-t-xl border-slate-50 dark:border-slate-700"
> >
<div class="flex items-center w-full gap-2" @keyup.space.prevent> <div class="flex items-center w-full gap-2" @keyup.space.prevent>
<fluent-icon <fluent-icon
icon="search" icon="search"
size="16" size="16"
class="text-n-slate-11 flex-shrink-0" class="text-slate-400 dark:text-slate-400 flex-shrink-0"
/> />
<input <input
v-model="value" v-model="value"
:placeholder="inputPlaceholder" :placeholder="inputPlaceholder"
type="text" type="text"
class="w-full mb-0 text-sm !outline-0 bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base" class="w-full mb-0 text-sm bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-75 reset-base"
/> />
</div> </div>
<!-- Clear filter button --> <!-- Clear filter button -->
<NextButton <woot-button
v-if="!modelValue && showClearFilter" v-if="!modelValue && showClearFilter"
faded size="small"
xs variant="clear"
class="flex-shrink-0" color-scheme="primary"
:label="$t('REPORT.FILTER_ACTIONS.CLEAR_FILTER')" class="!px-1 !py-1.5"
@click="emit('remove')" @click="emit('remove')"
/> >
{{ $t('REPORT.FILTER_ACTIONS.CLEAR_FILTER') }}
</woot-button>
</div> </div>
</template> </template>
@@ -0,0 +1,129 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
export default {
name: 'WootButton',
components: { EmojiOrIcon, Spinner },
props: {
type: {
type: String,
default: 'submit',
},
variant: {
type: String,
default: '',
},
size: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
emoji: {
type: String,
default: '',
},
colorScheme: {
type: String,
default: 'primary',
},
classNames: {
type: [String, Object],
default: '',
},
isDisabled: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
isExpanded: {
type: Boolean,
default: false,
},
},
computed: {
variantClasses() {
if (this.variant.includes('link')) {
return `clear ${this.variant}`;
}
return this.variant;
},
hasOnlyIcon() {
const hasEmojiOrIcon = this.emoji || this.icon;
return !this.$slots.default && hasEmojiOrIcon;
},
hasOnlyIconClasses() {
return this.hasOnlyIcon ? 'button--only-icon' : '';
},
buttonClasses() {
return [
this.variantClasses,
this.hasOnlyIconClasses,
this.size,
this.colorScheme,
this.classNames,
this.isDisabled ? 'disabled' : '',
this.isExpanded ? 'expanded' : '',
];
},
iconSize() {
switch (this.size) {
case 'tiny':
return 12;
case 'small':
return 14;
case 'medium':
return 16;
case 'large':
return 18;
default:
return 16;
}
},
showDarkSpinner() {
return (
this.colorScheme === 'secondary' ||
this.variant === 'clear' ||
this.variant === 'link' ||
this.variant === 'hollow'
);
},
},
};
</script>
<template>
<button
class="button"
:type="type"
:class="buttonClasses"
:disabled="isDisabled || isLoading"
>
<Spinner
v-if="isLoading"
size="small"
:color-scheme="showDarkSpinner ? 'dark' : ''"
/>
<EmojiOrIcon
v-else-if="icon || emoji"
class="icon"
:emoji="emoji"
:icon="icon"
:icon-size="iconSize"
/>
<span
v-if="$slots.default"
class="button__content"
:class="{ 'text-left rtl:text-right': size !== 'expanded' }"
>
<slot />
</span>
</button>
</template>
@@ -17,9 +17,6 @@ export default {
hasFbConfigured() { hasFbConfigured() {
return window.chatwootConfig?.fbAppId; return window.chatwootConfig?.fbAppId;
}, },
hasInstagramConfigured() {
return window.chatwootConfig?.instagramAppId;
},
isActive() { isActive() {
const { key } = this.channel; const { key } = this.channel;
if (Object.keys(this.enabledFeatures).length === 0) { if (Object.keys(this.enabledFeatures).length === 0) {
@@ -35,12 +32,6 @@ export default {
return this.enabledFeatures.channel_email; return this.enabledFeatures.channel_email;
} }
if (key === 'instagram') {
return (
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
);
}
return [ return [
'website', 'website',
'twilio', 'twilio',
@@ -49,7 +40,6 @@ export default {
'sms', 'sms',
'telegram', 'telegram',
'line', 'line',
'instagram',
].includes(key); ].includes(key);
}, },
}, },
@@ -1,7 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
// Props
const props = defineProps({ const props = defineProps({
currentPage: { currentPage: {
type: Number, type: Number,
@@ -21,6 +21,13 @@ const hasFirstPage = computed(() => props.currentPage === 1);
const hasNextPage = computed(() => props.currentPage === props.totalPages); const hasNextPage = computed(() => props.currentPage === props.totalPages);
const hasPrevPage = computed(() => props.currentPage === 1); const hasPrevPage = computed(() => props.currentPage === 1);
function buttonClass(hasPage) {
if (hasPage) {
return 'hover:!bg-slate-50 dark:hover:!bg-slate-800';
}
return 'dark:hover:!bg-slate-700/50';
}
function onPageChange(newPage) { function onPageChange(newPage) {
emit('pageChange', newPage); emit('pageChange', newPage);
} }
@@ -48,61 +55,84 @@ const onLastPage = () => {
</script> </script>
<template> <template>
<div <div class="flex items-center h-8 rounded-lg bg-slate-50 dark:bg-slate-800">
class="flex items-center h-8 outline outline-1 outline-n-weak rounded-lg" <woot-button
> size="small"
<NextButton variant="smooth"
faded color-scheme="secondary"
sm :is-disabled="hasFirstPage"
slate class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-l-lg ltr:rounded-r-none rtl:rounded-r-lg rtl:rounded-l-none"
icon="i-lucide-chevrons-left" :class="buttonClass(hasFirstPage)"
class="ltr:rounded-l-lg ltr:rounded-r-none rtl:rounded-r-lg rtl:rounded-l-none"
:disabled="hasFirstPage"
@click="onFirstPage" @click="onFirstPage"
/>
<div class="flex items-center justify-center bg-n-slate-9/10 h-full">
<div class="w-px h-4 rounded-sm bg-n-strong" />
</div>
<NextButton
faded
sm
slate
icon="i-lucide-chevron-left"
class="rounded-none"
:disabled="hasPrevPage"
@click="onPrevPage"
/>
<div
class="flex items-center gap-3 px-3 tabular-nums bg-n-slate-9/10 h-full"
> >
<span class="text-sm text-n-slate-12"> <fluent-icon
icon="chevrons-left"
size="20"
icon-lib="lucide"
:class="hasFirstPage && 'opacity-40'"
/>
</woot-button>
<div class="w-px h-4 rounded-sm bg-slate-75 dark:bg-slate-700/50" />
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
:is-disabled="hasPrevPage"
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
:class="buttonClass(hasPrevPage)"
@click="onPrevPage"
>
<fluent-icon
icon="chevron-left-single"
size="20"
icon-lib="lucide"
:class="hasPrevPage && 'opacity-40'"
/>
</woot-button>
<div
class="flex items-center gap-3 px-3 tabular-nums bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-100"
>
<span class="text-sm text-slate-800 dark:text-slate-75">
{{ currentPage }} {{ currentPage }}
</span> </span>
<span class="text-n-slate-11">/</span> <span class="text-slate-600 dark:text-slate-500">/</span>
<span class="text-sm text-n-slate-11"> <span class="text-sm text-slate-600 dark:text-slate-500">
{{ totalPages }} {{ totalPages }}
</span> </span>
</div> </div>
<NextButton <woot-button
faded size="small"
sm variant="smooth"
slate color-scheme="secondary"
icon="i-lucide-chevron-right" :is-disabled="hasNextPage"
class="rounded-none" class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
:disabled="hasNextPage" :class="buttonClass(hasNextPage)"
@click="onNextPage" @click="onNextPage"
/> >
<div class="flex items-center justify-center bg-n-slate-9/10 h-full"> <fluent-icon
<div class="w-px h-4 rounded-sm bg-n-strong" /> icon="chevron-right-single"
</div> size="20"
<NextButton icon-lib="lucide"
faded :class="hasNextPage && 'opacity-40'"
sm />
slate </woot-button>
icon="i-lucide-chevrons-right" <div class="w-px h-4 rounded-sm bg-slate-75 dark:bg-slate-700/50" />
class="ltr:rounded-r-lg ltr:rounded-l-none rtl:rounded-l-lg rtl:rounded-r-none" <woot-button
:disabled="hasLastPage" size="small"
variant="smooth"
color-scheme="secondary"
class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-r-lg ltr:rounded-l-none rtl:rounded-l-lg rtl:rounded-r-none"
:class="buttonClass(hasLastPage)"
:is-disabled="hasLastPage"
@click="onLastPage" @click="onLastPage"
/> >
<fluent-icon
icon="chevrons-right"
size="20"
icon-lib="lucide"
:class="hasLastPage && 'opacity-40'"
/>
</woot-button>
</div> </div>
</template> </template>
@@ -220,7 +220,6 @@ const plugins = computed(() => {
trigger: '@', trigger: '@',
showMenu: showUserMentions, showMenu: showUserMentions,
searchTerm: mentionSearchKey, searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate,
}), }),
createSuggestionPlugin({ createSuggestionPlugin({
trigger: '/', trigger: '/',
@@ -775,24 +774,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
} }
.ProseMirror-prompt { .ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl; @apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
h5 { h5 {
@apply text-n-slate-12 mb-1.5; @apply dark:text-slate-25 text-slate-800;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
} }
} }
@@ -328,24 +328,11 @@ export default {
} }
.ProseMirror-prompt { .ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl; z-index: var(--z-index-highest);
background: var(--white);
h5 { box-shadow: var(--shadow-large);
@apply text-n-slate-12 mb-1.5; border-radius: var(--border-radius-normal);
} border: 1px solid var(--color-border);
min-width: 25rem;
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
} }
</style> </style>
@@ -10,7 +10,6 @@ import {
ALLOWED_FILE_TYPES, ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP, ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE, ALLOWED_FILE_TYPES_FOR_LINE,
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
} from 'shared/constants/messages'; } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue'; import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue'; import AIAssistanceButton from '../AIAssistanceButton.vue';
@@ -114,10 +113,6 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
conversationType: {
type: String,
default: '',
},
}, },
emits: [ emits: [
'replaceText', 'replaceText',
@@ -192,9 +187,6 @@ export default {
showAudioPlayStopButton() { showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio; return this.showAudioRecorder && this.isRecordingAudio;
}, },
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() { allowedFileTypes() {
if (this.isATwilioWhatsAppChannel) { if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP; return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
@@ -202,10 +194,6 @@ export default {
if (this.isALineChannel) { if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE; return ALLOWED_FILE_TYPES_FOR_LINE;
} }
if (this.isAnInstagramChannel || this.isInstagramDM) {
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
}
return ALLOWED_FILE_TYPES; return ALLOWED_FILE_TYPES;
}, },
enableDragAndDrop() { enableDragAndDrop() {
@@ -106,3 +106,43 @@ export default {
/> />
</div> </div>
</template> </template>
<style lang="scss" scoped>
.button-group {
@apply flex border-0 p-0 m-0;
.button {
@apply text-sm font-medium py-2.5 px-4 m-0 relative z-10;
&.is-active {
@apply bg-white dark:bg-slate-900;
}
}
.button--reply {
@apply border-r rounded-none border-b-0 border-l-0 border-t-0 border-slate-50 dark:border-slate-700;
&:hover,
&:focus {
@apply border-r border-slate-50 dark:border-slate-700;
}
}
.button--note {
@apply border-l-0 rounded-none;
&.is-active {
@apply border-r border-b-0 bg-yellow-100 dark:bg-yellow-800 border-t-0 border-slate-50 dark:border-slate-700;
}
&:hover,
&:active {
@apply text-yellow-700 dark:text-yellow-700;
}
}
}
.button--note {
@apply text-yellow-600 dark:text-yellow-600 bg-transparent dark:bg-transparent;
}
</style>
@@ -2,12 +2,8 @@
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators'; import { required, minLength, email } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: {
NextButton,
},
props: { props: {
show: { show: {
type: Boolean, type: Boolean,
@@ -157,18 +153,13 @@ export default {
</div> </div>
</div> </div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2"> <div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton <woot-submit-button
faded :button-text="$t('EMAIL_TRANSCRIPT.SUBMIT')"
slate
type="reset"
:label="$t('EMAIL_TRANSCRIPT.CANCEL')"
@click.prevent="onCancel"
/>
<NextButton
type="submit"
:label="$t('EMAIL_TRANSCRIPT.SUBMIT')"
:disabled="!isFormValid" :disabled="!isFormValid"
/> />
<button class="button clear" @click.prevent="onCancel">
{{ $t('EMAIL_TRANSCRIPT.CANCEL') }}
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -36,7 +36,6 @@ import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags'; import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -210,22 +209,6 @@ export default {
return contactLastSeenAt; return contactLastSeenAt;
}, },
// Check there is a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
const { additional_attributes: additionalAttributes = {} } = this.inbox;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return (
this.inbox.channel_type === INBOX_TYPES.FB &&
additionalAttributes.type === 'instagram_direct_message' &&
instagramInbox
);
},
replyWindowBannerMessage() { replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) { if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY'); return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
@@ -235,37 +218,24 @@ export default {
if (additionalAttributes) { if (additionalAttributes) {
const { const {
agent_reply_time_window_message: agentReplyTimeWindowMessage, agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes; } = additionalAttributes;
return ( return agentReplyTimeWindowMessage;
agentReplyTimeWindowMessage ||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
} }
return ''; return '';
} }
return this.$t('CONVERSATION.CANNOT_REPLY'); return this.$t('CONVERSATION.CANNOT_REPLY');
}, },
replyWindowLink() { replyWindowLink() {
if (this.isAFacebookInbox || this.isAnInstagramChannel) { if (this.isAWhatsAppChannel) {
return REPLY_POLICY.FACEBOOK; return REPLY_POLICY.FACEBOOK;
} }
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!this.isAPIInbox) { if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP; return REPLY_POLICY.TWILIO_WHATSAPP;
} }
return ''; return '';
}, },
replyWindowLinkText() { replyWindowLinkText() {
if ( if (this.isAWhatsAppChannel) {
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAnInstagramChannel
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW'); return this.$t('CONVERSATION.24_HOURS_WINDOW');
} }
if (!this.isAPIInbox) { if (!this.isAPIInbox) {
@@ -515,17 +485,11 @@ export default {
<Banner <Banner
v-if="!currentChat.can_reply" v-if="!currentChat.can_reply"
color-scheme="alert" color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg" class="mt-2 mx-2 rounded-lg overflow-hidden"
:banner-message="replyWindowBannerMessage" :banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink" :href-link="replyWindowLink"
:href-link-text="replyWindowLinkText" :href-link-text="replyWindowLinkText"
/> />
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div class="flex justify-end"> <div class="flex justify-end">
<NextButton <NextButton
faded faded
@@ -241,27 +241,15 @@ export default {
if (this.isAFacebookInbox) { if (this.isAFacebookInbox) {
return MESSAGE_MAX_LENGTH.FACEBOOK; return MESSAGE_MAX_LENGTH.FACEBOOK;
} }
if (this.isAnInstagramChannel) { if (this.isAWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
if (this.isATwilioWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP; return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
} }
if (this.isAWhatsAppCloudChannel) {
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
}
if (this.isASmsInbox) { if (this.isASmsInbox) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS; return MESSAGE_MAX_LENGTH.TWILIO_SMS;
} }
if (this.isAnEmailChannel) { if (this.isAnEmailChannel) {
return MESSAGE_MAX_LENGTH.EMAIL; return MESSAGE_MAX_LENGTH.EMAIL;
} }
if (this.isATwilioSMSChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
}
if (this.isAWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
}
return MESSAGE_MAX_LENGTH.GENERAL; return MESSAGE_MAX_LENGTH.GENERAL;
}, },
showFileUpload() { showFileUpload() {
@@ -273,8 +261,7 @@ export default {
this.isAnEmailChannel || this.isAnEmailChannel ||
this.isASmsInbox || this.isASmsInbox ||
this.isATelegramChannel || this.isATelegramChannel ||
this.isALineChannel || this.isALineChannel
this.isAnInstagramChannel
); );
}, },
replyButtonLabel() { replyButtonLabel() {
@@ -400,14 +387,9 @@ export default {
}, },
}, },
watch: { watch: {
currentChat(conversation, oldConversation) { currentChat(conversation) {
const { can_reply: canReply } = conversation; const { can_reply: canReply } = conversation;
if (oldConversation && oldConversation.id !== conversation.id) { this.setCCAndToEmailsFromLastChat();
// Only update email fields when switching to a completely different conversation (by ID)
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
// like self-assign or other updates that do not actually change the conversation context
this.setCCAndToEmailsFromLastChat();
}
if (this.isOnPrivateNote) { if (this.isOnPrivateNote) {
return; return;
@@ -423,12 +405,13 @@ export default {
}, },
// When moving from one conversation to another, the store may not have the // When moving from one conversation to another, the store may not have the
// list of all the messages. A fetch is subsequently made to get the messages. // list of all the messages. A fetch is subsequently made to get the messages.
// This watcher handles two main cases: // However, this update does not trigger the `currentChat` watcher.
// 1. When switching conversations and messages are fetched/updated, ensures CC/BCC fields are set from the latest OUTGOING/INCOMING email (not activity/private messages). // We can add a deep watcher to it, but then, that would be too broad of a net to cast
// 2. Fixes and issue where CC/BCC fields could be reset/lost after assignment/activity actions or message mutations that did not represent a true email context change. // And would impact performance too. So we watch the messages directly.
lastEmail: { // The watcher here is `deep` too, because the messages array is mutated and
handler(lastEmail) { // not replaced. So, a shallow watcher would not catch the change.
if (!lastEmail) return; 'currentChat.messages': {
handler() {
this.setCCAndToEmailsFromLastChat(); this.setCCAndToEmailsFromLastChat();
}, },
deep: true, deep: true,
@@ -437,7 +420,6 @@ export default {
if (conversationId !== oldConversationId) { if (conversationId !== oldConversationId) {
this.setToDraft(oldConversationId, this.replyType); this.setToDraft(oldConversationId, this.replyType);
this.getFromDraft(); this.getFromDraft();
this.resetRecorderAndClearAttachments();
} }
}, },
message(updatedMessage) { message(updatedMessage) {
@@ -547,12 +529,6 @@ export default {
); );
} }
}, },
resetRecorderAndClearAttachments() {
// Reset audio recorder UI state
this.resetAudioRecorderInput();
// Reset attached files
this.attachedFiles = [];
},
saveDraft(conversationId, replyType) { saveDraft(conversationId, replyType) {
if (this.message || this.message === '') { if (this.message || this.message === '') {
const key = `draft-${conversationId}-${replyType}`; const key = `draft-${conversationId}-${replyType}`;
@@ -702,11 +678,7 @@ export default {
this.isATwilioWhatsAppChannel || this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel || this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel; this.is360DialogWhatsAppChannel;
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages. if (isOnWhatsApp && !this.isPrivate) {
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
const isOnInstagram = this.isAnInstagramChannel;
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
this.sendMessageAsMultipleMessages(this.message); this.sendMessageAsMultipleMessages(this.message);
} else { } else {
const messagePayload = this.getMessagePayload(this.message); const messagePayload = this.getMessagePayload(this.message);
@@ -723,7 +695,7 @@ export default {
} }
}, },
sendMessageAsMultipleMessages(message) { sendMessageAsMultipleMessages(message) {
const messages = this.getMultipleMessagesPayload(message); const messages = this.getMessagePayloadForWhatsapp(message);
messages.forEach(messagePayload => { messages.forEach(messagePayload => {
this.sendMessage(messagePayload); this.sendMessage(messagePayload);
}); });
@@ -955,11 +927,11 @@ export default {
return payload; return payload;
}, },
getMultipleMessagesPayload(message) { getMessagePayloadForWhatsapp(message) {
const multipleMessagePayload = []; const multipleMessagePayload = [];
if (this.attachedFiles && this.attachedFiles.length) { if (this.attachedFiles && this.attachedFiles.length) {
let caption = this.isAnInstagramChannel ? '' : message; let caption = message;
this.attachedFiles.forEach(attachment => { this.attachedFiles.forEach(attachment => {
const attachedFile = this.globalConfig.directUploadsEnabled const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId ? attachment.blobSignedId
@@ -974,19 +946,9 @@ export default {
attachmentPayload = this.setReplyToInPayload(attachmentPayload); attachmentPayload = this.setReplyToInPayload(attachmentPayload);
multipleMessagePayload.push(attachmentPayload); multipleMessagePayload.push(attachmentPayload);
// For WhatsApp, only the first attachment gets a caption caption = '';
if (!this.isAnInstagramChannel) caption = '';
}); });
} } else {
const hasNoAttachments =
!this.attachedFiles || !this.attachedFiles.length;
// For Instagram, we need a separate text message
// For WhatsApp, we only need a text message if there are no attachments
if (
(this.isAnInstagramChannel && this.message) ||
(!this.isAnInstagramChannel && hasNoAttachments)
) {
let messagePayload = { let messagePayload = {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message, message,
@@ -1105,9 +1067,9 @@ export default {
<template> <template>
<Banner <Banner
v-if="showSelfAssignBanner" v-if="showSelfAssignBanner"
action-button-variant="ghost" action-button-variant="clear"
color-scheme="secondary" color-scheme="secondary"
class="mx-2 mb-2 rounded-lg banner--self-assign" class="banner--self-assign mx-2 mb-2 rounded-lg"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')" :banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')" :action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@@ -1166,7 +1128,7 @@ export default {
v-else-if="!showRichContentEditor" v-else-if="!showRichContentEditor"
ref="messageInput" ref="messageInput"
v-model="message" v-model="message"
class="rounded-none input" class="input"
:placeholder="messagePlaceHolder" :placeholder="messagePlaceHolder"
:min-height="4" :min-height="4"
:signature="signatureToApply" :signature="signatureToApply"
@@ -1226,7 +1188,6 @@ export default {
:mode="replyType" :mode="replyType"
:on-file-upload="onFileUpload" :on-file-upload="onFileUpload"
:on-send="onSendReply" :on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText" :recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState" :recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel" :send-button-text="replyButtonLabel"
@@ -69,6 +69,10 @@ const onAgentSelect = index => {
v-if="items.length" v-if="items.length"
ref="tagAgentsRef" ref="tagAgentsRef"
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong" class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
:class="{
'border-b-[0.5rem] border-solid border-white dark:!border-slate-700':
items.length <= 4,
}"
> >
<li <li
v-for="(agent, index) in items" v-for="(agent, index) in items"
@@ -4,12 +4,8 @@ import {
DuplicateContactException, DuplicateContactException,
ExceptionWithMessage, ExceptionWithMessage,
} from 'shared/helpers/CustomErrors'; } from 'shared/helpers/CustomErrors';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: {
NextButton,
},
props: { props: {
name: { name: {
type: String, type: String,
@@ -95,12 +91,9 @@ export default {
</p> </p>
</div> </div>
<div v-if="formattedPhoneNumber" class="link-wrap"> <div v-if="formattedPhoneNumber" class="link-wrap">
<NextButton <woot-button variant="clear" size="small" @click.prevent="addContact">
ghost {{ $t('CONVERSATION.SAVE_CONTACT') }}
xs </woot-button>
:label="$t('CONVERSATION.SAVE_CONTACT')"
@click.prevent="addContact"
/>
</div> </div>
</div> </div>
</template> </template>

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