Merge pull request #244 from fazer-ai/chore/merge-upstream-4.12.0

Chore/merge upstream 4.12.0
This commit is contained in:
Gabriel Jablonski 2026-03-20 01:00:39 -03:00 committed by GitHub
commit 2bf55d261a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2190 changed files with 68605 additions and 16394 deletions

View File

@ -1,3 +1,4 @@
--- ---
ignore: ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)

View File

@ -93,8 +93,8 @@ jobs:
exit 1 exit 1
fi fi
mkdir -p ~/tmp mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
# Bundle audit # Bundle audit
- run: - run:

1
.gitignore vendored
View File

@ -95,6 +95,7 @@ yarn-debug.log*
.claude/settings.local.json .claude/settings.local.json
.cursor .cursor
.codex/ .codex/
.claude/
CLAUDE.local.md CLAUDE.local.md
# Histoire deployment # Histoire deployment

View File

@ -15,8 +15,7 @@
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` - **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` - **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev` - **Run Project**: `overmind start -f Procfile.dev`
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`) - **Ruby Version**: Manage Ruby via `rvm`
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.) - Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
## Code Style ## Code Style
@ -68,6 +67,15 @@
- Example: `feat(auth): add user authentication` - Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages - Don't reference Claude in commit messages
## PR Description Format
- Start with a short, user-facing paragraph describing the product change.
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
- For feature PRs, add `How to test` from a product/UX standpoint.
- For bugfix PRs, use `How to reproduce` when helpful.
- Optionally add a `What changed` section for implementation highlights.
- Do not add a `How this was tested` section listing specs/commands.
## Project-Specific ## Project-Specific
- **Translations**: - **Translations**:

View File

@ -192,7 +192,7 @@ gem 'reverse_markdown'
gem 'iso-639' gem 'iso-639'
gem 'ruby-openai' gem 'ruby-openai'
gem 'ai-agents' gem 'ai-agents', '>= 0.9.1'
# TODO: Move this gem as a dependency of ai-agents # TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm', '>= 1.8.2'
@ -271,6 +271,7 @@ group :development, :test do
gem 'seed_dump' gem 'seed_dump'
gem 'shoulda-matchers' gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false gem 'simplecov', '>= 0.21', require: false
gem 'skooma'
gem 'spring' gem 'spring'
gem 'spring-watcher-listen' gem 'spring-watcher-listen'
end end

View File

@ -126,7 +126,7 @@ GEM
jbuilder (~> 2) jbuilder (~> 2)
rails (>= 4.2, < 7.2) rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
ai-agents (0.9.0) ai-agents (0.9.1)
ruby_llm (~> 1.9.1) ruby_llm (~> 1.9.1)
annotaterb (4.20.0) annotaterb (4.20.0)
activerecord (>= 6.0.0) activerecord (>= 6.0.0)
@ -191,7 +191,7 @@ GEM
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.3) connection_pool (2.5.5)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
@ -473,6 +473,12 @@ GEM
hana (~> 1.3) hana (~> 1.3)
regexp_parser (~> 2.0) regexp_parser (~> 2.0)
uri_template (~> 0.7) uri_template (~> 0.7)
json_skooma (0.2.5)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
uri-idna (~> 0.2)
zeitwerk (~> 2.6)
judoscale-rails (1.8.2) judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2) judoscale-ruby (= 1.8.2)
railties railties
@ -583,14 +589,14 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.18.9) nokogiri (1.19.1)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin) nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-x86_64-darwin) nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu) nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@ -736,7 +742,7 @@ GEM
ffi (~> 1.0) ffi (~> 1.0)
redis (5.0.6) redis (5.0.6)
redis-client (>= 0.9.0) redis-client (>= 0.9.0)
redis-client (0.22.2) redis-client (0.26.4)
connection_pool connection_pool
redis-namespace (1.10.0) redis-namespace (1.10.0)
redis (>= 4) redis (>= 4)
@ -912,6 +918,9 @@ GEM
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
skooma (0.3.7)
json_skooma (~> 0.2.5)
zeitwerk (~> 2.6)
slack-ruby-client (2.7.0) slack-ruby-client (2.7.0)
faraday (>= 2.0.1) faraday (>= 2.0.1)
faraday-mashify faraday-mashify
@ -974,6 +983,7 @@ GEM
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uniform_notifier (1.17.0) uniform_notifier (1.17.0)
uri (1.1.1) uri (1.1.1)
uri-idna (0.3.1)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (5.2.6) valid_email2 (5.2.6)
activemodel (>= 3.2) activemodel (>= 3.2)
@ -1028,7 +1038,7 @@ DEPENDENCIES
administrate (>= 0.20.1) administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3) administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0) administrate-field-belongs_to_search (>= 0.9.0)
ai-agents ai-agents (>= 0.9.1)
annotaterb annotaterb
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
@ -1148,6 +1158,7 @@ DEPENDENCIES
sidekiq_alive sidekiq_alive
simplecov (>= 0.21) simplecov (>= 0.21)
simplecov_json_formatter simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0) slack-ruby-client (~> 2.7.0)
spring spring
spring-watcher-listen spring-watcher-listen

View File

