Merge branch 'release/4.1.0'

This commit is contained in:
Sojan
2025-04-16 23:10:46 -07:00
412 changed files with 12607 additions and 5321 deletions

View File

@@ -10,7 +10,7 @@ on:
jobs:
test:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4

View File

@@ -2,7 +2,7 @@
# #
# # Linux nightly installer action
# # This action will try to install and setup
# # chatwoot on an Ubuntu 20.04 machine using
# # chatwoot on an Ubuntu 22.04 machine using
# # the linux installer script.
# #
# # This is set to run daily at midnight.

View File

@@ -9,7 +9,7 @@ on:
jobs:
test:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
services:
postgres:
image: pgvector/pgvector:pg15

View File

@@ -7,7 +7,7 @@ on:
jobs:
test:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4

3
.gitignore vendored
View File

@@ -91,3 +91,6 @@ yarn-debug.log*
# Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files
*.local
# Claude.ai config file
CLAUDE.md

View File

@@ -501,14 +501,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.18.3)
nokogiri (1.18.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.3-arm64-darwin)
nokogiri (1.18.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.3-x86_64-darwin)
nokogiri (1.18.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-gnu)
nokogiri (1.18.4-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)

View File

@@ -12,11 +12,50 @@ class ContactInboxBuilder
private
def generate_source_id
ContactInbox::SourceIdService.new(
contact: @contact,
channel_type: @inbox.channel_type,
medium: @inbox.channel.try(:medium)
).generate
case @inbox.channel_type
when 'Channel::TwilioSms'
twilio_source_id
when 'Channel::Whatsapp'
wa_source_id
when 'Channel::Email'
email_source_id
when 'Channel::Sms'
phone_source_id
when 'Channel::Api', 'Channel::WebWidget'
SecureRandom.uuid
else
raise "Unsupported operation for this channel: #{@inbox.channel_type}"
end
end
def email_source_id
raise ActionController::ParameterMissing, 'contact email' unless @contact.email
@contact.email
end
def phone_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
@contact.phone_number
end
def wa_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
# whatsapp doesn't want the + in e164 format
@contact.phone_number.delete('+').to_s
end
def twilio_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
case @inbox.channel.medium
when 'sms'
@contact.phone_number
when 'whatsapp'
"whatsapp:#{@contact.phone_number}"
end
end
def create_contact_inbox
@@ -52,7 +91,7 @@ class ContactInboxBuilder
def new_source_id
if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
"#{@source_id}#{rand(100)}"
"whatsapp:#{@source_id}#{rand(100)}"
else
"#{rand(10)}#{@source_id}"
end

View File

@@ -63,9 +63,33 @@ class ContactInboxWithContactBuilder
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact
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)
return if identifier.blank?

View File

@@ -0,0 +1,178 @@
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

View File

@@ -1,200 +1,42 @@
# 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
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
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 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
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def attachments
@messaging[:message][:attachments] || {}
def get_story_object_from_source_id(source_id)
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}"
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
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def handle_error_response(response)
parsed_response = JSON.parse(response.body)
error_code = parsed_response.dig('error', 'code')
def message_identifier
message[:mid]
end
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
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 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
# There was a problem scraping data from the provided link.
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
if error_code == 1_609_005
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end
def find_or_build_for_multiple_conversations
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
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

View File

@@ -0,0 +1,33 @@
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

View File

@@ -68,20 +68,8 @@ class Messages::Messenger::MessageBuilder
message.save!
end
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
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id)
{}
end

View File

@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end
def permitted_params
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {})
end
def process_avatar_from_url

View File

@@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def allowed_agent_params
[:name, :email, :name, :role, :availability, :auto_offline]
[:name, :email, :role, :availability, :auto_offline]
end
def agent_params

View File

@@ -9,8 +9,6 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private

View File

@@ -1,17 +1,21 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
def index
@conversations = Current.account.conversations.includes(
# Start with all conversations for this contact
conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
end
).where(contact_id: @contact.id)
private
# Apply permission-based filtering using the existing service
conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
def inbox_ids
if Current.user.administrator? || Current.user.agent?
Current.user.assigned_inboxes.pluck(:id)
else
[]
end
# Only allow conversations from inboxes the user has access to
inbox_ids = Current.user.assigned_inboxes.pluck(:id)
conversations = conversations.where(inbox_id: inbox_ids)
@conversations = conversations.order(last_activity_at: :desc).limit(20)
end
end

View File

@@ -48,7 +48,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def filter
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform
@conversations = result[:conversations]
@conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,

View File

@@ -0,0 +1,30 @@
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

View File

@@ -9,11 +9,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portals = Current.account.portals
end
def add_members
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
@portal.members << agents
end
def show
@all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale])
@@ -85,10 +80,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
{ channel_web_widget_id: inbox.channel.id }
end
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
def set_current_page
@current_page = params[:page] || 1
end

View File

@@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end
def check_authorization
return if Current.account_user.administrator?
raise Pundit::NotAuthorizedError
authorize :report, :view?
end
def common_params
@@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')

View File

@@ -0,0 +1,74 @@
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

View File

@@ -36,7 +36,7 @@ class DashboardController < ActionController::Base
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV',
'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
'INSTALLATION_PRICING_PLAN'
).merge(app_config)
end
@@ -65,6 +65,7 @@ class DashboardController < ActionController::Base
VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
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'),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),

View File

@@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
def validate_business_account?
# return true if the user is a business account, false if it is a gmail account
auth_hash['info']['email'].exclude?('@gmail.com')
auth_hash['info']['email'].downcase.exclude?('@gmail.com')
end
def create_account_for_user

View File

@@ -0,0 +1,163 @@
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

View File

@@ -43,6 +43,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear'
%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
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end

View File

@@ -15,6 +15,9 @@ class Webhooks::InstagramController < ActionController::API
private
def valid_token?(token)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
# Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and
# INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') ||
token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '')
end
end

