diff --git a/.circleci/config.yml b/.circleci/config.yml
index 24119bc75..09bd5191d 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -76,7 +76,7 @@ jobs:
bundle install
- node/install:
- node-version: '23.7'
+ node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -117,7 +117,7 @@ jobs:
steps:
- checkout
- node/install:
- node-version: '23.7'
+ node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -148,7 +148,7 @@ jobs:
steps:
- checkout
- node/install:
- node-version: '23.7'
+ node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
diff --git a/.devcontainer/docker-compose.base.yml b/.devcontainer/docker-compose.base.yml
index 6932b5f10..375742ff7 100644
--- a/.devcontainer/docker-compose.base.yml
+++ b/.devcontainer/docker-compose.base.yml
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
- NODE_VERSION: '23.7.0'
+ NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index a9185ea09..d696f99cc 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
- NODE_VERSION: '23.7.0'
+ NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
diff --git a/.env.example b/.env.example
index 55750b2f2..bc7380a29 100644
--- a/.env.example
+++ b/.env.example
@@ -276,3 +276,4 @@ AZURE_APP_SECRET=
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
+# REDIS_VELMA_SIZE=10
diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml
index 45ff25203..1d1116d0c 100644
--- a/.github/workflows/frontend-fe.yml
+++ b/.github/workflows/frontend-fe.yml
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-node@v4
with:
- node-version: 23
+ node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml
index 011f862b0..c2a626388 100644
--- a/.github/workflows/run_foss_spec.yml
+++ b/.github/workflows/run_foss_spec.yml
@@ -28,7 +28,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
- node-version: 23
+ node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
@@ -43,7 +43,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
- node-version: 23
+ node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
@@ -94,7 +94,7 @@ jobs:
- uses: actions/setup-node@v4
with:
- node-version: 23
+ node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml
index c2a4bd174..7869bf89c 100644
--- a/.github/workflows/size-limit.yml
+++ b/.github/workflows/size-limit.yml
@@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-node@v4
with:
- node-version: 23
+ node-version: 24
cache: 'pnpm'
- name: pnpm
diff --git a/.nvmrc b/.nvmrc
index b88575e38..cf2efde81 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-23.7.0
\ No newline at end of file
+24.13.0
\ No newline at end of file
diff --git a/VERSION_CW b/VERSION_CW
index 5b341fd79..ad96464c4 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.9.1
+4.10.1
diff --git a/VERSION_CWCTL b/VERSION_CWCTL
index 6cb9d3dd0..1545d9665 100644
--- a/VERSION_CWCTL
+++ b/VERSION_CWCTL
@@ -1 +1 @@
-3.4.3
+3.5.0
diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb
index 8b40ba3c9..818c217ca 100644
--- a/app/builders/messages/instagram/base_message_builder.rb
+++ b/app/builders/messages/instagram/base_message_builder.rb
@@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
+ create_story_reply_attachment(story_reply_attributes['url'])
+ end
+
+ def create_story_reply_attachment(story_url)
+ return if story_url.blank?
+
+ attachment = @message.attachments.new(
+ file_type: :ig_story,
+ account_id: @message.account_id,
+ external_url: story_url
+ )
+ attachment.save!
+ begin
+ attach_file(attachment, story_url)
+ rescue Down::Error, StandardError => e
+ Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
+ end
+ @message.content_attributes[:image_type] = 'ig_story_reply'
+ @message.save!
end
def build_conversation
diff --git a/app/builders/v2/reports/channel_summary_builder.rb b/app/builders/v2/reports/channel_summary_builder.rb
new file mode 100644
index 000000000..2df8fc081
--- /dev/null
+++ b/app/builders/v2/reports/channel_summary_builder.rb
@@ -0,0 +1,38 @@
+class V2::Reports::ChannelSummaryBuilder
+ include DateRangeHelper
+
+ pattr_initialize [:account!, :params!]
+
+ def build
+ conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
+ end
+
+ private
+
+ def conversations_by_channel_and_status
+ account.conversations
+ .joins(:inbox)
+ .where(created_at: range)
+ .group('inboxes.channel_type', 'conversations.status')
+ .count
+ .each_with_object({}) do |((channel_type, status), count), grouped|
+ grouped[channel_type] ||= {}
+ grouped[channel_type][status] = count
+ end
+ end
+
+ def build_channel_stats(status_counts)
+ open_count = status_counts['open'] || 0
+ resolved_count = status_counts['resolved'] || 0
+ pending_count = status_counts['pending'] || 0
+ snoozed_count = status_counts['snoozed'] || 0
+
+ {
+ open: open_count,
+ resolved: resolved_count,
+ pending: pending_count,
+ snoozed: snoozed_count,
+ total: open_count + resolved_count + pending_count + snoozed_count
+ }
+ end
+end
diff --git a/app/builders/v2/reports/inbox_label_matrix_builder.rb b/app/builders/v2/reports/inbox_label_matrix_builder.rb
new file mode 100644
index 000000000..c3715019d
--- /dev/null
+++ b/app/builders/v2/reports/inbox_label_matrix_builder.rb
@@ -0,0 +1,65 @@
+class V2::Reports::InboxLabelMatrixBuilder
+ include DateRangeHelper
+
+ attr_reader :account, :params
+
+ def initialize(account:, params:)
+ @account = account
+ @params = params
+ end
+
+ def build
+ {
+ inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
+ labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
+ matrix: build_matrix
+ }
+ end
+
+ private
+
+ def filtered_inboxes
+ @filtered_inboxes ||= begin
+ inboxes = account.inboxes
+ inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
+ inboxes.order(:name).to_a
+ end
+ end
+
+ def filtered_labels
+ @filtered_labels ||= begin
+ labels = account.labels
+ labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
+ labels.order(:title).to_a
+ end
+ end
+
+ def conversation_filter
+ filter = { account_id: account.id }
+ filter[:created_at] = range if range.present?
+ filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
+ filter
+ end
+
+ def fetch_grouped_counts
+ label_names = filtered_labels.map(&:title)
+ return {} if label_names.empty?
+
+ ActsAsTaggableOn::Tagging
+ .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
+ .joins('INNER JOIN tags ON taggings.tag_id = tags.id')
+ .where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
+ .where(tags: { name: label_names })
+ .group('conversations.inbox_id', 'tags.name')
+ .count
+ end
+
+ def build_matrix
+ counts = fetch_grouped_counts
+ filtered_inboxes.map do |inbox|
+ filtered_labels.map do |label|
+ counts[[inbox.id, label.title]] || 0
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
new file mode 100644
index 000000000..156c031fa
--- /dev/null
+++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
@@ -0,0 +1,76 @@
+class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
+ before_action :current_account
+ before_action :authorize_account_update, only: [:update]
+
+ def show
+ render json: preferences_payload
+ end
+
+ def update
+ params_to_update = captain_params
+ @current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
+ @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
+ @current_account.save!
+
+ render json: preferences_payload
+ end
+
+ private
+
+ def preferences_payload
+ {
+ providers: Llm::Models.providers,
+ models: Llm::Models.models,
+ features: features_with_account_preferences
+ }
+ end
+
+ def authorize_account_update
+ authorize @current_account, :update?
+ end
+
+ def captain_params
+ permitted = {}
+ permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
+ permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
+ permitted
+ end
+
+ def merged_captain_models
+ existing_models = @current_account.captain_models || {}
+ existing_models.merge(permitted_captain_models)
+ end
+
+ def merged_captain_features
+ existing_features = @current_account.captain_features || {}
+ existing_features.merge(permitted_captain_features)
+ end
+
+ def permitted_captain_models
+ params.require(:captain_models).permit(
+ :editor, :assistant, :copilot, :label_suggestion,
+ :audio_transcription, :help_center_search
+ ).to_h.stringify_keys
+ end
+
+ def permitted_captain_features
+ params.require(:captain_features).permit(
+ :editor, :assistant, :copilot, :label_suggestion,
+ :audio_transcription, :help_center_search
+ ).to_h.stringify_keys
+ end
+
+ def features_with_account_preferences
+ preferences = Current.account.captain_preferences
+ account_features = preferences[:features] || {}
+ account_models = preferences[:models] || {}
+
+ Llm::Models.feature_keys.index_with do |feature_key|
+ config = Llm::Models.feature_config(feature_key)
+ config.merge(
+ enabled: account_features[feature_key] == true,
+ selected: account_models[feature_key] || config[:default]
+ )
+ end
+ end
+end
diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb
index e6270c807..14d4f2c89 100644
--- a/app/controllers/api/v1/accounts/contacts_controller.rb
+++ b/app/controllers/api/v1/accounts/contacts_controller.rb
@@ -24,13 +24,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
- contacts = resolved_contacts.where(
- 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
- OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
+ contacts = Current.account.contacts.where(
+ 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q].strip}%"
)
- @contacts = fetch_contacts(contacts)
- @contacts_count = @contacts.total_count
+ @contacts = fetch_contacts_with_has_more(contacts)
end
def import
@@ -143,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
.per(RESULTS_PER_PAGE)
end
+ def fetch_contacts_with_has_more(contacts)
+ includes_hash = { avatar_attachment: [:blob] }
+ includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
+
+ # Calculate offset manually to fetch one extra record for has_more check
+ offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
+ results = filtrate(contacts)
+ .includes(includes_hash)
+ .offset(offset)
+ .limit(RESULTS_PER_PAGE + 1)
+ .to_a
+
+ @has_more = results.size > RESULTS_PER_PAGE
+ results = results.first(RESULTS_PER_PAGE) if @has_more
+ @contacts_count = results.size
+ results
+ end
+
def build_contact_inbox
return if params[:inbox_id].blank?
diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb
index 4301eaa4a..e2b930ac9 100644
--- a/app/controllers/api/v1/accounts/conversations_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations_controller.rb
@@ -110,6 +110,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen
+ # High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
+ # Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
+ # Always update immediately if there are unread messages to maintain accurate read/unread state.
+ return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
+ return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
+
+ # No unread messages - apply throttling to limit DB writes
+ return unless should_update_last_seen?
+
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end
@@ -142,12 +151,25 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen_on_conversation(last_seen_at, update_assignee)
+ updates = { agent_last_seen_at: last_seen_at }
+ updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
+
# rubocop:disable Rails/SkipsModelValidations
- @conversation.update_column(:agent_last_seen_at, last_seen_at)
- @conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
+ @conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
end
+ def should_update_last_seen?
+ # Update if at least one relevant timestamp is older than 1 hour or not set
+ # This prevents redundant DB writes when agents repeatedly view the same conversation
+ agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
+ return agent_needs_update unless assignee?
+
+ # For assignees, check both timestamps - update if either is old
+ assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago
+ agent_needs_update || assignee_needs_update
+ end
+
def set_conversation_status
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
diff --git a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
index f5bed6c34..0cde5f5c1 100644
--- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
+++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
@@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
+
+Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
index d17fe35fb..bb5dab680 100644
--- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
+++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
@@ -1,38 +1,27 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
- DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
- DEFAULT_LANGUAGE = 'en'.freeze
-
before_action :fetch_inbox
before_action :validate_whatsapp_channel
def show
- template = @inbox.csat_config&.dig('template')
- return render json: { template_exists: false } unless template
+ service = CsatTemplateManagementService.new(@inbox)
+ result = service.template_status
- template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
- status_result = @inbox.channel.provider_service.get_template_status(template_name)
-
- render_template_status_response(status_result, template_name)
- rescue StandardError => e
- Rails.logger.error "Error fetching CSAT template status: #{e.message}"
- render json: { error: e.message }, status: :internal_server_error
+ if result[:service_error]
+ render json: { error: result[:service_error] }, status: :internal_server_error
+ else
+ render json: result
+ end
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
- # Delete existing template even though we are using a new one.
- # We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
- delete_existing_template_if_needed
-
- result = create_template_via_provider(template_params)
+ service = CsatTemplateManagementService.new(@inbox)
+ result = service.create_template(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
- rescue StandardError => e
- Rails.logger.error "Error creating CSAT template: #{e.message}"
- render json: { error: 'Template creation failed' }, status: :internal_server_error
end
private
@@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
end
def validate_whatsapp_channel
- return if @inbox.whatsapp?
+ return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
- render json: { error: 'CSAT template operations only available for WhatsApp channels' },
+ render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
status: :bad_request
end
@@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
- def create_template_via_provider(template_params)
- template_config = {
- message: template_params[:message],
- button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
- base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
- language: template_params[:language] || DEFAULT_LANGUAGE,
- template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
- }
-
- @inbox.channel.provider_service.create_csat_template(template_config)
- end
-
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
+ elsif result[:service_error]
+ render json: { error: result[:service_error] }, status: :internal_server_error
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
- render json: {
- template: {
- name: result[:template_name],
- template_id: result[:template_id],
- status: 'PENDING',
- language: result[:language] || DEFAULT_LANGUAGE
- }
- }, status: :created
+ if @inbox.twilio_whatsapp?
+ render json: {
+ template: {
+ friendly_name: result[:friendly_name],
+ content_sid: result[:content_sid],
+ status: result[:status] || 'pending',
+ language: result[:language] || 'en'
+ }
+ }, status: :created
+ else
+ render json: {
+ template: {
+ name: result[:template_name],
+ template_id: result[:template_id],
+ status: 'PENDING',
+ language: result[:language] || 'en'
+ }
+ }, status: :created
+ end
end
def render_failed_template_creation(result)
@@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
}, status: :unprocessable_entity
end
- def delete_existing_template_if_needed
- template = @inbox.csat_config&.dig('template')
- return true if template.blank?
-
- template_name = template['name']
- return true if template_name.blank?
-
- template_status = @inbox.channel.provider_service.get_template_status(template_name)
- return true unless template_status[:success]
-
- deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
- if deletion_result[:success]
- Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
- true
- else
- Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
- false
- end
- rescue StandardError => e
- Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
- false
- end
-
- def render_template_status_response(status_result, template_name)
- if status_result[:success]
- render json: {
- template_exists: true,
- template_name: template_name,
- status: status_result[:template][:status],
- template_id: status_result[:template][:id]
- }
- else
- render json: {
- template_exists: false,
- error: 'Template not found'
- }
- end
- end
-
def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank?
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 1c8845c04..322c7c7fe 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
- template: [:name, :template_id, :created_at, :language] }] }]
+ template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
end
def permitted_params(channel_attributes = [])
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 57062a5b2..bcbf80355 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -92,8 +92,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
- params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
- conversation_required_attributes: [])
+ params.permit(*permitted_settings_attributes)
+ end
+
+ def permitted_settings_attributes
+ [:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
end
def check_signup_enabled
@@ -112,3 +115,5 @@ class Api::V1::AccountsController < Api::BaseController
}
end
end
+
+Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')
diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb
index 6e2d0ff4c..82576cf90 100644
--- a/app/controllers/api/v2/accounts/reports_controller.rb
+++ b/app/controllers/api/v2/accounts/reports_controller.rb
@@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
+ def conversations_summary
+ @report_data = generate_conversations_report
+ generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
+ end
+
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
@@ -57,6 +62,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
+ def inbox_label_matrix
+ builder = V2::Reports::InboxLabelMatrixBuilder.new(
+ account: Current.account,
+ params: inbox_label_matrix_params
+ )
+ render json: builder.build
+ end
+
private
def generate_csv(filename, template)
@@ -134,4 +147,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
+
+ def inbox_label_matrix_params
+ {
+ since: params[:since],
+ until: params[:until],
+ inbox_ids: params[:inbox_ids],
+ label_ids: params[:label_ids]
+ }
+ end
end
diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb
index f31a53c7e..98b3f05d7 100644
--- a/app/controllers/api/v2/accounts/summary_reports_controller.rb
+++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
- before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
+ before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder)
end
+ def channel
+ return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
+
+ render_report_with(V2::Reports::ChannelSummaryBuilder)
+ end
+
private
def check_authorization
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params
params.permit(:since, :until, :business_hours)
end
+
+ def date_range_too_long?
+ return false if permitted_params[:since].blank? || permitted_params[:until].blank?
+
+ since_time = Time.zone.at(permitted_params[:since].to_i)
+ until_time = Time.zone.at(permitted_params[:until].to_i)
+ (until_time - since_time) > 6.months
+ end
end
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
new file mode 100644
index 000000000..fdf969a39
--- /dev/null
+++ b/app/controllers/health_controller.rb
@@ -0,0 +1,7 @@
+# Inherits from ActionController::Base to skip all middleware,
+# authentication, and callbacks. Used for health checks
+class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
+ def show
+ render json: { status: 'woot' }
+ end
+end
diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb
index ec51305b5..b910a9c9a 100644
--- a/app/controllers/super_admin/app_configs_controller.rb
+++ b/app/controllers/super_admin/app_configs_controller.rb
@@ -46,15 +46,16 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
- 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
+ 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
- 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
+ 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
+ 'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
}
@allowed_configs = mapping.fetch(
@config,
- %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
+ %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
end
diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb
index 23694d08d..1f34d7e97 100644
--- a/app/helpers/api/v2/accounts/reports_helper.rb
+++ b/app/helpers/api/v2/accounts/reports_helper.rb
@@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
end
end
+ def generate_conversations_report
+ builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
+ summary = builder.summary
+
+ [generate_conversation_report_metrics(summary)]
+ end
+
private
def build_params(base_params)
@@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
report[:resolved_conversations_count]
]
end
+
+ def generate_conversation_report_metrics(summary)
+ [
+ summary[:conversations_count],
+ summary[:incoming_messages_count],
+ summary[:outgoing_messages_count],
+ Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
+ Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
+ summary[:resolutions_count],
+ Reports::TimeFormatPresenter.new(summary[:reply_time]).format
+ ]
+ end
end
diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml
index 34c7a8138..f21a97f78 100644
--- a/app/helpers/super_admin/features.yml
+++ b/app/helpers/super_admin/features.yml
@@ -2,13 +2,6 @@
# No need to replicate the same values in two places
# ------- Premium Features ------- #
-captain:
- name: 'Captain'
- description: 'Enable AI-powered conversations with your customers.'
- enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
- icon: 'icon-captain'
- config_key: 'captain'
- enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
@@ -48,6 +41,12 @@ help_center:
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
+captain:
+ name: 'Captain'
+ description: 'Enable AI-powered conversations with your customers.'
+ enabled: true
+ icon: 'icon-captain'
+ config_key: 'captain'
# ------- Communication Channels ------- #
live_chat:
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index a63cb1a90..61d7631e7 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -131,7 +131,7 @@ export default {
diff --git a/app/javascript/dashboard/api/captain/preferences.js b/app/javascript/dashboard/api/captain/preferences.js
new file mode 100644
index 000000000..f1ce30582
--- /dev/null
+++ b/app/javascript/dashboard/api/captain/preferences.js
@@ -0,0 +1,18 @@
+/* global axios */
+import ApiClient from '../ApiClient';
+
+class CaptainPreferences extends ApiClient {
+ constructor() {
+ super('captain/preferences', { accountScoped: true });
+ }
+
+ get() {
+ return axios.get(this.url);
+ }
+
+ updatePreferences(data) {
+ return axios.put(this.url, data);
+ }
+}
+
+export default new CaptainPreferences();
diff --git a/app/javascript/dashboard/api/captain/tasks.js b/app/javascript/dashboard/api/captain/tasks.js
new file mode 100644
index 000000000..1b5a38335
--- /dev/null
+++ b/app/javascript/dashboard/api/captain/tasks.js
@@ -0,0 +1,107 @@
+/* global axios */
+import ApiClient from '../ApiClient';
+
+/**
+ * A client for the Captain Tasks API.
+ * @extends ApiClient
+ */
+class TasksAPI extends ApiClient {
+ /**
+ * Creates a new TasksAPI instance.
+ */
+ constructor() {
+ super('captain/tasks', { accountScoped: true });
+ }
+
+ /**
+ * Rewrites content with a specific operation.
+ * @param {Object} options - The rewrite options.
+ * @param {string} options.content - The content to rewrite.
+ * @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc).
+ * @param {string} [options.conversationId] - The conversation ID for context (required for 'improve').
+ * @param {AbortSignal} [signal] - AbortSignal to cancel the request.
+ * @returns {Promise} A promise that resolves with the rewritten content.
+ */
+ rewrite({ content, operation, conversationId }, signal) {
+ return axios.post(
+ `${this.url}/rewrite`,
+ {
+ content,
+ operation,
+ conversation_display_id: conversationId,
+ },
+ { signal }
+ );
+ }
+
+ /**
+ * Summarizes a conversation.
+ * @param {string} conversationId - The conversation ID to summarize.
+ * @param {AbortSignal} [signal] - AbortSignal to cancel the request.
+ * @returns {Promise} A promise that resolves with the summary.
+ */
+ summarize(conversationId, signal) {
+ return axios.post(
+ `${this.url}/summarize`,
+ {
+ conversation_display_id: conversationId,
+ },
+ { signal }
+ );
+ }
+
+ /**
+ * Gets a reply suggestion for a conversation.
+ * @param {string} conversationId - The conversation ID.
+ * @param {AbortSignal} [signal] - AbortSignal to cancel the request.
+ * @returns {Promise} A promise that resolves with the reply suggestion.
+ */
+ replySuggestion(conversationId, signal) {
+ return axios.post(
+ `${this.url}/reply_suggestion`,
+ {
+ conversation_display_id: conversationId,
+ },
+ { signal }
+ );
+ }
+
+ /**
+ * Gets label suggestions for a conversation.
+ * @param {string} conversationId - The conversation ID.
+ * @param {AbortSignal} [signal] - AbortSignal to cancel the request.
+ * @returns {Promise} A promise that resolves with label suggestions.
+ */
+ labelSuggestion(conversationId, signal) {
+ return axios.post(
+ `${this.url}/label_suggestion`,
+ {
+ conversation_display_id: conversationId,
+ },
+ { signal }
+ );
+ }
+
+ /**
+ * Sends a follow-up message to continue refining a previous task result.
+ * @param {Object} options - The follow-up options.
+ * @param {Object} options.followUpContext - The follow-up context from a previous task.
+ * @param {string} options.message - The follow-up message/request from the user.
+ * @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
+ * @param {AbortSignal} [signal] - AbortSignal to cancel the request.
+ * @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
+ */
+ followUp({ followUpContext, message, conversationId }, signal) {
+ return axios.post(
+ `${this.url}/follow_up`,
+ {
+ follow_up_context: followUpContext,
+ message,
+ conversation_display_id: conversationId,
+ },
+ { signal }
+ );
+ }
+}
+
+export default new TasksAPI();
diff --git a/app/javascript/dashboard/api/integrations/openapi.js b/app/javascript/dashboard/api/integrations/openapi.js
deleted file mode 100644
index 3fcf241ee..000000000
--- a/app/javascript/dashboard/api/integrations/openapi.js
+++ /dev/null
@@ -1,81 +0,0 @@
-/* global axios */
-
-import ApiClient from '../ApiClient';
-
-/**
- * Represents the data object for a OpenAI hook.
- * @typedef {Object} ConversationMessageData
- * @property {string} [tone] - The tone of the message.
- * @property {string} [content] - The content of the message.
- * @property {string} [conversation_display_id] - The display ID of the conversation (optional).
- */
-
-/**
- * A client for the OpenAI API.
- * @extends ApiClient
- */
-class OpenAIAPI extends ApiClient {
- /**
- * Creates a new OpenAIAPI instance.
- */
- constructor() {
- super('integrations', { accountScoped: true });
-
- /**
- * The conversation events supported by the API.
- * @type {string[]}
- */
- this.conversation_events = [
- 'summarize',
- 'reply_suggestion',
- 'label_suggestion',
- ];
-
- /**
- * The message events supported by the API.
- * @type {string[]}
- */
- this.message_events = ['rephrase'];
- }
-
- /**
- * Processes an event using the OpenAI API.
- * @param {Object} options - The options for the event.
- * @param {string} [options.type='rephrase'] - The type of event to process.
- * @param {string} [options.content] - The content of the event.
- * @param {string} [options.tone] - The tone of the event.
- * @param {string} [options.conversationId] - The ID of the conversation to process the event for.
- * @param {string} options.hookId - The ID of the hook to use for processing the event.
- * @returns {Promise} A promise that resolves with the result of the event processing.
- */
- processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
- /**
- * @type {ConversationMessageData}
- */
- let data = {
- tone,
- content,
- };
-
- // Always include conversation_display_id when available for session tracking
- if (conversationId) {
- data.conversation_display_id = conversationId;
- }
-
- // For conversation-level events, only send conversation_display_id
- if (this.conversation_events.includes(type)) {
- data = {
- conversation_display_id: conversationId,
- };
- }
-
- return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
- event: {
- name: type,
- data,
- },
- });
- }
-}
-
-export default new OpenAIAPI();
diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js
index c87dfc82b..00f040f8e 100644
--- a/app/javascript/dashboard/api/reports.js
+++ b/app/javascript/dashboard/api/reports.js
@@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient {
});
}
+ getConversationsSummaryReports({ from: since, to: until, businessHours }) {
+ return axios.get(`${this.url}/conversations_summary`, {
+ params: { since, until, business_hours: businessHours },
+ });
+ }
+
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
diff --git a/app/javascript/dashboard/assets/images/auth/signup-bg.jpg b/app/javascript/dashboard/assets/images/auth/signup-bg.jpg
new file mode 100644
index 000000000..884a57922
Binary files /dev/null and b/app/javascript/dashboard/assets/images/auth/signup-bg.jpg differ
diff --git a/app/javascript/dashboard/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss
index f23c01d42..784edce6c 100644
--- a/app/javascript/dashboard/assets/scss/_next-colors.scss
+++ b/app/javascript/dashboard/assets/scss/_next-colors.scss
@@ -94,21 +94,52 @@
--gray-11: 100 100 100;
--gray-12: 32 32 32;
- --background-color: 253 253 253;
- --text-blue: 8 109 224;
+ --violet-1: 253 252 254;
+ --violet-2: 250 248 255;
+ --violet-3: 244 240 254;
+ --violet-4: 235 228 255;
+ --violet-5: 225 217 255;
+ --violet-6: 212 202 254;
+ --violet-7: 194 178 248;
+ --violet-8: 169 153 236;
+ --violet-9: 110 86 207;
+ --violet-10: 100 84 196;
+ --violet-11: 101 85 183;
+ --violet-12: 47 38 95;
+
+ --background-color: 247 247 247;
+ --surface-1: 254 254 254;
+ --surface-2: 255 255 255;
+ --surface-active: 255 255 255;
+ --background-input-box: 0, 0, 0, 0.03;
+ --text-blue: 1 22 44;
+ --text-purple: 2 4 49;
+ --text-amber: 37 24 1;
--border-container: 236 236 236;
- --border-strong: 235 235 235;
+ --border-strong: 226 227 231;
--border-weak: 234 234 234;
+ --border-blue-strong: 18 61 117;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
- --solid-amber: 252 232 193;
+ --solid-amber: 255 228 181;
--solid-blue: 218 236 255;
+ --solid-blue-2: 251 253 255;
--solid-iris: 230 231 255;
+ --solid-purple: 230 231 255;
+ --solid-red: 254 200 201;
+ --solid-amber-button: 255 221 141;
+ --card-color: 255 255 255;
+ --overlay: 0, 0, 0, 0.12;
+ --overlay-avatar: 255, 255, 255, 0.67;
+ --button-color: 255 255 255;
+ --button-hover-color: 255, 255, 255, 0.2;
+ --label-background: 247 247 247;
+ --label-border: 0, 0, 0, 0.04;
- --alpha-1: 67, 67, 67, 0.06;
- --alpha-2: 201, 202, 207, 0.15;
+ --alpha-1: 215, 215, 215, 0.22;
+ --alpha-2: 196, 197, 198, 0.22;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
@@ -209,25 +240,56 @@
--gray-11: 180 180 180;
--gray-12: 238 238 238;
- --background-color: 18 18 19;
- --border-strong: 52 52 52;
- --border-weak: 38 38 42;
+ --violet-1: 20 17 31;
+ --violet-2: 27 21 37;
+ --violet-3: 41 31 67;
+ --violet-4: 50 37 85;
+ --violet-5: 60 46 105;
+ --violet-6: 71 56 135;
+ --violet-7: 86 70 151;
+ --violet-8: 110 86 171;
+ --violet-9: 110 86 207;
+ --violet-10: 125 109 217;
+ --violet-11: 169 153 236;
+ --violet-12: 226 221 254;
+
+ --background-color: 28 29 32;
+ --surface-1: 20 21 23;
+ --surface-2: 22 23 26;
+ --surface-active: 53 57 66;
+ --background-input-box: 255, 255, 255, 0.02;
+ --text-blue: 213 234 255;
+ --text-purple: 232 233 254;
+ --text-amber: 255 247 234;
+ --border-strong: 46 45 50;
+ --border-weak: 31 31 37;
+ --border-blue-strong: 201 226 255;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
- --solid-amber: 42 37 30;
- --solid-blue: 16 49 91;
+ --solid-amber: 56 50 41;
+ --solid-blue: 15 57 102;
+ --solid-blue-2: 26 29 35;
--solid-iris: 38 42 101;
- --text-blue: 126 182 255;
+ --solid-purple: 51 51 107;
+ --solid-red: 90 33 34;
+ --solid-amber-button: 255 221 141;
+ --card-color: 28 30 34;
+ --overlay: 0, 0, 0, 0.4;
+ --overlay-avatar: 0, 0, 0, 0.05;
+ --button-color: 42 43 51;
+ --button-hover-color: 0, 0, 0, 0.15;
+ --label-background: 36 38 45;
+ --label-border: 255, 255, 255, 0.03;
- --alpha-1: 36, 36, 36, 0.8;
- --alpha-2: 139, 147, 182, 0.15;
- --alpha-3: 36, 38, 45, 0.9;
+ --alpha-1: 35, 36, 42, 0.8;
+ --alpha-2: 147, 153, 176, 0.12;
+ --alpha-3: 33, 34, 38, 0.95;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
- --border-container: 236, 236, 236, 0;
+ --border-container: 255, 255, 255, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
index b4154e4d8..9567d66c3 100644
--- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
+++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
@@ -7,7 +7,7 @@
@apply bg-n-slate-3;
&::after {
- @apply text-n-blue-text;
+ @apply text-n-blue-11;
}
}
}
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue
index 351e3240f..965f532f4 100644
--- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue
@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
- class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
+ class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
@add="onClickAddTag"
/>
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue
index 184e8bd10..01a207797 100644
--- a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue
+++ b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue
@@ -20,7 +20,7 @@ const handleButtonClick = () => {
-
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue
index 7e5ca21b9..00bd2297f 100644
--- a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue
+++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
- class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
+ class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>