@ -40,8 +40,12 @@ run:
fi fi
force_run: force_run:
rm -f ./.overmind.sock @echo "Cleaning up Overmind processes..."
rm -f tmp/pids/*.pid @lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
@rm -f ./.overmind.sock
@rm -f tmp/pids/*.pid
@echo "Cleanup complete"
overmind start -f Procfile.dev overmind start -f Procfile.dev
force_run_tunnel: force_run_tunnel:

View File

@ -1 +1 @@
4.11.0 4.12.0

View File

@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error # blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs @contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! @contact.save! if @contact.changed?
enqueue_avatar_job enqueue_avatar_job
end end

View File

@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
def message_params def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{ {
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
message_type: @message_type, message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content, content: response.content,
source_id: response.identifier, source_id: response.identifier,
content_attributes: { content_attributes: content_attributes,
in_reply_to_external_id: response.in_reply_to_external_id
},
sender: @outgoing_echo ? nil : @contact_inbox.contact sender: @outgoing_echo ? nil : @contact_inbox.contact
} }
end end

View File

@ -2,12 +2,17 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper include ::FileTypeHelper
def process_attachment(attachment) def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one usupported file # This check handles very rare case if there are multiple files to attach with only one unsupported file
return if unsupported_file_type?(attachment['type']) return if unsupported_file_type?(attachment['type'])
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url)) params = attachment_params(attachment)
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save! attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] if facebook_reel?(attachment)
update_facebook_reel_content(attachment)
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story' fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post' fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
@ -26,7 +31,7 @@ class Messages::Messenger::MessageBuilder
end end
def attachment_params(attachment) def attachment_params(attachment)
file_type = attachment['type'].to_sym file_type = normalize_file_type(attachment['type'])
params = { file_type: file_type, account_id: @message.account_id } params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
@ -100,6 +105,28 @@ class Messages::Messenger::MessageBuilder
private private
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
attachment['type'].to_sym == :reel
end
def update_facebook_reel_content(attachment)
url = attachment.dig('payload', 'url')
return if url.blank?
@message.update!(content: url) if @message.content.blank?
end
def unsupported_file_type?(attachment_type) def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym [:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end end

View File

@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end end
def reorder def reorder
Article.update_positions(params[:positions_hash]) Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok head :ok
end end

View File

@ -1,7 +1,7 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal before_action :portal
before_action :check_authorization before_action :check_authorization
before_action :fetch_category, except: [:index, :create] before_action :fetch_category, except: [:index, :create, :reorder]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
def index def index
@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
head :ok head :ok
end end
def reorder
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private private
def fetch_category def fetch_category
@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
end end
def portal def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id]) @portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end end
def related_categories_records def related_categories_records

View File

@ -0,0 +1,55 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end

View File

@ -210,7 +210,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def fetch_contact def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) contact_scope = Current.account.contacts
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
@contact = contact_scope.find(params[:id])
end end
def process_avatar_from_url def process_avatar_from_url

View File

@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def meta def meta
result = conversation_finder.perform result = conversation_finder.perform_meta_only
@conversations_count = result[:count] @conversations_count = result[:count]
end end
@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def toggle_typing_status def toggle_typing_status
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params) typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params)
typing_status_manager.toggle_typing_status typing_status_manager.toggle_typing_status
head :ok head :ok
end end

View File

@ -1,6 +1,7 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox before_action :fetch_inbox
before_action :validate_whatsapp_channel before_action :validate_whatsapp_channel
before_action :validate_captain_enabled, only: [:analyze]
def show def show
service = CsatTemplateManagementService.new(@inbox) service = CsatTemplateManagementService.new(@inbox)
@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end end
def analyze
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
result = CsatTemplateUtilityAnalysisService.new(
account: Current.account,
inbox: @inbox,
message: template_params[:message],
button_text: template_params[:button_text],
language: template_params[:language]
).perform
render json: result
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def link def link
link_params = params.require(:template).permit(:name, :language, body_variables: {}) link_params = params.require(:template).permit(:name, :language, body_variables: {})
return render json: { error: 'Template name is required' }, status: :unprocessable_entity if link_params[:name].blank? return render json: { error: 'Template name is required' }, status: :unprocessable_entity if link_params[:name].blank?
@ -66,6 +84,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity render json: { error: 'Message is required' }, status: :unprocessable_entity
end end
def validate_captain_enabled
return if Current.account.feature_enabled?('captain_integration')
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
end
def render_link_result(result) def render_link_result(result)
if result[:success] if result[:success]
render json: { render json: {

View File

@ -1,11 +1,14 @@
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create] before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create] before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox # we are already handling the authorization in fetch inbox
# rubocop:disable Rails/LexicallyScopedActionFilter -- health is defined in WhatsappHealthManagement concern
before_action :check_authorization, except: [:show, :health, :setup_channel_provider] before_action :check_authorization, except: [:show, :health, :setup_channel_provider]
before_action :validate_whatsapp_cloud_channel, only: [:health] before_action :validate_whatsapp_cloud_channel, only: [:health]
# rubocop:enable Rails/LexicallyScopedActionFilter
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@ -94,23 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def on_whatsapp def on_whatsapp
params.require(:phone_number) params.require(:phone_number)
phone_number = params[:phone_number] phone_number = params[:phone_number]
@ -136,12 +122,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type]) return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@ -239,18 +219,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
def get_channel_attributes(channel_type) def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')

View File

@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
return unless @hook&.access_token return unless @hook&.access_token
begin begin
linear_client = Linear.new(@hook.access_token) linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
linear_client.revoke_token linear_client.revoke_token
rescue StandardError => e rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}" Rails.logger.error "Failed to revoke Linear token: #{e.message}"

View File

@ -80,7 +80,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.require(:portal).permit( params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link, :id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, :custom_head_html, :custom_body_html, :name, :page_title, :slug, :archived, :custom_head_html, :custom_body_html,
{ config: [:default_locale, :show_author, { allowed_locales: [] }] } { config: [:default_locale, :show_author, { allowed_locales: [] }, { draft_locales: [] }] }
) )
end end

View File

@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def check_signup_enabled def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end end
def validate_captcha def validate_captcha

View File

@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact contact = @contact
end end
@contact_inbox.update!(hmac_verified: true) if should_verify_hmac? && valid_hmac? @contact_inbox.update!(hmac_verified: true) if should_verify_hmac?
identify_contact(contact) identify_contact(contact)
end end

View File

@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
end end
def check_signup_enabled def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end end
def validate_captcha def validate_captcha

View File

@ -1,6 +1,6 @@
module AccessTokenAuthHelper module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = { BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes], 'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create'] 'api/v1/accounts/conversations/assignments' => ['create']
}.freeze }.freeze
@ -28,7 +28,7 @@ module AccessTokenAuthHelper
def validate_bot_access_token! def validate_bot_access_token!
return if Current.user.is_a?(User) return if Current.user.is_a?(User)
return if agent_bot_accessible? return if @resource.is_a?(AgentBot) && agent_bot_accessible?
render_unauthorized('Access to this endpoint is not authorized for bots') render_unauthorized('Access to this endpoint is not authorized for bots')
end end

View File

@ -51,8 +51,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end end
def account_signup_allowed? def account_signup_allowed?
# set it to true by default, this is the behaviour across the app GlobalConfigService.account_signup_enabled?
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
end end
def resource_class(_mapping = nil) def resource_class(_mapping = nil)

View File

@ -2,6 +2,8 @@ class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper include Linear::IntegrationHelper
def show def show
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
@response = oauth_client.auth_code.get_token( @response = oauth_client.auth_code.get_token(
params[:code], params[:code],
redirect_uri: "#{base_url}/linear/callback" redirect_uri: "#{base_url}/linear/callback"
@ -10,7 +12,7 @@ class Linear::CallbacksController < ApplicationController
handle_response handle_response
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}") Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri redirect_to safe_linear_redirect_uri
end end
private private
@ -31,22 +33,19 @@ class Linear::CallbacksController < ApplicationController
end end
def handle_response def handle_response
hook = account.hooks.new( raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
hook.assign_attributes(
access_token: parsed_body['access_token'], access_token: parsed_body['access_token'],
status: 'enabled', status: 'enabled',
app_id: 'linear', settings: merged_integration_settings(hook.settings)
settings: {
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
scope: parsed_body['scope']
}
) )
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
hook.save! hook.save!
redirect_to linear_redirect_uri redirect_to linear_redirect_uri
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}") Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri redirect_to safe_linear_redirect_uri
end end
def account def account
@ -54,19 +53,47 @@ class Linear::CallbacksController < ApplicationController
end end
def account_id def account_id
return unless params[:state] return @account_id if instance_variable_defined?(:@account_id)
verify_linear_token(params[:state]) @account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil
end end
def linear_redirect_uri def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end end
def safe_linear_redirect_uri
return base_url if account_id.blank?
linear_redirect_uri
rescue StandardError
base_url
end
def parsed_body def parsed_body
@parsed_body ||= @response.response.parsed @parsed_body ||= @response.response.parsed
end end
def integration_settings
{
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
expires_on: expires_on,
scope: parsed_body['scope'],
refresh_token: parsed_body['refresh_token']
}.compact
end
def merged_integration_settings(existing_settings)
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
end
def expires_on
return if parsed_body['expires_in'].blank?
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
end
def base_url def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end

View File

@ -6,6 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
layout 'portal' layout 'portal'
def index def index
@search_query = list_params[:query]
@articles = @portal.articles.published.includes(:category, :author) @articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@ -73,7 +74,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end end
def list_params def list_params
params.permit(:query, :locale, :sort, :status, :page, :per_page) @list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
permitted[:query] = permitted[:query].to_s.strip.presence
end
end end
def permitted_params def permitted_params

View File

@ -77,13 +77,23 @@ class WidgetsController < ActionController::Base
end end
def allow_iframe_requests def allow_iframe_requests
if @web_widget.allowed_domains.blank? if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
response.headers.delete('X-Frame-Options') response.headers.delete('X-Frame-Options')
else else
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ') domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}" response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
end end
end end
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
# which cannot match any domain in frame-ancestors. When the per-inbox flag
# is enabled, skip frame-ancestors for these requests.
def embedded_from_non_web_origin?
return false unless @web_widget.allow_mobile_webview?
origin = request.headers['Origin']
origin.blank? || origin == 'null' || origin&.start_with?('file://')
end
end end
WidgetsController.prepend_mod_with('WidgetsController') WidgetsController.prepend_mod_with('WidgetsController')

View File

@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard
current_sign_in_ip: Field::String, current_sign_in_ip: Field::String,
last_sign_in_ip: Field::String, last_sign_in_ip: Field::String,
confirmation_token: Field::String, confirmation_token: Field::String,
confirmed_at: Field::DateTime, confirmed_at: ConfirmedAtField,
confirmation_sent_at: Field::DateTime, confirmation_sent_at: Field::DateTime,
unconfirmed_email: Field::String, unconfirmed_email: Field::String,
name: Field::String.with_options(searchable: true), name: Field::String.with_options(searchable: true),

View File

@ -0,0 +1,4 @@
require 'administrate/field/base'
class ConfirmedAtField < Administrate::Field::DateTime
end

View File

@ -11,6 +11,7 @@ class ConversationFinder
'priority_desc' => %w[sort_on_priority desc], 'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc], 'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc], 'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
# To be removed in v3.5.0 # To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc], 'latest' => %w[sort_on_last_activity_at desc],
@ -55,6 +56,22 @@ class ConversationFinder
} }
end end
def perform_meta_only
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
{
count: {
mine_count: mine_count,
assigned_count: assigned_count,
unassigned_count: unassigned_count,
all_count: all_count
}
}
end
private private
def set_up def set_up

View File

@ -57,39 +57,35 @@ module Api::V1::InboxesHelper
end end
def check_smtp_connection(channel_data, smtp) def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login) channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish smtp.finish
rescue Net::SMTPAuthenticationError
raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
rescue OpenSSL::SSL::SSLError
raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
rescue StandardError => e
raise StandardError, e.message
end end
def set_smtp_encryption(channel_data, smtp) def set_smtp_encryption(channel_data, smtp)
if channel_data[:smtp_enable_ssl_tls] if channel_data[:smtp_enable_ssl_tls]
set_enable_tls(channel_data, smtp) set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
elsif channel_data[:smtp_enable_starttls_auto] elsif channel_data[:smtp_enable_starttls_auto]
set_enable_starttls_auto(channel_data, smtp) set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
end end
end end
def set_enable_starttls_auto(channel_data, smtp) def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
return unless smtp.respond_to?(:enable_starttls_auto) return unless smtp.respond_to?(method)
if channel_data[:smtp_openssl_verify_mode] context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode]) context ? smtp.send(method, context) : smtp.send(method)
smtp.enable_starttls_auto(context)
else
smtp.enable_starttls_auto
end
end
def set_enable_tls(channel_data, smtp)
return unless smtp.respond_to?(:enable_tls)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_tls(context)
else
smtp.enable_tls
end
end end
def enable_openssl_mode(smtp_openssl_verify_mode) def enable_openssl_mode(smtp_openssl_verify_mode)

View File

@ -47,11 +47,15 @@ module Filters::FilterHelper
def handle_additional_attributes(query_hash, filter_operator_value, data_type) def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive' if data_type == 'text_case_insensitive'
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \ ActiveRecord::Base.sanitize_sql_array(
"#{filter_operator_value} #{query_hash[:query_operator]}" ["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
query_hash[:attribute_key]]
)
else else
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \ ActiveRecord::Base.sanitize_sql_array(
"#{filter_operator_value} #{query_hash[:query_operator]} " ["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
query_hash[:attribute_key]]
)
end end
end end
@ -70,7 +74,7 @@ module Filters::FilterHelper
def date_filter(current_filter, query_hash, filter_operator_value) def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \ "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}" "#{filter_operator_value} #{query_hash[:query_operator]}"
end end
def text_case_insensitive_filter(query_hash, filter_operator_value) def text_case_insensitive_filter(query_hash, filter_operator_value)

View File

@ -1,4 +1,10 @@
module TimezoneHelper module TimezoneHelper
def timezone_name_from_params(timezone, offset)
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
timezone_name_from_offset(offset)
end
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset] # ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone, # would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below # this method uses zone.now.utc_offset for comparison as referenced in the issues below

View File

@ -164,10 +164,4 @@ export default {
.v-popper--theme-tooltip .v-popper__arrow-container { .v-popper--theme-tooltip .v-popper__arrow-container {
display: none; display: none;
} }
.multiselect__input {
margin-bottom: 0px !important;
}
</style> </style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>

View File

@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
return axios.post(`${this.url}/${contactId}/labels`, { labels }); return axios.post(`${this.url}/${contactId}/labels`, { labels });
} }
search(search = '', page = 1, sortAttr = 'name', label = '') { search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
let requestURL = `${this.url}/search?${buildContactParams( let requestURL = `${this.url}/search?${buildContactParams(
page, page,
sortAttr, sortAttr,
label, label,
search search
)}`; )}`;
return axios.get(requestURL); return axios.get(requestURL, { signal: options.signal });
} }
active(page = 1, sortAttr = 'name') { active(page = 1, sortAttr = 'name') {

View File

@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI {
delete({ portalSlug, categoryId }) { delete({ portalSlug, categoryId }) {
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`); return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
} }
reorder({ portalSlug, reorderedGroup }) {
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
positions_hash: reorderedGroup,
});
}
} }
export default new CategoriesAPI(); export default new CategoriesAPI();

View File

@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
getHealthStatus(inboxId) { getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`); return axios.get(`${this.url}/${inboxId}/health`);
} }
registerWebhook(inboxId) {
return axios.post(`${this.url}/${inboxId}/register_webhook`);
}
} }
export default new InboxHealthAPI(); export default new InboxHealthAPI();

View File

@ -43,6 +43,12 @@ class Inboxes extends CacheEnabledApiClient {
return axios.get(`${this.url}/${inboxId}/csat_template`); return axios.get(`${this.url}/${inboxId}/csat_template`);
} }
analyzeCSATTemplateUtility(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
template,
});
}
linkCSATTemplate(inboxId, template) { linkCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/link`, { return axios.post(`${this.url}/${inboxId}/csat_template/link`, {
template, template,

View File

@ -68,7 +68,19 @@ describe('#ContactsAPI', () => {
it('#search', () => { it('#search', () => {
contactAPI.search('leads', 1, 'date', 'customer-support'); contactAPI.search('leads', 1, 'date', 'customer-support');
expect(axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: undefined }
);
});
it('#search with signal', () => {
const controller = new AbortController();
contactAPI.search('leads', 1, 'date', 'customer-support', {
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: controller.signal }
); );
}); });

View File

@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
expect(categoriesAPI).toHaveProperty('create'); expect(categoriesAPI).toHaveProperty('create');
expect(categoriesAPI).toHaveProperty('update'); expect(categoriesAPI).toHaveProperty('update');
expect(categoriesAPI).toHaveProperty('delete'); expect(categoriesAPI).toHaveProperty('delete');
expect(categoriesAPI).toHaveProperty('reorder');
}); });
}); });

View File

@ -66,7 +66,7 @@ textarea {
// Field base styles (Input, TextArea, Select) // Field base styles (Input, TextArea, Select)
@layer components { @layer components {
.field-base { .field-base {
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6; @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
} }
.field-disabled { .field-disabled {
@ -78,7 +78,7 @@ textarea {
} }
} }
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)"; $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)";
#{$form-input-selector} { #{$form-input-selector} {
@apply field-base h-10; @apply field-base h-10;
@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not
} }
} }
input[type='file']:not(.multiselect__input) { input[type='file'] {
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm; @apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
} }
@ -126,13 +126,6 @@ label:has(.help-text) {
} }
} }
// Error handling
.has-multi-select-error {
div.multiselect {
@apply mb-1;
}
}
// FormKit support // FormKit support
.formkit-outer[data-invalid='true'] { .formkit-outer[data-invalid='true'] {
#{$form-input-selector}, #{$form-input-selector},
@ -150,9 +143,7 @@ label:has(.help-text) {
#{$form-input-selector}, #{$form-input-selector},
input:not([type]), input:not([type]),
textarea, textarea,
select, select {
.multiselect > .multiselect__tags,
.multiselect:not(.no-margin) {
@apply field-error; @apply field-error;
} }

View File

@ -13,7 +13,6 @@
@import 'base'; @import 'base';
// Plugins // Plugins
@import 'plugins/multiselect';
@import 'plugins/date-picker'; @import 'plugins/date-picker';
html, html,
@ -66,4 +65,84 @@ body {
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
/**
* ============================================================================
* TYPOGRAPHY UTILITIES
* ============================================================================
*
* | Class | Use Case |
* |--------------------|----------------------------------------------------|
* | .text-body-main | <p>, <span>, general body text |
* | .text-body-para | <p> for paragraphs, larger text blocks |
* | .text-heading-1 | <h1>, page titles, panel headers |
* | .text-heading-2 | <h2>, section headings, card titles |
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
* | .text-label | <label>, form labels, field names |
* | .text-label-small | <small>, footnotes, tags, badges, captions |
* | .text-button | <button>, standard button text |
* | .text-button-small | <button>, small/compact button text |
*/
/* body-text-main: Main text style for general body text */
.text-body-main {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* body-text-paragraph: For paragraphs or larger blocks of text */
.text-body-para {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.21px;
}
/* heading-1: Large heading for pages and panels */
.text-heading-1 {
@apply font-inter text-lg font-520;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-2: Secondary heading for sections */
.text-heading-2 {
@apply font-inter text-base font-medium;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-3: For card headings, breadcrumbs, subsections */
.text-heading-3 {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
letter-spacing: -0.27px;
}
/* label: Standard label text for form fields */
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
}
/* label-small: Smallest font for labels, footnotes, tags */
.text-label-small {
@apply font-inter text-xs font-440;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
/* button-text: Text for standard size buttons */
.text-button {
@apply font-inter text-sm font-460;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* button-text-small: Text for smaller buttons */
.text-button-small {
@apply font-inter text-xs font-440;
line-height: 18px; /* 150% */
letter-spacing: -0.24px;
}
} }

View File

@ -1,273 +0,0 @@
@mixin label-multiselect-hover {
&::after {
@apply text-n-brand;
}
&:hover {
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-11;
}
}
}
.multiselect {
&:not(.no-margin) {
@apply mb-4;
}
&.invalid .multiselect__tags {
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
&.multiselect--disabled {
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
.multiselect__select {
@apply cursor-not-allowed bg-transparent rounded-lg;
}
}
.multiselect--active {
> .multiselect__tags {
@apply outline-n-blue-border;
}
}
.multiselect__select {
@apply min-h-[2.875rem] p-0 right-0 top-0;
&::before {
@apply right-0;
}
}
.multiselect__content-wrapper {
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
}
.multiselect__content {
@apply max-w-full;
.multiselect__option {
@apply text-sm font-normal flex justify-between items-center;
span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
}
p {
@apply mb-0;
}
&::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
}
&.multiselect__option--highlight {
@apply bg-n-alpha-black2 text-n-slate-12;
}
&.multiselect__option--highlight:hover {
@apply bg-n-brand/10 text-n-slate-12;
&::after {
@apply bg-transparent text-center text-n-slate-12;
}
}
&.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12;
}
&.multiselect__option--selected {
@apply bg-n-brand/20 text-n-slate-12;
&::after {
@apply bg-transparent;
}
&.multiselect__option--highlight:hover {
@apply bg-transparent;
&::after:hover {
@apply text-n-slate-12 bg-transparent;
}
}
}
}
}
.multiselect__tags {
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
input {
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
}
}
.multiselect__spinner {
background-color: transparent;
}
.multiselect__tags-wrap {
@apply inline-block leading-none mt-1;
}
.multiselect__placeholder {
@apply text-n-slate-10 font-normal pt-3;
}
.multiselect__tag {
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
}
.multiselect__tag-icon {
@include label-multiselect-hover;
}
.multiselect__input {
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
}
.multiselect__single {
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
}
}
.sidebar-labels-wrap {
&.has-edited,
&:hover {
.multiselect {
@apply cursor-pointer;
}
}
.multiselect {
> .multiselect__select {
@apply invisible;
}
> .multiselect__tags {
@apply outline-transparent;
}
&.multiselect--active > .multiselect__tags {
@apply outline-n-blue-border;
}
}
}
.multiselect-wrap--small {
// To be removed one SLA reports date picker is created
&.tiny {
.multiselect.no-margin {
@apply min-h-[32px];
}
.multiselect__select {
@apply min-h-[32px] h-8;
&::before {
@apply top-[60%];
}
}
.multiselect__tags {
@apply min-h-[32px] max-h-[32px];
.multiselect__single {
@apply pt-1 pb-1;
}
}
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
}
.multiselect__input {
@apply h-[2.375rem] min-h-[2.375rem];
}
.multiselect__single {
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
}
.multiselect__placeholder {
@apply m-0 py-2 px-0.5;
}
.multiselect__tag {
@apply py-[6px] my-[1px];
}
.multiselect__select {
@apply min-h-[2.5rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
}
.multiselect--disabled {
background-color: rgba(var(--black-alpha-2)) !important;
.multiselect__tags {
@apply hover:outline-n-weak;
}
}
.multiselect--active {
.multiselect__select::before {
@apply top-[62%];
}
}
.multiselect__select::before {
top: 60% !important;
}
.multiselect-wrap--medium {
.multiselect__tags,
.multiselect__input {
@apply items-center flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
}
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0 !important;
}
.multiselect__single {
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
}
.multiselect__placeholder {
@apply m-0 py-1 px-0.5;
}
.multiselect__select {
@apply min-h-[3rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
.multiselect__tags-wrap {
@apply flex-shrink-0;
}
}

View File

@ -49,7 +49,7 @@ const handleFetchUsers = () => {
<div class="flex flex-col gap-2 relative justify-between w-full"> <div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full"> <div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1"> <h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
{{ name }} {{ name }}
</h3> </h3>
<CardPopover <CardPopover
@ -78,7 +78,7 @@ const handleFetchUsers = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" /> <Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div> </div>
</div> </div>
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1"> <p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
{{ description }} {{ description }}
</p> </p>
</div> </div>

View File

@ -20,7 +20,7 @@ const handleClick = () => {
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick"> <CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
<div class="flex flex-col items-start gap-2"> <div class="flex flex-col items-start gap-2">
<div class="flex justify-between w-full items-center"> <div class="flex justify-between w-full items-center">
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3> <h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3>
<Button <Button
xs xs
slate slate
@ -29,14 +29,14 @@ const handleClick = () => {
@click.stop="handleClick" @click.stop="handleClick"
/> />
</div> </div>
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p> <p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p>
</div> </div>
<ul class="flex flex-col items-start gap-3 mt-3"> <ul class="flex flex-col items-start gap-3 mt-3">
<li <li
v-for="feature in features" v-for="feature in features"
:key="feature.id" :key="feature.id"
class="flex items-center gap-3 text-sm" class="flex items-center gap-3 text-body-para"
> >
<Icon <Icon
:icon="feature.icon" :icon="feature.icon"

View File

@ -60,23 +60,19 @@ const handleFetchInboxes = () => {
<div class="flex flex-col gap-2 relative justify-between w-full"> <div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full"> <div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1"> <h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
{{ name }} {{ name }}
</h3> </h3>
<div class="flex items-center gap-2"> <CardPopover
<CardPopover :title="
:title=" t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER')
t( "
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER' icon="i-lucide-inbox"
) :count="assignedInboxCount"
" :items="inboxes"
icon="i-lucide-inbox" :is-fetching="isFetchingInboxes"
:count="assignedInboxCount" @fetch="handleFetchInboxes"
:items="inboxes" />
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
</div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
@ -93,18 +89,18 @@ const handleFetchInboxes = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" /> <Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div> </div>
</div> </div>
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1"> <p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
{{ description }} {{ description }}
</p> </p>
<div class="flex items-center gap-3 py-1.5"> <div class="flex items-center gap-3 py-1.5">
<span v-if="order" class="text-n-slate-11 text-sm"> <span v-if="order" class="text-n-slate-11 text-body-para">
{{ {{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:` `${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
}} }}
<span class="text-n-slate-12">{{ order }}</span> <span class="text-n-slate-12">{{ order }}</span>
</span> </span>
<div v-if="order" class="w-px h-3 bg-n-strong" /> <div v-if="order" class="w-px h-3 bg-n-strong" />
<span v-if="priority" class="text-n-slate-11 text-sm"> <span v-if="priority" class="text-n-slate-11 text-body-para">
{{ {{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:` `${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
}} }}

View File

@ -2,7 +2,7 @@
import { computed, ref, onMounted } from 'vue'; import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue'; import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue'; import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue'; import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants'; import { DURATION_UNITS } from 'dashboard/components-next/input/constants';

View File

@ -1,87 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
disabledMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
}
};
</script>
<template>
<div
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
<div class="absolute top-4 right-4">
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
/>
</div>
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<span
v-if="disabled"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
>
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
)
}}
</span>
</div>
<p class="text-sm text-n-slate-11">
{{ disabled && disabledMessage ? disabledMessage : description }}
</p>
</div>
</div>
</template>

View File

@ -1,4 +1,5 @@
<script setup> <script setup>
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
defineProps({ defineProps({
@ -21,14 +22,18 @@ const handleButtonClick = () => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 lg:px-0"> <header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto"> <div class="w-full max-w-5xl mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2"> <div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12"> <span class="text-heading-1 text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}
</span> </span>
<div <div
v-on-clickaway="() => emit('close')" v-on-click-outside="[
() => emit('close'),
// This will prevent closing the modal when the editor Create link popup is open
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative group/campaign-button" class="relative group/campaign-button"
> >
<Button <Button
@ -43,8 +48,8 @@ const handleButtonClick = () => {
</div> </div>
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0"> <main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] mx-auto py-4"> <div class="w-full max-w-5xl mx-auto py-4">
<slot name="default" /> <slot name="default" />
</div> </div>
</main> </main>

View File

@ -19,7 +19,7 @@ const handleClick = () => {
<template> <template>
<div <div
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2" class="flex flex-col w-full outline-1 outline outline-n-container -outline-offset-1 group/cardLayout rounded-xl bg-n-solid-2"
> >
<div <div
class="flex w-full gap-3 py-5" class="flex w-full gap-3 py-5"

View File

@ -15,11 +15,11 @@ const emit = defineEmits(['search', 'update:sort']);
</script> </script>
<template> <template>
<header class="sticky top-0 z-10"> <header class="sticky top-0 z-10 px-6">
<div <div
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]" class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
> >
<span class="text-xl font-medium truncate text-n-slate-12"> <span class="text-heading-1 truncate text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}
</span> </span>
<div class="flex items-center flex-row flex-shrink-0 gap-2"> <div class="flex items-center flex-row flex-shrink-0 gap-2">

View File

@ -32,17 +32,18 @@ const updateCurrentPage = page => {
@search="emit('search', $event)" @search="emit('search', $event)"
@update:sort="emit('update:sort', $event)" @update:sort="emit('update:sort', $event)"
/> />
<main class="flex-1 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]"> <div class="w-full mx-auto max-w-5xl py-4">
<slot name="default" /> <slot name="default" />
</div> </div>
</main> </main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4"> <footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
<PaginationFooter <PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING" current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage" :current-page="currentPage"
:total-items="totalItems" :total-items="totalItems"
:items-per-page="25" :items-per-page="25"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage" @update:current-page="updateCurrentPage"
/> />
</footer> </footer>

View File

@ -3,8 +3,8 @@ import { computed, watch, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store'; import { useMapGetter, useStore } from 'dashboard/composables/store';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue'; import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue'; import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
const props = defineProps({ const props = defineProps({
contactId: { contactId: {

View File

@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill', LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill', FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line', INSTAGRAM: 'i-ri-instagram-line',
TELEGRAM: 'i-ri-telegram-fill',
TIKTOK: 'i-ri-tiktok-fill', TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill', TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill', GITHUB: 'i-ri-github-fill',
@ -66,6 +67,7 @@ const defaultState = {
facebook: '', facebook: '',
github: '', github: '',
instagram: '', instagram: '',
telegram: '',
tiktok: '', tiktok: '',
linkedin: '', linkedin: '',
twitter: '', twitter: '',
@ -103,9 +105,13 @@ const prepareStateBasedOnProps = () => {
countryCode = '', countryCode = '',
country = '', country = '',
city = '', city = '',
socialTelegramUserName = '',
socialProfiles = {}, socialProfiles = {},
} = additionalAttributes || {}; } = additionalAttributes || {};
const telegramUsername =
socialProfiles?.telegram || socialTelegramUserName || '';
Object.assign(state, { Object.assign(state, {
id, id,
name, name,
@ -119,7 +125,10 @@ const prepareStateBasedOnProps = () => {
countryCode, countryCode,
country, country,
city, city,
socialProfiles, socialProfiles: {
...socialProfiles,
telegram: telegramUsername,
},
}, },
}); });
}; };

View File

@ -32,9 +32,9 @@ const emit = defineEmits([
</script> </script>
<template> <template>
<header class="sticky top-0 z-10"> <header class="sticky top-0 z-10 px-6">
<div <div
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]" class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
> >
<span class="text-xl font-medium truncate text-n-slate-12"> <span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}

View File

@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS') t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
" "
:show-clear-button="!hasActiveSegments" :show-clear-button="!hasActiveSegments"
class="max-w-[60rem] px-6" class="max-w-5xl"
@open-filter="emit('openFilter')" @open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
/> />

View File

@ -98,8 +98,8 @@ const showPagination = computed(() => {
@apply-filter="emit('applyFilter', $event)" @apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
/> />
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto px-6">
<div class="w-full mx-auto max-w-[60rem]"> <div class="w-full mx-auto max-w-5xl">
<ContactsActiveFiltersPreview <ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview" v-if="showActiveFiltersPreview"
:active-segment="activeSegment" :active-segment="activeSegment"
@ -114,11 +114,12 @@ const showPagination = computed(() => {
/> />
</div> </div>
</main> </main>
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4"> <footer v-if="showPagination" class="sticky bottom-0 z-0">
<PaginationFooter <PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING" current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage" :current-page="currentPage"
:total-items="totalItems" :total-items="totalItems"
class="max-w-[67rem]"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage" @update:current-page="updateCurrentPage"
/> />

View File

@ -1,8 +1,10 @@
<script setup> <script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue'; import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
attribute: { attribute: {
@ -18,7 +20,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete']); const emit = defineEmits(['edit', 'delete']);
const iconByType = { const iconByType = {
text: 'i-lucide-align-justify', text: 'i-lucide-menu',
checkbox: 'i-lucide-circle-check-big', checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list', list: 'i-lucide-list',
date: 'i-lucide-calendar', date: 'i-lucide-calendar',
@ -28,61 +30,66 @@ const iconByType = {
const attributeIcon = computed(() => { const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase(); const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify'; return iconByType[typeKey] || 'i-lucide-menu';
}); });
</script> </script>
<template> <template>
<div <div class="flex flex-col py-4 min-w-0">
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container" <div class="flex justify-between flex-row items-center gap-4 min-w-0">
> <div class="flex items-center gap-4 min-w-0">
<div class="flex flex-wrap gap-2 justify-between items-center"> <div
<div class="flex flex-wrap gap-2 items-center min-w-0"> class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
<h4 class="text-sm font-medium truncate text-n-slate-12"> >
{{ attribute.label }} <Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
</h4> </div>
<div class="w-px h-3 bg-n-strong" /> <div class="flex flex-col gap-1.5 items-start min-w-0 overflow-hidden">
<div class="flex gap-2 items-center text-sm text-n-slate-11"> <div class="flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 text-n-slate-11"> <h4 class="text-heading-3 truncate text-n-slate-12 min-w-0">
<Icon :icon="attributeIcon" class="size-4" /> {{ attribute.label }}
<span class="text-sm">{{ attribute.type }}</span> </h4>
<div class="flex items-center gap-1.5">
<Label :label="attribute.type" compact />
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
</div>
</div> </div>
<div class="w-px h-3 bg-n-weak" /> <div class="grid grid-cols-[auto_1fr] items-center gap-1.5">
<div class="flex items-center gap-1.5 text-n-slate-11"> <Icon icon="i-lucide-key-round" class="size-3.5 text-n-slate-11" />
<Icon icon="i-lucide-key-round" class="size-4" /> <div class="flex items-center gap-2 min-w-0">
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span> <span class="text-body-main text-n-slate-11 truncate">
{{ attribute.value }}
</span>
<template
v-if="attribute.attribute_description || attribute.description"
>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.attribute_description || attribute.description }}
</span>
</template>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-3 justify-end flex-shrink-0">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<Button <Button
icon="i-lucide-pencil-line" icon="i-woot-edit-pen"
size="sm" slate
color="slate" sm
ghost
@click="emit('edit', attribute)" @click="emit('edit', attribute)"
/> />
<div class="w-px h-3 bg-n-strong" />
<Button <Button
icon="i-lucide-trash" icon="i-woot-bin"
size="sm" slate
color="slate" sm
ghost class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
@click="emit('delete', attribute)" @click="emit('delete', attribute)"
/> />
</div> </div>
</div> </div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div> </div>
</template> </template>

View File

@ -35,18 +35,20 @@ const handleDelete = () => {
<template> <template>
<div class="flex justify-between items-center px-4 py-3 w-full"> <div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center"> <div class="flex gap-3 items-center">
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1"> <h5 class="text-heading-3 text-n-slate-12 line-clamp-1">
{{ attribute.label }} {{ attribute.label }}
</h5> </h5>
<div class="w-px h-2.5 bg-n-slate-5" /> <div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" /> <Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span> <span class="text-body-para text-n-slate-11">{{ attribute.type }}</span>
</div> </div>
<div class="w-px h-2.5 bg-n-slate-5" /> <div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" /> <Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span> <span class="text-body-para text-n-slate-11">{{
attribute.value
}}</span>
</div> </div>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">

View File

@ -129,10 +129,10 @@ const handleDelete = attribute => {
<div class="flex flex-col gap-2 items-start px-5 py-4"> <div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full"> <div class="flex justify-between items-center w-full">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h3 class="text-base font-medium text-n-slate-12"> <h3 class="text-heading-2 text-n-slate-12">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }} {{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
</h3> </h3>
<p class="mb-0 text-sm text-n-slate-11"> <p class="mb-0 text-body-para text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }} {{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
</p> </p>
</div> </div>

View File

@ -2,6 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({ const props = defineProps({
type: { type: {
@ -18,11 +19,13 @@ const attributeConfig = {
colorClass: 'text-n-blue-11', colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle', icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT', labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
color: 'slate',
}, },
resolution: { resolution: {
colorClass: 'text-n-teal-11', colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big', icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION', labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
color: 'slate',
}, },
}; };
const config = computed( const config = computed(
@ -31,12 +34,9 @@ const config = computed(
</script> </script>
<template> <template>
<div <Label :label="t(config.labelKey)" :color="config.color" compact>
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2" <template #icon>
> <Icon :icon="config.icon" class="size-3.5 text-n-slate-12" />
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" /> </template>
<span class="text-xs" :class="config.colorClass">{{ </Label>
t(config.labelKey)
}}</span>
</div>
</template> </template>

View File

@ -28,7 +28,7 @@ const props = defineProps({
medium: { type: String, default: '' }, medium: { type: String, default: '' },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue', 'executeCopilotAction']);
const slots = useSlots(); const slots = useSlots();
@ -113,6 +113,9 @@ watch(
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@execute-copilot-action="
(...args) => emit('executeCopilotAction', ...args)
"
/> />
<div <div
v-if="showCharacterCount || slots.actions" v-if="showCharacterCount || slots.actions"

View File

@ -26,7 +26,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden" class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
> >
<div <div
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]" class="relative w-full max-w-5xl mx-auto overflow-hidden h-full max-h-[28rem]"
> >
<div <div
v-if="showBackdrop" v-if="showBackdrop"

View File

@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
<EmptyStateLayout <EmptyStateLayout
:title="$t('HELP_CENTER.TITLE')" :title="$t('HELP_CENTER.TITLE')"
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')" :subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
class="bg-n-surface-1"
> >
<template #empty-state-item> <template #empty-state-item>
<div class="grid grid-cols-2 gap-4 p-px"> <div class="grid grid-cols-2 gap-4 p-px">

View File

@ -59,8 +59,8 @@ const togglePortalSwitcher = () => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0"> <header class="sticky top-0 z-10 px-6 pb-3">
<div class="w-full max-w-[60rem] mx-auto lg:px-6"> <div class="w-full max-w-5xl mx-auto">
<div <div
v-if="showHeaderTitle" v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2" class="flex items-center justify-start h-20 gap-2"
@ -95,16 +95,17 @@ const togglePortalSwitcher = () => {
<slot name="header-actions" /> <slot name="header-actions" />
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0"> <main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6"> <div class="w-full max-w-5xl mx-auto py-3">
<slot name="content" /> <slot name="content" />
</div> </div>
</main> </main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4"> <footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<PaginationFooter <PaginationFooter
:current-page="currentPage" :current-page="currentPage"
:total-items="totalItems" :total-items="totalItems"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage" @update:current-page="updateCurrentPage"
/> />
</footer> </footer>

View File

@ -1,8 +1,22 @@
<script setup> <script setup>
import LocaleCard from './LocaleCard.vue'; import LocaleCard from './LocaleCard.vue';
const locales = [ const locales = [
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 }, {
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 }, name: 'English',
code: 'en',
isDefault: true,
isDraft: false,
articleCount: 29,
categoryCount: 5,
},
{
name: 'Spanish',
code: 'es',
isDefault: false,
isDraft: true,
articleCount: 29,
categoryCount: 5,
},
]; ];
</script> </script>
@ -19,6 +33,8 @@ const locales = [
<LocaleCard <LocaleCard
:locale="locale.name" :locale="locale.name"
:is-default="locale.isDefault" :is-default="locale.isDefault"
:is-draft="locale.isDraft"
:locale-code="locale.code"
:article-count="locale.articleCount" :article-count="locale.articleCount"
:category-count="locale.categoryCount" :category-count="locale.categoryCount"
/> />

View File

@ -2,7 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core'; import { useToggle } from '@vueuse/core';
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper'; import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
@ -17,6 +17,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
required: true, required: true,
}, },
isDraft: {
type: Boolean,
required: true,
},
localeCode: { localeCode: {
type: String, type: String,
required: true, required: true,
@ -37,11 +41,28 @@ const { t } = useI18n();
const [showDropdownMenu, toggleDropdown] = useToggle(); const [showDropdownMenu, toggleDropdown] = useToggle();
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
const localeMenuLabels = computed(() => ({
'change-default': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
),
'move-to-draft': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
),
'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
const localeMenuItems = computed(() => const localeMenuItems = computed(() =>
LOCALE_MENU_ITEMS.map(item => ({ buildLocaleMenuItems({
isDefault: props.isDefault,
isDraft: props.isDraft,
}).map(item => ({
...item, ...item,
label: t(item.label), label: localeMenuLabels.value[item.action],
disabled: props.isDefault,
})) }))
); );
@ -56,7 +77,7 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2"> <div class="flex items-center justify-start gap-2">
<span class="text-sm font-medium text-n-slate-12 line-clamp-1"> <span class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ locale }} ({{ localeCode }}) {{ localeLabel }}
</span> </span>
<span <span
v-if="isDefault" v-if="isDefault"
@ -64,6 +85,12 @@ const handleAction = ({ action, value }) => {
> >
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }} {{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span> </span>
<span
v-else-if="isDraft"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
</span>
</div> </div>
<div class="flex items-center justify-end gap-4"> <div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@ -86,6 +113,7 @@ const handleAction = ({ action, value }) => {
</span> </span>
</div> </div>
<div <div
v-if="localeMenuItems.length"
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
class="relative group" class="relative group"
> >

View File

@ -58,18 +58,22 @@ const openArticle = id => {
} }
}; };
const onReorder = reorderedGroup => { const onReorder = async reorderedGroup => {
store.dispatch('articles/reorder', { try {
reorderedGroup, await store.dispatch('articles/reorder', {
portalSlug: route.params.portalSlug, reorderedGroup,
}); portalSlug: route.params.portalSlug,
});
} catch {
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
}
}; };
const onDragEnd = () => { const onDragEnd = () => {
// Reuse existing positions to maintain order within the current group // Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedArticlePositions = localArticles.value const sortedArticlePositions = localArticles.value
.map(article => article.position) .map((article, index) => article.position || index + 1)
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly .sort((a, b) => a - b);
const orderedArticles = localArticles.value.map(article => article.id); const orderedArticles = localArticles.value.map(article => article.id);

View File

@ -98,6 +98,17 @@ const handleAction = ({ action, id, category: categoryData }) => {
deleteCategory(categoryData); deleteCategory(categoryData);
} }
}; };
const reorderCategories = async reorderedGroup => {
try {
await store.dispatch('categories/reorder', {
portalSlug: route.params.portalSlug,
reorderedGroup,
});
} catch {
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
}
};
</script> </script>
<template> <template>
@ -122,6 +133,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
:categories="categories" :categories="categories"
@click="openCategoryArticles" @click="openCategoryArticles"
@action="handleAction" @action="handleAction"
@reorder="reorderCategories"
/> />
<CategoryEmptyState <CategoryEmptyState
v-else v-else

View File

@ -1,14 +1,22 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue';
import Draggable from 'vuedraggable';
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue'; import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
defineProps({ const props = defineProps({
categories: { categories: {
type: Array, type: Array,
required: true, required: true,
}, },
}); });
const emit = defineEmits(['click', 'action']); const emit = defineEmits(['click', 'action', 'reorder']);
const localCategories = ref(props.categories);
const dragEnabled = computed(() => {
return localCategories.value?.length > 1;
});
const handleClick = slug => { const handleClick = slug => {
emit('click', slug); emit('click', slug);
@ -17,21 +25,57 @@ const handleClick = slug => {
const handleAction = ({ action, value, id }, category) => { const handleAction = ({ action, value, id }, category) => {
emit('action', { action, value, id, category }); emit('action', { action, value, id, category });
}; };
const onDragEnd = () => {
// Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedPositions = localCategories.value
.map((category, index) => category.position || index + 1)
.sort((a, b) => a - b);
const reorderedGroup = localCategories.value.reduce(
(obj, category, index) => {
obj[category.id] = sortedPositions[index];
return obj;
},
{}
);
emit('reorder', reorderedGroup);
};
watch(
() => props.categories,
newCategories => {
localCategories.value = newCategories;
},
{ deep: true }
);
</script> </script>
<template> <template>
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"> <Draggable
<CategoryCard v-model="localCategories"
v-for="category in categories" :disabled="!dragEnabled"
:id="category.id" item-key="id"
:key="category.id" tag="ul"
:title="category.name" role="list"
:icon="category.icon" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
:description="category.description" @end="onDragEnd"
:articles-count="category.meta.articles_count || 0" >
:slug="category.slug" <template #item="{ element }">
@click="handleClick(category.slug)" <li class="list-none">
@action="handleAction($event, category)" <CategoryCard
/> :id="element.id"
</ul> :title="element.name"
:icon="element.icon"
:description="element.description"
:articles-count="element.meta?.articles_count || 0"
:slug="element.slug"
:class="{ 'cursor-grab': dragEnabled }"
@click="handleClick(element.slug)"
@action="handleAction($event, element)"
/>
</li>
</template>
</Draggable>
</template> </template>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables'; import { useAlert, useTrack } from 'dashboard/composables';
@ -24,12 +24,20 @@ const dialogRef = ref(null);
const isUpdating = ref(false); const isUpdating = ref(false);
const selectedLocale = ref(''); const selectedLocale = ref('');
const localeStatus = ref('published');
const addedLocales = computed(() => { const addedLocales = computed(() => {
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {}; const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
return allowedLocales.map(locale => locale.code); return allowedLocales.map(locale => locale.code);
}); });
const draftedLocales = computed(() => {
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
return allowedLocales
.filter(locale => locale.draft)
.map(locale => locale.code);
});
const locales = computed(() => { const locales = computed(() => {
return Object.keys(allLocales) return Object.keys(allLocales)
.map(key => { .map(key => {
@ -41,17 +49,44 @@ const locales = computed(() => {
.filter(locale => !addedLocales.value.includes(locale.value)); .filter(locale => !addedLocales.value.includes(locale.value));
}); });
const statusOptions = computed(() => [
{
value: 'published',
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
},
{
value: 'draft',
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
},
]);
const resetForm = () => {
selectedLocale.value = '';
localeStatus.value = 'published';
};
watch(localeStatus, value => {
if (!value) {
localeStatus.value = 'published';
}
});
const onCreate = async () => { const onCreate = async () => {
if (!selectedLocale.value) return; if (!selectedLocale.value) return;
isUpdating.value = true; isUpdating.value = true;
const updatedLocales = [...addedLocales.value, selectedLocale.value]; const updatedLocales = [...addedLocales.value, selectedLocale.value];
const updatedDraftLocales =
localeStatus.value === 'draft'
? [...new Set([...draftedLocales.value, selectedLocale.value])]
: draftedLocales.value;
try { try {
await store.dispatch('portals/update', { await store.dispatch('portals/update', {
portalSlug: props.portal?.slug, portalSlug: props.portal?.slug,
config: { config: {
allowed_locales: updatedLocales, allowed_locales: updatedLocales,
draft_locales: updatedDraftLocales,
default_locale: props.portal?.meta?.default_locale, default_locale: props.portal?.meta?.default_locale,
}, },
}); });
@ -62,6 +97,7 @@ const onCreate = async () => {
from: route.name, from: route.name,
}); });
resetForm();
dialogRef.value?.close(); dialogRef.value?.close();
useAlert( useAlert(
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE') t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
@ -86,6 +122,7 @@ defineExpose({ dialogRef });
type="edit" type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')" :title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')" :description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
@close="resetForm"
@confirm="onCreate" @confirm="onCreate"
> >
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
@ -97,6 +134,16 @@ defineExpose({ dialogRef });
" "
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5" class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/> />
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
</span>
<ComboBox
v-model="localeStatus"
:options="statusOptions"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
</div> </div>
</Dialog> </Dialog>
</template> </template>

View File

@ -29,6 +29,7 @@ const isLocaleDefault = code => {
const updatePortalLocales = async ({ const updatePortalLocales = async ({
newAllowedLocales, newAllowedLocales,
newDraftLocales,
defaultLocale, defaultLocale,
messageKey, messageKey,
}) => { }) => {
@ -39,6 +40,7 @@ const updatePortalLocales = async ({
config: { config: {
default_locale: defaultLocale, default_locale: defaultLocale,
allowed_locales: newAllowedLocales, allowed_locales: newAllowedLocales,
draft_locales: newDraftLocales,
}, },
}); });
@ -53,8 +55,12 @@ const updatePortalLocales = async ({
const changeDefaultLocale = ({ localeCode }) => { const changeDefaultLocale = ({ localeCode }) => {
const newAllowedLocales = props.locales.map(locale => locale.code); const newAllowedLocales = props.locales.map(locale => locale.code);
const newDraftLocales = props.locales
.filter(locale => locale.isDraft)
.map(locale => locale.code);
updatePortalLocales({ updatePortalLocales({
newAllowedLocales, newAllowedLocales,
newDraftLocales,
defaultLocale: localeCode, defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE', messageKey: 'CHANGE_DEFAULT_LOCALE',
}); });
@ -81,11 +87,15 @@ const deletePortalLocale = async ({ localeCode }) => {
const updatedLocales = props.locales const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode) .filter(locale => locale.code !== localeCode)
.map(locale => locale.code); .map(locale => locale.code);
const updatedDraftLocales = props.locales
.filter(locale => locale.code !== localeCode && locale.isDraft)
.map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale; const defaultLocale = props.portal.meta.default_locale;
await updatePortalLocales({ await updatePortalLocales({
newAllowedLocales: updatedLocales, newAllowedLocales: updatedLocales,
newDraftLocales: updatedDraftLocales,
defaultLocale, defaultLocale,
messageKey: 'DELETE_LOCALE', messageKey: 'DELETE_LOCALE',
}); });
@ -98,9 +108,46 @@ const deletePortalLocale = async ({ localeCode }) => {
}); });
}; };
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
const newAllowedLocales = props.locales.map(locale => locale.code);
const currentDraftLocales = props.locales
.filter(locale => locale.isDraft)
.map(locale => locale.code);
const newDraftLocales = shouldDraft
? [...new Set([...currentDraftLocales, localeCode])]
: currentDraftLocales.filter(locale => locale !== localeCode);
await updatePortalLocales({
newAllowedLocales,
newDraftLocales,
defaultLocale: props.portal.meta.default_locale,
messageKey,
});
};
const moveLocaleToDraft = async ({ localeCode }) => {
await updateDraftLocales({
localeCode,
shouldDraft: true,
messageKey: 'DRAFT_LOCALE',
});
};
const publishLocale = async ({ localeCode }) => {
await updateDraftLocales({
localeCode,
shouldDraft: false,
messageKey: 'PUBLISH_LOCALE',
});
};
const handleAction = ({ action }, localeCode) => { const handleAction = ({ action }, localeCode) => {
if (action === 'change-default') { if (action === 'change-default') {
changeDefaultLocale({ localeCode: localeCode }); changeDefaultLocale({ localeCode: localeCode });
} else if (action === 'move-to-draft') {
moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode });
} else if (action === 'delete') { } else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode }); deletePortalLocale({ localeCode: localeCode });
} }
@ -114,6 +161,7 @@ const handleAction = ({ action }, localeCode) => {
:key="index" :key="index"
:locale="locale.name" :locale="locale.name"
:is-default="isLocaleDefault(locale.code)" :is-default="isLocaleDefault(locale.code)"
:is-draft="locale.isDraft"
:locale-code="locale.code" :locale-code="locale.code"
:article-count="locale.articlesCount || 0" :article-count="locale.articlesCount || 0"
:category-count="locale.categoriesCount || 0" :category-count="locale.categoriesCount || 0"

View File

@ -4,37 +4,49 @@ import LocalesPage from './LocalesPage.vue';
const locales = [ const locales = [
{ {
name: 'English (en-US)', name: 'English (en-US)',
code: 'en',
isDefault: true, isDefault: true,
isDraft: false,
articleCount: 5, articleCount: 5,
categoryCount: 5, categoryCount: 5,
}, },
{ {
name: 'Spanish (es-ES)', name: 'Spanish (es-ES)',
code: 'es',
isDefault: false, isDefault: false,
isDraft: true,
articleCount: 20, articleCount: 20,
categoryCount: 10, categoryCount: 10,
}, },
{ {
name: 'English (en-UK)', name: 'English (en-UK)',
code: 'en_GB',
isDefault: false, isDefault: false,
isDraft: false,
articleCount: 15, articleCount: 15,
categoryCount: 7, categoryCount: 7,
}, },
{ {
name: 'Malay (ms-MY)', name: 'Malay (ms-MY)',
code: 'ms',
isDefault: false, isDefault: false,
isDraft: false,
articleCount: 15, articleCount: 15,
categoryCount: 7, categoryCount: 7,
}, },
{ {
name: 'Malayalam (ml-IN)', name: 'Malayalam (ml-IN)',
code: 'ml',
isDefault: false, isDefault: false,
isDraft: false,
articleCount: 10, articleCount: 10,
categoryCount: 5, categoryCount: 5,
}, },
{ {
name: 'Hindi (hi-IN)', name: 'Hindi (hi-IN)',
code: 'hi',
isDefault: false, isDefault: false,
isDraft: false,
articleCount: 15, articleCount: 15,
categoryCount: 7, categoryCount: 7,
}, },

View File

@ -14,7 +14,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { import {
searchContacts, createContactSearcher,
createNewContact, createNewContact,
fetchContactableInboxes, fetchContactableInboxes,
processContactableInboxes, processContactableInboxes,
@ -44,6 +44,7 @@ const props = defineProps({
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const searchContacts = createContactSearcher();
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
@ -194,15 +195,17 @@ const onContactSearch = debounce(
isSearching.value = true; isSearching.value = true;
contacts.value = []; contacts.value = [];
try { try {
contacts.value = await searchContacts(query); const results = await searchContacts(query);
// null means the request was aborted (a newer search is in-flight),
if (results === null) return;
contacts.value = results;
isSearching.value = false; isSearching.value = false;
} catch (error) { } catch (error) {
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false; isSearching.value = false;
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} }
}, },
300, 400,
false false
); );
@ -221,6 +224,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
contact = rest; contact = rest;
} }
selectedContact.value = contact; selectedContact.value = contact;
contacts.value = [];
if (contact?.id) { if (contact?.id) {
isFetchingInboxes.value = true; isFetchingInboxes.value = true;
try { try {
@ -355,7 +359,7 @@ useKeyboardEvents(keyboardEvents);
handleClickOutside, handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785 // Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open // This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['div.ProseMirror-prompt'] }, { ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]" ]"
class="relative" class="relative"
:class="{ :class="{

View File

@ -13,6 +13,9 @@ import {
prepareWhatsAppMessagePayload, prepareWhatsAppMessagePayload,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js'; } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import ContactSelector from './ContactSelector.vue'; import ContactSelector from './ContactSelector.vue';
import InboxSelector from './InboxSelector.vue'; import InboxSelector from './InboxSelector.vue';
import EmailOptions from './EmailOptions.vue'; import EmailOptions from './EmailOptions.vue';
@ -20,6 +23,7 @@ import MessageEditor from './MessageEditor.vue';
import ActionButtons from './ActionButtons.vue'; import ActionButtons from './ActionButtons.vue';
import InboxEmptyState from './InboxEmptyState.vue'; import InboxEmptyState from './InboxEmptyState.vue';
import AttachmentPreviews from './AttachmentPreviews.vue'; import AttachmentPreviews from './AttachmentPreviews.vue';
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
const props = defineProps({ const props = defineProps({
contacts: { type: Array, default: () => [] }, contacts: { type: Array, default: () => [] },
@ -41,6 +45,7 @@ const props = defineProps({
const emit = defineEmits([ const emit = defineEmits([
'searchContacts', 'searchContacts',
'resetContactSearch',
'discard', 'discard',
'updateSelectedContact', 'updateSelectedContact',
'updateTargetInbox', 'updateTargetInbox',
@ -50,6 +55,8 @@ const emit = defineEmits([
const DEFAULT_FORMATTING = 'Context::Default'; const DEFAULT_FORMATTING = 'Context::Default';
const copilot = useCopilotReply();
const showContactsDropdown = ref(false); const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false); const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false); const showCcEmailsDropdown = ref(false);
@ -159,22 +166,8 @@ const isAnyDropdownActive = computed(() => {
}); });
const handleContactSearch = value => { const handleContactSearch = value => {
showContactsDropdown.value = true; showContactsDropdown.value = value.trim().length > 1;
const query = typeof value === 'string' ? value.trim() : ''; emit('searchContacts', value);
const hasAlphabet = Array.from(query).some(char => {
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return lower !== upper;
});
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
const keys = ['email', 'phone_number', 'name'].filter(key => {
if (key === 'phone_number' && hasAlphabet) return false;
if (key === 'name' && isEmailLike) return false;
return true;
});
emit('searchContacts', { keys, query: value });
}; };
const handleDropdownUpdate = (type, value) => { const handleDropdownUpdate = (type, value) => {
@ -188,13 +181,17 @@ const handleDropdownUpdate = (type, value) => {
}; };
const searchCcEmails = value => { const searchCcEmails = value => {
showCcEmailsDropdown.value = true; showBccEmailsDropdown.value = false;
emit('searchContacts', { keys: ['email'], query: value }); emit('resetContactSearch');
showCcEmailsDropdown.value = value.trim().length >= 2;
emit('searchContacts', value);
}; };
const searchBccEmails = value => { const searchBccEmails = value => {
showBccEmailsDropdown.value = true; showCcEmailsDropdown.value = false;
emit('searchContacts', { keys: ['email'], query: value }); emit('resetContactSearch');
showBccEmailsDropdown.value = value.trim().length >= 2;
emit('searchContacts', value);
}; };
const setSelectedContact = async ({ value, action, ...rest }) => { const setSelectedContact = async ({ value, action, ...rest }) => {
@ -212,6 +209,7 @@ const stripMessageFormatting = channelType => {
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => { const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset(); v$.value.$reset();
copilot.reset(false);
// Strip unsupported formatting when changing the target inbox // Strip unsupported formatting when changing the target inbox
if (channelType) { if (channelType) {
@ -226,6 +224,7 @@ const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
copilot.reset(false);
stripMessageFormatting(DEFAULT_FORMATTING); stripMessageFormatting(DEFAULT_FORMATTING);
@ -234,6 +233,7 @@ const removeTargetInbox = value => {
}; };
const clearSelectedContact = () => { const clearSelectedContact = () => {
copilot.reset(false);
emit('clearSelectedContact'); emit('clearSelectedContact');
state.message = ''; state.message = '';
state.attachedFiles = []; state.attachedFiles = [];
@ -248,6 +248,7 @@ const handleAttachFile = files => {
}; };
const clearForm = () => { const clearForm = () => {
copilot.reset(false);
Object.assign(state, { Object.assign(state, {
message: '', message: '',
subject: '', subject: '',
@ -312,6 +313,24 @@ const shouldShowMessageEditor = computed(() => {
!inboxTypes.value.isTwilioWhatsapp !inboxTypes.value.isTwilioWhatsapp
); );
}); });
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
const onSubmitCopilotReply = () => {
const acceptedMessage = copilot.accept();
state.message = acceptedMessage;
};
useKeyboardEvents({
'$mod+Enter': {
action: () => {
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
onSubmitCopilotReply();
}
},
allowOnFocusedInput: true,
},
});
</script> </script>
<template> <template>
@ -342,6 +361,7 @@ const shouldShowMessageEditor = computed(() => {
:show-inboxes-dropdown="showInboxesDropdown" :show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList" :contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid" :has-errors="validationStates.isInboxInvalid"
:is-fetching-inboxes="isFetchingInboxes"
@update-inbox="removeTargetInbox" @update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event" @toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction" @handle-inbox-action="handleInboxAction"
@ -370,6 +390,7 @@ const shouldShowMessageEditor = computed(() => {
:has-errors="validationStates.isMessageInvalid" :has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType" :channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''" :medium="targetInbox?.medium || ''"
:copilot="copilot"
/> />
<AttachmentPreviews <AttachmentPreviews
@ -379,7 +400,15 @@ const shouldShowMessageEditor = computed(() => {
/> />
</div> </div>
<CopilotReplyBottomPanel
v-if="isCopilotActive"
:is-generating-content="copilot.isButtonDisabled.value"
class="h-[3.25rem] !px-4 !py-2"
@submit="onSubmitCopilotReply"
@cancel="copilot.reset"
/>
<ActionButtons <ActionButtons
v-else
:attached-files="state.attachedFiles" :attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp" :is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys" :is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys"

View File

@ -44,14 +44,16 @@ const bccEmailsArray = computed(() =>
); );
const contactEmailsList = computed(() => { const contactEmailsList = computed(() => {
return props.contacts?.map(({ name, id, email }) => ({ return props.contacts
id, ?.filter(contact => contact.email)
label: email, .map(({ name, id, email }) => ({
email, id,
thumbnail: { name: name, src: '' }, label: email,
value: id, email,
action: 'email', thumbnail: { name: name, src: '' },
})); value: id,
action: 'email',
}));
}); });
// Handle updates from TagInput and convert array back to string // Handle updates from TagInput and convert array back to string
@ -97,7 +99,6 @@ const inputClass = computed(() => {
type="email" type="email"
allow-create allow-create
class="flex-1 min-h-7" class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)" @input="emit('searchCcEmails', $event)"
@on-click-outside="emit('updateDropdown', 'cc', false)" @on-click-outside="emit('updateDropdown', 'cc', false)"
@update:model-value="handleCcUpdate" @update:model-value="handleCcUpdate"
@ -131,7 +132,6 @@ const inputClass = computed(() => {
allow-create allow-create
class="flex-1 min-h-7" class="flex-1 min-h-7"
focus-on-mount focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@input="emit('searchBccEmails', $event)" @input="emit('searchBccEmails', $event)"
@on-click-outside="emit('updateDropdown', 'bcc', false)" @on-click-outside="emit('updateDropdown', 'bcc', false)"
@update:model-value="handleBccUpdate" @update:model-value="handleBccUpdate"

View File

@ -1,9 +1,15 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template> <template>
<div <div
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3" class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
> >
<span class="text-sm dark:text-n-amber-11 text-n-amber-11"> <span class="text-sm dark:text-n-amber-11 text-n-amber-11">
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }} {{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
</span> </span>
</div> </div>
</template> </template>

View File

@ -6,6 +6,7 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({ const props = defineProps({
targetInbox: { targetInbox: {
@ -28,6 +29,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isFetchingInboxes: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -71,7 +76,9 @@ const targetInboxLabel = computed(() => {
v-on-click-outside="() => emit('toggleDropdown', false)" v-on-click-outside="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7" class="relative flex items-center h-7"
> >
<Spinner v-if="isFetchingInboxes" :size="16" />
<Button <Button
v-else
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')" :label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
variant="link" variant="link"
size="sm" size="sm"

View File

@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
const props = defineProps({ const props = defineProps({
hasErrors: { type: Boolean, default: false }, hasErrors: { type: Boolean, default: false },
@ -10,6 +11,7 @@ const props = defineProps({
messageSignature: { type: String, default: '' }, messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' }, channelType: { type: String, default: '' },
medium: { type: String, default: '' }, medium: { type: String, default: '' },
copilot: { type: Object, default: null },
}); });
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`); const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
@ -20,29 +22,67 @@ const modelValue = defineModel({
type: String, type: String,
default: '', default: '',
}); });
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
const executeCopilotAction = (action, data) => {
if (props.copilot) {
props.copilot.execute(action, data);
}
};
</script> </script>
<template> <template>
<div class="flex-1 h-full"> <div class="flex-1 h-full px-4 py-4">
<Editor <Transition
v-model="modelValue" mode="out-in"
:editor-key="editorKey" enter-active-class="transition-all duration-300 ease-out"
:placeholder=" enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER') enter-to-class="opacity-100 translate-y-0 scale-100"
" leave-active-class="transition-all duration-200 ease-in"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]" leave-from-class="opacity-100 translate-y-0 scale-100"
:class=" leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
hasErrors >
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9' <div
: '' :key="copilot ? copilot.editorTransitionKey.value : 'rich'"
" class="h-full"
enable-variables >
:show-character-count="false" <CopilotEditorSection
:signature="messageSignature" v-if="isCopilotActive"
allow-signature :show-copilot-editor="copilot.showEditor.value"
:send-with-signature="sendWithSignature" :is-generating-content="copilot.isGenerating.value"
:channel-type="channelType" :generated-content="copilot.generatedContent.value"
:medium="medium" class="!mb-0"
/> @focus="() => {}"
@blur="() => {}"
@clear-selection="() => {}"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<Editor
v-else
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
enable-captain-tools
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
@execute-copilot-action="executeCopilotAction"
/>
</div>
</Transition>
</div> </div>
</template> </template>

View File

@ -189,38 +189,43 @@ export const prepareWhatsAppMessagePayload = ({
}; };
}; };
export const generateContactQuery = ({ keys = ['email'], query }) => {
return {
payload: keys.map(key => {
const filterPayload = {
attribute_key: key,
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
};
if (keys.findIndex(k => k === key) !== keys.length - 1) {
filterPayload.query_operator = 'or';
}
return filterPayload;
}),
};
};
// API Calls // API Calls
export const searchContacts = async ({ keys, query }) => { const MIN_SEARCH_LENGTH = 2;
const {
data: { payload }, export const createContactSearcher = () => {
} = await ContactAPI.filter( let controller = null;
undefined,
'name', return async (query, { skipMinLength = false } = {}) => {
generateContactQuery({ keys, query }) const trimmed = typeof query === 'string' ? query.trim() : '';
);
const camelCasedPayload = camelcaseKeys(payload, { deep: true }); controller?.abort();
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter( if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
contact => contact.phoneNumber || contact.email return [];
);
return filteredPayload || []; controller = new AbortController();
const { signal } = controller;
try {
const {
data: { payload },
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
} catch (error) {
// Return null for aborted requests so callers can distinguish
// "request was cancelled" from "no results found"
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
return null;
}
throw error;
}
};
}; };
export const createNewContact = async input => { export const createNewContact = async input => {

View File

@ -70,7 +70,7 @@ describe('composeConversationHelper', () => {
const result = helpers.buildContactableInboxesList(inboxes); const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({ expect(result[0]).toMatchObject({
id: 1, id: 1,
icon: 'i-ri-mail-line', icon: 'i-woot-mail',
label: 'Email Inbox (support@example.com)', label: 'Email Inbox (support@example.com)',
action: 'inbox', action: 'inbox',
value: 1, value: 1,
@ -336,72 +336,13 @@ describe('composeConversationHelper', () => {
}); });
}); });
describe('generateContactQuery', () => {
it('generates correct query structure for contact search', () => {
const query = 'test@example.com';
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
},
],
};
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
expected
);
});
it('handles empty query', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({ keys: ['email'], query: '' })
).toEqual(expected);
});
it('handles mutliple keys', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
query_operator: 'or',
},
{
attribute_key: 'phone_number',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({
keys: ['email', 'phone_number'],
query: 'john',
})
).toEqual(expected);
});
});
describe('API calls', () => { describe('API calls', () => {
describe('searchContacts', () => { describe('createContactSearcher', () => {
let searchContacts;
beforeEach(() => {
searchContacts = helpers.createContactSearcher();
});
it('searches contacts and returns camelCase results', async () => { it('searches contacts and returns camelCase results', async () => {
const mockPayload = [ const mockPayload = [
{ {
@ -413,14 +354,11 @@ describe('composeConversationHelper', () => {
}, },
]; ];
ContactAPI.filter.mockResolvedValue({ ContactAPI.search.mockResolvedValue({
data: { payload: mockPayload }, data: { payload: mockPayload },
}); });
const result = await helpers.searchContacts({ const result = await searchContacts('john');
keys: ['email'],
query: 'john',
});
expect(result).toEqual([ expect(result).toEqual([
{ {
@ -432,16 +370,56 @@ describe('composeConversationHelper', () => {
}, },
]); ]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', { expect(ContactAPI.search).toHaveBeenCalledWith(
payload: [ 'john',
{ 1,
attribute_key: 'email', 'name',
filter_operator: 'contains', '',
values: ['john'], expect.objectContaining({ signal: expect.any(AbortSignal) })
attribute_model: 'standard', );
}, });
],
it('returns empty array for queries shorter than 2 characters', async () => {
const result = await searchContacts('j');
expect(result).toEqual([]);
expect(ContactAPI.search).not.toHaveBeenCalled();
});
it('returns empty array for empty or whitespace-only queries', async () => {
expect(await searchContacts('')).toEqual([]);
expect(await searchContacts(' ')).toEqual([]);
expect(await searchContacts(null)).toEqual([]);
expect(ContactAPI.search).not.toHaveBeenCalled();
});
it('aborts previous in-flight request when a new search starts', async () => {
const mockPayload = [
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
];
let resolveFirst;
const firstCall = new Promise(resolve => {
resolveFirst = resolve;
}); });
ContactAPI.search
.mockReturnValueOnce(firstCall)
.mockResolvedValueOnce({ data: { payload: mockPayload } });
// Start first search (will hang)
const first = searchContacts('alpha');
// Start second search (aborts first)
const second = searchContacts('beta');
// Resolve the first call with CanceledError (simulating axios abort)
const canceledError = new Error('canceled');
canceledError.name = 'CanceledError';
resolveFirst(Promise.reject(canceledError));
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult).toBeNull();
expect(secondResult).toEqual([
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
]);
}); });
it('searches contacts and returns only contacts with email or phone number', async () => { it('searches contacts and returns only contacts with email or phone number', async () => {
@ -469,14 +447,11 @@ describe('composeConversationHelper', () => {
}, },
]; ];
ContactAPI.filter.mockResolvedValue({ ContactAPI.search.mockResolvedValue({
data: { payload: mockPayload }, data: { payload: mockPayload },
}); });
const result = await helpers.searchContacts({ const result = await searchContacts('john');
keys: ['email'],
query: 'john',
});
// Should only return contacts with either email or phone number // Should only return contacts with either email or phone number
expect(result).toEqual([ expect(result).toEqual([
@ -496,24 +471,21 @@ describe('composeConversationHelper', () => {
}, },
]); ]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', { expect(ContactAPI.search).toHaveBeenCalledWith(
payload: [ 'john',
{ 1,
attribute_key: 'email', 'name',
filter_operator: 'contains', '',
values: ['john'], expect.objectContaining({ signal: expect.any(AbortSignal) })
attribute_model: 'standard', );
},
],
});
}); });
it('handles empty search results', async () => { it('handles empty search results', async () => {
ContactAPI.filter.mockResolvedValue({ ContactAPI.search.mockResolvedValue({
data: { payload: [] }, data: { payload: [] },
}); });
const result = await helpers.searchContacts('nonexistent'); const result = await searchContacts('nonexistent');
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@ -536,11 +508,11 @@ describe('composeConversationHelper', () => {
}, },
]; ];
ContactAPI.filter.mockResolvedValue({ ContactAPI.search.mockResolvedValue({
data: { payload: mockPayload }, data: { payload: mockPayload },
}); });
const result = await helpers.searchContacts('test'); const result = await searchContacts('test');
expect(result).toEqual([ expect(result).toEqual([
{ {
@ -562,6 +534,36 @@ describe('composeConversationHelper', () => {
}); });
}); });
describe('createContactSearcher isolation', () => {
it('creates isolated searcher instances that do not cancel each other', async () => {
const searcherA = helpers.createContactSearcher();
const searcherB = helpers.createContactSearcher();
const payloadA = [
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
];
const payloadB = [
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
];
ContactAPI.search
.mockResolvedValueOnce({ data: { payload: payloadA } })
.mockResolvedValueOnce({ data: { payload: payloadB } });
const [resultA, resultB] = await Promise.all([
searcherA('alice'),
searcherB('bob'),
]);
expect(resultA).toEqual([
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
]);
expect(resultB).toEqual([
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
]);
});
});
describe('createNewContact', () => { describe('createNewContact', () => {
it('creates new contact with capitalized name', async () => { it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' }; const mockContact = { id: 1, name: 'John', email: 'john@example.com' };

View File

@ -0,0 +1,31 @@
<script setup>
import { useToggle } from '@vueuse/core';
const props = defineProps({
title: {
type: String,
required: true,
},
defaultOpen: {
type: Boolean,
default: true,
},
});
const [isOpen, toggle] = useToggle(props.defaultOpen);
</script>
<template>
<div
v-bind="$attrs"
class="flex items-center justify-between w-full cursor-pointer pb-2 pt-4 border-t border-n-weak"
@click="toggle()"
>
<span class="text-heading-2 text-n-slate-12 w-full">
{{ title }}
</span>
</div>
<div v-if="isOpen" class="w-full space-y-4 pt-4 mb-4">
<slot />
</div>
</template>

View File

@ -0,0 +1,37 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
helpText: {
type: String,
default: '',
},
});
</script>
<template>
<div class="w-full py-2 mb-2 [interpolate-size:allow-keywords]">
<div
class="grid grid-cols-1 lg:grid-cols-8 gap-1.5 lg:gap-4 items-start lg:items-center"
>
<label class="text-heading-3 text-n-slate-12 col-span-1 lg:col-span-2">
{{ label }}
</label>
<div class="col-span-1 lg:col-span-6">
<slot />
</div>
</div>
<div v-if="helpText" class="grid grid-cols-1 lg:grid-cols-8">
<div class="col-span-1 lg:col-span-2 invisible" />
<p
v-if="helpText"
class="mt-1.5 col-span-1 lg:col-span-6 text-label-small text-n-slate-11 ltr:ml-1 rtl:mr-1"
>
{{ helpText }}
</p>
</div>
<slot name="extra" />
</div>
</template>

View File

@ -0,0 +1,50 @@
<script setup>
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
defineProps({
header: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
compact: {
type: Boolean,
default: false,
},
hideToggle: {
type: Boolean,
default: false,
},
});
const modelValue = defineModel({ type: Boolean, default: false });
</script>
<template>
<div
class="flex flex-col items-start outline outline-1 -outline-offset-1 outline-n-weak rounded-xl [interpolate-size:allow-keywords]"
>
<div class="flex flex-col gap-1 items-start w-full px-4 py-3">
<div class="flex items-center gap-3 w-full justify-between">
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<div v-if="hideToggle" class="size-2" />
<ToggleSwitch v-else v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
{{ description }}
</span>
</div>
<div
v-if="$slots.editor"
class="w-full border-t border-n-weak"
:class="{ 'p-0': compact, 'px-4 pb-4 pt-2': !compact }"
>
<slot name="editor" />
</div>
</div>
</template>

View File

@ -112,6 +112,19 @@ const containerStyles = computed(() => ({
height: `${props.size}px`, height: `${props.size}px`,
})); }));
const borderRadiusClass = computed(() => {
if (props.roundedFull) {
return 'rounded-full';
}
// Approximates 25% of size
if (props.size <= 16) return 'rounded'; // 4px
if (props.size <= 24) return 'rounded-md'; // 6px
if (props.size <= 32) return 'rounded-lg'; // 8px
if (props.size <= 48) return 'rounded-xl'; // 12px
return 'rounded-2xl'; // 16px
});
const avatarStyles = computed(() => ({ const avatarStyles = computed(() => ({
...containerStyles.value, ...containerStyles.value,
backgroundColor: backgroundColor:
@ -184,7 +197,7 @@ watch(
<template> <template>
<span <span
class="relative inline-flex group/avatar z-0 flex-shrink-0" class="relative inline-flex group/avatar z-0 flex-shrink-0 align-middle"
:style="containerStyles" :style="containerStyles"
> >
<!-- Status Badge --> <!-- Status Badge -->
@ -216,9 +229,9 @@ watch(
<!-- Avatar Container --> <!-- Avatar Container -->
<span <span
role="img" role="img"
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium" class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium outline outline-1 -outline-offset-1 outline-[rgb(0_0_0_/_0.03)] dark:outline-[rgb(255_255_255_/_0.04)]"
:class="[ :class="[
roundedFull ? 'rounded-full' : 'rounded-xl', borderRadiusClass,
{ {
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]': 'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
!showDefaultAvatar && (!src || !isImageValid), !showDefaultAvatar && (!src || !isImageValid),
@ -267,7 +280,8 @@ watch(
:handle-image-upload="handleImageUpload" :handle-image-upload="handleImageUpload"
> >
<div <div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100" class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
:class="borderRadiusClass"
@click="handleUploadAvatar" @click="handleUploadAvatar"
> >
<Icon <Icon

View File

@ -117,7 +117,7 @@ const handleCreateAssistant = () => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6"> <header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto"> <div class="w-full max-w-5xl mx-auto">
<div <div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
> >
@ -140,7 +140,9 @@ const handleCreateAssistant = () => {
> >
<Button <Button
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
variant="ghost" :variant="
showAssistantSwitcherDropdown ? 'faded' : 'ghost'
"
color="slate" color="slate"
size="xs" size="xs"
:disabled="isFetchingAssistants" :disabled="isFetchingAssistants"
@ -204,7 +206,7 @@ const handleCreateAssistant = () => {
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] h-full mx-auto py-4"> <div class="w-full max-w-5xl h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" /> <slot v-if="!showPaywall" name="controls" />
<div <div
v-if="isFetching" v-if="isFetching"
@ -222,11 +224,12 @@ const handleCreateAssistant = () => {
<slot /> <slot />
</div> </div>
</main> </main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4"> <footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<PaginationFooter <PaginationFooter
:current-page="currentPage" :current-page="currentPage"
:total-items="totalCount" :total-items="totalCount"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="handlePageChange" @update:current-page="handlePageChange"
/> />
</footer> </footer>

View File

@ -18,10 +18,18 @@ const newMessage = ref('');
const isLoading = ref(false); const isLoading = ref(false);
const formatMessagesForApi = () => { const formatMessagesForApi = () => {
return messages.value.map(message => ({ return messages.value.map(message => {
role: message.sender, const payload = {
content: message.content, role: message.sender,
})); content: message.content,
};
if (message.sender === 'assistant' && message.agentName) {
payload.agent_name = message.agentName;
}
return payload;
});
}; };
const resetConversation = () => { const resetConversation = () => {
@ -62,6 +70,7 @@ const sendMessage = async () => {
messages.value.push({ messages.value.push({
content: data.response, content: data.response,
sender: 'assistant', sender: 'assistant',
agentName: data.agent_name,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} catch (error) { } catch (error) {
@ -71,6 +80,12 @@ const sendMessage = async () => {
isLoading.value = false; isLoading.value = false;
} }
}; };
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
</script> </script>
<template> <template>
@ -104,7 +119,7 @@ const sendMessage = async () => {
v-model="newMessage" v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10" class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')" :placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage" @keydown.enter.exact="handleEnterKey"
/> />
<NextButton <NextButton
ghost ghost

View File

@ -27,7 +27,7 @@ const openBilling = () => {
<template> <template>
<div <div
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center" class="w-full max-w-5xl mx-auto h-full max-h-[448px] grid place-content-center"
> >
<BasePaywallModal <BasePaywallModal
class="mx-auto" class="mx-auto"

View File

@ -79,8 +79,8 @@ defineExpose({
:aria-multiselectable="multiple" :aria-multiselectable="multiple"
> >
<li <li
v-for="option in options" v-for="(option, index) in options"
:key="option.value" :key="`${option.value}-${index}`"
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2" class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
:class="{ :class="{
'bg-n-alpha-2': isSelected(option), 'bg-n-alpha-2': isSelected(option),

View File

@ -29,6 +29,12 @@ const handleInput = () => {
nextTick(adjustHeight); nextTick(adjustHeight);
}; };
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
onMounted(() => { onMounted(() => {
nextTick(adjustHeight); nextTick(adjustHeight);
}); });
@ -43,7 +49,7 @@ onMounted(() => {
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12" class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1" rows="1"
@input="handleInput" @input="handleInput"
@keydown.enter.exact.prevent="sendMessage" @keydown.enter.exact="handleEnterKey"
/> />
<button <button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11" class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"

View File

@ -96,6 +96,17 @@ const close = () => {
isOpen.value = false; isOpen.value = false;
}; };
// Only close if the close event originated from this dialog,
// not from a child dialog (e.g. ProseMirror prompt) bubbling up.
const handleDialogClose = e => e.target === dialogRef.value && close();
// Only close on click-outside if this dialog is the topmost one.
// If another dialog (e.g. ProseMirror prompt) is open on top, ignore.
const handleClickOutside = () => {
const dialogs = document.querySelectorAll('dialog[open]');
if (dialogs[dialogs.length - 1] === dialogRef.value) close();
};
const confirm = () => { const confirm = () => {
emit('confirm'); emit('confirm');
}; };
@ -113,9 +124,9 @@ defineExpose({ open, close });
positionClass, positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible', overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]" ]"
@close="close" @close.prevent="handleDialogClose"
> >
<OnClickOutside @trigger="close"> <OnClickOutside @trigger="handleClickOutside">
<form <form
ref="dialogContentRef" ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl" class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"

View File

@ -50,7 +50,7 @@ const openLink = link => {
]" ]"
> >
<section <section
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80" class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80 z-20"
> >
<div <div
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10" class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"

View File

@ -9,12 +9,12 @@ export function useChannelIcon(inbox) {
'Channel::Sms': 'i-woot-sms', 'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram', 'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms', 'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-ri-twitter-x-fill', 'Channel::TwitterProfile': 'i-woot-x',
'Channel::WebWidget': 'i-woot-website', 'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram', 'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok', 'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-ri-phone-fill', 'Channel::Voice': 'i-woot-voice',
}; };
const providerIconMap = { const providerIconMap = {

View File

@ -22,7 +22,7 @@ describe('useChannelIcon', () => {
it('returns correct icon for Voice channel', () => { it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' }; const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox); const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-phone-fill'); expect(icon).toBe('i-woot-voice');
}); });
it('returns correct icon for Line channel', () => { it('returns correct icon for Line channel', () => {
@ -46,7 +46,7 @@ describe('useChannelIcon', () => {
it('returns correct icon for Twitter channel', () => { it('returns correct icon for Twitter channel', () => {
const inbox = { channel_type: 'Channel::TwitterProfile' }; const inbox = { channel_type: 'Channel::TwitterProfile' };
const { value: icon } = useChannelIcon(inbox); const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-twitter-x-fill'); expect(icon).toBe('i-woot-x');
}); });
it('returns correct icon for WebWidget channel', () => { it('returns correct icon for WebWidget channel', () => {

View File

@ -108,7 +108,7 @@ onMounted(() => {
<label <label
v-if="label" v-if="label"
:for="uniqueId" :for="uniqueId"
class="mb-0.5 text-sm font-medium text-n-slate-12" class="mb-0.5 text-heading-3 text-n-slate-12"
> >
{{ label }} {{ label }}
</label> </label>
@ -145,7 +145,7 @@ onMounted(() => {
/> />
<p <p
v-if="message" v-if="message"
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out" class="min-w-0 mt-1 mb-0 text-label-small truncate transition-all duration-500 ease-in-out"
:class="messageClass" :class="messageClass"
> >
{{ message }} {{ message }}

View File

@ -0,0 +1,71 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: {
type: [Object, String],
required: true,
},
compact: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'slate',
validator: value =>
['slate', 'amber', 'teal', 'ruby', 'blue', 'iris'].includes(value),
},
});
const COLOR_CLASSES = {
slate: 'bg-n-label-color outline-n-label-border text-n-slate-12',
amber: 'bg-n-amber-2 outline-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-2 outline-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-2 outline-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-2 outline-n-blue-4 text-n-blue-11',
iris: 'bg-n-iris-2 outline-n-iris-4 text-n-iris-11',
};
const isStringLabel = computed(() => typeof props.label === 'string');
const labelTitle = computed(() => {
return isStringLabel.value ? props.label : props.label?.title;
});
const labelDescription = computed(() => {
return (!isStringLabel.value && props.label?.description) || '';
});
const labelColor = computed(() => {
return isStringLabel.value ? null : props.label.color;
});
const colorClasses = computed(() => COLOR_CLASSES[props.color]);
</script>
<template>
<div
:title="labelDescription"
class="rounded-lg -outline-offset-1 outline outline-1 inline-flex items-center flex-shrink-0"
:class="[
colorClasses,
compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 gap-1.5 rounded-lg',
]"
>
<span
v-if="labelColor"
class="rounded-sm flex-shrink-0"
:class="compact ? 'size-1.5' : 'size-2'"
:style="{ background: labelColor }"
/>
<slot v-else name="icon" />
<span
class="whitespace-nowrap"
:class="compact ? 'text-label-small' : 'text-label !font-420'"
>
{{ labelTitle }}
</span>
<slot name="action" />
</div>
</template>

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