View File

@@ -81,7 +81,8 @@ class AccountDashboard < Administrate::BaseDashboard
COLLECTION_FILTERS = {
active: ->(resources) { resources.where(status: :active) },
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
# Overwrite this method to customize how accounts are displayed

View File

@@ -32,6 +32,7 @@ class ConversationFinder
def initialize(current_user, params)
@current_user = current_user
@current_account = current_user.account
@is_admin = current_account.account_users.find_by(user_id: current_user.id)&.administrator?
@params = params
end
@@ -85,8 +86,19 @@ class ConversationFinder
@team = current_account.teams.find(params[:team_id]) if params[:team_id]
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
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
find_conversation_by_inbox
# Apply permission-based filtering
@conversations = Conversations::PermissionFilterService.new(
@conversations,
current_user,
current_account
).perform
filter_by_conversation_type if params[:conversation_type]
@conversations
end

View File

@@ -18,4 +18,8 @@ module BillingHelper
def non_web_inboxes(account)
account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count
end
def agents(account)
account.users.count
end
end

View File

@@ -0,0 +1,49 @@
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

View File

@@ -4,7 +4,6 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
@@ -31,7 +30,6 @@ export default {
UpdateBanner,
PaymentPendingBanner,
WootSnackbarBox,
UpgradeBanner,
PendingEmailVerificationBanner,
},
setup() {
@@ -146,7 +144,6 @@ export default {
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
<UpgradeBanner />
</template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">

View File

@@ -1,9 +1,26 @@
/* global axios */
import ApiClient from './ApiClient';
class AgentBotsAPI extends ApiClient {
constructor() {
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();

View File

@@ -0,0 +1,14 @@
/* 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();

View File

@@ -17,6 +17,12 @@ class EnterpriseAccountAPI extends ApiClient {
getLimits() {
return axios.get(`${this.url}limits`);
}
toggleDeletion(action) {
return axios.post(`${this.url}toggle_deletion`, {
action_type: action,
});
}
}
export default new EnterpriseAccountAPI();

View File

@@ -10,6 +10,7 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('update');
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
});
describe('API calls', () => {
@@ -42,5 +43,21 @@ describe('#enterpriseAccountAPI', () => {
'/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' }
);
});
});
});

View File

@@ -29,6 +29,19 @@
--iris-11: 87 83 198;
--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-2: 255 247 248;
--ruby-3: 254 234 237;
@@ -131,6 +144,19 @@
--iris-11: 158 177 255;
--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-2: 30 21 23;
--ruby-3: 58 20 30;

View File

@@ -81,28 +81,6 @@
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
@@ -177,71 +155,6 @@
}
}
// 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-button {
&.small {
@@ -264,11 +177,6 @@
}
}
// Widget builder
.widget-builder-container .widget-preview {
direction: initial;
}
// Modal
.modal-container {
text-align: right;
@@ -282,7 +190,6 @@
}
// Other changes
.colorpicker--chrome {
direction: initial;
}
@@ -291,14 +198,6 @@
direction: initial;
}
.contact--details .contact--bio {
direction: ltr;
}
.merge-contacts .child-contact-wrap {
direction: ltr;
}
.contact--form .input-group {
direction: initial;
}

View File

@@ -29,7 +29,6 @@
@import 'rtl';
@import 'widgets/base';
@import 'widgets/buttons';
@import 'widgets/conversation-view';
@import 'widgets/tabs';
@import 'widgets/woot-tables';

View File

@@ -40,6 +40,12 @@ dl:not(.reset-base) {
@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
// -------------------------
label {

View File

@@ -1,228 +0,0 @@
// 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;
}
}
}

View File

@@ -51,6 +51,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>

View File

@@ -31,10 +31,6 @@ const sortMenus = [
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.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'),
value: 'company_name',

View File

@@ -25,11 +25,6 @@ export const generateLabelForContactableInboxesList = ({
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
) {
// Handled separately for Twilio Inbox where phone number is not mandatory.
// You can send message to a contact with Messaging Service Id.
if (!phoneNumber) {
return name;
}
return `${name} (${phoneNumber})`;
}
return name;

View File

@@ -8,8 +8,8 @@ vi.mock('dashboard/api/contacts');
describe('composeConversationHelper', () => {
describe('generateLabelForContactableInboxesList', () => {
const contact = {
name: 'Priority Inbox',
email: 'hello@example.com',
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
};
@@ -19,7 +19,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: INBOX_TYPES.EMAIL,
})
).toBe('Priority Inbox (hello@example.com)');
).toBe('John Doe (john@example.com)');
});
it('generates label for twilio inbox', () => {
@@ -28,14 +28,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: INBOX_TYPES.TWILIO,
})
).toBe('Priority Inbox (+1234567890)');
expect(
helpers.generateLabelForContactableInboxesList({
name: 'Priority Inbox',
channelType: INBOX_TYPES.TWILIO,
})
).toBe('Priority Inbox');
).toBe('John Doe (+1234567890)');
});
it('generates label for whatsapp inbox', () => {
@@ -44,7 +37,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: INBOX_TYPES.WHATSAPP,
})
).toBe('Priority Inbox (+1234567890)');
).toBe('John Doe (+1234567890)');
});
it('generates label for other inbox types', () => {
@@ -53,7 +46,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: 'Channel::Api',
})
).toBe('Priority Inbox');
).toBe('John Doe');
});
});

View File

@@ -1,3 +1,5 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup>
import { computed } from 'vue';

View File

@@ -33,6 +33,8 @@ const insertIntoRichEditor = computed(() => {
);
});
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => {
if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -53,9 +55,17 @@ const useCopilotResponse = () => {
/>
<div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<div v-dompurify-html="messageContent" class="prose-sm break-words" />
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
{{ $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">
<Button
v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')"
faded
sm

View File

@@ -125,7 +125,10 @@ defineExpose({ open, close });
<slot />
<!-- Dialog content will be injected here -->
<slot name="footer">
<div class="flex items-center justify-between w-full gap-3">
<div
v-if="showCancelButton || showConfirmButton"
class="flex items-center justify-between w-full gap-3"
>
<Button
v-if="showCancelButton"
variant="faded"

View File

@@ -103,7 +103,7 @@ export default {
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
</h3>
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
<div>
<label :class="{ error: v$.name.$error }">
<input
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"
@@ -116,14 +116,14 @@ export default {
>
{{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }}
</span>
</div>
</label>
<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">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
</NextButton>
<NextButton solid blue sm :disabled="isButtonDisabled">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON') }}
</NextButton>
</div>
</form>
</div>

View File

@@ -12,6 +12,7 @@ export function useChannelIcon(inbox) {
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
};
const providerIconMap = {

View File

@@ -19,10 +19,17 @@ const {
isAWebWidgetInbox,
isAWhatsAppChannel,
isAnEmailChannel,
isAInstagramChannel,
} = useInbox();
const { status, isPrivate, createdAt, sourceId, messageType } =
useMessageContext();
const {
status,
isPrivate,
createdAt,
sourceId,
messageType,
contentAttributes,
} = useMessageContext();
const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
@@ -30,6 +37,11 @@ const readableTime = computed(() =>
const showStatusIndicator = computed(() => {
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.TEMPLATE) return true;
@@ -47,7 +59,8 @@ const isSent = computed(() => {
isATwilioChannel.value ||
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value
isATelegramChannel.value ||
isAInstagramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -86,7 +99,8 @@ const isRead = computed(() => {
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value
isAFacebookInbox.value ||
isAInstagramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -102,7 +116,6 @@ const statusToShow = computed(() => {
if (isRead.value) return MESSAGE_STATUS.READ;
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
if (isSent.value) return MESSAGE_STATUS.SENT;
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
return MESSAGE_STATUS.PROGRESS;
});

View File

@@ -0,0 +1,24 @@
<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>

View File

@@ -9,9 +9,11 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { useMessageContext } from '../../provider.js';
import { MESSAGE_TYPES } from 'next/message/constants.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, contentAttributes, attachments, messageType } =
useMessageContext();
@@ -19,35 +21,77 @@ const { content, contentAttributes, attachments, messageType } =
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const renderOriginal = ref(false);
const contentContainer = useTemplateRef('contentContainer');
onMounted(() => {
isExpandable.value = contentContainer.value?.scrollHeight > 400;
});
const isOutgoing = computed(() => {
return messageType.value === MESSAGE_TYPES.OUTGOING;
});
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isIncoming = computed(() => !isOutgoing.value);
const textToShow = computed(() => {
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
const originalEmailText = computed(() => {
const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>');
});
// Use TextContent as the default to fullHTML
const originalEmailHtml = computed(
() =>
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(() => {
return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original HTML
return originalEmailHtml.value;
});
const unquotedHTML = computed(() => {
return EmailQuoteExtractor.extractQuotes(fullHTML.value);
const unquotedHTML = computed(() =>
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 hasQuotedMessage = computed(() => {
return EmailQuoteExtractor.hasQuotes(fullHTML.value);
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
</script>
<template>
@@ -75,7 +119,7 @@ const hasQuotedMessage = computed(() => {
>
<div
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-gray-3 via-n-gray-3 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-slate-4 via-n-slate-4 via-20% to-transparent"
>
<button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@@ -88,11 +132,12 @@ const hasQuotedMessage = computed(() => {
<FormattedContent
v-if="isOutgoing && content"
class="text-n-slate-12"
:content="content"
:content="messageContent"
/>
<template v-else>
<Letter
v-if="showQuotedMessage"
:key="`letter-quoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[
...allowedCssProperties,
@@ -104,6 +149,7 @@ const hasQuotedMessage = computed(() => {
/>
<Letter
v-else
:key="`letter-unquoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHTML"
:allowed-css-properties="[
@@ -135,6 +181,12 @@ const hasQuotedMessage = computed(() => {
</button>
</div>
</section>
<TranslationToggle
v-if="hasTranslations"
class="py-2 px-3"
:showing-original="renderOriginal"
@toggle="handleSeeOriginal"
/>
<section
v-if="Array.isArray(attachments) && attachments.length"
class="px-4 pb-4 space-y-2"

View File

@@ -3,16 +3,16 @@ import { computed, ref } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { MESSAGE_TYPES } from '../../constants';
import { useMessageContext } from '../../provider.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, attachments, contentAttributes, messageType } =
useMessageContext();
const hasTranslations = computed(() => {
const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
const renderOriginal = ref(false);
@@ -22,8 +22,7 @@ const renderContent = computed(() => {
}
if (hasTranslations.value) {
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
return translationContent.value;
}
return content.value;
@@ -37,12 +36,6 @@ const isEmpty = computed(() => {
return !content.value && !attachments.value?.length;
});
const viewToggleKey = computed(() => {
return renderOriginal.value
? 'CONVERSATION.VIEW_TRANSLATED'
: 'CONVERSATION.VIEW_ORIGINAL';
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
@@ -55,15 +48,12 @@ const handleSeeOriginal = () => {
{{ $t('CONVERSATION.NO_CONTENT') }}
</span>
<FormattedContent v-if="renderContent" :content="renderContent" />
<span class="-mt-3">
<span
v-if="hasTranslations"
class="text-xs text-n-slate-11 cursor-pointer hover:underline"
@click="handleSeeOriginal"
>
{{ $t(viewToggleKey) }}
</span>
</span>
<TranslationToggle
v-if="hasTranslations"
class="-mt-3"
:showing-original="renderOriginal"
@toggle="handleSeeOriginal"
/>
<AttachmentChips :attachments="attachments" class="gap-2" />
<template v-if="isTemplate">
<div

View File

@@ -39,7 +39,7 @@ const textColorClass = computed(() => {
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
json: 'text-n-slate-12',
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
pdf: 'text-n-ruby-12',
pdf: 'text-n-slate-12',
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
rar: 'dark:text-[#EDEEF0] text-[#2F265F]',

View File

@@ -8,7 +8,6 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -37,18 +36,6 @@ 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);
// We're using localStorage to store the expanded item in the sidebar
@@ -116,32 +103,7 @@ const 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 reportRoutes = computed(() => newReportRoutes());
const menuItems = computed(() => {
return [

View File

@@ -26,6 +26,12 @@ const showAccountSwitcher = computed(
() => 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 accountUrl = `/app/accounts/${newId}/dashboard`;
window.location.href = accountUrl;
@@ -70,7 +76,7 @@ const emitNewAccount = () => {
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
<DropdownItem
v-for="account in currentUser.accounts"
v-for="account in sortedCurrentUserAccounts"
:id="`account-${account.id}`"
:key="account.id"
class="cursor-pointer"

View File

@@ -35,7 +35,7 @@ const onToggle = () => {
<template>
<div class="text-sm">
<button
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="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="{ 'rounded-bl-none rounded-br-none': isOpen }"
@click.stop="onToggle"
>
@@ -55,7 +55,7 @@ const onToggle = () => {
</button>
<div
v-if="isOpen"
class="bg-n-background border border-n-weak dark:border-n-slate-2 border-t-0 rounded-br-lg rounded-bl-lg"
class="bg-n-background outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
:class="compact ? 'p-0' : 'px-2 py-4'"
>
<slot />

View File

@@ -61,6 +61,7 @@ import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
@@ -105,7 +106,7 @@ const advancedFilterTypes = ref(
);
const currentUser = useMapGetter('getCurrentUser');
const chatLists = useMapGetter('getAllConversations');
const chatLists = useMapGetter('getFilteredConversations');
const mineChatsList = useMapGetter('getMineChats');
const allChatList = useMapGetter('getAllStatusChats');
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
@@ -324,6 +325,14 @@ const conversationList = computed(() => {
} else {
localConversationList = [...chatLists.value];
}
if (activeFolder.value) {
const { payload } = activeFolder.value.query;
localConversationList = localConversationList.filter(conversation => {
return matchesFilters(conversation, payload);
});
}
return localConversationList;
});
@@ -460,6 +469,12 @@ function setParamsForEditFolderModal() {
campaigns: campaigns.value,
languages: languages,
countries: countries,
priority: [
{ id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
{ id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
],
filterTypes: advancedFilterTypes.value,
allCustomAttributes: conversationCustomAttributes.value,
};

View File

@@ -1,64 +0,0 @@
<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>

View File

@@ -3,12 +3,16 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { mapGetters } from 'vuex';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
size: {
type: String,
default: 'small',
default: 'sm',
},
},
computed: {
@@ -33,13 +37,13 @@ export default {
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<woot-button
<NextButton
v-if="!hasNextSidebar"
ghost
slate
:size="size"
variant="clear"
color-scheme="secondary"
class="-ml-3 text-black-900 dark:text-slate-300"
icon="list"
icon="i-lucide-menu"
class="-ml-3"
@click="onMenuItemClick"
/>
</template>

View File

@@ -35,7 +35,7 @@ export default {
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
action-button-icon="mail"
action-button-icon="i-lucide-mail"
has-action-button
@primary-action="resendVerificationEmail"
/>

View File

@@ -1,52 +0,0 @@
<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>

View File

@@ -1,66 +0,0 @@
<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>

View File

@@ -134,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div
class="rounded-lg shadow button-group outline-1 outline"
class="rounded-lg shadow outline-1 outline"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
<Button

View File

@@ -1,7 +1,6 @@
// [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file.
/* eslint no-plusplus: 0 */
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
import Button from './ui/WootButton.vue';
import Code from './Code.vue';
import ColorPicker from './widgets/ColorPicker.vue';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
@@ -18,7 +17,6 @@ import ModalHeader from './ModalHeader.vue';
import Modal from './Modal.vue';
import SidemenuIcon from './SidemenuIcon.vue';
import Spinner from 'shared/components/Spinner.vue';
import SubmitButton from './buttons/FormSubmitButton.vue';
import Tabs from './ui/Tabs/Tabs.vue';
import TabsItem from './ui/Tabs/TabsItem.vue';
import Thumbnail from './widgets/Thumbnail.vue';
@@ -26,7 +24,6 @@ import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
Button,
Code,
ColorPicker,
ConfirmDeleteModal,
@@ -43,7 +40,6 @@ const WootUIKit = {
ModalHeader,
SidemenuIcon,
Spinner,
SubmitButton,
Tabs,
TabsItem,
Thumbnail,

View File

@@ -7,6 +7,7 @@ import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge.vue';
import wootConstants from 'dashboard/constants/globals';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
@@ -17,6 +18,7 @@ export default {
WootDropdownMenu,
WootDropdownItem,
AvailabilityStatusBadge,
NextButton,
},
data() {
return {
@@ -101,19 +103,21 @@ export default {
:key="status.value"
class="flex items-baseline"
>
<woot-button
size="small"
:color-scheme="status.disabled ? '' : 'secondary'"
:variant="status.disabled ? 'smooth' : 'clear'"
class="status-change--dropdown-button"
<NextButton
sm
:color="status.disabled ? 'blue' : 'slate'"
:variant="status.disabled ? 'faded' : 'ghost'"
class="status-change--dropdown-button !w-full !justify-start"
@click="changeAvailabilityStatus(status.value)"
>
<AvailabilityStatusBadge :status="status.value" />
{{ status.label }}
</woot-button>
<span class="min-w-0 truncate font-medium text-xs">
{{ status.label }}
</span>
</NextButton>
</WootDropdownItem>
<WootDropdownDivider />
<WootDropdownItem class="flex items-center justify-between p-2 m-0">
<WootDropdownItem class="flex items-center justify-between px-3 py-2 m-0">
<div class="flex items-center">
<fluent-icon
v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')"
@@ -123,7 +127,7 @@ export default {
/>
<span
class="mx-1 my-0 text-xs font-medium text-slate-600 dark:text-slate-100"
class="mx-2 my-0 text-xs font-medium text-slate-600 dark:text-slate-100"
>
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
</span>

View File

@@ -127,7 +127,6 @@ const settings = accountId => ({
meta: {
permissions: ['administrator'],
},
globalConfigFlag: 'csmlEditorHost',
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots',
featureFlag: FEATURE_FLAGS.AGENT_BOTS,

View File

@@ -1,7 +1,11 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['toggleAccounts'],
data() {
return { showSwitchButton: false };
@@ -46,14 +50,13 @@ 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"
>
<div class="mx-2 my-0">
<woot-button
variant="clear"
size="tiny"
icon="arrow-swap"
<NextButton
ghost
xs
icon="i-lucide-arrow-right-left"
:label="$t('SIDEBAR.SWITCH')"
@click="$emit('toggleAccounts')"
>
{{ $t('SIDEBAR.SWITCH') }}
</woot-button>
/>
</div>
</div>
</transition>

View File

@@ -3,8 +3,12 @@ import { required, minLength } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
show: {
type: Boolean,
@@ -86,19 +90,24 @@ export default {
/>
</label>
</div>
<div class="w-full">
<div class="w-full">
<woot-submit-button
:disabled="
v$.accountName.$invalid ||
v$.accountName.$invalid ||
uiFlags.isCreating
"
:button-text="$t('CREATE_ACCOUNT.FORM.SUBMIT')"
:loading="uiFlags.isCreating"
button-class="large expanded"
/>
</div>
<div class="w-full flex justify-end gap-2 items-center">
<NextButton
faded
slate
type="reset"
:label="$t('CREATE_ACCOUNT.FORM.CANCEL')"
@click.prevent="() => $emit('closeAccountCreateModal')"
/>
<NextButton
type="submit"
:label="$t('CREATE_ACCOUNT.FORM.SUBMIT')"
:is-loading="uiFlags.isCreating"
:disabled="
v$.accountName.$invalid ||
v$.accountName.$invalid ||
uiFlags.isCreating
"
/>
</div>
</form>
</div>

View File

@@ -1,10 +1,12 @@
<script>
import { mapGetters } from 'vuex';
import Thumbnail from '../../widgets/Thumbnail.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Thumbnail,
NextButton,
},
emits: ['toggleMenu'],
computed: {
@@ -25,10 +27,10 @@ export default {
</script>
<template>
<woot-button
<NextButton
v-tooltip.right="$t(`SIDEBAR.PROFILE_SETTINGS`)"
variant="link"
class="flex items-center rounded-full"
link
class="rounded-full"
@click="handleClick"
>
<Thumbnail
@@ -37,6 +39,7 @@ export default {
:status="statusOfAgent"
should-show-status-always
size="32px"
class="flex-shrink-0"
/>
</woot-button>
</NextButton>
</template>

View File

@@ -5,12 +5,14 @@ import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus.vue';
import { FEATURE_FLAGS } from '../../../featureFlags';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
WootDropdownMenu,
WootDropdownItem,
AvailabilityStatus,
NextButton,
},
props: {
show: {
@@ -82,37 +84,46 @@ export default {
<AvailabilityStatus />
<WootDropdownMenu>
<WootDropdownItem v-if="showChangeAccountOption">
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="arrow-swap"
<NextButton
ghost
sm
slate
icon="i-lucide-arrow-right-left"
class="!w-full !justify-start"
@click="$emit('toggleAccounts')"
>
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
</woot-button>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
</span>
</NextButton>
</WootDropdownItem>
<WootDropdownItem v-if="showChatSupport">
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="chat-help"
<NextButton
ghost
sm
slate
icon="i-lucide-message-circle-question"
class="!w-full !justify-start"
@click="$emit('showSupportChatWindow')"
>
{{ $t('SIDEBAR_ITEMS.CONTACT_SUPPORT') }}
</woot-button>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.CONTACT_SUPPORT') }}
</span>
</NextButton>
</WootDropdownItem>
<WootDropdownItem>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="keyboard"
<NextButton
ghost
sm
slate
icon="i-lucide-keyboard"
class="!w-full !justify-start"
@click="handleKeyboardHelpClick"
>
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
</woot-button>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
</span>
</NextButton>
</WootDropdownItem>
<WootDropdownItem>
<router-link
@@ -122,56 +133,70 @@ export default {
>
<a
:href="href"
class="h-8 bg-white button small clear secondary dark:bg-slate-800"
:class="{ 'is-active': isActive }"
@click="e => handleProfileSettingClick(e, navigate)"
>
<fluent-icon icon="person" size="14" class="icon icon--font" />
<span class="button__content">
{{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
</span>
<NextButton
ghost
sm
slate
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>
</router-link>
</WootDropdownItem>
<WootDropdownItem>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="appearance"
<NextButton
ghost
sm
slate
icon="i-lucide-sun-moon"
class="!w-full !justify-start"
@click="openAppearanceOptions"
>
{{ $t('SIDEBAR_ITEMS.APPEARANCE') }}
</woot-button>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.APPEARANCE') }}
</span>
</NextButton>
</WootDropdownItem>
<WootDropdownItem v-if="currentUser.type === 'SuperAdmin'">
<a
href="/super_admin"
class="h-8 bg-white button small clear secondary dark:bg-slate-800"
target="_blank"
rel="noopener nofollow noreferrer"
@click="$emit('close')"
>
<fluent-icon
icon="content-settings"
size="14"
class="icon icon--font"
/>
<span class="button__content">
{{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
</span>
<NextButton
ghost
sm
slate
icon="i-lucide-layout-dashboard"
class="!w-full !justify-start"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
</span>
</NextButton>
</a>
</WootDropdownItem>
<WootDropdownItem>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="power"
<NextButton
ghost
sm
slate
icon="i-lucide-circle-power"
class="!w-full !justify-start"
@click="logout"
>
{{ $t('SIDEBAR_ITEMS.LOGOUT') }}
</woot-button>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.LOGOUT') }}
</span>
</NextButton>
</WootDropdownItem>
</WootDropdownMenu>
</div>

View File

@@ -13,9 +13,10 @@ import {
isOnUnattendedView,
} from '../../../store/modules/conversations/helpers/actionHelpers';
import Policy from '../../policy.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { SecondaryChildNavItem, Policy },
components: { SecondaryChildNavItem, Policy, NextButton },
props: {
menuItem: {
type: Object,
@@ -48,13 +49,6 @@ export default {
return !!this.menuItem.children;
},
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;
if (this.menuItem.featureFlag) {
isFeatureEnabled = this.isFeatureEnabledonAccount(
@@ -205,14 +199,7 @@ export default {
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="menuItem.showNewButton" class="flex items-center">
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
class="p-0 ml-2"
@click="onClickOpen"
/>
<NextButton ghost xs slate icon="i-lucide-plus" @click="onClickOpen" />
</div>
</div>
<router-link
@@ -272,16 +259,15 @@ export default {
>
<li class="pl-1">
<a :href="href">
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
<NextButton
ghost
xs
slate
icon="i-lucide-plus"
:label="$t(`SIDEBAR.${menuItem.newLinkTag}`)"
:data-testid="menuItem.dataTestid"
@click="e => newLinkClick(e, navigate)"
>
{{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }}
</woot-button>
/>
</a>
</li>
</router-link>

View File

@@ -41,7 +41,6 @@ describe('AccountSelector', () => {
'fluent-icon': FluentIcon,
},
stubs: {
WootButton: { template: '<button />' },
// override global stub
WootModalHeader: false,
},

View File

@@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AgentDetails from '../AgentDetails.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import WootButton from 'dashboard/components/ui/WootButton.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
describe('AgentDetails', () => {
const currentUser = {
@@ -40,12 +40,12 @@ describe('AgentDetails', () => {
plugins: [store],
components: {
Thumbnail,
WootButton,
NextButton,
},
directives: {
tooltip: mockTooltipDirective, // Mocking the tooltip directive
},
stubs: { WootButton: { template: '<button><slot /></button>' } },
stubs: { NextButton: { template: '<button><slot /></button>' } },
},
});
});

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AvailabilityStatus from '../AvailabilityStatus.vue';
import WootButton from 'dashboard/components/ui/WootButton.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
@@ -40,7 +40,7 @@ describe('AvailabilityStatus', () => {
global: {
plugins: [store],
components: {
WootButton,
NextButton,
WootDropdownItem,
WootDropdownMenu,
WootDropdownHeader,

View File

@@ -22,7 +22,7 @@ const store = createStore({
describe('SidemenuIcon', () => {
test('matches snapshot', () => {
const wrapper = shallowMount(SidemenuIcon, {
stubs: { WootButton: { template: '<button><slot /></button>' } },
stubs: { NextButton: { template: '<button><slot /></button>' } },
global: { plugins: [store] },
});
expect(wrapper.vm).toBeTruthy();

View File

@@ -2,11 +2,11 @@
exports[`SidemenuIcon > matches snapshot 1`] = `
<button
class="-ml-3 text-black-900 dark:text-slate-300"
color-scheme="secondary"
icon="list"
size="small"
variant="clear"
class="-ml-3"
ghost=""
icon="i-lucide-menu"
size="sm"
slate=""
>

View File

@@ -1,5 +1,10 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
bannerMessage: {
type: String,
@@ -19,7 +24,7 @@ export default {
},
actionButtonVariant: {
type: String,
default: '',
default: 'faded',
},
actionButtonLabel: {
type: String,
@@ -27,7 +32,7 @@ export default {
},
actionButtonIcon: {
type: String,
default: 'arrow-right',
default: 'i-lucide-arrow-right',
},
colorScheme: {
type: String,
@@ -48,6 +53,18 @@ export default {
}
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: {
onClick(e) {
@@ -77,27 +94,23 @@ export default {
</a>
</span>
<div class="actions">
<woot-button
<NextButton
v-if="hasActionButton"
size="tiny"
xs
:icon="actionButtonIcon"
:variant="actionButtonVariant"
color-scheme="primary"
class-names="banner-action__button"
:color="getButtonColor"
:label="actionButtonLabel"
@click="onClick"
>
{{ actionButtonLabel }}
</woot-button>
<woot-button
/>
<NextButton
v-if="hasCloseButton"
size="tiny"
:color-scheme="colorScheme"
icon="dismiss-circle"
class-names="banner-action__button"
xs
icon="i-lucide-circle-x"
:color="getButtonColor"
:label="$t('GENERAL_SETTINGS.DISMISS')"
@click="onClickClose"
>
{{ $t('GENERAL_SETTINGS.DISMISS') }}
</woot-button>
/>
</div>
</div>
</template>
@@ -106,13 +119,6 @@ export default {
.banner {
&.primary {
@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 {
@@ -124,13 +130,6 @@ export default {
&.alert {
@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 {
@apply text-n-ruby-12;
@@ -146,21 +145,12 @@ export default {
&.gray {
@apply text-black-500 dark:text-black-500;
.banner-action__button {
@apply text-white dark:text-white;
}
}
a {
@apply ml-1 underline text-white dark:text-white text-xs;
}
.banner-action__button {
::v-deep .button__content {
@apply whitespace-nowrap;
}
}
.banner-message {
@apply flex items-center;
}

View File

@@ -218,14 +218,14 @@ const emitDateRange = () => {
/>
<div
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 border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
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"
>
<CalendarDateRange
:selected-range="selectedRange"
@set-range="setDateRange"
/>
<div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-n-strong"
>
<div class="flex justify-around h-fit">
<!-- Calendars for Start and End Dates -->
@@ -251,12 +251,12 @@ const emitDateRange = () => {
@validate="updateManualInput($event, calendar)"
@error="handleManualInputError($event)"
/>
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
<div class="py-5 border-b border-n-strong">
<div
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
:class="
calendar === START_CALENDAR &&
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
'ltr:border-r rtl:border-l border-n-strong'
"
>
<CalendarYear

View File

@@ -1,6 +1,8 @@
<script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
calendarType: {
type: String,
@@ -38,42 +40,38 @@ const onClickSetView = (type, mode) => {
<template>
<div class="flex items-start justify-between w-full h-9">
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
<NextButton
slate
ghost
xs
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@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">
<button
v-if="firstButtonLabel"
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"
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
@click="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
<button
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
:class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
class="p-0 text-sm font-medium text-center text-n-slate-12"
:class="{ 'hover:text-n-brand': viewMode }"
@click="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
</div>
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
<NextButton
slate
ghost
xs
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
@click="onClickNext(calendarType)"
>
<fluent-icon
icon="chevron-right"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
/>
</div>
</template>

View File

@@ -65,7 +65,7 @@ const validateDate = () => {
<input
v-model="localDateValue"
type="text"
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"
class="!text-sm !mb-0 disabled:!outline-n-strong"
:placeholder="dateFormat"
:disabled="isDisabled"
@keypress.enter="validateDate"

View File

@@ -18,7 +18,7 @@ const setDateRange = range => {
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
@@ -26,11 +26,11 @@ const setDateRange = range => {
<button
v-for="range in dateRanges"
:key="range.label"
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="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="
range.value === selectedRange
? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
: 'text-slate-600 dark:text-slate-200'
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>

View File

@@ -1,4 +1,6 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['clear', 'change']);
const onClickClear = () => {
@@ -11,18 +13,19 @@ const onClickApply = () => {
</script>
<template>
<div class="h-[56px] flex justify-between px-5 py-3 items-center">
<button
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"
<div class="h-[56px] flex justify-between gap-2 px-2 py-3 items-center">
<NextButton
slate
ghost
sm
:label="$t('DATE_PICKER.CLEAR_BUTTON')"
@click="onClickClear"
>
{{ $t('DATE_PICKER.CLEAR_BUTTON') }}
</button>
<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"
/>
<NextButton
sm
ghost
:label="$t('DATE_PICKER.APPLY_BUTTON')"
@click="onClickApply"
>
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
</button>
/>
</div>
</template>

View File

@@ -71,10 +71,12 @@ const selectMonth = index => {
<button
v-for="(month, index) in months"
:key="index"
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="p-2 text-sm font-medium text-center text-n-slate-12 w-[92px] h-10 rounded-lg py-2.5 px-2"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
'bg-n-brand text-white hover:bg-n-blue-10':
index === activeMonthIndex,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex,
}"
@click="selectMonth(index)"
>

View File

@@ -107,17 +107,16 @@ const isNextDayInRange = day => {
};
const dayClasses = day => ({
'text-slate-500 dark:text-slate-400 pointer-events-none':
!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':
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day),
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7':
isInCurrentMonth(day),
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
'bg-n-brand text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-woot-50 dark:bg-woot-800':
'bg-n-blue-4 dark:bg-n-blue-5':
(isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day),
'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-text':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
@@ -164,7 +163,7 @@ const dayClasses = day => ({
!isLastDayOfMonth(day) &&
isInCurrentMonth(day)
"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
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"
/>
</div>
</div>

View File

@@ -72,10 +72,10 @@ const selectYear = year => {
<button
v-for="year in years"
:key="year"
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="p-2 text-sm font-medium text-center text-n-slate-12 w-[144px] h-10 rounded-lg py-2.5 px-2"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
year === activeYear,
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
}"
@click="selectYear(year)"
>

View File

@@ -48,7 +48,7 @@ const openDatePicker = () => {
<template>
<button
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"
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"
@click="openDatePicker"
>
<fluent-icon

View File

@@ -1,14 +1,16 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
buttonText: {
type: String,
default: '',
},
rightIcon: {
type: String,
default: '',
trailingIcon: {
type: Boolean,
default: false,
},
leftIcon: {
icon: {
type: String,
default: '',
},
@@ -16,32 +18,15 @@ defineProps({
</script>
<template>
<button
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"
<Button
ghost
slate
sm
class="relative"
:icon="icon"
:trailing-icon="trailingIcon"
>
<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>
<span class="min-w-0 truncate">{{ buttonText }}</span>
<slot name="dropdown" />
</button>
</Button>
</template>

View File

@@ -8,9 +8,7 @@ defineProps({
</script>
<template>
<div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
<div class="flex items-center justify-center h-10 text-sm text-n-slate-11">
{{ message }}
</div>
</template>

View File

@@ -78,7 +78,7 @@ const shouldShowEmptyState = computed(() => {
<template>
<div
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]"
class="absolute z-20 w-40 bg-n-solid-2 border-0 outline outline-1 outline-n-weak shadow rounded-xl max-h-[400px]"
@click.stop
>
<slot name="search">

View File

@@ -21,7 +21,7 @@ defineProps({
<template>
<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:bg-slate-50 dark:hover:bg-slate-700 active:bg-slate-75 dark:active:bg-slate-800"
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"
>
<div class="inline-flex items-center gap-3 overflow-hidden">
<fluent-icon
@@ -30,16 +30,14 @@ defineProps({
size="18"
:style="{ color: iconColor }"
/>
<span
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ buttonText }}
</span>
<fluent-icon
v-if="isActive"
icon="checkmark"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
class="flex-shrink-0 text-n-slate-12"
/>
</div>
<slot name="dropdown" />

View File

@@ -8,9 +8,7 @@ defineProps({
</script>
<template>
<div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
<div class="flex items-center justify-center h-10 text-sm text-n-slate-11">
{{ message }}
</div>
</template>

View File

@@ -1,5 +1,7 @@
<script setup>
import { defineEmits, defineModel } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
inputPlaceholder: {
type: String,
@@ -21,31 +23,29 @@ const value = defineModel({
<template>
<div
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"
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"
>
<div class="flex items-center w-full gap-2" @keyup.space.prevent>
<fluent-icon
icon="search"
size="16"
class="text-slate-400 dark:text-slate-400 flex-shrink-0"
class="text-n-slate-11 flex-shrink-0"
/>
<input
v-model="value"
:placeholder="inputPlaceholder"
type="text"
class="w-full mb-0 text-sm bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-75 reset-base"
class="w-full mb-0 text-sm !outline-0 bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
/>
</div>
<!-- Clear filter button -->
<woot-button
<NextButton
v-if="!modelValue && showClearFilter"
size="small"
variant="clear"
color-scheme="primary"
class="!px-1 !py-1.5"
faded
xs
class="flex-shrink-0"
:label="$t('REPORT.FILTER_ACTIONS.CLEAR_FILTER')"
@click="emit('remove')"
>
{{ $t('REPORT.FILTER_ACTIONS.CLEAR_FILTER') }}
</woot-button>
/>
</div>
</template>

View File

@@ -1,129 +0,0 @@
<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>

View File

@@ -17,6 +17,9 @@ export default {
hasFbConfigured() {
return window.chatwootConfig?.fbAppId;
},
hasInstagramConfigured() {
return window.chatwootConfig?.instagramAppId;
},
isActive() {
const { key } = this.channel;
if (Object.keys(this.enabledFeatures).length === 0) {
@@ -32,6 +35,12 @@ export default {
return this.enabledFeatures.channel_email;
}
if (key === 'instagram') {
return (
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
);
}
return [
'website',
'twilio',
@@ -40,6 +49,7 @@ export default {
'sms',
'telegram',
'line',
'instagram',
].includes(key);
},
},

View File

@@ -1,7 +1,7 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
// Props
const props = defineProps({
currentPage: {
type: Number,
@@ -21,13 +21,6 @@ const hasFirstPage = computed(() => props.currentPage === 1);
const hasNextPage = computed(() => props.currentPage === props.totalPages);
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) {
emit('pageChange', newPage);
}
@@ -55,84 +48,61 @@ const onLastPage = () => {
</script>
<template>
<div class="flex items-center h-8 rounded-lg bg-slate-50 dark:bg-slate-800">
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
:is-disabled="hasFirstPage"
class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-l-lg ltr:rounded-r-none rtl:rounded-r-lg rtl:rounded-l-none"
:class="buttonClass(hasFirstPage)"
<div
class="flex items-center h-8 outline outline-1 outline-n-weak rounded-lg"
>
<NextButton
faded
sm
slate
icon="i-lucide-chevrons-left"
class="ltr:rounded-l-lg ltr:rounded-r-none rtl:rounded-r-lg rtl:rounded-l-none"
:disabled="hasFirstPage"
@click="onFirstPage"
>
<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)"
/>
<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"
>
<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"
class="flex items-center gap-3 px-3 tabular-nums bg-n-slate-9/10 h-full"
>
<span class="text-sm text-slate-800 dark:text-slate-75">
<span class="text-sm text-n-slate-12">
{{ currentPage }}
</span>
<span class="text-slate-600 dark:text-slate-500">/</span>
<span class="text-sm text-slate-600 dark:text-slate-500">
<span class="text-n-slate-11">/</span>
<span class="text-sm text-n-slate-11">
{{ totalPages }}
</span>
</div>
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
:is-disabled="hasNextPage"
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
:class="buttonClass(hasNextPage)"
<NextButton
faded
sm
slate
icon="i-lucide-chevron-right"
class="rounded-none"
:disabled="hasNextPage"
@click="onNextPage"
>
<fluent-icon
icon="chevron-right-single"
size="20"
icon-lib="lucide"
:class="hasNextPage && '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"
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"
/>
<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-chevrons-right"
class="ltr:rounded-r-lg ltr:rounded-l-none rtl:rounded-l-lg rtl:rounded-r-none"
:disabled="hasLastPage"
@click="onLastPage"
>
<fluent-icon
icon="chevrons-right"
size="20"
icon-lib="lucide"
:class="hasLastPage && 'opacity-40'"
/>
</woot-button>
/>
</div>
</template>

View File

@@ -220,6 +220,7 @@ const plugins = computed(() => {
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate,
}),
createSuggestionPlugin({
trigger: '/',
@@ -774,10 +775,24 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.ProseMirror-prompt {
@apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply dark:text-slate-25 text-slate-800;
@apply text-n-slate-12 mb-1.5;
}
.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;
}
}
}
}

View File

@@ -328,11 +328,24 @@ export default {
}
.ProseMirror-prompt {
z-index: var(--z-index-highest);
background: var(--white);
box-shadow: var(--shadow-large);
border-radius: var(--border-radius-normal);
border: 1px solid var(--color-border);
min-width: 25rem;
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.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>

View File

@@ -10,6 +10,7 @@ import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
} from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
@@ -113,6 +114,10 @@ export default {
type: String,
required: true,
},
conversationType: {
type: String,
default: '',
},
},
emits: [
'replaceText',
@@ -187,6 +192,9 @@ export default {
showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio;
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
@@ -194,6 +202,10 @@ export default {
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
if (this.isAInstagramChannel || this.isInstagramDM) {
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
}
return ALLOWED_FILE_TYPES;
},
enableDragAndDrop() {

View File

@@ -106,43 +106,3 @@ export default {
/>
</div>
</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>

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