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