diff --git a/.circleci/config.yml b/.circleci/config.yml
index f764cb611..59702c139 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
- parallelism: 20
+ parallelism: 18
steps:
- checkout
- node/install:
diff --git a/AGENTS.md b/AGENTS.md
index 2ab6373b7..2430fae2b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,13 +43,18 @@
## General Guidelines
-- MVP focus: Least code change, happy-path only
-- No unnecessary defensive programming
-- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
+- Prefer the smallest production-ready change that solves the current problem.
+- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
+- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
+- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
+- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
+- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
+- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
+- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
diff --git a/Gemfile b/Gemfile
index 7533cf3cf..7735dc099 100644
--- a/Gemfile
+++ b/Gemfile
@@ -195,7 +195,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
-gem 'ai-agents', '>= 0.10.0'
+gem 'ai-agents', '>= 0.12.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
diff --git a/Gemfile.lock b/Gemfile.lock
index 8da80f52c..bd41474a3 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
- ai-agents (0.10.0)
+ ai-agents (0.12.0)
ruby_llm (~> 1.14)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -198,7 +198,7 @@ GEM
crack (1.0.0)
bigdecimal
rexml
- crass (1.0.6)
+ crass (1.0.7)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
@@ -570,7 +570,7 @@ GEM
minitest (5.25.5)
mock_redis (0.36.0)
ruby2_keywords
- msgpack (1.8.0)
+ msgpack (1.8.3)
multi_json (1.15.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
@@ -1058,7 +1058,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
- ai-agents (>= 0.10.0)
+ ai-agents (>= 0.12.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb
index 24b6d9e70..c7608399e 100644
--- a/app/builders/messages/facebook/message_builder.rb
+++ b/app/builders/messages/facebook/message_builder.rb
@@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
- fallback_title: attachment['title'],
+ fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
+ # Both `share` and `post` attachment types carry a page URL rather than a media file,
+ # so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
- return :fallback if type.to_sym == :share
+ return :fallback if [:share, :post].include?(type.to_sym)
super
end
diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb
index 1807d6afb..0150cb677 100644
--- a/app/controllers/api/v1/accounts/assignment_policies_controller.rb
+++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb
@@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
- :fair_distribution_limit, :fair_distribution_window, :enabled
+ :fair_distribution_limit, :fair_distribution_window, :enabled,
+ :exclude_older_than_hours
)
end
end
diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
index 156c031fa..04eeff92b 100644
--- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb
+++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
@@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
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.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
+ @current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.save!
render json: preferences_payload
@@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models
existing_models = @current_account.captain_models || {}
- existing_models.merge(permitted_captain_models)
+ existing_models.merge(permitted_captain_models).compact_blank.presence
end
def merged_captain_features
@@ -47,29 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def permitted_captain_models
- params.require(:captain_models).permit(
- :editor, :assistant, :copilot, :label_suggestion,
- :audio_transcription, :help_center_search
- ).to_h.stringify_keys
+ params.require(:captain_models).permit(*captain_feature_keys).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
+ params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
+ end
+
+ def captain_feature_keys
+ Llm::Models.feature_keys.map(&:to_sym)
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)
+ route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
enabled: account_features[feature_key] == true,
- selected: account_models[feature_key] || config[:default]
+ model: route[:model],
+ selected: route[:model],
+ provider: route[:provider],
+ source: route[:source]
)
end
end
diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb
index 67381a715..b632ac78d 100644
--- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb
@@ -52,6 +52,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
render json: { content: translated_content }
+ rescue Google::Cloud::Error => e
+ # `details` carries the clean human message; `message` includes gRPC debug noise
+ render_could_not_create_error(e.details.presence || e.message)
end
private
diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb
index 845caab5e..7bda1c802 100644
--- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb
+++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
render_response(
- dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
+ dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
)
end
diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb
index 181e4965e..d7c49b35d 100644
--- a/app/controllers/api/v1/accounts/onboardings_controller.rb
+++ b/app/controllers/api/v1/accounts/onboardings_controller.rb
@@ -1,17 +1,19 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
+ ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
+ STEP_ACCOUNT_DETAILS = 'account_details'.freeze
+ STEP_INBOX_SETUP = 'inbox_setup'.freeze
+ ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
+
def update
+ return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
+
@account = Current.account
- finalize = finalizing_account_details?
-
- @account.assign_attributes(account_params)
- @account.custom_attributes.merge!(custom_attributes_params)
- @account.custom_attributes.delete('onboarding_step') if finalize
- @account.save!
-
- # TODO: re-enable when the help center generation UI is ready to surface progress
- # Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
+ # The client declares the step it is completing; `account_details` runs
+ # `complete_account_details`, and so on. The known-step guard above keeps the
+ # client value from `send`-ing an arbitrary method.
+ send("complete_#{params[:onboarding_step]}")
render 'api/v1/accounts/update', format: :json
end
@@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
private
- def finalizing_account_details?
- @account.custom_attributes['onboarding_step'] == 'account_details'
+ def complete_account_details
+ # Only act while the cursor still points here, so a stale replay after
+ # onboarding finished can't re-enter it.
+ return unless current_step == STEP_ACCOUNT_DETAILS
+
+ @account.assign_attributes(account_params)
+ @account.custom_attributes.merge!(custom_attributes_params)
+
+ # inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
+ # environment check); self-hosted finishes onboarding here.
+ if ChatwootApp.chatwoot_cloud?
+ move_to_step(STEP_INBOX_SETUP)
+ create_onboarding_inboxes
+ else
+ finish_onboarding
+ end
end
- def website
- custom_attributes_params[:website]
+ def complete_inbox_setup
+ # Only finalize while the cursor still points here, so a stale or out-of-order
+ # request can't end onboarding early. Replays are no-ops.
+ return unless current_step == STEP_INBOX_SETUP
+
+ finish_onboarding
+ end
+
+ def current_step
+ @account.custom_attributes[ONBOARDING_STEP_KEY]
+ end
+
+ def move_to_step(step)
+ @account.custom_attributes[ONBOARDING_STEP_KEY] = step
+ @account.save!
+ end
+
+ def finish_onboarding
+ @account.custom_attributes.delete(ONBOARDING_STEP_KEY)
+ @account.save!
+ end
+
+ def create_onboarding_inboxes
+ Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
end
def account_params
diff --git a/app/controllers/api/v1/accounts/teams_controller.rb b/app/controllers/api/v1/accounts/teams_controller.rb
index e8688dcfb..6239e00eb 100644
--- a/app/controllers/api/v1/accounts/teams_controller.rb
+++ b/app/controllers/api/v1/accounts/teams_controller.rb
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def team_params
- params.require(:team).permit(:name, :description, :allow_auto_assign)
+ params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
end
end
diff --git a/app/controllers/api/v1/widget/integrations/dyte_controller.rb b/app/controllers/api/v1/widget/integrations/dyte_controller.rb
index 0661b4a3c..fde425b26 100644
--- a/app/controllers/api/v1/widget/integrations/dyte_controller.rb
+++ b/app/controllers/api/v1/widget/integrations/dyte_controller.rb
@@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'],
- @conversation.contact
+ @conversation.contact,
+ @message
)
render_response(response)
end
diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb
new file mode 100644
index 000000000..633071301
--- /dev/null
+++ b/app/controllers/concerns/portal_home_data.rb
@@ -0,0 +1,29 @@
+module PortalHomeData
+ extend ActiveSupport::Concern
+
+ private
+
+ def load_home_data
+ base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
+ @visible_categories = @portal.categories
+ .where(locale: @locale)
+ .joins(:articles).where(articles: { status: :published })
+ .order(position: :asc)
+ .group('categories.id')
+ @popular_topics = @visible_categories.first(3)
+ @featured = base_articles.order_by_views.limit(6)
+ @category_contributors = build_category_contributors(@visible_categories)
+ end
+
+ def build_category_contributors(categories)
+ category_ids = categories.map(&:id)
+ return {} if category_ids.empty?
+
+ @portal.articles
+ .published
+ .where(locale: @locale, category_id: category_ids)
+ .includes(:author)
+ .group_by(&:category_id)
+ .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
+ end
+end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index b6df015f7..a369830b6 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -1,5 +1,6 @@
class DashboardController < ActionController::Base
include SwitchLocale
+ include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[
LOGO
@@ -63,6 +64,10 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
+ if @portal.layout == 'documentation'
+ request.variant = :documentation
+ load_home_data
+ end
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb
index 2991b84d2..323440304 100644
--- a/app/controllers/public/api/v1/portals/base_controller.rb
+++ b/app/controllers/public/api/v1/portals/base_controller.rb
@@ -39,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
- @locale = validate_and_get_locale(params[:locale])
+ # Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
+ # while UI translations fall back to an available I18n locale (e.g. th).
+ @locale = params[:locale]
- I18n.with_locale(@locale, &)
+ I18n.with_locale(validate_and_get_locale(@locale), &)
end
def switch_locale_with_article(&)
@@ -49,13 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
- article_locale = if article.category.present?
- article.category.locale
- else
- article.locale
- end
- @locale = validate_and_get_locale(article_locale)
- I18n.with_locale(@locale, &)
+ @locale = if article.category.present?
+ article.category.locale
+ else
+ article.locale
+ end
+ I18n.with_locale(validate_and_get_locale(@locale), &)
end
def allow_iframe_requests
diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb
index 57db11aec..4982278d7 100644
--- a/app/controllers/public/api/v1/portals_controller.rb
+++ b/app/controllers/public/api/v1/portals_controller.rb
@@ -1,4 +1,6 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
+ include PortalHomeData
+
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
@@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
-
- def load_home_data
- base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
- @visible_categories = @portal.categories
- .where(locale: @locale)
- .joins(:articles).where(articles: { status: :published })
- .order(position: :asc)
- .group('categories.id')
- @popular_topics = @visible_categories.first(3)
- @featured = base_articles.order_by_views.limit(6)
- @category_contributors = build_category_contributors(@visible_categories)
- end
-
- def build_category_contributors(categories)
- category_ids = categories.map(&:id)
- return {} if category_ids.empty?
-
- @portal.articles
- .published
- .where(locale: @locale, category_id: category_ids)
- .includes(:author)
- .group_by(&:category_id)
- .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
- end
end
diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb
index 27ce587f7..59b99c37e 100644
--- a/app/controllers/super_admin/accounts_controller.rb
+++ b/app/controllers/super_admin/accounts_controller.rb
@@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
#
def resource_params
permitted_params = super
- permitted_params[:limits] = permitted_params[:limits].to_h.compact
+ permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
+ permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params
end
diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb
index 9be674f11..b2683f2e0 100644
--- a/app/dashboards/account_dashboard.rb
+++ b/app/dashboards/account_dashboard.rb
@@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard
# Add all_features last so it appears after manually_managed_features
attributes[:all_features] = AccountFeaturesField
+ attributes[:captain_models] = CaptainModelOverridesField
attributes
else
@@ -57,6 +58,7 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[custom_attributes limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
+ attrs << :captain_models
attrs
else
[]
@@ -79,6 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
+ attrs << :captain_models
attrs
else
[]
@@ -117,7 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard
# to prevent an error from being raised (wrong number of arguments)
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
def permitted_attributes(action)
- attrs = super + [limits: {}]
+ attrs = super + [limits: {}, captain_models: {}]
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue
index e6bd3c272..bca6e877b 100644
--- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue
+++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue
@@ -20,6 +20,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
+ [VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -28,13 +29,18 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
+ [VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
- [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
+ [
+ VOICE_CALL_STATUS.NO_ANSWER,
+ VOICE_CALL_STATUS.FAILED,
+ VOICE_CALL_STATUS.REJECTED,
+ ].includes(props.status)
);
const labelKey = computed(() => {
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue
index ee20fded5..c689065fb 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue
@@ -29,7 +29,6 @@ const initialState = {
handoffMessage: '',
resolutionMessage: '',
instructions: '',
- temperature: 1,
};
const state = reactive({ ...initialState });
@@ -57,7 +56,6 @@ const updateStateFromAssistant = assistant => {
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
- state.temperature = config.temperature || 1;
};
const handleSystemMessagesUpdate = async () => {
@@ -80,7 +78,6 @@ const handleSystemMessagesUpdate = async () => {
...props.assistant.config,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
- temperature: state.temperature || 1,
},
};
@@ -131,26 +128,6 @@ watch(
class="z-0"
/>
-
-
-
-
- {{ state.temperature }}
-
-
- {{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
-
-
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue
index 3a1d65bc6..7e4fbc84e 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue
@@ -1,21 +1,22 @@
+
+
+
+
+
+
+
+ {{ t(channel.labelKey) }}
+
+
+ {{ connectedName }}
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECTED') }}
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECT') }}
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue
new file mode 100644
index 000000000..c0157398e
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
{{ status }}
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue
new file mode 100644
index 000000000..8c0e1decc
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue
new file mode 100644
index 000000000..45f170137
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue
new file mode 100644
index 000000000..a3631649d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue
new file mode 100644
index 000000000..b5c138c65
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_EMAIL') }}
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_VOICE') }}
+
+
+
+
+
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS.VIEW_ALL') }}
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue
new file mode 100644
index 000000000..48be07821
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LOADING') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES') }}
+
+
+
+
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR') }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue
new file mode 100644
index 000000000..aeb40e24c
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js
new file mode 100644
index 000000000..0ebdd728f
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js
@@ -0,0 +1,18 @@
+import { INBOX_TYPES } from 'dashboard/helper/inbox';
+
+// A detected channel maps to a real inbox when they share a channel_type. Gmail
+// and Outlook both use Channel::Email, so for email we also match on provider.
+// `stub` is a channel's `{ channel_type, provider }` shape (e.g. channel.inbox).
+
+// Returns the matching inbox (not a boolean) so callers can show the connected
+// account's real name rather than the detected handle.
+export const findConnectedInbox = (inboxes, stub) =>
+ inboxes.find(
+ inbox =>
+ inbox.channel_type === stub?.channel_type &&
+ (stub?.channel_type !== INBOX_TYPES.EMAIL ||
+ inbox.provider === stub?.provider)
+ );
+
+export const isChannelConnected = (inboxes, stub) =>
+ Boolean(stub) && Boolean(findConnectedInbox(inboxes, stub));
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js
new file mode 100644
index 000000000..91e800d06
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js
@@ -0,0 +1,149 @@
+import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
+
+// Channels whose connect flow opens the channels dialog preselected to their
+// in-dialog step — Facebook (page picker) and the credential-form channels
+// (Telegram, Line) — rather than redirecting through OAuth.
+export const DIALOG_CHANNELS = [
+ CHANNEL_TYPES.FACEBOOK,
+ CHANNEL_TYPES.TELEGRAM,
+ CHANNEL_TYPES.LINE,
+];
+
+// Suggested channels (in priority order) to offer as rows when nothing is
+// detected, so the step isn't empty. The mainstream OAuth channels show on
+// configured installs, while credential-free Telegram/LINE keep the list
+// non-empty on a bare self-host.
+export const DEFAULT_CHANNEL_TYPES = [
+ CHANNEL_TYPES.WHATSAPP,
+ CHANNEL_TYPES.FACEBOOK,
+ CHANNEL_TYPES.INSTAGRAM,
+ CHANNEL_TYPES.TELEGRAM,
+ CHANNEL_TYPES.LINE,
+];
+
+// Channels offered in the onboarding "View all" dialog. `inbox` is a stub shaped
+// like a real inbox so ChannelIcon can resolve the icon from the shared provider.
+// With `use-brand-icon`, ChannelIcon renders the full-color brand logo when one
+// exists and falls back to the monochrome glyph otherwise, so no per-channel
+// style flag is needed. Entries without a channel type (Voice, Other Email
+// Providers) render `fallbackIcon` instead. `form: true` swaps the grid for an
+// inline credential form; `setupLater: true` defers the channel to in-app setup
+// for this phase. `labelKey` is an i18n key — most reuse the shared channel
+// titles from the inbox settings (INBOX_MGMT.ADD.AUTH.CHANNEL.*.TITLE) so the
+// names translate without duplicating strings; resolve it with `t()` at display.
+export const CHANNEL_LIST = [
+ {
+ type: 'website',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE',
+ inbox: { channel_type: 'Channel::WebWidget' },
+ },
+ {
+ type: 'whatsapp',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
+ inbox: { channel_type: 'Channel::Whatsapp' },
+ },
+ {
+ type: 'instagram',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
+ inbox: { channel_type: 'Channel::Instagram' },
+ },
+ {
+ type: 'facebook',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
+ inbox: { channel_type: 'Channel::FacebookPage' },
+ },
+ {
+ type: 'tiktok',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
+ inbox: { channel_type: 'Channel::Tiktok' },
+ },
+ {
+ type: 'telegram',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
+ inbox: { channel_type: 'Channel::Telegram' },
+ form: true,
+ },
+ {
+ type: 'line',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
+ inbox: { channel_type: 'Channel::Line' },
+ form: true,
+ },
+ // Email channels (including Gmail/Outlook OAuth) are set up later in-app for
+ // this phase; they will be enabled in a future PR.
+ {
+ type: 'gmail',
+ labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.GMAIL',
+ inbox: { channel_type: 'Channel::Email', provider: 'google' },
+ setupLater: true,
+ },
+ {
+ type: 'outlook',
+ labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OUTLOOK',
+ inbox: { channel_type: 'Channel::Email', provider: 'microsoft' },
+ setupLater: true,
+ },
+ {
+ type: 'sms',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE',
+ inbox: { channel_type: 'Channel::Sms' },
+ setupLater: true,
+ },
+ {
+ type: 'api',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE',
+ inbox: { channel_type: 'Channel::Api' },
+ setupLater: true,
+ },
+ {
+ type: 'voice',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE',
+ fallbackIcon: 'i-woot-voice',
+ setupLater: true,
+ },
+ {
+ type: 'email',
+ labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OTHER_EMAIL',
+ fallbackIcon: 'i-woot-mail',
+ setupLater: true,
+ },
+];
+
+const channelByType = type =>
+ CHANNEL_LIST.find(channel => channel.type === type);
+
+// Icons shown next to "View all" when every detected channel is already
+// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs
+// aren't duplicated.
+export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map(
+ channelByType
+);
+
+// Social channels that detected brand_info socials map to, keyed by social type
+// in the order they're offered as rows. Derived from CHANNEL_LIST so channel
+// identity (label, channel_type) has a single source. Keys mirror
+// SocialLinkParser::SOCIAL_DOMAIN_MAP.
+const SOCIAL_PLATFORM_TYPES = [
+ 'whatsapp',
+ 'facebook',
+ 'line',
+ 'instagram',
+ 'telegram',
+ 'tiktok',
+];
+
+export const SOCIAL_PLATFORMS = Object.fromEntries(
+ SOCIAL_PLATFORM_TYPES.map(type => {
+ const { labelKey, inbox } = channelByType(type);
+ return [type, { labelKey, channelType: inbox.channel_type }];
+ })
+);
+
+// Mailbox providers inferred from the signup domain's MX records, keyed by
+// Channel::Email#provider. Derived from CHANNEL_LIST's email entries.
+export const EMAIL_PROVIDERS = Object.fromEntries(
+ CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [
+ channel.inbox.provider,
+ { labelKey: channel.labelKey },
+ ])
+);
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
new file mode 100644
index 000000000..ba2d6f0dc
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
@@ -0,0 +1,29 @@
+import { useMapGetter } from 'dashboard/composables/store';
+
+// OAuth/SDK channels need installation-level app credentials to be usable. When
+// the credential is missing the channel is "not configured" and is hidden from
+// onboarding entirely. Channels without an entry (Website, Telegram, Line, …)
+// need no installation credential and are always considered configured.
+// Mirrors the availability checks in ChannelItem.vue.
+export function useChannelConfig() {
+ const globalConfig = useMapGetter('globalConfig/get');
+ const installationConfig = window.chatwootConfig || {};
+
+ const CHANNEL_CONFIGURED = {
+ // WhatsApp is onboarded only via Meta embedded signup, which needs both the
+ // app id (not the 'none' sentinel) and the signup configuration id.
+ whatsapp: () =>
+ Boolean(installationConfig.whatsappAppId) &&
+ installationConfig.whatsappAppId !== 'none' &&
+ Boolean(installationConfig.whatsappConfigurationId),
+ facebook: () => Boolean(installationConfig.fbAppId),
+ instagram: () => Boolean(installationConfig.instagramAppId),
+ tiktok: () => Boolean(installationConfig.tiktokAppId),
+ gmail: () => Boolean(installationConfig.googleOAuthClientId),
+ outlook: () => Boolean(globalConfig.value.azureAppId),
+ };
+
+ const isConfigured = type => CHANNEL_CONFIGURED[type]?.() ?? true;
+
+ return { isConfigured };
+}
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js
new file mode 100644
index 000000000..bd34d5f20
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js
@@ -0,0 +1,67 @@
+import { useI18n } from 'vue-i18n';
+import { useAlert } from 'dashboard/composables';
+import { useStore } from 'dashboard/composables/store';
+import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
+import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
+import googleClient from 'dashboard/api/channel/googleClient';
+import microsoftClient from 'dashboard/api/channel/microsoftClient';
+import instagramClient from 'dashboard/api/channel/instagramClient';
+import tiktokClient from 'dashboard/api/channel/tiktokClient';
+
+// Channels that complete via an OAuth redirect. Email channels are keyed by their
+// Channel::Email provider, others by channel type. The request is tagged with a
+// return hint so the callback brings the user back to onboarding instead of the
+// inbox settings page.
+const OAUTH_CLIENTS = {
+ google: googleClient,
+ microsoft: microsoftClient,
+ instagram: instagramClient,
+ tiktok: tiktokClient,
+};
+
+export function useChannelConnect() {
+ const { t } = useI18n();
+ const store = useStore();
+ const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
+
+ const connectViaOAuth = async provider => {
+ const client = OAUTH_CLIENTS[provider];
+ if (!client) return;
+
+ try {
+ const {
+ data: { url },
+ } = await client.generateAuthorization({ return_to: 'onboarding' });
+ window.location.href = url;
+ } catch {
+ useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
+ }
+ };
+
+ // WhatsApp connects via Meta's embedded-signup popup instead of the redirect
+ // OAuth flow above. Collect the signup credentials, exchange them for an
+ // inbox, and surface the result inline — then refetch so the connected state
+ // reflects the freshly created inbox (and renders its real channel icon).
+ const connectWhatsapp = async () => {
+ let credentials;
+ try {
+ credentials = await runEmbeddedSignup();
+ } catch {
+ useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
+ return;
+ }
+ if (!credentials) return; // user dismissed the popup
+
+ try {
+ await store.dispatch('inboxes/createWhatsAppEmbeddedSignup', credentials);
+ await store.dispatch('inboxes/get');
+ useAlert(t('ONBOARDING_INBOX_SETUP.WHATSAPP_CONNECTED'));
+ } catch (error) {
+ useAlert(
+ parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR')
+ );
+ }
+ };
+
+ return { connectViaOAuth, connectWhatsapp };
+}
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js
new file mode 100644
index 000000000..6ba68c6bf
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js
@@ -0,0 +1,130 @@
+import { computed } from 'vue';
+import { useMapGetter } from 'dashboard/composables/store';
+import { useAccount } from 'dashboard/composables/useAccount';
+import {
+ SOCIAL_PLATFORMS,
+ EMAIL_PROVIDERS,
+ DEFAULT_CHANNEL_TYPES,
+} from './constants';
+import { findConnectedInbox } from './channelMatchers';
+import { useChannelConfig } from './useChannelConfig';
+
+// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES
+// is config-gated like everything else, then sliced to this limit.
+const DISPLAYED_CHANNEL_LIMIT = 3;
+
+// Pull the handle/username out of a detected social URL, formatted per channel.
+const extractHandle = ({ type, url }) => {
+ try {
+ const { pathname } = new URL(url);
+ const path = pathname.replace(/^\/+|\/+$/g, '');
+ if (type === 'whatsapp') {
+ const digits = path.replace(/\D/g, '');
+ return digits ? `+${digits}` : '';
+ }
+ if (type === 'line') return path;
+ return path.startsWith('@') ? path : `@${path}`;
+ } catch {
+ return '';
+ }
+};
+
+// Derives the channel rows for the inbox-setup step from the account's detected
+// brand_info (socials + mailbox provider) and the real connected inboxes,
+// keeping InboxSetup.vue focused on layout, connect routing, and completion.
+export function useDetectedChannels() {
+ const { currentAccount } = useAccount();
+ const inboxes = useMapGetter('inboxes/getInboxes');
+ const { isConfigured } = useChannelConfig();
+
+ const brandSocials = computed(
+ () => currentAccount.value?.custom_attributes?.brand_info?.socials || []
+ );
+
+ const connectedChannels = computed(() =>
+ brandSocials.value
+ .filter(social => SOCIAL_PLATFORMS[social.type] && social.url)
+ .map(social => ({
+ type: social.type,
+ handle: extractHandle(social),
+ labelKey: SOCIAL_PLATFORMS[social.type].labelKey,
+ inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType },
+ }))
+ );
+
+ const detectedEmailChannel = computed(() => {
+ const brandInfo = currentAccount.value?.custom_attributes?.brand_info;
+ const provider = brandInfo?.email_provider;
+ if (!EMAIL_PROVIDERS[provider]) return null;
+
+ return {
+ type: 'email',
+ handle: brandInfo?.email || '',
+ labelKey: EMAIL_PROVIDERS[provider].labelKey,
+ inbox: { channel_type: 'Channel::Email', provider },
+ };
+ });
+
+ // The real inbox backing a channel, if one exists — returned (not just a
+ // boolean) so the row can show the connected account's real name.
+ const connectedInbox = channel =>
+ findConnectedInbox(inboxes.value, channel.inbox);
+
+ // A channel row built from a social type, with no detected handle — used for
+ // the default suggestions when nothing was detected.
+ const toChannelRow = type => ({
+ type,
+ handle: '',
+ labelKey: SOCIAL_PLATFORMS[type].labelKey,
+ inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType },
+ });
+
+ const detectedChannels = computed(() =>
+ [detectedEmailChannel.value, ...connectedChannels.value]
+ .filter(Boolean)
+ // Email channels (including Gmail/Outlook OAuth) are disabled for this
+ // phase; they will be enabled in a future PR.
+ .filter(channel => channel.type !== 'email')
+ // Hide channels whose installation OAuth credentials are missing — their
+ // connect flow would only error.
+ .filter(channel => isConfigured(channel.type))
+ );
+
+ const defaultChannels = computed(() =>
+ DEFAULT_CHANNEL_TYPES.filter(isConfigured)
+ .slice(0, DISPLAYED_CHANNEL_LIMIT)
+ .map(toChannelRow)
+ );
+
+ // Show the detected channels, or fall back to the default suggestions so the
+ // step is never an empty list.
+ const displayedChannels = computed(() =>
+ detectedChannels.value.length
+ ? detectedChannels.value
+ : defaultChannels.value
+ );
+
+ const remainingChannels = computed(() => {
+ // Exclude whatever is already shown as a row (detected or defaulted) so the
+ // footer preview doesn't duplicate it.
+ const shownTypes = new Set(displayedChannels.value.map(c => c.type));
+ return Object.entries(SOCIAL_PLATFORMS)
+ .filter(([type]) => !shownTypes.has(type))
+ .filter(([type]) => isConfigured(type))
+ .slice(0, 3)
+ .map(([type, { labelKey, channelType }]) => ({
+ type,
+ labelKey,
+ inbox: { channel_type: channelType },
+ }));
+ });
+
+ const hasDetectedChannels = computed(() => detectedChannels.value.length > 0);
+
+ return {
+ displayedChannels,
+ remainingChannels,
+ connectedInbox,
+ hasDetectedChannels,
+ };
+}
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue
similarity index 81%
rename from app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue
rename to app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue
index 63b3fa391..90abff58f 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue
@@ -5,11 +5,12 @@ defineProps({
greeting: { type: String, required: true },
subtitle: { type: String, default: '' },
continueLabel: { type: String, default: 'Continue' },
+ skipLabel: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
-defineEmits(['continue']);
+defineEmits(['continue', 'skip']);
@@ -20,7 +21,7 @@ defineEmits(['continue']);
-
+
-
- {{ continueLabel }}
-
+
+
+ {{ continueLabel }}
+
+
+ {{ skipLabel }}
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingSection.vue b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingSection.vue
similarity index 87%
rename from app/javascript/dashboard/routes/dashboard/onboarding/OnboardingSection.vue
rename to app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingSection.vue
index 479aa4c81..976e5fa9d 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingSection.vue
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingSection.vue
@@ -4,6 +4,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
+ bare: { type: Boolean, default: false },
});
@@ -42,8 +43,12 @@ defineProps({
-
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/constants.js b/app/javascript/dashboard/routes/dashboard/onboarding/shared/constants.js
similarity index 100%
rename from app/javascript/dashboard/routes/dashboard/onboarding/constants.js
rename to app/javascript/dashboard/routes/dashboard/onboarding/shared/constants.js
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js
new file mode 100644
index 000000000..a8d85010a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js
@@ -0,0 +1,186 @@
+import { defineComponent, h, ref } from 'vue';
+import { createStore } from 'vuex';
+import { mount } from '@vue/test-utils';
+import { useRoute } from 'vue-router';
+import { useAccountEnrichment } from '../../account-details/useAccountEnrichment';
+
+vi.mock('vue-router');
+
+const ENABLED_LANGUAGES = [
+ { iso_639_1_code: 'en', name: 'English' },
+ { iso_639_1_code: 'fr', name: 'French' },
+];
+
+// Mounts the composable against a real store and the real useAccount/useConfig
+// (only useRoute and the underlying account getter / window config are faked),
+// so a change to how those resolve their data is exercised here too. `presets`
+// seeds form fields as if the user had already typed them.
+const mountComposable = ({
+ account = {},
+ enabledLanguages = ENABLED_LANGUAGES,
+ presets = {},
+} = {}) => {
+ window.chatwootConfig = { enabledLanguages };
+
+ const store = createStore({
+ modules: {
+ accounts: {
+ namespaced: true,
+ getters: { getAccount: () => () => account },
+ },
+ },
+ });
+
+ const fields = {
+ locale: ref(presets.locale || ''),
+ website: ref(presets.website || ''),
+ timezone: ref(presets.timezone || ''),
+ companySize: ref(presets.companySize || ''),
+ industry: ref(presets.industry || ''),
+ referralSource: ref(presets.referralSource || ''),
+ };
+
+ let api;
+ const Component = defineComponent({
+ setup() {
+ api = useAccountEnrichment(fields);
+ return () => h('div');
+ },
+ });
+ const wrapper = mount(Component, { global: { plugins: [store] } });
+ return { ...api, fields, wrapper };
+};
+
+beforeEach(() => {
+ useRoute.mockReturnValue({ params: { accountId: '1' } });
+});
+
+afterEach(() => {
+ delete window.chatwootConfig;
+});
+
+describe('useAccountEnrichment', () => {
+ describe('populateFormFields', () => {
+ it('fills empty fields from the enriched attributes on mount', () => {
+ const { fields } = mountComposable({
+ account: {
+ locale: 'en',
+ custom_attributes: {
+ website: 'https://acme.com',
+ timezone: 'America/New_York',
+ company_size: '11-50',
+ industry: 'Technology',
+ referral_source: 'google',
+ },
+ },
+ });
+
+ expect(fields.website.value).toBe('https://acme.com');
+ expect(fields.timezone.value).toBe('America/New_York');
+ expect(fields.companySize.value).toBe('11-50');
+ expect(fields.industry.value).toBe('Technology');
+ expect(fields.referralSource.value).toBe('google');
+ });
+
+ it('falls back to brand_info for website and industry', () => {
+ const { fields } = mountComposable({
+ account: {
+ custom_attributes: {
+ brand_info: {
+ domain: 'acme.com',
+ industries: [{ industry: 'Retail & E-commerce' }],
+ },
+ },
+ },
+ });
+
+ expect(fields.website.value).toBe('acme.com');
+ expect(fields.industry.value).toBe('Retail & E-commerce');
+ });
+
+ it('does not clobber fields the user already set', () => {
+ const { fields } = mountComposable({
+ presets: { website: 'mysite.com', industry: 'Finance' },
+ account: {
+ custom_attributes: {
+ website: 'https://enriched.com',
+ industry: 'Technology',
+ },
+ },
+ });
+
+ expect(fields.website.value).toBe('mysite.com');
+ expect(fields.industry.value).toBe('Finance');
+ });
+
+ it('detects the locale from the browser, else the account locale', () => {
+ // jsdom reports navigator.language as 'en-US' -> base 'en' is enabled.
+ const { fields } = mountComposable({ account: { locale: 'de' } });
+ expect(fields.locale.value).toBe('en');
+
+ // No enabled language matches the browser -> fall back to account locale.
+ const { fields: other } = mountComposable({
+ account: { locale: 'de' },
+ enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }],
+ });
+ expect(other.locale.value).toBe('de');
+ });
+ });
+
+ describe('isEnriching', () => {
+ it('is true while the account is on the enrichment step', () => {
+ const { isEnriching } = mountComposable({
+ account: { custom_attributes: { onboarding_step: 'enrichment' } },
+ });
+ expect(isEnriching.value).toBe(true);
+ });
+
+ it('is false on any other step', () => {
+ const { isEnriching } = mountComposable({
+ account: { custom_attributes: { onboarding_step: 'account_details' } },
+ });
+ expect(isEnriching.value).toBe(false);
+ });
+
+ it('times out after 30s, flipping to false and populating', () => {
+ vi.useFakeTimers();
+ try {
+ const { isEnriching, fields } = mountComposable({
+ account: {
+ custom_attributes: {
+ onboarding_step: 'enrichment',
+ company_size: '51-200',
+ },
+ },
+ });
+ expect(isEnriching.value).toBe(true);
+
+ vi.advanceTimersByTime(30000);
+
+ expect(isEnriching.value).toBe(false);
+ expect(fields.companySize.value).toBe('51-200');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+ });
+
+ describe('getChangedFields', () => {
+ it('lists only enrichable fields edited after auto-fill', () => {
+ const { fields, getChangedFields } = mountComposable({
+ account: {
+ custom_attributes: {
+ website: 'https://acme.com',
+ company_size: '11-50',
+ industry: 'Technology',
+ },
+ },
+ });
+
+ expect(getChangedFields()).toEqual([]);
+
+ fields.industry.value = 'Finance';
+ expect(getChangedFields()).toEqual(['industry']);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js
new file mode 100644
index 000000000..68dca058a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js
@@ -0,0 +1,123 @@
+import { flushPromises, mount } from '@vue/test-utils';
+import HelpCenterCreationStatus from '../../inbox-setup/HelpCenterCreationStatus.vue';
+import OnboardingAPI from 'dashboard/api/onboarding';
+
+vi.mock('dashboard/api/onboarding', () => ({
+ default: {
+ getHelpCenterGeneration: vi.fn(),
+ },
+}));
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key, params = {}) => {
+ if (key.endsWith('HELP_CENTER_CATEGORIES')) {
+ return `${params.count} categories`;
+ }
+ if (key.endsWith('HELP_CENTER_SUMMARY')) {
+ return `${params.count} articles across ${params.categories}`;
+ }
+ if (key.endsWith('HELP_CENTER_ARTICLES')) {
+ return `${params.count} articles`;
+ }
+ return key;
+ },
+ }),
+}));
+
+const mountStatus = () =>
+ mount(HelpCenterCreationStatus, {
+ global: {
+ stubs: {
+ CreationStatusRow: {
+ props: ['ready', 'title', 'description', 'status'],
+ template:
+ '{{ status }}
',
+ },
+ },
+ },
+ });
+
+describe('HelpCenterCreationStatus', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ });
+
+ it('renders completed summary from the status endpoint', async () => {
+ OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
+ data: {
+ generation_id: 'generation-123',
+ state: { status: 'completed' },
+ articles_count: 3,
+ categories_count: 2,
+ },
+ });
+
+ const wrapper = mountStatus();
+ await flushPromises();
+
+ expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
+ 'true'
+ );
+ expect(wrapper.find('[data-test="row"]').text()).toBe(
+ '3 articles across 2 categories'
+ );
+ });
+
+ it('hides the row when generation is skipped', async () => {
+ OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
+ data: {
+ generation_id: 'generation-123',
+ state: { status: 'skipped' },
+ },
+ });
+
+ const wrapper = mountStatus();
+ await flushPromises();
+
+ expect(wrapper.find('[data-test="row"]').exists()).toBe(false);
+ });
+
+ it('polls while generating and stops after completion', async () => {
+ vi.useFakeTimers();
+ OnboardingAPI.getHelpCenterGeneration
+ .mockResolvedValueOnce({
+ data: {
+ generation_id: 'generation-123',
+ state: { status: 'generating' },
+ articles_count: 1,
+ categories_count: 0,
+ },
+ })
+ .mockResolvedValueOnce({
+ data: {
+ generation_id: 'generation-123',
+ state: { status: 'completed' },
+ articles_count: 2,
+ categories_count: 1,
+ },
+ });
+
+ const wrapper = mountStatus();
+ await flushPromises();
+
+ expect(wrapper.find('[data-test="row"]').text()).toBe('1 articles');
+
+ vi.advanceTimersByTime(5000);
+ await flushPromises();
+
+ expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
+ expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
+ 'true'
+ );
+ expect(wrapper.find('[data-test="row"]').text()).toBe(
+ '2 articles across 1 categories'
+ );
+
+ vi.advanceTimersByTime(5000);
+ await flushPromises();
+
+ expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js
new file mode 100644
index 000000000..bf150f95d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js
@@ -0,0 +1,59 @@
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
+
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
+vi.mock('dashboard/composables/store', () => ({
+ useMapGetter: () => ({ value: {} }),
+}));
+vi.mock('../../inbox-setup/useChannelConnect', () => ({
+ useChannelConnect: () => ({
+ connectViaOAuth: vi.fn(),
+ connectWhatsapp: vi.fn(),
+ }),
+}));
+
+const mountDialog = () =>
+ mount(InboxChannelsDialog, {
+ props: { inboxes: [] },
+ global: {
+ stubs: {
+ Dialog: {
+ template: '
',
+ methods: { open() {}, close() {} },
+ },
+ InboxFacebookForm: { template: '' },
+ InboxChannelForm: { template: '' },
+ ChannelIcon: true,
+ Icon: true,
+ },
+ },
+ });
+
+describe('InboxChannelsDialog Facebook gating', () => {
+ afterEach(() => {
+ delete window.chatwootConfig;
+ });
+
+ it('opens the Facebook page picker when fbAppId is configured', async () => {
+ window.chatwootConfig = { fbAppId: 'fb-app' };
+ const wrapper = mountDialog();
+
+ wrapper.vm.open('facebook');
+ await nextTick();
+
+ expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(true);
+ });
+
+ it('shows the grid (not the picker) when fbAppId is missing', async () => {
+ window.chatwootConfig = {};
+ const wrapper = mountDialog();
+
+ wrapper.vm.open('facebook');
+ await nextTick();
+
+ expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(false);
+ // The channel grid renders its cards instead.
+ expect(wrapper.find('button').exists()).toBe(true);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js
new file mode 100644
index 000000000..888082f68
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js
@@ -0,0 +1,158 @@
+import { flushPromises, mount } from '@vue/test-utils';
+import { ref, nextTick } from 'vue';
+import InboxFacebookForm from '../../inbox-setup/InboxFacebookForm.vue';
+import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
+
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
+vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
+vi.mock('dashboard/store/utils/api', () => ({
+ parseAPIErrorResponse: vi.fn(),
+}));
+vi.mock('dashboard/composables/useFacebookPageConnect', () => ({
+ useFacebookPageConnect: vi.fn(),
+}));
+
+const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn() }));
+vi.mock('dashboard/composables/store', () => ({
+ useStore: () => ({ dispatch }),
+}));
+
+const NextButtonStub = {
+ props: ['label', 'disabled', 'isLoading'],
+ emits: ['click'],
+ template: `{{ label }}`,
+};
+const ComboBoxStub = {
+ props: ['modelValue', 'options'],
+ emits: ['update:modelValue'],
+ template: '',
+};
+
+const PAGES = [
+ { id: 'p1', name: 'Page One', access_token: 'pt1' },
+ { id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
+];
+
+const LAUNCH = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH';
+const CONNECT = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT';
+
+let loginAndFetchPages;
+let preloadSdk;
+
+const mountForm = () =>
+ mount(InboxFacebookForm, {
+ global: {
+ stubs: {
+ NextButton: NextButtonStub,
+ ComboBox: ComboBoxStub,
+ Spinner: true,
+ },
+ },
+ });
+
+const clickButton = (wrapper, label) =>
+ wrapper
+ .findAll('button')
+ .find(button => button.text() === label)
+ .trigger('click');
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ preloadSdk = vi.fn();
+ loginAndFetchPages = vi.fn();
+ useFacebookPageConnect.mockReturnValue({
+ isAuthenticating: ref(false),
+ preloadSdk,
+ loginAndFetchPages,
+ });
+ dispatch.mockResolvedValue({ id: 1 });
+});
+
+describe('InboxFacebookForm', () => {
+ it('preloads the SDK on mount', () => {
+ mountForm();
+ expect(preloadSdk).toHaveBeenCalled();
+ });
+
+ it('lists only connectable pages and creates an inbox for the selected one', async () => {
+ loginAndFetchPages.mockResolvedValue({
+ userAccessToken: 'tok',
+ pages: PAGES,
+ });
+ const wrapper = mountForm();
+
+ await clickButton(wrapper, LAUNCH);
+ await flushPromises();
+ await nextTick();
+
+ // p2 is already connected (exists), so only p1 is offered.
+ const combobox = wrapper.findComponent(ComboBoxStub);
+ expect(combobox.props('options')).toEqual([
+ { value: 'p1', label: 'Page One' },
+ ]);
+
+ combobox.vm.$emit('update:modelValue', 'p1');
+ await nextTick();
+
+ await clickButton(wrapper, CONNECT);
+ await flushPromises();
+
+ expect(dispatch).toHaveBeenCalledWith('inboxes/createFBChannel', {
+ user_access_token: 'tok',
+ page_access_token: 'pt1',
+ page_id: 'p1',
+ inbox_name: 'Page One',
+ });
+ expect(wrapper.emitted('created')).toBeTruthy();
+ });
+
+ it('shows the empty state when every page is already connected', async () => {
+ loginAndFetchPages.mockResolvedValue({
+ userAccessToken: 'tok',
+ pages: [
+ { id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
+ ],
+ });
+ const wrapper = mountForm();
+
+ await clickButton(wrapper, LAUNCH);
+ await flushPromises();
+ await nextTick();
+
+ expect(wrapper.text()).toContain(
+ 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES'
+ );
+ expect(wrapper.find('[data-test="combobox"]').exists()).toBe(false);
+ });
+
+ it('shows an error when the connection fails', async () => {
+ loginAndFetchPages.mockRejectedValue(new Error('boom'));
+ const wrapper = mountForm();
+
+ await clickButton(wrapper, LAUNCH);
+ await flushPromises();
+ await nextTick();
+
+ expect(wrapper.text()).toContain(
+ 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
+ );
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+
+ it('stays on the connect prompt without an error when cancelled', async () => {
+ loginAndFetchPages.mockResolvedValue(null);
+ const wrapper = mountForm();
+
+ await clickButton(wrapper, LAUNCH);
+ await flushPromises();
+ await nextTick();
+
+ expect(wrapper.text()).not.toContain(
+ 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
+ );
+ // Launch button is still available to retry.
+ expect(
+ wrapper.findAll('button').some(button => button.text() === LAUNCH)
+ ).toBe(true);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js
new file mode 100644
index 000000000..acde1159d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js
@@ -0,0 +1,62 @@
+import {
+ findConnectedInbox,
+ isChannelConnected,
+} from '../../inbox-setup/channelMatchers';
+
+const WHATSAPP = { id: 1, channel_type: 'Channel::Whatsapp' };
+const GMAIL = { id: 2, channel_type: 'Channel::Email', provider: 'google' };
+const OUTLOOK = {
+ id: 3,
+ channel_type: 'Channel::Email',
+ provider: 'microsoft',
+};
+
+describe('channelMatchers', () => {
+ describe('findConnectedInbox', () => {
+ it('returns the inbox sharing the channel type', () => {
+ expect(
+ findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
+ ).toBe(WHATSAPP);
+ });
+
+ it('matches email inboxes on provider', () => {
+ expect(
+ findConnectedInbox([OUTLOOK, GMAIL], {
+ channel_type: 'Channel::Email',
+ provider: 'google',
+ })
+ ).toBe(GMAIL);
+ });
+
+ it('does not match a different email provider', () => {
+ expect(
+ findConnectedInbox([OUTLOOK], {
+ channel_type: 'Channel::Email',
+ provider: 'google',
+ })
+ ).toBeUndefined();
+ });
+
+ it('returns undefined when nothing matches', () => {
+ expect(
+ findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Telegram' })
+ ).toBeUndefined();
+ });
+ });
+
+ describe('isChannelConnected', () => {
+ it('is true when a matching inbox exists', () => {
+ expect(
+ isChannelConnected([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
+ ).toBe(true);
+ });
+
+ it('is false when no inbox matches', () => {
+ expect(isChannelConnected([WHATSAPP], GMAIL)).toBe(false);
+ });
+
+ it('is false for a channel without an inbox stub', () => {
+ expect(isChannelConnected([WHATSAPP], undefined)).toBe(false);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js
new file mode 100644
index 000000000..1134d4494
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js
@@ -0,0 +1,300 @@
+import { defineComponent, h } from 'vue';
+import { createStore } from 'vuex';
+import { mount } from '@vue/test-utils';
+import { useRoute } from 'vue-router';
+import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
+
+vi.mock('vue-router');
+
+// Mounts the composable against a real store and the real useAccount (only
+// useRoute and the underlying getters are faked), so a change to how useAccount
+// resolves the current account is exercised here too. The real ./constants are
+// used, so assertions validate against the actual channel identity (label keys,
+// channel_type, social ordering) derived from CHANNEL_LIST.
+const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
+ const store = createStore({
+ modules: {
+ accounts: {
+ namespaced: true,
+ getters: {
+ getAccount: () => () => ({
+ id: 1,
+ custom_attributes: { brand_info: brandInfo },
+ }),
+ },
+ },
+ inboxes: {
+ namespaced: true,
+ getters: { getInboxes: () => inboxes },
+ },
+ },
+ });
+
+ let result;
+ const Component = defineComponent({
+ setup() {
+ result = useDetectedChannels();
+ return () => h('div');
+ },
+ });
+ mount(Component, { global: { plugins: [store] } });
+ return result;
+};
+
+beforeEach(() => {
+ useRoute.mockReturnValue({ params: { accountId: '1' } });
+ // Configure the installation OAuth credentials so detected channels aren't
+ // hidden by the config gate; individual tests clear this to assert hiding.
+ window.chatwootConfig = {
+ fbAppId: 'fb',
+ instagramAppId: 'ig',
+ tiktokAppId: 'tt',
+ whatsappAppId: 'wa',
+ whatsappConfigurationId: 'wa-config',
+ };
+});
+
+afterEach(() => {
+ delete window.chatwootConfig;
+});
+
+describe('useDetectedChannels', () => {
+ describe('displayedChannels', () => {
+ it('maps detected socials with a url to channel rows', () => {
+ const { displayedChannels } = mountComposable({
+ brandInfo: {
+ socials: [
+ { type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' },
+ { type: 'instagram', url: 'https://instagram.com/acme' },
+ ],
+ },
+ });
+
+ expect(displayedChannels.value).toEqual([
+ {
+ type: 'whatsapp',
+ handle: '+14155552671',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
+ inbox: { channel_type: 'Channel::Whatsapp' },
+ },
+ {
+ type: 'instagram',
+ handle: '@acme',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
+ inbox: { channel_type: 'Channel::Instagram' },
+ },
+ ]);
+ });
+
+ it('skips socials without a url or with an unknown type', () => {
+ const { displayedChannels } = mountComposable({
+ brandInfo: {
+ socials: [
+ { type: 'telegram' }, // no url
+ { type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown
+ { type: 'tiktok', url: 'https://tiktok.com/@acme' },
+ ],
+ },
+ });
+
+ expect(displayedChannels.value.map(channel => channel.type)).toEqual([
+ 'tiktok',
+ ]);
+ });
+
+ it('uses the raw path for line and falls back to empty on a bad url', () => {
+ const { displayedChannels } = mountComposable({
+ brandInfo: {
+ socials: [
+ { type: 'line', url: 'https://line.me/acme' },
+ { type: 'facebook', url: 'not-a-url' },
+ ],
+ },
+ });
+
+ expect(displayedChannels.value).toEqual([
+ {
+ type: 'line',
+ handle: 'acme',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
+ inbox: { channel_type: 'Channel::Line' },
+ },
+ {
+ type: 'facebook',
+ handle: '',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
+ inbox: { channel_type: 'Channel::FacebookPage' },
+ },
+ ]);
+ });
+
+ it('omits the detected email channel while email is disabled for this phase', () => {
+ const { displayedChannels } = mountComposable({
+ brandInfo: {
+ email_provider: 'google',
+ email: 'support@acme.com',
+ socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
+ },
+ });
+
+ expect(displayedChannels.value.map(channel => channel.type)).toEqual([
+ 'whatsapp',
+ ]);
+ });
+
+ it('falls back to the default channel suggestions when nothing is detected', () => {
+ const { displayedChannels } = mountComposable({ brandInfo: undefined });
+
+ // The configured mainstream channels, with no detected handle.
+ expect(displayedChannels.value).toEqual([
+ {
+ type: 'whatsapp',
+ handle: '',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
+ inbox: { channel_type: 'Channel::Whatsapp' },
+ },
+ {
+ type: 'facebook',
+ handle: '',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
+ inbox: { channel_type: 'Channel::FacebookPage' },
+ },
+ {
+ type: 'instagram',
+ handle: '',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
+ inbox: { channel_type: 'Channel::Instagram' },
+ },
+ ]);
+ });
+
+ it('gates the default suggestions by installation config, keeping the list non-empty', () => {
+ window.chatwootConfig = {}; // no OAuth credentials configured
+ const { displayedChannels } = mountComposable({ brandInfo: undefined });
+
+ // Only the credential-free defaults survive (Telegram, LINE).
+ expect(displayedChannels.value.map(channel => channel.type)).toEqual([
+ 'telegram',
+ 'line',
+ ]);
+ });
+
+ it('hides detected channels whose installation OAuth credentials are missing', () => {
+ window.chatwootConfig = {}; // nothing configured
+ const { displayedChannels } = mountComposable({
+ brandInfo: {
+ socials: [
+ { type: 'facebook', url: 'https://facebook.com/acme' },
+ { type: 'line', url: 'https://line.me/acme' },
+ ],
+ },
+ });
+
+ // Facebook needs fbAppId (absent → hidden); LINE needs no install credential.
+ expect(displayedChannels.value.map(channel => channel.type)).toEqual([
+ 'line',
+ ]);
+ });
+ });
+
+ describe('remainingChannels', () => {
+ it('returns the platforms not already shown as default rows', () => {
+ // Nothing detected → displayed falls back to the defaults (WhatsApp,
+ // Facebook, Instagram), so the footer previews the remaining platforms.
+ const { remainingChannels } = mountComposable({ brandInfo: {} });
+
+ expect(remainingChannels.value).toEqual([
+ {
+ type: 'line',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
+ inbox: { channel_type: 'Channel::Line' },
+ },
+ {
+ type: 'telegram',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
+ inbox: { channel_type: 'Channel::Telegram' },
+ },
+ {
+ type: 'tiktok',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
+ inbox: { channel_type: 'Channel::Tiktok' },
+ },
+ ]);
+ });
+
+ it('excludes already-detected socials, preserving order', () => {
+ const { remainingChannels } = mountComposable({
+ brandInfo: {
+ socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
+ },
+ });
+
+ expect(remainingChannels.value.map(channel => channel.type)).toEqual([
+ 'facebook',
+ 'line',
+ 'instagram',
+ ]);
+ });
+
+ it('excludes channels whose installation OAuth credentials are missing', () => {
+ window.chatwootConfig = {}; // nothing configured
+ const { remainingChannels } = mountComposable({ brandInfo: {} });
+
+ // The only configured channels (Telegram, LINE) are shown as default rows,
+ // and every other platform is gated out — so nothing remains for the footer.
+ expect(remainingChannels.value).toEqual([]);
+ });
+ });
+
+ describe('connectedInbox', () => {
+ it('returns the real inbox sharing the channel type', () => {
+ const inbox = {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ name: 'WA Biz',
+ };
+ const { connectedInbox } = mountComposable({
+ brandInfo: {},
+ inboxes: [inbox],
+ });
+
+ expect(
+ connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } })
+ ).toBe(inbox);
+ });
+
+ it('matches email inboxes on provider', () => {
+ const gmail = {
+ id: 1,
+ channel_type: 'Channel::Email',
+ provider: 'google',
+ };
+ const outlook = {
+ id: 2,
+ channel_type: 'Channel::Email',
+ provider: 'microsoft',
+ };
+ const { connectedInbox } = mountComposable({
+ brandInfo: {},
+ inboxes: [outlook, gmail],
+ });
+
+ expect(
+ connectedInbox({
+ inbox: { channel_type: 'Channel::Email', provider: 'google' },
+ })
+ ).toBe(gmail);
+ });
+
+ it('returns undefined when nothing matches', () => {
+ const { connectedInbox } = mountComposable({
+ brandInfo: {},
+ inboxes: [],
+ });
+
+ expect(
+ connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } })
+ ).toBeUndefined();
+ });
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue b/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue
index 034f40d35..54eafa59e 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import WithLabel from 'v3/components/Form/WithLabel.vue';
-import TextArea from 'next/textarea/TextArea.vue';
+import Editor from 'next/Editor/Editor.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
@@ -162,9 +162,13 @@ const toggleAutoResolve = async () => {
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
-