Compare commits

..
Author SHA1 Message Date
Shivam Mishra 670ab2400a feat: disable matches filter 2025-03-27 16:31:52 +05:30
Shivam Mishra 4a800eb8d1 chore: get changes from dbc1b492 2025-03-27 16:23:12 +05:30
Shivam Mishra 1bf1075427 feat: disable get filtered conversations 2025-03-27 16:16:22 +05:30
321 changed files with 4065 additions and 9030 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
test:
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -2,7 +2,7 @@
# #
# # Linux nightly installer action
# # This action will try to install and setup
# # chatwoot on an Ubuntu 22.04 machine using
# # chatwoot on an Ubuntu 20.04 machine using
# # the linux installer script.
# #
# # This is set to run daily at midnight.
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs:
test:
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
services:
postgres:
image: pgvector/pgvector:pg15
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
test:
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
-3
View File
@@ -91,6 +91,3 @@ yarn-debug.log*
# Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files
*.local
# Claude.ai config file
CLAUDE.md
@@ -63,33 +63,9 @@ 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?
@@ -1,178 +0,0 @@
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue StandardError => e
handle_error(e)
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
find_conversation_scope.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_conversation_scope
Conversation.where(conversation_params)
end
def find_or_build_for_multiple_conversations
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
# Duplicate webhook events may be sent for the same message
# when a user is connected to the Instagram account through both Messenger and Instagram login.
# There is chance for echo events to be sent for the same message.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_conversation_attributes
))
end
def additional_conversation_attributes
{}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
true
end
# Abstract methods to be implemented by subclasses
def get_story_object_from_source_id(source_id)
raise NotImplementedError
end
end
@@ -1,42 +1,200 @@
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end
private
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
def attachments
@messaging[:message][:attachments] || {}
end
def handle_error_response(response)
parsed_response = JSON.parse(response.body)
error_code = parsed_response.dig('error', 'code')
def message_type
@outgoing_echo ? :outgoing : :incoming
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_identifier
message[:mid]
end
# 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'))
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
end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
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
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
@@ -1,33 +0,0 @@
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
def find_conversation_scope
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def additional_conversation_attributes
{ type: 'instagram_direct_message' }
end
end
@@ -68,8 +68,20 @@ class Messages::Messenger::MessageBuilder
message.save!
end
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id)
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
@@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def allowed_agent_params
[:name, :email, :role, :availability, :auto_offline]
[:name, :email, :name, :role, :availability, :auto_offline]
end
def agent_params
@@ -1,21 +1,17 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
def index
# Start with all conversations for this contact
conversations = Current.account.conversations.includes(
@conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).where(contact_id: @contact.id)
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
end
# Apply permission-based filtering using the existing service
conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
private
# 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)
def inbox_ids
if Current.user.administrator? || Current.user.agent?
Current.user.assigned_inboxes.pluck(:id)
else
[]
end
end
end
@@ -48,7 +48,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def filter
result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations]
@conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,
@@ -1,30 +0,0 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern
include Instagram::IntegrationHelper
before_action :check_authorization
def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
redirect_url = instagram_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/instagram/callback",
scope: REQUIRED_SCOPES.join(','),
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
state: generate_instagram_token(Current.account.id)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -9,6 +9,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portals = Current.account.portals
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])
@@ -80,6 +85,10 @@ 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
@@ -66,7 +66,9 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end
def check_authorization
authorize :report, :view?
return if Current.account_user.administrator?
raise Pundit::NotAuthorizedError
end
def common_params
@@ -135,3 +137,5 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
@@ -1,74 +0,0 @@
module InstagramConcern
extend ActiveSupport::Concern
def instagram_client
::OAuth2::Client.new(
client_id,
client_secret,
{
site: 'https://api.instagram.com',
authorize_url: 'https://api.instagram.com/oauth/authorize',
token_url: 'https://api.instagram.com/oauth/access_token',
auth_scheme: :request_body,
token_method: :post
}
)
end
private
def client_id
GlobalConfigService.load('INSTAGRAM_APP_ID', nil)
end
def client_secret
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
end
def exchange_for_long_lived_token(short_lived_token)
endpoint = 'https://graph.instagram.com/access_token'
params = {
grant_type: 'ig_exchange_token',
client_secret: client_secret,
access_token: short_lived_token,
client_id: client_id
}
make_api_request(endpoint, params, 'Failed to exchange token')
end
def fetch_instagram_user_details(access_token)
endpoint = 'https://graph.instagram.com/v22.0/me'
params = {
fields: 'id,username,user_id,name,profile_picture_url,account_type',
access_token: access_token
}
make_api_request(endpoint, params, 'Failed to fetch Instagram user details')
end
def make_api_request(endpoint, params, error_prefix)
response = HTTParty.get(
endpoint,
query: params,
headers: { 'Accept' => 'application/json' }
)
unless response.success?
Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
raise "#{error_prefix}: #{response.body}"
end
begin
JSON.parse(response.body)
rescue JSON::ParserError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Invalid JSON response: #{response.body}"
raise e
end
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -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'].downcase.exclude?('@gmail.com')
auth_hash['info']['email'].exclude?('@gmail.com')
end
def create_account_for_user
@@ -1,163 +0,0 @@
class Instagram::CallbacksController < ApplicationController
include InstagramConcern
include Instagram::IntegrationHelper
def show
# Check if Instagram redirected with an error (user canceled authorization)
# See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
if params[:error].present?
handle_authorization_error
return
end
process_successful_authorization
rescue StandardError => e
handle_error(e)
end
private
# Process the authorization code and create inbox
def process_successful_authorization
@response = instagram_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/#{provider_name}/callback",
grant_type: 'authorization_code'
)
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
end
end
# Handle all errors that might occur during authorization
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response
def handle_error(error)
Rails.logger.error("Instagram Channel creation Error: #{error.message}")
ChatwootExceptionTracker.new(error).capture_exception
error_info = extract_error_info(error)
redirect_to_error_page(error_info)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
begin
# Instagram returns JSON error response which we parse to extract error details
JSON.parse(error.message)
rescue JSON::ParseError
# Fall back to a generic OAuth error if JSON parsing fails
{ 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message }
end
else
# For other unexpected errors
{ 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message }
end
end
# Handles the case when a user denies permissions or cancels the authorization flow
# Error parameters are documented at:
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
def handle_authorization_error
error_info = {
'error_type' => params[:error] || 'authorization_error',
'code' => 400,
'error_message' => params[:error_description] || 'Authorization was denied'
}
Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}")
redirect_to_error_page(error_info)
end
# Centralized method to redirect to error page with appropriate parameters
# This ensures consistent error handling across different error scenarios
# Frontend will handle the error page based on the error_type
def redirect_to_error_page(error_info)
redirect_to app_new_instagram_inbox_url(
account_id: account_id,
error_type: error_info['error_type'],
code: error_info['code'],
error_message: error_info['error_message']
)
end
def find_or_create_inbox
user_details = fetch_instagram_user_details(@long_lived_token_response['access_token'])
channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s)
channel_exists = channel_instagram.present?
if channel_instagram
update_channel(channel_instagram, user_details)
else
channel_instagram = create_channel_with_inbox(user_details)
end
# reauthorize channel, this code path only triggers when instagram auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_instagram.reauthorized!
[channel_instagram.inbox, channel_exists]
end
def find_channel_by_instagram_id(instagram_id)
Channel::Instagram.find_by(instagram_id: instagram_id, account: account)
end
def update_channel(channel_instagram, user_details)
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram.update!(
access_token: @long_lived_token_response['access_token'],
expires_at: expires_at
)
# Update inbox name if username changed
channel_instagram.inbox.update!(name: user_details['username'])
channel_instagram
end
def create_channel_with_inbox(user_details)
ActiveRecord::Base.transaction do
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram = Channel::Instagram.create!(
access_token: @long_lived_token_response['access_token'],
instagram_id: user_details['user_id'].to_s,
account: account,
expires_at: expires_at
)
account.inboxes.create!(
account: account,
channel: channel_instagram,
name: user_details['username']
)
channel_instagram
end
end
def account_id
return unless params[:state]
verify_instagram_token(params[:state])
end
def oauth_code
params[:code]
end
def account
@account ||= Account.find(account_id)
end
def provider_name
'instagram'
end
end
@@ -43,8 +43,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
['MAILER_INBOUND_EMAIL_DOMAIN']
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
@@ -15,9 +15,6 @@ class Webhooks::InstagramController < ActionController::API
private
def valid_token?(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', '')
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
end
end
+1 -2
View File
@@ -81,8 +81,7 @@ 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) },
marked_for_deletion: ->(resources) { resources.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") }
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
}.freeze
# Overwrite this method to customize how accounts are displayed
+2 -12
View File
@@ -86,19 +86,9 @@ 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
find_conversation_by_inbox
# Apply permission-based filtering
@conversations = Conversations::PermissionFilterService.new(
@conversations,
current_user,
current_account
).perform
@conversations = current_account.conversations
@conversations = @conversations.where(inbox_id: @inbox_ids) unless @is_admin
filter_by_conversation_type if params[:conversation_type]
@conversations
end
-4
View File
@@ -18,8 +18,4 @@ 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
@@ -1,49 +0,0 @@
module Instagram::IntegrationHelper
REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze
# Generates a signed JWT token for Instagram integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_instagram_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
nil
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Instagram JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_instagram_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
end
private
def client_secret
@client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
nil
end
end
+3
View File
@@ -4,6 +4,7 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad
import LoadingState from './components/widgets/LoadingState.vue';
import 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';
@@ -30,6 +31,7 @@ export default {
UpdateBanner,
PaymentPendingBanner,
WootSnackbarBox,
UpgradeBanner,
PendingEmailVerificationBanner,
},
setup() {
@@ -144,6 +146,7 @@ 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">
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class InstagramChannel extends ApiClient {
constructor() {
super('instagram', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new InstagramChannel();
@@ -17,12 +17,6 @@ class EnterpriseAccountAPI extends ApiClient {
getLimits() {
return axios.get(`${this.url}limits`);
}
toggleDeletion(action) {
return axios.post(`${this.url}toggle_deletion`, {
action_type: action,
});
}
}
export default new EnterpriseAccountAPI();
@@ -10,7 +10,6 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('update');
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
});
describe('API calls', () => {
@@ -43,21 +42,5 @@ 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' }
);
});
});
});
@@ -29,19 +29,6 @@
--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;
@@ -144,19 +131,6 @@
--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;
@@ -81,6 +81,28 @@
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
@@ -155,6 +177,71 @@
}
}
// Help center
.article-container .row--article-block {
td:last-child {
direction: initial;
}
}
.portal-popover__container .portal {
.actions-container {
margin-left: unset;
margin-right: var(--space-one);
}
}
.edit-article--container {
.header-right--wrap {
.button-group .button:first-child {
border-bottom-left-radius: 0;
border-bottom-right-radius: var(--border-radius-normal);
border-top-left-radius: 0;
border-top-right-radius: var(--border-radius-normal);
}
.button-group .button:last-child {
border-bottom-left-radius: var(--border-radius-normal);
border-bottom-right-radius: 0;
border-top-left-radius: var(--border-radius-normal);
border-top-right-radius: 0;
}
}
.header-left--wrap {
.back-button {
direction: initial;
}
}
.article--buttons {
.dropdown-pane {
left: 0;
position: absolute;
right: unset;
}
}
.sidebar-button {
transform: rotate(180deg);
}
}
.article-settings--container {
border-left: 0;
border-right: 1px solid var(--color-border-light);
flex-direction: row-reverse;
margin-left: 0;
margin-right: var(--space-normal);
padding-left: 0;
padding-right: var(--space-normal);
}
.category-list--container .header-left--wrap {
direction: initial;
justify-content: flex-end;
}
// Toggle switch
.toggle-button {
&.small {
@@ -177,6 +264,11 @@
}
}
// Widget builder
.widget-builder-container .widget-preview {
direction: initial;
}
// Modal
.modal-container {
text-align: right;
@@ -190,6 +282,7 @@
}
// Other changes
.colorpicker--chrome {
direction: initial;
}
@@ -198,6 +291,14 @@
direction: initial;
}
.contact--details .contact--bio {
direction: ltr;
}
.merge-contacts .child-contact-wrap {
direction: ltr;
}
.contact--form .input-group {
direction: initial;
}
@@ -29,6 +29,7 @@
@import 'rtl';
@import 'widgets/base';
@import 'widgets/buttons';
@import 'widgets/conversation-view';
@import 'widgets/tabs';
@import 'widgets/woot-tables';
@@ -40,12 +40,6 @@ 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 {
@@ -0,0 +1,228 @@
// scss-lint:disable SpaceAfterPropertyColon
// scss-lint:disable MergeableSelector
button {
font-family: inherit;
transition:
background-color 0.25s ease-out,
color 0.25s ease-out;
@apply inline-block items-center mb-0 text-center align-middle cursor-pointer text-sm mt-0 mx-0 py-1 px-2.5 border border-solid border-transparent dark:border-transparent rounded-[0.3125rem];
&:disabled,
&.disabled {
@apply opacity-40 cursor-not-allowed;
}
}
.button-group {
@apply mb-0 flex flex-nowrap items-stretch;
.button {
flex: 0 0 auto;
@apply m-0 text-sm rounded-none first:rounded-tl-[0.3125rem] first:rounded-bl-[0.3125rem] last:rounded-tr-[0.3125rem] last:rounded-br-[0.3125rem] rtl:space-x-reverse;
}
.button--only-icon {
@apply w-10 justify-center pl-0 pr-0;
}
}
.back-button {
@apply m-0;
}
.button {
@apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
.button__content {
@apply w-full whitespace-nowrap overflow-hidden text-ellipsis;
img,
svg {
@apply inline-block;
}
}
&:hover:not(:disabled):not(.success):not(.alert):not(.warning):not(
.clear
):not(.smooth):not(.hollow) {
@apply bg-n-brand/80 dark:bg-n-brand/80;
}
&:disabled,
&.disabled {
@apply opacity-40 cursor-not-allowed;
}
&.success {
@apply bg-n-teal-9 text-white dark:text-white;
}
&.secondary {
@apply bg-n-solid-3 text-white dark:text-white;
}
&.primary {
@apply bg-n-brand text-white dark:text-white;
}
&.clear {
@apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent;
}
&.alert {
@apply bg-n-ruby-9 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
}
}
&.warning {
@apply bg-n-amber-9 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
}
}
&.tiny {
@apply h-6 text-[10px];
}
&.small {
@apply h-8 text-xs;
}
.spinner {
@apply px-2 py-0;
}
// @TODDO - Remove after moving all buttons to woot-button
.icon + .button__content {
@apply w-auto;
}
&.expanded {
@apply flex justify-center text-center;
}
&.round {
@apply rounded-full;
}
// @TODO Use with link
&.compact {
@apply pb-0 pt-0;
}
&.hollow {
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:enabled:bg-n-brand/20;
&.secondary {
@apply text-n-slate-12 border-n-slate-5 hover:enabled:bg-n-slate-5;
}
&.success {
@apply text-n-teal-9 border-n-teal-8 hover:enabled:bg-n-teal-5;
}
&.alert {
@apply text-n-ruby-9 border-n-ruby-8 hover:enabled:bg-n-ruby-5;
}
&.warning {
@apply text-n-amber-9 border-n-amber-8 hover:enabled:bg-n-amber-5;
}
}
// Smooth style
&.smooth {
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:enabled:bg-n-brand/20 dark:hover:enabled:bg-n-brand/40;
&.secondary {
@apply bg-n-slate-4 text-n-slate-11 hover:enabled:text-n-slate-11 hover:enabled:bg-n-slate-5;
}
&.success {
@apply bg-n-teal-4 text-n-teal-11 hover:enabled:text-n-teal-11 hover:enabled:bg-n-teal-5;
}
&.alert {
@apply bg-n-ruby-4 text-n-ruby-11 hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-5;
}
&.warning {
@apply bg-n-amber-4 text-n-amber-11 hover:enabled:text-n-amber-11 hover:enabled:bg-n-amber-5;
}
}
&.clear {
@apply text-n-blue-text hover:enabled:bg-n-brand/10 dark:hover:enabled:bg-n-brand/30;
&.secondary {
@apply text-n-slate-12 hover:enabled:bg-n-slate-4;
}
&.success {
@apply text-n-teal-10 hover:enabled:bg-n-teal-4;
}
&.alert {
@apply text-n-ruby-11 hover:enabled:bg-n-ruby-4;
}
&.warning {
@apply text-n-amber-11 hover:enabled:bg-n-amber-4;
}
&:active {
&.secondary {
@apply active:bg-n-slate-3 dark:active:bg-n-slate-7;
}
}
&:focus {
&.secondary {
@apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6;
}
}
}
// Sizes
&.tiny {
@apply h-6;
}
&.small {
@apply h-8 pb-1 pt-1;
}
&.large {
@apply h-12;
}
&.button--only-icon {
@apply justify-center pl-0 pr-0 w-10;
&.tiny {
@apply w-6;
}
&.small {
@apply w-8;
}
&.large {
@apply w-12;
}
}
&.link {
@apply h-auto m-0 p-0;
&:hover {
@apply underline;
}
}
}
@@ -51,7 +51,6 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
@@ -31,6 +31,10 @@ 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',
@@ -1,5 +1,3 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup>
import { computed } from 'vue';
@@ -33,8 +33,6 @@ 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);
@@ -55,17 +53,9 @@ const useCopilotResponse = () => {
/>
<div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<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 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
@@ -103,7 +103,7 @@ export default {
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
</h3>
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
<label :class="{ error: v$.name.$error }">
<div>
<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>
</label>
</div>
<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>
@@ -12,7 +12,6 @@ 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 = {
@@ -19,17 +19,10 @@ const {
isAWebWidgetInbox,
isAWhatsAppChannel,
isAnEmailChannel,
isAInstagramChannel,
} = useInbox();
const {
status,
isPrivate,
createdAt,
sourceId,
messageType,
contentAttributes,
} = useMessageContext();
const { status, isPrivate, createdAt, sourceId, messageType } =
useMessageContext();
const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
@@ -37,11 +30,6 @@ 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;
@@ -59,8 +47,7 @@ const isSent = computed(() => {
isATwilioChannel.value ||
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value ||
isAInstagramChannel.value
isATelegramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -99,8 +86,7 @@ const isRead = computed(() => {
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isAInstagramChannel.value
isAFacebookInbox.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -116,6 +102,7 @@ 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;
});
@@ -26,12 +26,6 @@ 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;
@@ -76,7 +70,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 sortedCurrentUserAccounts"
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
:key="account.id"
class="cursor-pointer"
@@ -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 outline outline-1 outline-n-weak m-0 cursor-grab justify-between py-2 px-4 drag-handle"
class="flex items-center select-none w-full rounded-lg bg-n-slate-2 border border-n-weak m-0 cursor-grab justify-between py-2 px-4 drag-handle"
:class="{ 'rounded-bl-none rounded-br-none': isOpen }"
@click.stop="onToggle"
>
@@ -55,7 +55,7 @@ const onToggle = () => {
</button>
<div
v-if="isOpen"
class="bg-n-background outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
class="bg-n-background border border-n-weak dark:border-n-slate-2 border-t-0 rounded-br-lg rounded-bl-lg"
:class="compact ? 'p-0' : 'px-2 py-4'"
>
<slot />
@@ -61,7 +61,7 @@ import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
// 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';
@@ -106,7 +106,7 @@ const advancedFilterTypes = ref(
);
const currentUser = useMapGetter('getCurrentUser');
const chatLists = useMapGetter('getFilteredConversations');
const chatLists = useMapGetter('getAllConversations');
const mineChatsList = useMapGetter('getMineChats');
const allChatList = useMapGetter('getAllStatusChats');
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
@@ -326,12 +326,12 @@ const conversationList = computed(() => {
localConversationList = [...chatLists.value];
}
if (activeFolder.value) {
const { payload } = activeFolder.value.query;
localConversationList = localConversationList.filter(conversation => {
return matchesFilters(conversation, payload);
});
}
// if (activeFolder.value) {
// const { payload } = activeFolder.value.query;
// localConversationList = localConversationList.filter(conversation => {
// return matchesFilters(conversation, payload);
// });
// }
return localConversationList;
});
@@ -0,0 +1,64 @@
<script>
import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
export default {
props: {
value: {
type: String,
default: '',
},
},
data() {
return {
masked: true,
};
},
methods: {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
toggleMasked() {
this.masked = !this.masked;
},
},
};
</script>
<template>
<div class="text--container">
<woot-button size="small" class="button--text" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</woot-button>
<woot-button
variant="clear"
size="small"
class="button--visibility"
color-scheme="secondary"
:icon="masked ? 'eye-show' : 'eye-hide'"
@click.prevent="toggleMasked"
/>
<highlightjs v-if="value" :code="masked ? '•'.repeat(10) : value" />
</div>
</template>
<style lang="scss" scoped>
.text--container {
position: relative;
text-align: left;
.button--text,
.button--visibility {
margin-top: 0;
position: absolute;
right: 0;
}
.button--visibility {
right: 60px;
}
}
</style>
@@ -3,16 +3,12 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { 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: 'sm',
default: 'small',
},
},
computed: {
@@ -37,13 +33,13 @@ export default {
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<NextButton
<woot-button
v-if="!hasNextSidebar"
ghost
slate
:size="size"
icon="i-lucide-menu"
class="-ml-3"
variant="clear"
color-scheme="secondary"
class="-ml-3 text-black-900 dark:text-slate-300"
icon="list"
@click="onMenuItemClick"
/>
</template>
@@ -35,7 +35,7 @@ export default {
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
action-button-icon="i-lucide-mail"
action-button-icon="mail"
has-action-button
@primary-action="resendVerificationEmail"
/>
@@ -0,0 +1,52 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
Spinner,
},
props: {
isLoading: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
buttonIconClass: {
type: String,
default: '',
},
type: {
type: String,
default: 'button',
},
variant: {
type: String,
default: 'primary',
},
},
created() {
if (import.meta.env.DEV) {
// eslint-disable-next-line
console.warn(
'[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead'
);
}
},
};
</script>
<template>
<button :type="type" class="button nice" :class="variant">
<fluent-icon
v-if="!isLoading && icon"
class="icon"
:class="buttonIconClass"
:icon="icon"
/>
<Spinner v-if="isLoading" />
<slot />
</button>
</template>
@@ -0,0 +1,66 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
Spinner,
},
props: {
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
buttonText: {
type: String,
default: '',
},
buttonClass: {
type: String,
default: '',
},
iconClass: {
type: String,
default: '',
},
spinnerClass: {
type: String,
default: '',
},
type: {
type: String,
default: 'submit',
},
},
computed: {
computedClass() {
return `button nice gap-2 ${this.buttonClass || ' '}`;
},
},
};
</script>
<template>
<button
:type="type"
data-testid="submit_button"
:disabled="disabled"
:class="computedClass"
>
<fluent-icon v-if="!!iconClass" :icon="iconClass" class="icon" />
<span>{{ buttonText }}</span>
<Spinner v-if="loading" class="ml-2" :color-scheme="spinnerClass" />
</button>
</template>
<style lang="scss" scoped>
button:disabled {
@apply bg-woot-100 dark:bg-woot-500/25 dark:text-slate-500 opacity-100;
&:hover {
@apply bg-woot-100 dark:bg-woot-500/25;
}
}
</style>
@@ -134,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div
class="rounded-lg shadow outline-1 outline"
class="rounded-lg shadow button-group outline-1 outline"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
<Button
@@ -1,6 +1,7 @@
// [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';
@@ -17,6 +18,7 @@ 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';
@@ -24,6 +26,7 @@ import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
Button,
Code,
ColorPicker,
ConfirmDeleteModal,
@@ -40,6 +43,7 @@ const WootUIKit = {
ModalHeader,
SidemenuIcon,
Spinner,
SubmitButton,
Tabs,
TabsItem,
Thumbnail,
@@ -7,7 +7,6 @@ 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;
@@ -18,7 +17,6 @@ export default {
WootDropdownMenu,
WootDropdownItem,
AvailabilityStatusBadge,
NextButton,
},
data() {
return {
@@ -103,21 +101,19 @@ export default {
:key="status.value"
class="flex items-baseline"
>
<NextButton
sm
:color="status.disabled ? 'blue' : 'slate'"
:variant="status.disabled ? 'faded' : 'ghost'"
class="status-change--dropdown-button !w-full !justify-start"
<woot-button
size="small"
:color-scheme="status.disabled ? '' : 'secondary'"
:variant="status.disabled ? 'smooth' : 'clear'"
class="status-change--dropdown-button"
@click="changeAvailabilityStatus(status.value)"
>
<AvailabilityStatusBadge :status="status.value" />
<span class="min-w-0 truncate font-medium text-xs">
{{ status.label }}
</span>
</NextButton>
{{ status.label }}
</woot-button>
</WootDropdownItem>
<WootDropdownDivider />
<WootDropdownItem class="flex items-center justify-between px-3 py-2 m-0">
<WootDropdownItem class="flex items-center justify-between p-2 m-0">
<div class="flex items-center">
<fluent-icon
v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')"
@@ -127,7 +123,7 @@ export default {
/>
<span
class="mx-2 my-0 text-xs font-medium text-slate-600 dark:text-slate-100"
class="mx-1 my-0 text-xs font-medium text-slate-600 dark:text-slate-100"
>
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
</span>
@@ -1,11 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['toggleAccounts'],
data() {
return { showSwitchButton: false };
@@ -50,13 +46,14 @@ export default {
class="absolute top-0 right-0 flex items-center justify-end w-full h-full rounded-md ltr:overlay-shadow ltr:dark:overlay-shadow-dark rtl:rtl-overlay-shadow rtl:dark:rtl-overlay-shadow-dark"
>
<div class="mx-2 my-0">
<NextButton
ghost
xs
icon="i-lucide-arrow-right-left"
:label="$t('SIDEBAR.SWITCH')"
<woot-button
variant="clear"
size="tiny"
icon="arrow-swap"
@click="$emit('toggleAccounts')"
/>
>
{{ $t('SIDEBAR.SWITCH') }}
</woot-button>
</div>
</div>
</transition>
@@ -3,12 +3,8 @@ 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,
@@ -90,24 +86,19 @@ export default {
/>
</label>
</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 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>
</form>
</div>
@@ -1,12 +1,10 @@
<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: {
@@ -27,10 +25,10 @@ export default {
</script>
<template>
<NextButton
<woot-button
v-tooltip.right="$t(`SIDEBAR.PROFILE_SETTINGS`)"
link
class="rounded-full"
variant="link"
class="flex items-center rounded-full"
@click="handleClick"
>
<Thumbnail
@@ -39,7 +37,6 @@ export default {
:status="statusOfAgent"
should-show-status-always
size="32px"
class="flex-shrink-0"
/>
</NextButton>
</woot-button>
</template>
@@ -5,14 +5,12 @@ 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: {
@@ -84,46 +82,37 @@ export default {
<AvailabilityStatus />
<WootDropdownMenu>
<WootDropdownItem v-if="showChangeAccountOption">
<NextButton
ghost
sm
slate
icon="i-lucide-arrow-right-left"
class="!w-full !justify-start"
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="arrow-swap"
@click="$emit('toggleAccounts')"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
</span>
</NextButton>
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
</woot-button>
</WootDropdownItem>
<WootDropdownItem v-if="showChatSupport">
<NextButton
ghost
sm
slate
icon="i-lucide-message-circle-question"
class="!w-full !justify-start"
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="chat-help"
@click="$emit('showSupportChatWindow')"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.CONTACT_SUPPORT') }}
</span>
</NextButton>
{{ $t('SIDEBAR_ITEMS.CONTACT_SUPPORT') }}
</woot-button>
</WootDropdownItem>
<WootDropdownItem>
<NextButton
ghost
sm
slate
icon="i-lucide-keyboard"
class="!w-full !justify-start"
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="keyboard"
@click="handleKeyboardHelpClick"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
</span>
</NextButton>
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
</woot-button>
</WootDropdownItem>
<WootDropdownItem>
<router-link
@@ -133,70 +122,56 @@ 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)"
>
<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>
<fluent-icon icon="person" size="14" class="icon icon--font" />
<span class="button__content">
{{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
</span>
</a>
</router-link>
</WootDropdownItem>
<WootDropdownItem>
<NextButton
ghost
sm
slate
icon="i-lucide-sun-moon"
class="!w-full !justify-start"
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="appearance"
@click="openAppearanceOptions"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.APPEARANCE') }}
</span>
</NextButton>
{{ $t('SIDEBAR_ITEMS.APPEARANCE') }}
</woot-button>
</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')"
>
<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>
<fluent-icon
icon="content-settings"
size="14"
class="icon icon--font"
/>
<span class="button__content">
{{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
</span>
</a>
</WootDropdownItem>
<WootDropdownItem>
<NextButton
ghost
sm
slate
icon="i-lucide-circle-power"
class="!w-full !justify-start"
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="power"
@click="logout"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.LOGOUT') }}
</span>
</NextButton>
{{ $t('SIDEBAR_ITEMS.LOGOUT') }}
</woot-button>
</WootDropdownItem>
</WootDropdownMenu>
</div>
@@ -13,10 +13,9 @@ 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, NextButton },
components: { SecondaryChildNavItem, Policy },
props: {
menuItem: {
type: Object,
@@ -206,7 +205,14 @@ export default {
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="menuItem.showNewButton" class="flex items-center">
<NextButton ghost xs slate icon="i-lucide-plus" @click="onClickOpen" />
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
class="p-0 ml-2"
@click="onClickOpen"
/>
</div>
</div>
<router-link
@@ -266,15 +272,16 @@ export default {
>
<li class="pl-1">
<a :href="href">
<NextButton
ghost
xs
slate
icon="i-lucide-plus"
:label="$t(`SIDEBAR.${menuItem.newLinkTag}`)"
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
:data-testid="menuItem.dataTestid"
@click="e => newLinkClick(e, navigate)"
/>
>
{{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }}
</woot-button>
</a>
</li>
</router-link>
@@ -41,6 +41,7 @@ describe('AccountSelector', () => {
'fluent-icon': FluentIcon,
},
stubs: {
WootButton: { template: '<button />' },
// override global stub
WootModalHeader: false,
},
@@ -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 NextButton from 'dashboard/components-next/button/Button.vue';
import WootButton from 'dashboard/components/ui/WootButton.vue';
describe('AgentDetails', () => {
const currentUser = {
@@ -40,12 +40,12 @@ describe('AgentDetails', () => {
plugins: [store],
components: {
Thumbnail,
NextButton,
WootButton,
},
directives: {
tooltip: mockTooltipDirective, // Mocking the tooltip directive
},
stubs: { NextButton: { template: '<button><slot /></button>' } },
stubs: { WootButton: { template: '<button><slot /></button>' } },
},
});
});
@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AvailabilityStatus from '../AvailabilityStatus.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import WootButton from 'dashboard/components/ui/WootButton.vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import 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: {
NextButton,
WootButton,
WootDropdownItem,
WootDropdownMenu,
WootDropdownHeader,
@@ -22,7 +22,7 @@ const store = createStore({
describe('SidemenuIcon', () => {
test('matches snapshot', () => {
const wrapper = shallowMount(SidemenuIcon, {
stubs: { NextButton: { template: '<button><slot /></button>' } },
stubs: { WootButton: { template: '<button><slot /></button>' } },
global: { plugins: [store] },
});
expect(wrapper.vm).toBeTruthy();
@@ -2,11 +2,11 @@
exports[`SidemenuIcon > matches snapshot 1`] = `
<button
class="-ml-3"
ghost=""
icon="i-lucide-menu"
size="sm"
slate=""
class="-ml-3 text-black-900 dark:text-slate-300"
color-scheme="secondary"
icon="list"
size="small"
variant="clear"
>
@@ -1,10 +1,5 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
bannerMessage: {
type: String,
@@ -24,7 +19,7 @@ export default {
},
actionButtonVariant: {
type: String,
default: 'faded',
default: '',
},
actionButtonLabel: {
type: String,
@@ -32,7 +27,7 @@ export default {
},
actionButtonIcon: {
type: String,
default: 'i-lucide-arrow-right',
default: 'arrow-right',
},
colorScheme: {
type: String,
@@ -53,18 +48,6 @@ 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) {
@@ -94,23 +77,27 @@ export default {
</a>
</span>
<div class="actions">
<NextButton
<woot-button
v-if="hasActionButton"
xs
size="tiny"
:icon="actionButtonIcon"
:variant="actionButtonVariant"
:color="getButtonColor"
:label="actionButtonLabel"
color-scheme="primary"
class-names="banner-action__button"
@click="onClick"
/>
<NextButton
>
{{ actionButtonLabel }}
</woot-button>
<woot-button
v-if="hasCloseButton"
xs
icon="i-lucide-circle-x"
:color="getButtonColor"
:label="$t('GENERAL_SETTINGS.DISMISS')"
size="tiny"
:color-scheme="colorScheme"
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
/>
>
{{ $t('GENERAL_SETTINGS.DISMISS') }}
</woot-button>
</div>
</div>
</template>
@@ -119,6 +106,13 @@ 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 {
@@ -130,6 +124,13 @@ 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;
@@ -145,12 +146,21 @@ 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;
}
@@ -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 bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
>
<CalendarDateRange
:selected-range="selectedRange"
@set-range="setDateRange"
/>
<div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-n-strong"
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
>
<div class="flex justify-around h-fit">
<!-- 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-n-strong">
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
<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-n-strong'
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
"
>
<CalendarYear
@@ -1,8 +1,6 @@
<script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
calendarType: {
type: String,
@@ -40,38 +38,42 @@ const onClickSetView = (type, mode) => {
<template>
<div class="flex items-start justify-between w-full h-9">
<NextButton
slate
ghost
xs
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 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-n-slate-12 hover:text-n-brand"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50 hover:text-woot-600 dark:hover:text-woot-600"
@click="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
<button
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12"
:class="{ 'hover:text-n-brand': viewMode }"
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 }"
@click="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
</div>
<NextButton
slate
ghost
xs
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@click="onClickNext(calendarType)"
/>
>
<fluent-icon
icon="chevron-right"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
</div>
</template>
@@ -65,7 +65,7 @@ const validateDate = () => {
<input
v-model="localDateValue"
type="text"
class="!text-sm !mb-0 disabled:!outline-n-strong"
class="reset-base border bg-slate-25 dark:bg-slate-900 ring-offset-ash-900 border-slate-50 dark:border-slate-700/50 w-full disabled:text-slate-200 dark:disabled:text-slate-700 disabled:cursor-not-allowed text-slate-800 dark:text-slate-50 px-1.5 py-1 text-sm rounded-xl h-10"
:placeholder="dateFormat"
:disabled="isDisabled"
@keypress.enter="validateDate"
@@ -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-n-slate-12"
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</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-n-alpha-2 dark:hover:bg-n-solid-3"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-slate-50 dark:hover:bg-slate-700"
:class="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
: 'text-slate-600 dark:text-slate-200'
"
@click="setDateRange(range)"
>
@@ -1,6 +1,4 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['clear', 'change']);
const onClickClear = () => {
@@ -13,19 +11,18 @@ const onClickApply = () => {
</script>
<template>
<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')"
<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"
@click="onClickClear"
/>
<NextButton
sm
ghost
:label="$t('DATE_PICKER.APPLY_BUTTON')"
>
{{ $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"
@click="onClickApply"
/>
>
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
</button>
</div>
</template>
@@ -71,12 +71,10 @@ const selectMonth = index => {
<button
v-for="(month, index) in months"
:key="index"
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[92px] h-10 rounded-lg py-2.5 px-2"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[92px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{
'bg-n-brand text-white hover:bg-n-blue-10':
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
index === activeMonthIndex,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex,
}"
@click="selectMonth(index)"
>
@@ -107,16 +107,17 @@ const isNextDayInRange = day => {
};
const dayClasses = day => ({
'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':
'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':
isInCurrentMonth(day),
'bg-n-brand text-white':
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-n-blue-4 dark:bg-n-blue-5':
'bg-woot-50 dark:bg-woot-800':
(isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day),
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-text':
'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
@@ -163,7 +164,7 @@ const dayClasses = day => ({
!isLastDayOfMonth(day) &&
isInCurrentMonth(day)
"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-n-blue-4 dark:bg-n-blue-5 -z-10"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
/>
</div>
</div>
@@ -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-n-slate-12 w-[144px] h-10 rounded-lg py-2.5 px-2"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[144px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{
'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,
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
year === activeYear,
}"
@click="selectYear(year)"
>
@@ -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-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1"
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-slate-50 dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="openDatePicker"
>
<fluent-icon
@@ -1,16 +1,14 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
buttonText: {
type: String,
default: '',
},
trailingIcon: {
type: Boolean,
default: false,
rightIcon: {
type: String,
default: '',
},
icon: {
leftIcon: {
type: String,
default: '',
},
@@ -18,15 +16,32 @@ defineProps({
</script>
<template>
<Button
ghost
slate
sm
class="relative"
:icon="icon"
:trailing-icon="trailingIcon"
<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"
>
<span class="min-w-0 truncate">{{ buttonText }}</span>
<slot name="leftIcon">
<fluent-icon
v-if="leftIcon"
:icon="leftIcon"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</slot>
<span
v-if="buttonText"
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
{{ buttonText }}
</span>
<slot name="rightIcon">
<fluent-icon
v-if="rightIcon"
:icon="rightIcon"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</slot>
<slot name="dropdown" />
</Button>
</button>
</template>
@@ -8,7 +8,9 @@ defineProps({
</script>
<template>
<div class="flex items-center justify-center h-10 text-sm text-n-slate-11">
<div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
{{ message }}
</div>
</template>
@@ -78,7 +78,7 @@ const shouldShowEmptyState = computed(() => {
<template>
<div
class="absolute z-20 w-40 bg-n-solid-2 border-0 outline outline-1 outline-n-weak shadow rounded-xl max-h-[400px]"
class="absolute z-20 w-40 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-50 dark:border-slate-700/50 max-h-[400px]"
@click.stop
>
<slot name="search">
@@ -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:enabled:bg-n-alpha-2"
class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:bg-slate-50 dark:hover:bg-slate-700 active:bg-slate-75 dark:active:bg-slate-800"
>
<div class="inline-flex items-center gap-3 overflow-hidden">
<fluent-icon
@@ -30,14 +30,16 @@ defineProps({
size="18"
:style="{ color: iconColor }"
/>
<span class="text-sm font-medium truncate text-n-slate-12">
<span
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
{{ buttonText }}
</span>
<fluent-icon
v-if="isActive"
icon="checkmark"
size="18"
class="flex-shrink-0 text-n-slate-12"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</div>
<slot name="dropdown" />
@@ -8,7 +8,9 @@ defineProps({
</script>
<template>
<div class="flex items-center justify-center h-10 text-sm text-n-slate-11">
<div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
{{ message }}
</div>
</template>
@@ -1,7 +1,5 @@
<script setup>
import { defineEmits, defineModel } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
inputPlaceholder: {
type: String,
@@ -23,29 +21,31 @@ const value = defineModel({
<template>
<div
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-n-solid-2 dark:bg-n-solid-2 z-10 gap-2 px-3 border-b rounded-t-xl border-n-weak"
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-white z-10 dark:bg-slate-800 gap-2 px-3 border-b rounded-t-xl border-slate-50 dark:border-slate-700"
>
<div class="flex items-center w-full gap-2" @keyup.space.prevent>
<fluent-icon
icon="search"
size="16"
class="text-n-slate-11 flex-shrink-0"
class="text-slate-400 dark:text-slate-400 flex-shrink-0"
/>
<input
v-model="value"
:placeholder="inputPlaceholder"
type="text"
class="w-full mb-0 text-sm !outline-0 bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
class="w-full mb-0 text-sm bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-75 reset-base"
/>
</div>
<!-- Clear filter button -->
<NextButton
<woot-button
v-if="!modelValue && showClearFilter"
faded
xs
class="flex-shrink-0"
:label="$t('REPORT.FILTER_ACTIONS.CLEAR_FILTER')"
size="small"
variant="clear"
color-scheme="primary"
class="!px-1 !py-1.5"
@click="emit('remove')"
/>
>
{{ $t('REPORT.FILTER_ACTIONS.CLEAR_FILTER') }}
</woot-button>
</div>
</template>
@@ -0,0 +1,129 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
export default {
name: 'WootButton',
components: { EmojiOrIcon, Spinner },
props: {
type: {
type: String,
default: 'submit',
},
variant: {
type: String,
default: '',
},
size: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
emoji: {
type: String,
default: '',
},
colorScheme: {
type: String,
default: 'primary',
},
classNames: {
type: [String, Object],
default: '',
},
isDisabled: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
isExpanded: {
type: Boolean,
default: false,
},
},
computed: {
variantClasses() {
if (this.variant.includes('link')) {
return `clear ${this.variant}`;
}
return this.variant;
},
hasOnlyIcon() {
const hasEmojiOrIcon = this.emoji || this.icon;
return !this.$slots.default && hasEmojiOrIcon;
},
hasOnlyIconClasses() {
return this.hasOnlyIcon ? 'button--only-icon' : '';
},
buttonClasses() {
return [
this.variantClasses,
this.hasOnlyIconClasses,
this.size,
this.colorScheme,
this.classNames,
this.isDisabled ? 'disabled' : '',
this.isExpanded ? 'expanded' : '',
];
},
iconSize() {
switch (this.size) {
case 'tiny':
return 12;
case 'small':
return 14;
case 'medium':
return 16;
case 'large':
return 18;
default:
return 16;
}
},
showDarkSpinner() {
return (
this.colorScheme === 'secondary' ||
this.variant === 'clear' ||
this.variant === 'link' ||
this.variant === 'hollow'
);
},
},
};
</script>
<template>
<button
class="button"
:type="type"
:class="buttonClasses"
:disabled="isDisabled || isLoading"
>
<Spinner
v-if="isLoading"
size="small"
:color-scheme="showDarkSpinner ? 'dark' : ''"
/>
<EmojiOrIcon
v-else-if="icon || emoji"
class="icon"
:emoji="emoji"
:icon="icon"
:icon-size="iconSize"
/>
<span
v-if="$slots.default"
class="button__content"
:class="{ 'text-left rtl:text-right': size !== 'expanded' }"
>
<slot />
</span>
</button>
</template>
@@ -32,10 +32,6 @@ export default {
return this.enabledFeatures.channel_email;
}
if (key === 'instagram') {
return this.enabledFeatures.channel_instagram;
}
return [
'website',
'twilio',
@@ -44,7 +40,6 @@ export default {
'sms',
'telegram',
'line',
'instagram',
].includes(key);
},
},
@@ -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,6 +21,13 @@ 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);
}
@@ -48,61 +55,84 @@ const onLastPage = () => {
</script>
<template>
<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"
<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)"
@click="onFirstPage"
/>
<div class="flex items-center justify-center bg-n-slate-9/10 h-full">
<div class="w-px h-4 rounded-sm bg-n-strong" />
</div>
<NextButton
faded
sm
slate
icon="i-lucide-chevron-left"
class="rounded-none"
:disabled="hasPrevPage"
@click="onPrevPage"
/>
<div
class="flex items-center gap-3 px-3 tabular-nums bg-n-slate-9/10 h-full"
>
<span class="text-sm text-n-slate-12">
<fluent-icon
icon="chevrons-left"
size="20"
icon-lib="lucide"
:class="hasFirstPage && 'opacity-40'"
/>
</woot-button>
<div class="w-px h-4 rounded-sm bg-slate-75 dark:bg-slate-700/50" />
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
:is-disabled="hasPrevPage"
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
:class="buttonClass(hasPrevPage)"
@click="onPrevPage"
>
<fluent-icon
icon="chevron-left-single"
size="20"
icon-lib="lucide"
:class="hasPrevPage && 'opacity-40'"
/>
</woot-button>
<div
class="flex items-center gap-3 px-3 tabular-nums bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-100"
>
<span class="text-sm text-slate-800 dark:text-slate-75">
{{ currentPage }}
</span>
<span class="text-n-slate-11">/</span>
<span class="text-sm text-n-slate-11">
<span class="text-slate-600 dark:text-slate-500">/</span>
<span class="text-sm text-slate-600 dark:text-slate-500">
{{ totalPages }}
</span>
</div>
<NextButton
faded
sm
slate
icon="i-lucide-chevron-right"
class="rounded-none"
:disabled="hasNextPage"
<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)"
@click="onNextPage"
/>
<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"
>
<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"
@click="onLastPage"
/>
>
<fluent-icon
icon="chevrons-right"
size="20"
icon-lib="lucide"
:class="hasLastPage && 'opacity-40'"
/>
</woot-button>
</div>
</template>
@@ -220,7 +220,6 @@ const plugins = computed(() => {
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate,
}),
createSuggestionPlugin({
trigger: '/',
@@ -775,24 +774,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
@apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
h5 {
@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;
}
}
@apply dark:text-slate-25 text-slate-800;
}
}
@@ -328,24 +328,11 @@ export default {
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
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;
}
}
}
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;
}
</style>
@@ -10,7 +10,6 @@ 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';
@@ -114,10 +113,6 @@ export default {
type: String,
required: true,
},
conversationType: {
type: String,
default: '',
},
},
emits: [
'replaceText',
@@ -192,9 +187,6 @@ 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;
@@ -202,10 +194,6 @@ 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() {
@@ -106,3 +106,43 @@ 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>
@@ -2,12 +2,8 @@
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
show: {
type: Boolean,
@@ -157,18 +153,13 @@ export default {
</div>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('EMAIL_TRANSCRIPT.CANCEL')"
@click.prevent="onCancel"
/>
<NextButton
type="submit"
:label="$t('EMAIL_TRANSCRIPT.SUBMIT')"
<woot-submit-button
:button-text="$t('EMAIL_TRANSCRIPT.SUBMIT')"
:disabled="!isFormValid"
/>
<button class="button clear" @click.prevent="onCancel">
{{ $t('EMAIL_TRANSCRIPT.CANCEL') }}
</button>
</div>
</form>
</div>
@@ -218,37 +218,24 @@ export default {
if (additionalAttributes) {
const {
agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes;
return (
agentReplyTimeWindowMessage ||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
return agentReplyTimeWindowMessage;
}
return '';
}
return this.$t('CONVERSATION.CANNOT_REPLY');
},
replyWindowLink() {
if (this.isAFacebookInbox || this.isAInstagramChannel) {
if (this.isAWhatsAppChannel) {
return REPLY_POLICY.FACEBOOK;
}
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
},
replyWindowLinkText() {
if (
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAInstagramChannel
) {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
@@ -498,7 +485,7 @@ export default {
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
class="mt-2 mx-2 rounded-lg overflow-hidden"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
@@ -261,8 +261,7 @@ export default {
this.isAnEmailChannel ||
this.isASmsInbox ||
this.isATelegramChannel ||
this.isALineChannel ||
this.isAInstagramChannel
this.isALineChannel
);
},
replyButtonLabel() {
@@ -421,7 +420,6 @@ export default {
if (conversationId !== oldConversationId) {
this.setToDraft(oldConversationId, this.replyType);
this.getFromDraft();
this.resetRecorderAndClearAttachments();
}
},
message(updatedMessage) {
@@ -531,12 +529,6 @@ export default {
);
}
},
resetRecorderAndClearAttachments() {
// Reset audio recorder UI state
this.resetAudioRecorderInput();
// Reset attached files
this.attachedFiles = [];
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const key = `draft-${conversationId}-${replyType}`;
@@ -1075,9 +1067,9 @@ export default {
<template>
<Banner
v-if="showSelfAssignBanner"
action-button-variant="ghost"
action-button-variant="clear"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg banner--self-assign"
class="banner--self-assign mx-2 mb-2 rounded-lg"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@@ -1196,7 +1188,6 @@ export default {
:mode="replyType"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
@@ -69,6 +69,10 @@ const onAgentSelect = index => {
v-if="items.length"
ref="tagAgentsRef"
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
:class="{
'border-b-[0.5rem] border-solid border-white dark:!border-slate-700':
items.length <= 4,
}"
>
<li
v-for="(agent, index) in items"
@@ -4,12 +4,8 @@ import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
name: {
type: String,
@@ -95,12 +91,9 @@ export default {
</p>
</div>
<div v-if="formattedPhoneNumber" class="link-wrap">
<NextButton
ghost
xs
:label="$t('CONVERSATION.SAVE_CONTACT')"
@click.prevent="addContact"
/>
<woot-button variant="clear" size="small" @click.prevent="addContact">
{{ $t('CONVERSATION.SAVE_CONTACT') }}
</woot-button>
</div>
</div>
</template>
@@ -2,12 +2,8 @@
import DyteAPI from 'dashboard/api/integrations/dyte';
import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
messageId: {
type: Number,
@@ -45,25 +41,31 @@ export default {
<template>
<div>
<NextButton
blue
sm
icon="i-lucide-video"
:label="$t('INTEGRATION_SETTINGS.DYTE.CLICK_HERE_TO_JOIN')"
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
icon="video-add"
class="join-call-button"
:is-loading="isLoading"
@click="joinTheCall"
/>
>
{{ $t('INTEGRATION_SETTINGS.DYTE.CLICK_HERE_TO_JOIN') }}
</woot-button>
<div v-if="dyteAuthToken" class="video-call--container">
<iframe
:src="meetingLink"
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
/>
<NextButton
sm
class="mt-2"
:label="$t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM')"
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
class="join-call-button"
@click="leaveTheRoom"
/>
>
{{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }}
</woot-button>
</div>
</div>
</template>
@@ -1,6 +1,6 @@
<script>
// components
import NextButton from 'dashboard/components-next/button/Button.vue';
import WootButton from '../../../ui/WootButton.vue';
import Avatar from '../../Avatar.vue';
// composables
@@ -19,7 +19,7 @@ export default {
name: 'LabelSuggestion',
components: {
Avatar,
NextButton,
WootButton,
},
props: {
suggestedLabels: {
@@ -185,44 +185,42 @@ export default {
"
/>
</button>
<NextButton
<WootButton
v-if="preparedLabels.length === 1"
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
faded
xs
icon="i-lucide-x"
class="flex-shrink-0"
:color="isHovered ? 'ruby' : 'blue'"
variant="smooth"
:color-scheme="isHovered ? 'alert' : 'primary'"
class="label--add"
icon="dismiss"
size="tiny"
@click="dismissSuggestions"
/>
</div>
<div
v-if="preparedLabels.length > 1"
class="inline-flex items-center gap-1"
>
<NextButton
xs
icon="i-lucide-plus"
class="flex-shrink-0"
:variant="selectedLabels.length === 0 ? 'faded' : 'solid'"
:label="addButtonText"
<div v-if="preparedLabels.length > 1">
<WootButton
:variant="selectedLabels.length === 0 ? 'smooth' : ''"
class="label--add"
icon="add"
size="tiny"
@click="addAllLabels"
/>
<NextButton
>
{{ addButtonText }}
</WootButton>
<WootButton
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
faded
xs
icon="i-lucide-x"
class="flex-shrink-0"
:color="isHovered ? 'ruby' : 'blue'"
:color-scheme="isHovered ? 'alert' : 'primary'"
variant="smooth"
class="label--add"
icon="dismiss"
size="tiny"
@click="dismissSuggestions"
/>
</div>
@@ -107,10 +107,9 @@ const linkIssue = async () => {
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
>
<FilterButton
trailing-icon
icon="i-lucide-chevron-down"
right-icon="chevron-down"
:button-text="linkIssueTitle"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl bg-n-alpha-black2 outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl border border-slate-50 bg-slate-25 dark:border-slate-600 dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-900/50"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
@@ -50,10 +50,9 @@ const selectedItemId = computed(() => selectedItem.value?.id || null);
<label class="w-full" :class="{ error: hasError }">
{{ label }}
<FilterButton
trailing-icon
icon="i-lucide-chevron-down"
right-icon="chevron-down"
:button-text="selectedItemName"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl bg-n-alpha-black2 outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl border border-slate-50 bg-slate-25 dark:border-slate-600 dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-900/50"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
@@ -1,10 +1,5 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
label: {
type: String,
@@ -54,13 +49,15 @@ export default {
:username="usernameAvatar"
/>
<div v-if="src && deleteAvatar" class="avatar-delete-btn">
<NextButton
outline
xs
ruby
:label="$t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT')"
<woot-button
color-scheme="alert"
variant="hollow"
size="tiny"
type="button"
@click="onAvatarDelete"
/>
>
{{ $t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT') }}
</woot-button>
</div>
<label>
<input
@@ -121,10 +121,6 @@ export const useInbox = () => {
);
});
const isAInstagramChannel = computed(() => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
return {
inbox,
isAFacebookInbox,
@@ -141,6 +137,5 @@ export const useInbox = () => {
isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAInstagramChannel,
};
};
@@ -7,12 +7,8 @@ import { formatTime } from '@chatwoot/utils';
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
* @returns {Object} An object containing utility functions for report metrics.
*/
export function useReportMetrics(
accountSummaryKey = 'getAccountSummary',
summarFetchingKey = 'getAccountSummaryFetchingStatus'
) {
export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
const accountSummary = useMapGetter(accountSummaryKey);
const fetchingStatus = useMapGetter(summarFetchingKey);
/**
* Calculates the trend percentage for a given metric.
@@ -57,6 +53,5 @@ export function useReportMetrics(
calculateTrend,
isAverageMetricType,
displayMetric,
fetchingStatus,
};
}
@@ -36,7 +36,7 @@ const useConversationSidebarItemsOrder = uiSettings => {
const { conversation_sidebar_items_order: itemsOrder } = uiSettings.value;
// If the sidebar order is not set, use the default order.
if (!itemsOrder) {
return [...DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER];
return DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER;
}
// Create a copy of itemsOrder to avoid mutating the original store object.
const itemsOrderCopy = [...itemsOrder];
-1
View File
@@ -34,7 +34,6 @@ export const FEATURE_FLAGS = {
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
};
@@ -0,0 +1,29 @@
class ConversationMetaThrottleManager {
constructor() {
this.lastUpdatedTime = null;
}
shouldThrottle(threshold = 10000) {
if (!this.lastUpdatedTime) {
return false;
}
const currentTime = new Date().getTime();
const lastUpdatedTime = new Date(this.lastUpdatedTime).getTime();
if (currentTime - lastUpdatedTime < threshold) {
return true;
}
return false;
}
markUpdate() {
this.lastUpdatedTime = new Date();
}
reset() {
this.lastUpdatedTime = null;
}
}
export default new ConversationMetaThrottleManager();

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