Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeff17dfd7 | ||
|
|
02d4de7357 | ||
|
|
afa2b49e45 | ||
|
|
6ba86788b2 | ||
|
|
3c4225cf57 | ||
|
|
d0e7753258 | ||
|
|
563619c95d | ||
|
|
f5c6d375ef | ||
|
|
da8ce630db | ||
|
|
c964f53288 | ||
|
|
b9e7030908 | ||
|
|
76dc037c1a | ||
|
|
67f55c5b7f | ||
|
|
fd6dc62780 | ||
|
|
b1f8792e5f | ||
|
|
c1eceb8f98 | ||
|
|
ba474a4348 | ||
|
|
741e536f89 | ||
|
|
67568f37ca | ||
|
|
f96c3609a4 | ||
|
|
cd1472b0ac | ||
|
|
806d15c13b | ||
|
|
963f182466 | ||
|
|
5b4ff80777 | ||
|
|
9f17fbe697 | ||
|
|
5c39d896d9 | ||
|
|
926a9d8a69 | ||
|
|
9caceea858 | ||
|
|
8670f66155 | ||
|
|
729aca7b64 | ||
|
|
d04a2c2f56 | ||
|
|
2767bd434b | ||
|
|
ce2e10e89e | ||
|
|
56275b750e | ||
|
|
dca4c39900 | ||
|
|
6d6475232a | ||
|
|
4d95956900 | ||
|
|
33314768f4 | ||
|
|
9f5d55ae33 | ||
|
|
f0aacbf170 | ||
|
|
96d0757041 | ||
|
|
847bb13aa5 | ||
|
|
ef63752075 | ||
|
|
bb387dbd4a | ||
|
|
4dd630c683 | ||
|
|
1186aa26dc | ||
|
|
c86736ff70 | ||
|
|
6e31eefd35 | ||
|
|
3d066333eb | ||
|
|
65e5a9dbf1 | ||
|
|
5122536322 | ||
|
|
bf7a5792b2 | ||
|
|
84eb313f38 | ||
|
|
85cd87c410 | ||
|
|
299bc6c0a4 | ||
|
|
7522457740 | ||
|
|
e4ef2de8c8 | ||
|
|
d0b1c055e8 | ||
|
|
cf134deb37 | ||
|
|
b8b62ad0f1 | ||
|
|
4e26c5b4bb | ||
|
|
43e0f8a141 | ||
|
|
91d8a4e2a3 | ||
|
|
8b977b35a8 | ||
|
|
62cbeae95f | ||
|
|
7c7459b734 | ||
|
|
0849d2e070 | ||
|
|
cd1a214d98 | ||
|
|
f9cc702030 | ||
|
|
74db16158d | ||
|
|
e8edc9ebf5 | ||
|
|
e785620921 | ||
|
|
0af9900be2 | ||
|
|
84fda8a323 | ||
|
|
1c7e60880d | ||
|
|
8e27ca4282 | ||
|
|
ee5e20551d | ||
|
|
df79c0bbde | ||
|
|
647cfc2d83 | ||
|
|
961ec86ba9 |
@@ -144,7 +144,7 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 20
|
||||
parallelism: 18
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,11 +21,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@assignable_agents = @inbox.assignable_agents
|
||||
end
|
||||
|
||||
def assignable_owners
|
||||
@assignable_agents = @inbox.assignable_agents.select(&:confirmed?)
|
||||
@agent_bots = AgentBot.accessible_to(Current.account)
|
||||
end
|
||||
|
||||
def campaigns
|
||||
@campaigns = @inbox.campaigns
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
// Viewer's UTC offset in hours, matching the reports API convention so the
|
||||
// backend can anchor calendar ranges to the viewer's day.
|
||||
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
|
||||
class CaptainAssistant extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/assistants', { accountScoped: true });
|
||||
@@ -21,6 +25,18 @@ class CaptainAssistant extends ApiClient {
|
||||
message_history: messageHistory,
|
||||
});
|
||||
}
|
||||
|
||||
getStats({ assistantId, range }) {
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
});
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range }) {
|
||||
return axios.get(`${this.url}/${assistantId}/summary`, {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainAssistant();
|
||||
|
||||
@@ -62,10 +62,9 @@ class ConversationApi extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
assignAgent({ conversationId, agentId, assigneeType }) {
|
||||
assignAgent({ conversationId, agentId }) {
|
||||
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
||||
assignee_id: agentId,
|
||||
assignee_type: assigneeType,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,6 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
return axios.get(`${this.url}/${inboxId}/campaigns`);
|
||||
}
|
||||
|
||||
getAssignableOwners(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/assignable_owners`);
|
||||
}
|
||||
|
||||
deleteInboxAvatar(inboxId) {
|
||||
return axios.delete(`${this.url}/${inboxId}/avatar`);
|
||||
}
|
||||
|
||||
@@ -90,16 +90,11 @@ describe('#ConversationAPI', () => {
|
||||
});
|
||||
|
||||
it('#assignAgent', () => {
|
||||
conversationAPI.assignAgent({
|
||||
conversationId: 12,
|
||||
agentId: 34,
|
||||
assigneeType: 'AgentBot',
|
||||
});
|
||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/assignments`,
|
||||
{
|
||||
assignee_id: 34,
|
||||
assignee_type: 'AgentBot',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ describe('#InboxesAPI', () => {
|
||||
expect(inboxesAPI).toHaveProperty('create');
|
||||
expect(inboxesAPI).toHaveProperty('update');
|
||||
expect(inboxesAPI).toHaveProperty('delete');
|
||||
expect(inboxesAPI).toHaveProperty('getAssignableOwners');
|
||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||
@@ -38,13 +37,6 @@ describe('#InboxesAPI', () => {
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns');
|
||||
});
|
||||
|
||||
it('#getAssignableOwners', () => {
|
||||
inboxesAPI.getAssignableOwners(2);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/2/assignable_owners'
|
||||
);
|
||||
});
|
||||
|
||||
it('#deleteInboxAvatar', () => {
|
||||
inboxesAPI.deleteInboxAvatar(2);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
||||
|
||||
+7
-1
@@ -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(() => {
|
||||
|
||||
@@ -182,7 +182,8 @@ const handleCreateAssistant = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="headerActions" />
|
||||
<slot name="search" />
|
||||
<div
|
||||
v-if="!showPaywall && buttonLabel"
|
||||
|
||||
-23
@@ -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"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<input
|
||||
v-model="state.temperature"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
class="w-full"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11 italic">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Dismissal is remembered per assistant for 24 hours (setFlag's default expiry).
|
||||
const DISMISS_STORE = 'captain_overview_coverage_banner';
|
||||
|
||||
const accountId = computed(() => route.params.accountId);
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
|
||||
const dismissed = ref(
|
||||
LocalStorage.getFlag(DISMISS_STORE, accountId.value, assistantId.value)
|
||||
);
|
||||
|
||||
// Thin coverage paired with a large review backlog: approving the pending FAQs
|
||||
// is the quickest lever to lift auto-resolution, so nudge the team to act.
|
||||
const COVERAGE_THRESHOLD = 85;
|
||||
const PENDING_THRESHOLD = 100;
|
||||
|
||||
const showBanner = computed(
|
||||
() =>
|
||||
!dismissed.value &&
|
||||
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
|
||||
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
|
||||
);
|
||||
|
||||
const dismiss = () => {
|
||||
LocalStorage.setFlag(DISMISS_STORE, accountId.value, assistantId.value);
|
||||
dismissed.value = true;
|
||||
};
|
||||
|
||||
const goToPending = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_responses_pending',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: route.params.assistantId,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showBanner"
|
||||
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="shrink-0 i-lucide-triangle-alert size-4" />
|
||||
<span class="truncate">
|
||||
{{
|
||||
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
|
||||
count: knowledge.pending,
|
||||
coverage: knowledge.coverage,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
|
||||
@click="goToPending"
|
||||
>
|
||||
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
|
||||
:aria-label="$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.DISMISS')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<span class="i-lucide-x size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineProps({
|
||||
// Selected day-range, used only for the chart's start-axis label.
|
||||
range: { type: String, default: '30' },
|
||||
});
|
||||
|
||||
// Daily assistant credit usage (sample data) rendered as a lightweight bar
|
||||
// chart, so we avoid pulling in a chart library for placeholder visuals.
|
||||
const creditUsage = {
|
||||
total: '48,210',
|
||||
trend: '+18.2%',
|
||||
daily: [
|
||||
120, 145, 132, 160, 175, 158, 190, 210, 195, 230, 220, 245, 260, 240, 275,
|
||||
290, 270, 310, 295, 330, 350, 325, 360, 380, 355, 400, 420, 395, 440, 465,
|
||||
],
|
||||
};
|
||||
|
||||
// Bar heights as a percentage of the peak day, with a small floor so even the
|
||||
// lowest day stays visible.
|
||||
const bars = computed(() => {
|
||||
const max = Math.max(...creditUsage.daily);
|
||||
return creditUsage.daily.map((value, index) => ({
|
||||
key: index,
|
||||
value,
|
||||
height: Math.max(6, Math.round((value / max) * 100)),
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.TITLE') }}
|
||||
</h3>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-semibold tabular-nums text-n-slate-12">
|
||||
{{ creditUsage.total }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.UNIT') }}
|
||||
</span>
|
||||
<span class="text-sm font-medium tabular-nums text-n-slate-11">
|
||||
{{ creditUsage.trend }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="rounded-full size-2.5 bg-n-brand" />
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.LEGEND') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5 border rounded-xl bg-n-solid-1 border-n-weak">
|
||||
<div class="flex items-end gap-1 h-40">
|
||||
<div
|
||||
v-for="bar in bars"
|
||||
:key="bar.key"
|
||||
v-tooltip="`${bar.value} ${$t('CAPTAIN.OVERVIEW.CREDITS.UNIT')}`"
|
||||
class="flex-1 rounded-t transition-colors bg-n-brand/70 hover:bg-n-brand"
|
||||
:style="{ height: `${bar.height}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.AXIS_START', { count: range }) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.AXIS_END') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const dismissed = ref(false);
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const inboxes = useMapGetter('captainInboxes/getRecords');
|
||||
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
|
||||
|
||||
// Only surface once we know the assistant has no connected inboxes.
|
||||
const showBanner = computed(
|
||||
() =>
|
||||
!dismissed.value &&
|
||||
!uiFlags.value.fetchingList &&
|
||||
inboxes.value.length === 0
|
||||
);
|
||||
|
||||
watch(
|
||||
assistantId,
|
||||
id => {
|
||||
dismissed.value = false;
|
||||
if (id) store.dispatch('captainInboxes/get', { assistantId: id });
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const goToInboxes = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_inboxes_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: assistantId.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showBanner"
|
||||
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="shrink-0 i-lucide-triangle-alert size-4" />
|
||||
<span class="truncate">
|
||||
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.TEXT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
|
||||
@click="goToInboxes"
|
||||
>
|
||||
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.ACTION') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
|
||||
:aria-label="$t('CAPTAIN.OVERVIEW.INBOX_BANNER.DISMISS')"
|
||||
@click="dismissed = true"
|
||||
>
|
||||
<span class="i-lucide-x size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const approvedPct = computed(() => props.knowledge.coverage ?? 0);
|
||||
|
||||
const linkTo = routeName => ({
|
||||
name: routeName,
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: route.params.assistantId,
|
||||
},
|
||||
});
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
key: 'approved',
|
||||
value: props.knowledge.approved,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.APPROVED'),
|
||||
to: linkTo('captain_assistants_responses_index'),
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
value: props.knowledge.pending,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
|
||||
to: linkTo('captain_assistants_responses_pending'),
|
||||
},
|
||||
{
|
||||
key: 'documents',
|
||||
value: props.knowledge.documents,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.DOCUMENTS'),
|
||||
to: linkTo('captain_assistants_documents_index'),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.TITLE') }}
|
||||
</span>
|
||||
<span class="text-sm tabular-nums text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full h-2 overflow-hidden rounded-full bg-n-alpha-2">
|
||||
<div
|
||||
class="h-full rounded-full bg-n-brand"
|
||||
:style="{ width: `${approvedPct}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<RouterLink
|
||||
v-for="stat in stats"
|
||||
:key="stat.key"
|
||||
:to="stat.to"
|
||||
class="flex flex-col gap-1 group/stat"
|
||||
>
|
||||
<span class="text-xl font-semibold tabular-nums text-n-slate-12">
|
||||
{{ stat.value }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs transition-colors text-n-slate-11 group-hover/stat:text-n-slate-12"
|
||||
>
|
||||
{{ stat.label }}
|
||||
<span
|
||||
class="transition-opacity opacity-0 i-lucide-arrow-up-right size-3 group-hover/stat:opacity-100"
|
||||
/>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
value: { type: String, required: true },
|
||||
trend: { type: String, default: '' },
|
||||
hint: { type: String, default: '' },
|
||||
// null = neutral, true = good direction, false = bad direction
|
||||
trendGood: { type: Boolean, default: null },
|
||||
});
|
||||
|
||||
const trendClass = computed(() => {
|
||||
if (props.trendGood === null) return 'text-n-slate-11';
|
||||
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 p-5 group bg-n-solid-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
|
||||
<span
|
||||
v-if="hint"
|
||||
v-tooltip="hint"
|
||||
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end justify-between gap-2">
|
||||
<span
|
||||
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium tabular-nums" :class="trendClass">
|
||||
{{ trend }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, RouterLink } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const assistantParams = computed(() => ({
|
||||
accountId: route.params.accountId,
|
||||
assistantId: route.params.assistantId,
|
||||
}));
|
||||
|
||||
const links = computed(() => [
|
||||
{
|
||||
key: 'docs',
|
||||
title: t('CAPTAIN.OVERVIEW.LINKS.DOCS.TITLE'),
|
||||
description: t('CAPTAIN.OVERVIEW.LINKS.DOCS.DESCRIPTION'),
|
||||
icon: 'i-lucide-book-open',
|
||||
href: getHelpUrlForFeature('captain'),
|
||||
},
|
||||
{
|
||||
key: 'playground',
|
||||
title: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.TITLE'),
|
||||
description: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.DESCRIPTION'),
|
||||
icon: 'i-lucide-flask-conical',
|
||||
to: {
|
||||
name: 'captain_assistants_playground_index',
|
||||
params: assistantParams.value,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'billing',
|
||||
title: t('CAPTAIN.OVERVIEW.LINKS.BILLING.TITLE'),
|
||||
description: t('CAPTAIN.OVERVIEW.LINKS.BILLING.DESCRIPTION'),
|
||||
icon: 'i-lucide-credit-card',
|
||||
to: {
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: route.params.accountId },
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isOnChatwootCloud" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<component
|
||||
:is="link.href ? 'a' : RouterLink"
|
||||
v-for="link in links"
|
||||
:key="link.key"
|
||||
:href="link.href"
|
||||
:to="link.to"
|
||||
:target="link.href ? '_blank' : undefined"
|
||||
:rel="link.href ? 'noopener noreferrer' : undefined"
|
||||
class="flex items-center gap-3 p-4 transition-colors border rounded-xl bg-n-solid-1 border-n-weak hover:bg-n-alpha-1 group/link"
|
||||
>
|
||||
<span
|
||||
class="grid rounded-lg size-9 shrink-0 place-content-center bg-n-alpha-2 text-n-slate-11"
|
||||
>
|
||||
<span :class="link.icon" class="size-4" />
|
||||
</span>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ link.title }}
|
||||
</span>
|
||||
<span class="text-xs truncate text-n-slate-11">
|
||||
{{ link.description }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
:class="
|
||||
link.href ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'
|
||||
"
|
||||
class="ml-auto transition-opacity opacity-0 size-4 text-n-slate-10 group-hover/link:opacity-100"
|
||||
/>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const modelValue = defineModel({ type: String, default: '30' });
|
||||
|
||||
const { t } = useI18n();
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const DAY_RANGES = ['7', '30', '90'];
|
||||
|
||||
const decorate = item => ({
|
||||
...item,
|
||||
action: 'select',
|
||||
isSelected: item.value === modelValue.value,
|
||||
});
|
||||
|
||||
const menuSections = computed(() => {
|
||||
const dayItems = DAY_RANGES.map(value =>
|
||||
decorate({
|
||||
value,
|
||||
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_DAYS', { count: value }),
|
||||
})
|
||||
);
|
||||
const monthItems = [
|
||||
decorate({
|
||||
value: 'this_month',
|
||||
label: t('CAPTAIN.OVERVIEW.RANGES.THIS_MONTH'),
|
||||
}),
|
||||
decorate({
|
||||
value: 'last_month',
|
||||
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_MONTH'),
|
||||
}),
|
||||
];
|
||||
return [{ items: dayItems }, { items: monthItems }];
|
||||
});
|
||||
|
||||
const menuItems = computed(() =>
|
||||
menuSections.value.flatMap(section => section.items)
|
||||
);
|
||||
|
||||
const selectedLabel = computed(
|
||||
() => menuItems.value.find(item => item.isSelected)?.label || ''
|
||||
);
|
||||
|
||||
const handleAction = ({ value }) => {
|
||||
toggleDropdown(false);
|
||||
modelValue.value = value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
trailing-icon
|
||||
icon="i-lucide-chevron-down"
|
||||
:label="selectedLabel"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-items="menuItems"
|
||||
:menu-sections="menuSections"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Flagged responses broken down by report reason, mapped from
|
||||
// captain_message_reports.report_reason (sample data).
|
||||
const flagReasons = [
|
||||
{
|
||||
key: 'incorrect',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INCORRECT'),
|
||||
count: 11,
|
||||
},
|
||||
{
|
||||
key: 'incomplete',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INCOMPLETE'),
|
||||
count: 7,
|
||||
},
|
||||
{
|
||||
key: 'outdated',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.OUTDATED'),
|
||||
count: 4,
|
||||
},
|
||||
{
|
||||
key: 'inappropriate',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INAPPROPRIATE'),
|
||||
count: 2,
|
||||
},
|
||||
{ key: 'other', label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.OTHER'), count: 3 },
|
||||
];
|
||||
|
||||
const total = computed(() =>
|
||||
flagReasons.reduce((sum, reason) => sum + reason.count, 0)
|
||||
);
|
||||
|
||||
// Flagged responses as a share of all Captain replies (sample data).
|
||||
const rate = '1.3%';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.OVERVIEW.FLAGGED.TITLE') }}
|
||||
</span>
|
||||
<span class="text-sm tabular-nums text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.FLAGGED.TOTAL', { count: total, rate }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="reason in flagReasons"
|
||||
:key="reason.key"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span class="w-28 text-xs truncate text-n-slate-11 shrink-0">
|
||||
{{ reason.label }}
|
||||
</span>
|
||||
<div class="flex-1 h-2 overflow-hidden rounded-full bg-n-alpha-2">
|
||||
<div
|
||||
class="h-full rounded-full bg-n-amber-9"
|
||||
:style="{ width: `${Math.round((reason.count / total) * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="w-6 text-xs font-medium text-right tabular-nums text-n-slate-12"
|
||||
>
|
||||
{{ reason.count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
|
||||
const props = defineProps({
|
||||
range: {
|
||||
type: String,
|
||||
default: '30',
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
|
||||
// Markdown summary generated by the model from the assistant's stats (served by
|
||||
// the captain/assistants/:id/summary endpoint). Numbers are emphasised with
|
||||
// **bold** so we can highlight them.
|
||||
const welcomeMarkdown = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const fetchSummary = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getSummary({
|
||||
assistantId: assistantId.value,
|
||||
range: props.range,
|
||||
});
|
||||
welcomeMarkdown.value = data.message ?? '';
|
||||
} catch {
|
||||
welcomeMarkdown.value = '';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
|
||||
|
||||
// Split a line on **bold** runs so we can render emphasised numbers as
|
||||
// brand-highlighted spans with Tailwind (instead of styling raw HTML).
|
||||
const parseSegments = text => {
|
||||
const parts = [];
|
||||
const regex = /\*\*(.+?)\*\*/g;
|
||||
let lastIndex = 0;
|
||||
let match = regex.exec(text);
|
||||
while (match) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ text: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
parts.push({ text: match[1], bold: true });
|
||||
lastIndex = regex.lastIndex;
|
||||
match = regex.exec(text);
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ text: text.slice(lastIndex) });
|
||||
}
|
||||
return parts.map((part, index) => ({ ...part, key: index }));
|
||||
};
|
||||
|
||||
// Break the markdown into paragraphs on blank lines so newline breaks survive
|
||||
// (a single <p> would collapse them), each parsed into highlightable segments.
|
||||
const paragraphs = computed(() =>
|
||||
welcomeMarkdown.value
|
||||
.split(/\n{2,}/)
|
||||
.map(block => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block, index) => ({ key: index, segments: parseSegments(block) }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div v-if="isLoading || welcomeMarkdown" class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-1.5 text-n-slate-10">
|
||||
<span class="i-lucide-sparkles size-3.5" />
|
||||
<span class="text-xs">
|
||||
{{ $t('CAPTAIN.OVERVIEW.WELCOME.LABEL') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex flex-col gap-5"
|
||||
:aria-label="$t('CAPTAIN.OVERVIEW.WELCOME.LOADING')"
|
||||
>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="w-full h-5 rounded bg-n-slate-3 animate-pulse" />
|
||||
<div class="w-11/12 h-5 rounded bg-n-slate-3 animate-pulse" />
|
||||
<div class="w-4/6 h-5 rounded bg-n-slate-3 animate-pulse" />
|
||||
</div>
|
||||
<div class="w-5/6 h-5 rounded bg-n-slate-3 animate-pulse" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<p
|
||||
v-for="paragraph in paragraphs"
|
||||
:key="paragraph.key"
|
||||
class="text-lg leading-relaxed text-n-slate-12"
|
||||
>
|
||||
<template v-for="segment in paragraph.segments" :key="segment.key">
|
||||
<span
|
||||
v-if="segment.bold"
|
||||
class="font-bold tabular-nums text-n-brand"
|
||||
>{{ segment.text }}</span
|
||||
>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -168,6 +168,7 @@ const selectEmoji = emoji => {
|
||||
<Button
|
||||
v-if="showRemoveButton && value"
|
||||
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
|
||||
@@ -34,6 +34,7 @@ const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
|
||||
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x-bold',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -81,7 +82,11 @@ const isWhatsapp = computed(
|
||||
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
[
|
||||
VOICE_CALL_STATUS.NO_ANSWER,
|
||||
VOICE_CALL_STATUS.FAILED,
|
||||
VOICE_CALL_STATUS.REJECTED,
|
||||
].includes(status.value)
|
||||
);
|
||||
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
||||
const endReason = computed(() => call.value?.endReason);
|
||||
|
||||
@@ -90,6 +90,7 @@ export const VOICE_CALL_STATUS = {
|
||||
COMPLETED: 'completed',
|
||||
NO_ANSWER: 'no-answer',
|
||||
FAILED: 'failed',
|
||||
REJECTED: 'rejected',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_DIRECTION = {
|
||||
|
||||
@@ -440,6 +440,14 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CAPTAIN'),
|
||||
activeOn: ['captain_assistants_create_index'],
|
||||
children: [
|
||||
{
|
||||
name: 'Overview',
|
||||
label: t('SIDEBAR.CAPTAIN_OVERVIEW'),
|
||||
activeOn: ['captain_assistants_overview_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_overview_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'FAQs',
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
|
||||
@@ -31,12 +31,22 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createErrorMessage(error) {
|
||||
const responseError = error?.response?.data?.error;
|
||||
if (typeof responseError === 'string') return responseError;
|
||||
|
||||
return (
|
||||
responseError?.error?.message ||
|
||||
responseError?.message ||
|
||||
this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')
|
||||
);
|
||||
},
|
||||
async onClick() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
await DyteAPI.createAMeeting(this.conversationId);
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
|
||||
useAlert(this.createErrorMessage(error));
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,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 = {
|
||||
@@ -29,13 +30,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(() => {
|
||||
|
||||
@@ -31,7 +31,6 @@ const mockUseMapGetter = (overrides = {}) => {
|
||||
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
|
||||
getCurrentAccountId: ref(1),
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
|
||||
'inboxAssignableAgents/getAssignableOwners': ref(() => []),
|
||||
};
|
||||
|
||||
const mergedGetters = { ...defaultGetters, ...overrides };
|
||||
@@ -76,32 +75,6 @@ describe('useAgentsList', () => {
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
});
|
||||
|
||||
it('includes agent bots when includeAgentBots is true', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableOwners': ref(() => [
|
||||
{ id: 2, name: 'Agent', assignee_type: 'User' },
|
||||
{
|
||||
id: 1,
|
||||
name: 'Bot',
|
||||
thumbnail: '',
|
||||
assignee_type: 'AgentBot',
|
||||
icon: 'i-lucide-bot',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const { agentsList } = useAgentsList(true, true);
|
||||
|
||||
expect(agentsList.value).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
name: 'Bot',
|
||||
assignee_type: 'AgentBot',
|
||||
icon: 'i-lucide-bot',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
|
||||
@@ -10,21 +10,14 @@ import {
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
*
|
||||
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
|
||||
* @param {boolean} [includeAgentBots=false] - Whether to include agent bots as assignment options.
|
||||
* @returns {Object} An object containing the agents list and assignable agents.
|
||||
*/
|
||||
export function useAgentsList(
|
||||
includeNoneAgent = true,
|
||||
includeAgentBots = false
|
||||
) {
|
||||
export function useAgentsList(includeNoneAgent = true) {
|
||||
const { t } = useI18n();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
|
||||
const assignableOwners = useMapGetter(
|
||||
'inboxAssignableAgents/getAssignableOwners'
|
||||
);
|
||||
|
||||
const inboxId = computed(() => currentChat.value?.inbox_id);
|
||||
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
|
||||
@@ -49,22 +42,11 @@ export function useAgentsList(
|
||||
return inboxId.value ? assignable.value(inboxId.value) : [];
|
||||
});
|
||||
|
||||
const owners = computed(() => {
|
||||
return includeAgentBots && inboxId.value
|
||||
? assignableOwners.value(inboxId.value)
|
||||
: [];
|
||||
});
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = includeAgentBots
|
||||
? owners.value.filter(owner => owner.assignee_type === 'User')
|
||||
: assignableAgents.value || [];
|
||||
const bots = owners.value.filter(
|
||||
owner => owner.assignee_type === 'AgentBot'
|
||||
);
|
||||
const agents = assignableAgents.value || [];
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
@@ -78,7 +60,6 @@ export function useAgentsList(
|
||||
return [
|
||||
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
|
||||
...filteredAgentsByAvailability,
|
||||
...bots,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -163,6 +163,11 @@ export const FORMATTING = {
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Context::NoToolbar': {
|
||||
marks: ['strong', 'em', 'link'],
|
||||
nodes: ['bulletList', 'orderedList'],
|
||||
menu: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Editor menu options for Full Editor
|
||||
|
||||
@@ -162,4 +162,7 @@ export const SESSION_EVENTS = Object.freeze({
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
INBOX_SETUP_VISITED: 'Onboarding: Inbox setup visited',
|
||||
INBOX_SETUP_COMPLETED: 'Onboarding: Inbox setup completed',
|
||||
INBOX_SETUP_SKIPPED: 'Onboarding: Inbox setup skipped',
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = {
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
billing: 'https://chwt.app/pricing',
|
||||
saml: 'https://chwt.app/hc/saml',
|
||||
captain: 'https://chwt.app/captain-docs',
|
||||
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,24 @@ export const INBOX_TYPES = {
|
||||
TIKTOK: 'Channel::Tiktok',
|
||||
};
|
||||
|
||||
// Short channel-type slugs used to identify a channel without leaning on its
|
||||
// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps.
|
||||
export const CHANNEL_TYPES = {
|
||||
WEBSITE: 'website',
|
||||
WHATSAPP: 'whatsapp',
|
||||
FACEBOOK: 'facebook',
|
||||
INSTAGRAM: 'instagram',
|
||||
TIKTOK: 'tiktok',
|
||||
TELEGRAM: 'telegram',
|
||||
LINE: 'line',
|
||||
GMAIL: 'gmail',
|
||||
OUTLOOK: 'outlook',
|
||||
SMS: 'sms',
|
||||
API: 'api',
|
||||
VOICE: 'voice',
|
||||
EMAIL: 'email',
|
||||
};
|
||||
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
export const VOICE_CALL_PROVIDERS = {
|
||||
TWILIO: 'twilio',
|
||||
|
||||
@@ -7,6 +7,7 @@ export const TERMINAL_STATUSES = [
|
||||
'completed',
|
||||
'busy',
|
||||
'failed',
|
||||
'rejected',
|
||||
'no-answer',
|
||||
'canceled',
|
||||
'missed',
|
||||
|
||||
@@ -392,6 +392,93 @@
|
||||
"CAPTAIN": {
|
||||
"NAME": "Captain",
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
"OVERVIEW": {
|
||||
"HEADER": "Overview",
|
||||
"WELCOME": {
|
||||
"LABEL": "Captain summary",
|
||||
"LOADING": "Generating summary…"
|
||||
},
|
||||
"INBOX_BANNER": {
|
||||
"TEXT": "This assistant isn't connected to any inbox yet, so it won't respond to conversations.",
|
||||
"ACTION": "Connect inbox",
|
||||
"DISMISS": "Dismiss"
|
||||
},
|
||||
"COVERAGE_BANNER": {
|
||||
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
|
||||
"ACTION": "Review FAQs",
|
||||
"DISMISS": "Dismiss"
|
||||
},
|
||||
"RANGES": {
|
||||
"LAST_DAYS": "Last {count} days",
|
||||
"THIS_MONTH": "This month",
|
||||
"LAST_MONTH": "Last month"
|
||||
},
|
||||
"METRICS": {
|
||||
"HANDLED": {
|
||||
"LABEL": "Conversations handled",
|
||||
"HINT": "Distinct conversations this assistant replied in."
|
||||
},
|
||||
"AUTO_RESOLUTION": {
|
||||
"LABEL": "Auto-resolution rate",
|
||||
"HINT": "Share of handled conversations closed without a human reply."
|
||||
},
|
||||
"HANDOFF": {
|
||||
"LABEL": "Handoff rate",
|
||||
"HINT": "Share of handled conversations escalated to a human agent."
|
||||
},
|
||||
"HOURS_SAVED": {
|
||||
"LABEL": "Hours saved",
|
||||
"HINT": "Estimate: Captain replies times the team's average response time. Directional, not measured labor."
|
||||
},
|
||||
"REOPEN": {
|
||||
"LABEL": "Reopen rate",
|
||||
"HINT": "Auto-resolved conversations that were reopened afterwards."
|
||||
},
|
||||
"DEPTH": {
|
||||
"LABEL": "Messages / conversation",
|
||||
"HINT": "Average replies the assistant sends per conversation."
|
||||
}
|
||||
},
|
||||
"KNOWLEDGE": {
|
||||
"TITLE": "Knowledge coverage",
|
||||
"COVERAGE": "{pct}% approved",
|
||||
"APPROVED": "Approved FAQs",
|
||||
"PENDING": "Pending FAQs",
|
||||
"DOCUMENTS": "Documents"
|
||||
},
|
||||
"FLAGGED": {
|
||||
"TITLE": "Response quality",
|
||||
"TOTAL": "{count} flagged · {rate}"
|
||||
},
|
||||
"CREDITS": {
|
||||
"TITLE": "Credit usage",
|
||||
"UNIT": "credits",
|
||||
"LEGEND": "Daily credits used",
|
||||
"AXIS_START": "{count}d ago",
|
||||
"AXIS_END": "Today"
|
||||
},
|
||||
"LINKS": {
|
||||
"DOCS": {
|
||||
"TITLE": "Captain docs",
|
||||
"DESCRIPTION": "Guides and how-tos for Captain"
|
||||
},
|
||||
"PLAYGROUND": {
|
||||
"TITLE": "Playground",
|
||||
"DESCRIPTION": "Test this assistant's replies"
|
||||
},
|
||||
"BILLING": {
|
||||
"TITLE": "Billing",
|
||||
"DESCRIPTION": "Manage credits and plan"
|
||||
}
|
||||
},
|
||||
"FLAG_REASONS": {
|
||||
"INCORRECT": "Incorrect info",
|
||||
"INCOMPLETE": "Incomplete",
|
||||
"OUTDATED": "Outdated",
|
||||
"INAPPROPRIATE": "Inappropriate",
|
||||
"OTHER": "Other"
|
||||
}
|
||||
},
|
||||
"ASSISTANT_SWITCHER": {
|
||||
"ASSISTANTS": "Assistants",
|
||||
"SWITCH_ASSISTANT": "Switch between assistants",
|
||||
@@ -494,10 +581,6 @@
|
||||
"PLACEHOLDER": "Enter assistant name",
|
||||
"ERROR": "The name is required"
|
||||
},
|
||||
"TEMPERATURE": {
|
||||
"LABEL": "Response Temperature",
|
||||
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "Enter assistant description",
|
||||
|
||||
@@ -30,5 +30,59 @@
|
||||
"VALIDATION_ERROR": "Please fill in all required fields",
|
||||
"SUCCESS": "Details saved successfully",
|
||||
"ERROR": "Could not save details. Please try again."
|
||||
},
|
||||
"ONBOARDING_INBOX_SETUP": {
|
||||
"GREETING": "Let's set up a few things",
|
||||
"SUBTITLE": "This will give you head-start in your workspace",
|
||||
"CONTINUE": "Continue",
|
||||
"SKIP": "Skip",
|
||||
"ERROR": "Something went wrong. Please try again.",
|
||||
"WHATSAPP_CONNECTED": "WhatsApp connected successfully",
|
||||
"FACEBOOK_CONNECTED": "Facebook connected successfully",
|
||||
"CREATED_FOR_YOU": {
|
||||
"TITLE": "We've created the following for you",
|
||||
"LIVE_CHAT": "Live Chat widget",
|
||||
"LIVE_CHAT_DESCRIPTION": "Instant messenger for your website",
|
||||
"LIVE_CHAT_STATUS": "Almost done…",
|
||||
"LIVE_CHAT_READY": "Ready",
|
||||
"HELP_CENTER": "Help Center",
|
||||
"HELP_CENTER_DESCRIPTION": "Your digital encyclopedia",
|
||||
"HELP_CENTER_GENERATING": "Creating your help center…",
|
||||
"HELP_CENTER_ANALYZING_WEBSITE": "Analyzing your website…",
|
||||
"HELP_CENTER_SETTING_UP_CATEGORIES": "Setting up categories…",
|
||||
"HELP_CENTER_CURATING_ARTICLES": "Curating articles…",
|
||||
"HELP_CENTER_ARTICLES": "Created {count} article | Created {count} articles",
|
||||
"HELP_CENTER_CATEGORIES": "{count} category | {count} categories",
|
||||
"HELP_CENTER_SUMMARY": "Created {count} article across {categories} | Created {count} articles across {categories}"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"TITLE": "Connect all your conversation channels",
|
||||
"HEADER": "We found a few channels you can connect",
|
||||
"CONNECT": "Connect",
|
||||
"CONNECTED": "Connected",
|
||||
"MORE_CHANNELS_NOTE": "Set up {email} and {voice} channels later inside the app",
|
||||
"MORE_CHANNELS_EMAIL": "Email",
|
||||
"MORE_CHANNELS_VOICE": "Voice",
|
||||
"VIEW_ALL": "View all",
|
||||
"GMAIL": "Gmail",
|
||||
"OUTLOOK": "Outlook",
|
||||
"OTHER_EMAIL": "Other Email Providers"
|
||||
},
|
||||
"CHANNELS_DIALOG": {
|
||||
"TITLE": "Connect all your channels instantly",
|
||||
"SUBTITLE": "Manage all of them from one dashboard. You can also set up and edit inboxes later inside the app.",
|
||||
"NOTE": "SMS, API, Voice, and other email providers can be set up later from your dashboard.",
|
||||
"CONNECT_TITLE": "Connect your {name} account",
|
||||
"CONNECT_SUBTITLE": "Fill out these quick details",
|
||||
"CONNECT": "Connect",
|
||||
"BACK": "Back",
|
||||
"SETUP_LATER": "Setup later in app",
|
||||
"FACEBOOK_SUBTITLE": "Authorize access and choose a Page to connect.",
|
||||
"FACEBOOK_LAUNCH": "Continue with Facebook",
|
||||
"FACEBOOK_SELECT_PAGE": "Select a Page to connect",
|
||||
"FACEBOOK_LOADING": "Loading your Facebook Pages…",
|
||||
"FACEBOOK_NO_PAGES": "No connectable Pages found. All your Pages are already connected.",
|
||||
"FACEBOOK_ERROR": "Couldn't connect to Facebook. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +324,7 @@
|
||||
"ALL_COMPANIES": "All Companies",
|
||||
"CAPTAIN": "Captain",
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_OVERVIEW": "Overview",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
"CAPTAIN_RESPONSES": "FAQs",
|
||||
"CAPTAIN_TOOLS": "Tools",
|
||||
@@ -440,7 +441,9 @@
|
||||
"DESCRIPTION": "Enable or disable AI-powered features.",
|
||||
"AUDIO_TRANSCRIPTION": {
|
||||
"TITLE": "Audio Transcription",
|
||||
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
|
||||
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts.",
|
||||
"MODEL_TITLE": "Audio Transcription Model",
|
||||
"MODEL_DESCRIPTION": "Select the AI model to use for converting audio messages into text transcripts"
|
||||
},
|
||||
"HELP_CENTER_SEARCH": {
|
||||
"TITLE": "Help Center Search Indexing",
|
||||
@@ -798,10 +801,14 @@
|
||||
},
|
||||
"FAIR_DISTRIBUTION": {
|
||||
"LABEL": "Fair distribution policy",
|
||||
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
|
||||
"DESCRIPTION": "Cap conversations per agent within a time window to avoid overload. Defaults to 100 per hour.",
|
||||
"INPUT_MAX": "Assign max",
|
||||
"DURATION": "Conversations per agent in every"
|
||||
},
|
||||
"EXCLUDE_OLDER_THAN": {
|
||||
"LABEL": "Skip stale conversations",
|
||||
"DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable."
|
||||
},
|
||||
"INBOXES": {
|
||||
"LABEL": "Added inboxes",
|
||||
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
|
||||
|
||||
@@ -6,6 +6,7 @@ import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
|
||||
import {
|
||||
ACCOUNT_EVENTS,
|
||||
@@ -119,16 +120,20 @@ export default {
|
||||
handleClose(e) {
|
||||
this.$emit('close', e);
|
||||
},
|
||||
handleTranslate() {
|
||||
async handleTranslate() {
|
||||
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
|
||||
const agentLocale = this.getUISettings?.locale;
|
||||
const targetLanguage = agentLocale || accountLocale || 'en';
|
||||
this.$store.dispatch('translateMessage', {
|
||||
conversationId: this.conversationId,
|
||||
messageId: this.messageId,
|
||||
targetLanguage,
|
||||
});
|
||||
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
|
||||
try {
|
||||
await this.$store.dispatch('translateMessage', {
|
||||
conversationId: this.conversationId,
|
||||
messageId: this.messageId,
|
||||
targetLanguage,
|
||||
});
|
||||
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
|
||||
} catch (error) {
|
||||
useAlert(parseAPIErrorResponse(error));
|
||||
}
|
||||
this.handleClose();
|
||||
},
|
||||
handleReplyTo() {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
|
||||
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
|
||||
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
|
||||
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
|
||||
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
|
||||
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
|
||||
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const selectedRange = ref('this_month');
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const stats = ref(null);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getStats({
|
||||
assistantId: assistantId.value,
|
||||
range: selectedRange.value,
|
||||
});
|
||||
stats.value = data;
|
||||
} catch {
|
||||
stats.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
watch([selectedRange, assistantId], fetchStats, { immediate: true });
|
||||
|
||||
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
|
||||
// neutral, so we can colour the delta independently of its sign.
|
||||
const resolveTrendGood = (trendValue, direction) => {
|
||||
if (direction === 'neutral' || trendValue === 0) return null;
|
||||
return direction === 'up' ? trendValue > 0 : trendValue < 0;
|
||||
};
|
||||
|
||||
// Trend units mirror the backend pack mode: a relative percent change ('%') for
|
||||
// :percent metrics, a percentage-point delta (' pts') for rate metrics packed as
|
||||
// :point, and a plain number for :absolute counts like conversation depth.
|
||||
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const data = stats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
return {
|
||||
value: formatValue(data.current),
|
||||
trend: `${sign}${data.trend}${TREND_SUFFIX[trendKind]}`,
|
||||
trendGood: resolveTrendGood(data.trend, direction),
|
||||
};
|
||||
};
|
||||
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
key: 'handled',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
|
||||
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
|
||||
},
|
||||
{
|
||||
key: 'autoResolution',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
|
||||
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'handoff',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
|
||||
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'hoursSaved',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
|
||||
...metricFor('hours_saved', v => `${v}h`, 'up'),
|
||||
},
|
||||
{
|
||||
key: 'reopen',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
|
||||
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'depth',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
|
||||
...metricFor(
|
||||
'conversation_depth',
|
||||
v => v.toFixed(1),
|
||||
'neutral',
|
||||
'absolute'
|
||||
),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="$t('CAPTAIN.OVERVIEW.HEADER')"
|
||||
:is-empty="false"
|
||||
:show-pagination-footer="false"
|
||||
:show-know-more="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
>
|
||||
<template #headerActions>
|
||||
<RangeSelector v-model="selectedRange" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6">
|
||||
<InboxBanner />
|
||||
|
||||
<CoverageBanner :knowledge="stats?.knowledge" />
|
||||
|
||||
<WelcomeCard :range="selectedRange" />
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
|
||||
>
|
||||
<MetricCard
|
||||
v-for="metric in metrics"
|
||||
:key="metric.key"
|
||||
:label="metric.label"
|
||||
:value="metric.value"
|
||||
:trend="metric.trend"
|
||||
:hint="metric.hint"
|
||||
:trend-good="metric.trendGood"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KnowledgeCard :knowledge="stats?.knowledge" />
|
||||
|
||||
<QuickLinks />
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
|
||||
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
|
||||
import AssistantEmptyStateIndex from './assistants/Index.vue';
|
||||
|
||||
import AssistantOverviewIndex from './assistants/overview/Index.vue';
|
||||
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
|
||||
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
|
||||
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
|
||||
@@ -36,6 +37,12 @@ const metaV2 = {
|
||||
};
|
||||
|
||||
const assistantRoutes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/overview'),
|
||||
component: AssistantOverviewIndex,
|
||||
name: 'captain_assistants_overview_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
|
||||
component: ResponsesIndex,
|
||||
|
||||
@@ -53,6 +53,7 @@ const routeToLastActiveAssistant = () => {
|
||||
|
||||
const { navigationPath } = route.params;
|
||||
const isAValidRoute = [
|
||||
'captain_assistants_overview_index', // Overview page
|
||||
'captain_assistants_responses_index', // Faq page
|
||||
'captain_assistants_documents_index', // Document page
|
||||
'captain_assistants_scenarios_index', // Scenario page
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { agentsList } = useAgentsList(true, true);
|
||||
const { agentsList } = useAgentsList();
|
||||
return {
|
||||
agentsList,
|
||||
};
|
||||
@@ -81,27 +81,18 @@ export default {
|
||||
},
|
||||
assignedAgent: {
|
||||
get() {
|
||||
const assignee = this.currentChat.meta.assignee;
|
||||
if (!assignee) return assignee;
|
||||
|
||||
return {
|
||||
...assignee,
|
||||
assignee_type: this.currentChat.meta.assignee_type || 'User',
|
||||
};
|
||||
return this.currentChat.meta.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : null;
|
||||
const assigneeType = agent ? agent.assignee_type || 'User' : null;
|
||||
this.$store.dispatch('setCurrentChatAssignee', {
|
||||
conversationId: this.currentChat.id,
|
||||
assignee: agent,
|
||||
assigneeType,
|
||||
});
|
||||
this.$store
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
agentId,
|
||||
assigneeType,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
||||
@@ -167,12 +158,6 @@ export default {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch(
|
||||
'inboxAssignableAgents/fetchAssignableOwners',
|
||||
this.currentChat.inbox_id
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
onSelfAssign() {
|
||||
const {
|
||||
@@ -189,7 +174,6 @@ export default {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
assignee_type: 'User',
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
@@ -199,14 +183,7 @@ export default {
|
||||
this.assignedAgent = selfAssign;
|
||||
},
|
||||
onClickAssignAgent(selectedItem) {
|
||||
const currentAssigneeType = this.assignedAgent?.assignee_type || 'User';
|
||||
const selectedAssigneeType = selectedItem.assignee_type || 'User';
|
||||
|
||||
if (
|
||||
this.assignedAgent &&
|
||||
this.assignedAgent.id === selectedItem.id &&
|
||||
currentAssigneeType === selectedAssigneeType
|
||||
) {
|
||||
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
|
||||
this.assignedAgent = null;
|
||||
} else {
|
||||
this.assignedAgent = selectedItem;
|
||||
@@ -275,6 +252,7 @@ export default {
|
||||
<MultiselectDropdown
|
||||
:options="teamsList"
|
||||
:selected-item="assignedTeam"
|
||||
show-emoji-icon
|
||||
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
|
||||
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
|
||||
:no-search-result="
|
||||
|
||||
@@ -13,6 +13,7 @@ import AppContainer from './Dashboard.vue';
|
||||
import Suspended from './suspended/Index.vue';
|
||||
import NoAccounts from './noAccounts/Index.vue';
|
||||
import OnboardingAccountDetails from './onboarding/Index.vue';
|
||||
import OnboardingInboxSetup from './onboarding/InboxSetup.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -40,6 +41,14 @@ export default {
|
||||
},
|
||||
component: OnboardingAccountDetails,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/onboarding/inbox-setup'),
|
||||
name: 'onboarding_inbox_setup',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'custom_role'],
|
||||
},
|
||||
component: OnboardingInboxSetup,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/suspended'),
|
||||
name: 'account_suspended',
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import OnboardingLayout from './shared/OnboardingLayout.vue';
|
||||
import OnboardingSection from './shared/OnboardingSection.vue';
|
||||
import InboxChannelsDialog from './inbox-setup/InboxChannelsDialog.vue';
|
||||
import InboxChannelsFooter from './inbox-setup/InboxChannelsFooter.vue';
|
||||
import ChannelRow from './inbox-setup/ChannelRow.vue';
|
||||
import WebWidgetCreationStatus from './inbox-setup/WebWidgetCreationStatus.vue';
|
||||
import HelpCenterCreationStatus from './inbox-setup/HelpCenterCreationStatus.vue';
|
||||
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useChannelConnect } from './inbox-setup/useChannelConnect';
|
||||
import { useDetectedChannels } from './inbox-setup/useDetectedChannels';
|
||||
import { DIALOG_CHANNELS } from './inbox-setup/constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { accountId, currentAccount, finishOnboarding } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
|
||||
|
||||
const helpCenterGenerationId = computed(
|
||||
() => currentAccount.value?.custom_attributes?.help_center_generation_id
|
||||
);
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const {
|
||||
displayedChannels,
|
||||
remainingChannels,
|
||||
connectedInbox,
|
||||
hasDetectedChannels,
|
||||
} = useDetectedChannels();
|
||||
|
||||
const channelsDialogRef = ref(null);
|
||||
|
||||
// The initial inboxes fetch happens in WebWidgetCreationStatus, which polls
|
||||
// `inboxes/get` from its own mount — no need to dispatch it here too.
|
||||
onMounted(() => {
|
||||
useTrack(ONBOARDING_EVENTS.INBOX_SETUP_VISITED);
|
||||
});
|
||||
|
||||
const completeOnboarding = async event => {
|
||||
if (isSubmitting.value) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
// Declare the step we're completing so the controller only clears it when
|
||||
// the stored step still matches (idempotent). setUser then refreshes the
|
||||
// auth store so the router guard sees the cleared step and lets us in.
|
||||
await finishOnboarding({ onboarding_step: 'inbox_setup' });
|
||||
useTrack(event);
|
||||
await store.dispatch('setUser');
|
||||
router.push({ name: 'home', params: { accountId: accountId.value } });
|
||||
} catch {
|
||||
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () =>
|
||||
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_COMPLETED);
|
||||
const handleSkip = () =>
|
||||
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_SKIPPED);
|
||||
const openChannelsDialog = () => channelsDialogRef.value?.open();
|
||||
const refetchInboxes = () => store.dispatch('inboxes/get');
|
||||
|
||||
// WhatsApp connects via Meta's embedded-signup popup; the DIALOG_CHANNELS open
|
||||
// the channels dialog preselected to their in-dialog step; the rest go through
|
||||
// the redirect OAuth flow (Gmail/Outlook keyed by email provider, Instagram by
|
||||
// channel type).
|
||||
const connectChannel = channel => {
|
||||
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
|
||||
connectWhatsapp();
|
||||
return;
|
||||
}
|
||||
if (DIALOG_CHANNELS.includes(channel.type)) {
|
||||
channelsDialogRef.value?.open(channel.type);
|
||||
return;
|
||||
}
|
||||
connectViaOAuth(channel.inbox?.provider || channel.type);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<OnboardingLayout
|
||||
:greeting="t('ONBOARDING_INBOX_SETUP.GREETING')"
|
||||
:subtitle="t('ONBOARDING_INBOX_SETUP.SUBTITLE')"
|
||||
:continue-label="t('ONBOARDING_INBOX_SETUP.CONTINUE')"
|
||||
:skip-label="t('ONBOARDING_INBOX_SETUP.SKIP')"
|
||||
:is-loading="isSubmitting"
|
||||
@continue="handleContinue"
|
||||
@skip="handleSkip"
|
||||
>
|
||||
<template #greeting-icon>
|
||||
<Icon icon="i-lucide-wrench" class="size-4 text-n-slate-7" />
|
||||
</template>
|
||||
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.TITLE')"
|
||||
icon="i-lucide-sparkles"
|
||||
>
|
||||
<div class="divide-y divide-n-weak">
|
||||
<WebWidgetCreationStatus />
|
||||
<HelpCenterCreationStatus
|
||||
v-if="isEnterprise && helpCenterGenerationId"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingSection>
|
||||
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_INBOX_SETUP.CHANNELS.TITLE')"
|
||||
icon="i-lucide-inbox"
|
||||
>
|
||||
<div
|
||||
v-if="hasDetectedChannels"
|
||||
class="flex items-center gap-2 p-3 border-b border-dashed border-n-strong"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-lightbulb"
|
||||
class="size-4 text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 min-w-0 text-body-main text-n-slate-11">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.HEADER') }}
|
||||
</span>
|
||||
</div>
|
||||
<ChannelRow
|
||||
v-for="channel in displayedChannels"
|
||||
:key="channel.type"
|
||||
:channel="channel"
|
||||
:connected-inbox="connectedInbox(channel)"
|
||||
@connect="connectChannel"
|
||||
/>
|
||||
<InboxChannelsFooter
|
||||
:remaining-channels="remainingChannels"
|
||||
@view-all="openChannelsDialog"
|
||||
/>
|
||||
</OnboardingSection>
|
||||
</OnboardingLayout>
|
||||
<InboxChannelsDialog
|
||||
ref="channelsDialogRef"
|
||||
:inboxes="inboxes"
|
||||
@connected="refetchInboxes"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +1,22 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, computed, nextTick, onMounted } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import OnboardingLayout from './OnboardingLayout.vue';
|
||||
import OnboardingSection from './OnboardingSection.vue';
|
||||
import OnboardingFormRow from './OnboardingFormRow.vue';
|
||||
import OnboardingFormSelect from './OnboardingFormSelect.vue';
|
||||
import OnboardingLayout from './shared/OnboardingLayout.vue';
|
||||
import OnboardingSection from './shared/OnboardingSection.vue';
|
||||
import OnboardingFormRow from './account-details/OnboardingFormRow.vue';
|
||||
import OnboardingFormSelect from './account-details/OnboardingFormSelect.vue';
|
||||
import { useAccountEnrichment } from './account-details/useAccountEnrichment';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import {
|
||||
@@ -23,12 +24,16 @@ import {
|
||||
INDUSTRY_OPTIONS,
|
||||
REFERRAL_SOURCE_OPTIONS,
|
||||
USER_ROLE_OPTIONS,
|
||||
} from './constants';
|
||||
} from './shared/constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId, currentAccount, finishOnboarding } = useAccount();
|
||||
|
||||
// Where each onboarding cursor routes. The backend owns which steps run where;
|
||||
// the frontend just follows the cursor it advanced us to (no deployment checks).
|
||||
const STEP_ROUTES = { inbox_setup: 'onboarding_inbox_setup' };
|
||||
const { enabledLanguages } = useConfig();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
@@ -46,7 +51,10 @@ const showErrorOnFields = ref(false);
|
||||
|
||||
const validationRules = {
|
||||
userRole: {},
|
||||
website: {},
|
||||
// Website is required: the onboarding web-widget inbox can't be created
|
||||
// without a URL (Channel::WebWidget validates presence), so a blank value
|
||||
// would leave the "Live Chat widget" status polling forever.
|
||||
website: { required },
|
||||
locale: {},
|
||||
timezone: {},
|
||||
companySize: {},
|
||||
@@ -67,12 +75,15 @@ const v$ = useVuelidate(validationRules, {
|
||||
const userName = computed(() => currentUser.value?.name || '');
|
||||
const userEmail = computed(() => currentUser.value?.email || '');
|
||||
const accountName = computed(() => currentAccount.value?.name || '');
|
||||
const enrichmentTimedOut = ref(false);
|
||||
const isEnriching = computed(
|
||||
() =>
|
||||
!enrichmentTimedOut.value &&
|
||||
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
||||
);
|
||||
const { isEnriching, getChangedFields } = useAccountEnrichment({
|
||||
locale,
|
||||
website,
|
||||
timezone,
|
||||
companySize,
|
||||
industry,
|
||||
referralSource,
|
||||
});
|
||||
|
||||
const companyLogo = computed(() => {
|
||||
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
|
||||
if (!logos?.length) return '';
|
||||
@@ -98,94 +109,10 @@ const timezoneOptions = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Best-effort match browser language to enabled Chatwoot locales.
|
||||
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
|
||||
// falls back to account locale or 'en'.
|
||||
const detectBestLocale = () => {
|
||||
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
|
||||
const browserLang = navigator.language?.replace('-', '_');
|
||||
const accountLocale = currentAccount.value?.locale || 'en';
|
||||
if (!browserLang) return accountLocale;
|
||||
|
||||
if (codes.includes(browserLang)) return browserLang;
|
||||
const base = browserLang.split('_')[0];
|
||||
if (codes.includes(base)) return base;
|
||||
|
||||
return accountLocale;
|
||||
};
|
||||
|
||||
// Snapshot of auto-populated values to detect user edits at submit time
|
||||
const initialValues = ref({});
|
||||
|
||||
const snapshotInitialValues = () => {
|
||||
initialValues.value = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
};
|
||||
|
||||
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
||||
// populates untouched fields without clobbering user edits.
|
||||
const populateFormFields = () => {
|
||||
const account = currentAccount.value;
|
||||
const attrs = account?.custom_attributes || {};
|
||||
const brandInfo = attrs.brand_info;
|
||||
|
||||
if (!locale.value) locale.value = detectBestLocale();
|
||||
if (!website.value) {
|
||||
website.value = attrs.website || brandInfo?.domain || '';
|
||||
}
|
||||
if (!timezone.value) {
|
||||
timezone.value =
|
||||
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
}
|
||||
if (!companySize.value) companySize.value = attrs.company_size || '';
|
||||
if (!industry.value) {
|
||||
industry.value =
|
||||
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
|
||||
}
|
||||
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
|
||||
|
||||
snapshotInitialValues();
|
||||
};
|
||||
|
||||
let enrichmentTimer = null;
|
||||
|
||||
const startEnrichmentTimer = () => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
enrichmentTimer = setTimeout(() => {
|
||||
enrichmentTimedOut.value = true;
|
||||
populateFormFields();
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
populateFormFields();
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
|
||||
if (isEnriching.value) startEnrichmentTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
});
|
||||
|
||||
watch(isEnriching, newVal => {
|
||||
if (newVal) {
|
||||
startEnrichmentTimer();
|
||||
} else {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
populateFormFields();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-populate when account data arrives after mount, or when brand_info
|
||||
// appears after enrichment. populateFormFields is idempotent so this is safe.
|
||||
watch(
|
||||
() => currentAccount.value?.custom_attributes,
|
||||
() => populateFormFields()
|
||||
);
|
||||
|
||||
const enableWebsiteEditing = () => {
|
||||
isEditingWebsite.value = true;
|
||||
nextTick(() => websiteInput.value?.focus());
|
||||
@@ -204,8 +131,10 @@ const normalizeWebsiteUrl = raw => {
|
||||
const handleSubmit = async () => {
|
||||
// Block submit while enrichment is still running so users can't bypass
|
||||
// the form with empty values — the controller would otherwise clear
|
||||
// onboarding_step and persist incomplete data.
|
||||
if (isEnriching.value) return;
|
||||
// onboarding_step and persist incomplete data. Also guard against
|
||||
// re-entry while a submit is in flight (double-click/Enter), which would
|
||||
// fire parallel requests that can duplicate the auto-created inbox/portal.
|
||||
if (isEnriching.value || isSubmitting.value) return;
|
||||
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
@@ -214,22 +143,15 @@ const handleSubmit = async () => {
|
||||
setTimeout(() => {
|
||||
showErrorOnFields.value = false;
|
||||
}, 600);
|
||||
// The website field is read-only until edited; open it so a required-but-
|
||||
// empty value is immediately fixable rather than just shaking a locked field.
|
||||
if (v$.value.website.$error) enableWebsiteEditing();
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect which enrichable fields the user actually edited *before*
|
||||
// normalizing — otherwise an untouched auto-filled domain
|
||||
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
|
||||
// and gets falsely reported as changed, skewing onboarding telemetry.
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
const fieldsChanged = Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key);
|
||||
// Capture which enrichable fields the user edited *before* normalizing the
|
||||
// website, so an untouched auto-filled domain isn't falsely flagged.
|
||||
const fieldsChanged = getChangedFields();
|
||||
|
||||
// Persist with a scheme so downstream consumers (Firecrawl, portal
|
||||
// homepage_link) get a fully-qualified URL regardless of what the user typed.
|
||||
@@ -246,6 +168,7 @@ const handleSubmit = async () => {
|
||||
timezone: timezone.value,
|
||||
referral_source: referralSource.value,
|
||||
user_role: userRole.value,
|
||||
onboarding_step: 'account_details',
|
||||
});
|
||||
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
||||
@@ -260,8 +183,19 @@ const handleSubmit = async () => {
|
||||
});
|
||||
|
||||
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
|
||||
store.commit('RESET_ONBOARDING', accountId.value);
|
||||
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
|
||||
// Follow the cursor the backend advanced us to. A next step routes there; no
|
||||
// next step means onboarding is complete, so refresh the user (so the router
|
||||
// guard sees the cleared cursor) and head to the dashboard.
|
||||
const nextStep = currentAccount.value?.custom_attributes?.onboarding_step;
|
||||
if (STEP_ROUTES[nextStep]) {
|
||||
router.push({
|
||||
name: STEP_ROUTES[nextStep],
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
} else {
|
||||
await store.dispatch('setUser');
|
||||
router.push({ name: 'home', params: { accountId: accountId.value } });
|
||||
}
|
||||
} catch {
|
||||
useAlert(t('ONBOARDING_NEXT.ERROR'));
|
||||
} finally {
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
|
||||
const ENRICHMENT_TIMEOUT = 30000;
|
||||
|
||||
// Manages the post-signup enrichment lifecycle for the account-details form.
|
||||
// After signup the account is enriched asynchronously (onboarding_step ===
|
||||
// 'enrichment'); this fills empty form fields from the enriched
|
||||
// custom_attributes/brand_info as it arrives — idempotently, so it never
|
||||
// clobbers a value the user has already typed — waits the step out (with a
|
||||
// timeout fallback), and tracks which enrichable fields the user edited.
|
||||
//
|
||||
// `fields` is the set of form refs to populate, owned by the component so it can
|
||||
// still wire them to validation and the template.
|
||||
export function useAccountEnrichment(fields) {
|
||||
const { currentAccount } = useAccount();
|
||||
const { enabledLanguages } = useConfig();
|
||||
|
||||
const enrichmentTimedOut = ref(false);
|
||||
const isEnriching = computed(
|
||||
() =>
|
||||
!enrichmentTimedOut.value &&
|
||||
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
||||
);
|
||||
|
||||
// Best-effort match browser language to enabled Chatwoot locales: exact match
|
||||
// first (e.g. 'pt_BR'), then base language (e.g. 'pt'), else the account
|
||||
// locale or 'en'.
|
||||
const detectBestLocale = () => {
|
||||
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
|
||||
const browserLang = navigator.language?.replace('-', '_');
|
||||
const accountLocale = currentAccount.value?.locale || 'en';
|
||||
if (!browserLang) return accountLocale;
|
||||
|
||||
if (codes.includes(browserLang)) return browserLang;
|
||||
const base = browserLang.split('_')[0];
|
||||
if (codes.includes(base)) return base;
|
||||
|
||||
return accountLocale;
|
||||
};
|
||||
|
||||
// Snapshot of the auto-populated values, used to detect user edits at submit.
|
||||
const initialValues = ref({});
|
||||
const snapshotInitialValues = () => {
|
||||
initialValues.value = {
|
||||
website: fields.website.value,
|
||||
company_size: fields.companySize.value,
|
||||
industry: fields.industry.value,
|
||||
};
|
||||
};
|
||||
|
||||
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
||||
// populates untouched fields without clobbering user edits.
|
||||
const populateFormFields = () => {
|
||||
const {
|
||||
website,
|
||||
timezone,
|
||||
company_size: companySize,
|
||||
industry,
|
||||
referral_source: referralSource,
|
||||
brand_info: brandInfo,
|
||||
} = currentAccount.value?.custom_attributes || {};
|
||||
|
||||
const fillIfEmpty = (field, value) => {
|
||||
if (!field.value) field.value = value || '';
|
||||
};
|
||||
|
||||
fillIfEmpty(fields.locale, detectBestLocale());
|
||||
fillIfEmpty(fields.website, website || brandInfo?.domain);
|
||||
fillIfEmpty(
|
||||
fields.timezone,
|
||||
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
fillIfEmpty(fields.companySize, companySize);
|
||||
fillIfEmpty(
|
||||
fields.industry,
|
||||
industry || brandInfo?.industries?.[0]?.industry
|
||||
);
|
||||
fillIfEmpty(fields.referralSource, referralSource);
|
||||
|
||||
snapshotInitialValues();
|
||||
};
|
||||
|
||||
let enrichmentTimer = null;
|
||||
const startEnrichmentTimer = () => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
enrichmentTimer = setTimeout(() => {
|
||||
enrichmentTimedOut.value = true;
|
||||
populateFormFields();
|
||||
}, ENRICHMENT_TIMEOUT);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
populateFormFields();
|
||||
if (isEnriching.value) startEnrichmentTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
});
|
||||
|
||||
watch(isEnriching, enriching => {
|
||||
if (enriching) {
|
||||
startEnrichmentTimer();
|
||||
} else {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
populateFormFields();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-populate when account data arrives after mount, or when brand_info
|
||||
// appears after enrichment. populateFormFields is idempotent so this is safe.
|
||||
watch(
|
||||
() => currentAccount.value?.custom_attributes,
|
||||
() => populateFormFields()
|
||||
);
|
||||
|
||||
// Enrichable fields the user actually edited since they were auto-filled.
|
||||
// Compare against the snapshot *before* the caller normalizes any values —
|
||||
// otherwise an untouched auto-filled domain (acme.com -> https://acme.com)
|
||||
// compares unequal and gets falsely reported as changed.
|
||||
const getChangedFields = () => {
|
||||
const init = initialValues.value;
|
||||
const current = {
|
||||
website: fields.website.value,
|
||||
company_size: fields.companySize.value,
|
||||
industry: fields.industry.value,
|
||||
};
|
||||
return Object.entries(current)
|
||||
.filter(([key, value]) => value !== init[key])
|
||||
.map(([key]) => key);
|
||||
};
|
||||
|
||||
return { isEnriching, getChangedFields };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
channel: { type: Object, required: true },
|
||||
connectedInbox: { type: Object, default: null },
|
||||
});
|
||||
|
||||
defineEmits(['connect']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const connected = computed(() => Boolean(props.connectedInbox));
|
||||
// Prefer the real connected account's name over the detected handle — the user
|
||||
// may have connected a different account than the one we detected.
|
||||
const connectedName = computed(
|
||||
() =>
|
||||
props.connectedInbox?.name ||
|
||||
props.channel.handle ||
|
||||
t(props.channel.labelKey)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 p-3 border-b border-n-weak">
|
||||
<div class="size-4 rounded overflow-hidden flex-shrink-0">
|
||||
<ChannelIcon
|
||||
:inbox="channel.inbox"
|
||||
use-brand-icon
|
||||
class="size-4"
|
||||
:class="{ grayscale: !connected }"
|
||||
/>
|
||||
</div>
|
||||
<span class="flex-1 min-w-0 truncate text-body-main text-n-slate-12">
|
||||
{{ t(channel.labelKey) }}
|
||||
</span>
|
||||
<div
|
||||
v-if="connected"
|
||||
class="flex items-center gap-2 flex-shrink-0 text-body-main text-n-slate-11"
|
||||
>
|
||||
<span class="whitespace-nowrap">{{ connectedName }}</span>
|
||||
<span class="w-px h-3 bg-n-weak" />
|
||||
<span class="whitespace-nowrap">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECTED') }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="flex items-center flex-shrink-0 h-7 px-2 rounded-lg outline outline-1 outline-n-container bg-n-button-color text-button text-n-blue-11"
|
||||
@click="$emit('connect', channel)"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECT') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
// Presentational shell for the "Created for you" rows. The owning component
|
||||
// drives `ready` from its own lifecycle (widget polling, help-center events);
|
||||
// row separation is handled by the parent's `divide-y`.
|
||||
defineProps({
|
||||
ready: { type: Boolean, default: false },
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
status: { type: String, default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Icon
|
||||
v-if="ready"
|
||||
icon="i-lucide-check"
|
||||
class="size-4 text-n-teal-11 flex-shrink-0"
|
||||
/>
|
||||
<Spinner v-else :size="16" class="text-n-slate-9 flex-shrink-0" />
|
||||
<span class="text-sm font-medium text-n-slate-12 flex-shrink-0">
|
||||
{{ title }}
|
||||
</span>
|
||||
<span class="w-px h-4 bg-n-weak flex-shrink-0" />
|
||||
<span class="text-sm text-n-slate-11 truncate">{{ description }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 flex-shrink-0">{{ status }}</span>
|
||||
</div>
|
||||
</template>
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useTimeoutPoll } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import OnboardingAPI from 'dashboard/api/onboarding';
|
||||
import CreationStatusRow from './CreationStatusRow.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const POLL_INTERVAL = 5000;
|
||||
|
||||
const generation = ref({
|
||||
generation_id: null,
|
||||
state: null,
|
||||
articles_count: 0,
|
||||
categories_count: 0,
|
||||
});
|
||||
|
||||
// Before the first article arrives, advance through phases so the spinner
|
||||
// label doesn't sit on a single line through the whole curation step.
|
||||
const generatingPhases = computed(() => [
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_GENERATING'),
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ANALYZING_WEBSITE'),
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SETTING_UP_CATEGORIES'),
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CURATING_ARTICLES'),
|
||||
]);
|
||||
const PHASE_DELAY_BASE = 4000;
|
||||
const PHASE_DELAY_JITTER = 2000;
|
||||
|
||||
const phaseIndex = ref(0);
|
||||
let phaseTimer = null;
|
||||
|
||||
// Advance one phase after a jittered delay; stop once we reach the last line
|
||||
// (it then holds until the first article arrives).
|
||||
const scheduleNextPhase = () => {
|
||||
if (phaseIndex.value >= generatingPhases.value.length - 1) return;
|
||||
const delay = PHASE_DELAY_BASE + Math.random() * PHASE_DELAY_JITTER;
|
||||
phaseTimer = setTimeout(() => {
|
||||
phaseIndex.value += 1;
|
||||
scheduleNextPhase();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const status = computed(
|
||||
() =>
|
||||
generation.value.state?.status ||
|
||||
(generation.value.generation_id ? 'generating' : 'not_started')
|
||||
);
|
||||
const isCompleted = computed(() => status.value === 'completed');
|
||||
const isSkipped = computed(() => status.value === 'skipped');
|
||||
const isNotStarted = computed(() => status.value === 'not_started');
|
||||
const isTerminal = computed(
|
||||
() => isCompleted.value || isSkipped.value || isNotStarted.value
|
||||
);
|
||||
const articlesCount = computed(() => generation.value.articles_count || 0);
|
||||
const categoriesCount = computed(() => generation.value.categories_count || 0);
|
||||
|
||||
// Poll the generation status until it reaches a terminal state. useTimeoutPoll
|
||||
// waits for each request to settle before scheduling the next (so requests never
|
||||
// overlap), fires immediately on mount, and stops on unmount.
|
||||
const { pause: stopPolling } = useTimeoutPoll(
|
||||
async () => {
|
||||
try {
|
||||
const { data } = await OnboardingAPI.getHelpCenterGeneration();
|
||||
generation.value = data;
|
||||
if (isTerminal.value) stopPolling();
|
||||
} catch {
|
||||
// Keep polling; transient network failures should not strand onboarding.
|
||||
}
|
||||
},
|
||||
POLL_INTERVAL,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(scheduleNextPhase);
|
||||
onBeforeUnmount(() => clearTimeout(phaseTimer));
|
||||
|
||||
const articlesText = computed(() =>
|
||||
t(
|
||||
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ARTICLES',
|
||||
{ count: articlesCount.value },
|
||||
articlesCount.value
|
||||
)
|
||||
);
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isCompleted.value) {
|
||||
const categories = t(
|
||||
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CATEGORIES',
|
||||
{ count: categoriesCount.value },
|
||||
categoriesCount.value
|
||||
);
|
||||
return t(
|
||||
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SUMMARY',
|
||||
{ count: articlesCount.value, categories },
|
||||
articlesCount.value
|
||||
);
|
||||
}
|
||||
if (articlesCount.value > 0) return articlesText.value;
|
||||
return generatingPhases.value[phaseIndex.value];
|
||||
});
|
||||
|
||||
const isVisible = computed(() => !isSkipped.value && !isNotStarted.value);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<CreationStatusRow
|
||||
v-if="isVisible"
|
||||
:ready="isCompleted"
|
||||
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER')"
|
||||
:description="
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_DESCRIPTION')
|
||||
"
|
||||
:status="statusText"
|
||||
/>
|
||||
</template>
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
channel: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['back', 'created']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
// Per-channel field list + payload shape, mirroring the standalone settings
|
||||
// forms (channels/Line.vue, channels/Telegram.vue). Labels/placeholders reuse
|
||||
// the existing INBOX_MGMT translations so there's nothing new to localize.
|
||||
const FORMS = {
|
||||
line: {
|
||||
fields: [
|
||||
{
|
||||
key: 'lineChannelId',
|
||||
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.LABEL'),
|
||||
placeholder: t(
|
||||
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.PLACEHOLDER'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lineChannelSecret',
|
||||
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.LABEL'),
|
||||
placeholder: t(
|
||||
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.PLACEHOLDER'
|
||||
),
|
||||
type: 'password',
|
||||
},
|
||||
{
|
||||
key: 'lineChannelToken',
|
||||
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.LABEL'),
|
||||
placeholder: t(
|
||||
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.PLACEHOLDER'
|
||||
),
|
||||
type: 'password',
|
||||
},
|
||||
],
|
||||
errorMessage: t('INBOX_MGMT.ADD.LINE_CHANNEL.API.ERROR_MESSAGE'),
|
||||
// Inbox name is mandatory for Line; prefill it from the channel label
|
||||
// rather than asking, to keep the form to just the credentials.
|
||||
buildPayload: values => ({
|
||||
name: t(props.channel.labelKey),
|
||||
channel: {
|
||||
type: 'line',
|
||||
line_channel_id: values.lineChannelId,
|
||||
line_channel_secret: values.lineChannelSecret,
|
||||
line_channel_token: values.lineChannelToken,
|
||||
},
|
||||
}),
|
||||
},
|
||||
telegram: {
|
||||
fields: [
|
||||
{
|
||||
key: 'botToken',
|
||||
label: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.LABEL'),
|
||||
placeholder: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.PLACEHOLDER'),
|
||||
type: 'password',
|
||||
},
|
||||
],
|
||||
errorMessage: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.API.ERROR_MESSAGE'),
|
||||
buildPayload: values => ({
|
||||
channel: { type: 'telegram', bot_token: values.botToken },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const config = FORMS[props.channel.type];
|
||||
const values = reactive(
|
||||
Object.fromEntries(config.fields.map(field => [field.key, '']))
|
||||
);
|
||||
const isCreating = ref(false);
|
||||
|
||||
const isValid = computed(() =>
|
||||
config.fields.every(field => values[field.key].trim())
|
||||
);
|
||||
|
||||
const submit = async () => {
|
||||
if (!isValid.value || isCreating.value) return;
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxes/createChannel', config.buildPayload(values));
|
||||
emit('created');
|
||||
} catch (error) {
|
||||
useAlert(error?.message || config.errorMessage);
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- This sits inside the dialog's <form>; intercept Enter so it submits the
|
||||
channel here instead of bubbling up and completing onboarding. -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-4" @keydown.enter.prevent="submit">
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
|
||||
<template v-for="field in config.fields" :key="field.key">
|
||||
<label :for="field.key" class="text-sm font-medium text-n-slate-12">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<Input
|
||||
:id="field.key"
|
||||
v-model="values[field.key]"
|
||||
:type="field.type || 'text'"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NextButton
|
||||
type="button"
|
||||
slate
|
||||
faded
|
||||
class="flex-1 justify-center"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
blue
|
||||
class="flex-1 justify-center"
|
||||
:is-loading="isCreating"
|
||||
:disabled="!isValid"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useChannelConnect } from './useChannelConnect';
|
||||
import { useChannelConfig } from './useChannelConfig';
|
||||
import { CHANNEL_LIST } from './constants';
|
||||
import { isChannelConnected } from './channelMatchers';
|
||||
import InboxChannelForm from './InboxChannelForm.vue';
|
||||
import InboxFacebookForm from './InboxFacebookForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['connected']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
|
||||
const { isConfigured } = useChannelConfig();
|
||||
|
||||
// Maps the dialog's display types to the OAuth client key the flow expects.
|
||||
// Types without an entry (manual-setup channels) are no-ops for now.
|
||||
const OAUTH_PROVIDERS = {
|
||||
[CHANNEL_TYPES.GMAIL]: 'google',
|
||||
[CHANNEL_TYPES.OUTLOOK]: 'microsoft',
|
||||
[CHANNEL_TYPES.INSTAGRAM]: 'instagram',
|
||||
[CHANNEL_TYPES.TIKTOK]: 'tiktok',
|
||||
};
|
||||
|
||||
// A card's availability — what the user can do with it right now:
|
||||
// available — usable now (configured, not deferred)
|
||||
// setupLater — deferred to in-app setup (SMS/API/Voice/Email cards)
|
||||
// Channels whose installation OAuth credentials are missing are hidden entirely
|
||||
// (see channelCards), so they never reach this state.
|
||||
// `connected` (a real inbox already backs it) is orthogonal and tracked
|
||||
// separately, since a connected channel can still be in any of these states.
|
||||
const channelAvailability = channel =>
|
||||
channel.setupLater ? 'setupLater' : 'available';
|
||||
|
||||
const CARD_CLASS = {
|
||||
available: 'bg-n-solid-1 hover:outline-n-slate-6 cursor-pointer',
|
||||
setupLater: 'bg-n-slate-2 cursor-not-allowed',
|
||||
};
|
||||
|
||||
// Decorate the catalog with per-render state so the template reads plain fields
|
||||
// rather than calling predicates for each card. Channels needing an absent
|
||||
// installation credential are dropped so they don't show at all; deferred
|
||||
// (setupLater) channels stay since they aren't a configuration problem.
|
||||
const channelCards = computed(() =>
|
||||
CHANNEL_LIST.filter(
|
||||
channel => channel.setupLater || isConfigured(channel.type)
|
||||
).map(channel => {
|
||||
const connected = isChannelConnected(props.inboxes, channel.inbox);
|
||||
// Website inboxes are only auto-created during onboarding — there is no
|
||||
// manual creation path, so an unconnected Website card defers rather than
|
||||
// offering a click that can't do anything.
|
||||
const availability =
|
||||
channel.type === CHANNEL_TYPES.WEBSITE && !connected
|
||||
? 'setupLater'
|
||||
: channelAvailability(channel);
|
||||
return { ...channel, availability, connected };
|
||||
})
|
||||
);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
// Credential-form channels (Line, Telegram) and Facebook swap the grid for an
|
||||
// inline view; OAuth channels redirect; the rest are no-ops for now.
|
||||
const selectedChannel = ref(null);
|
||||
|
||||
// An inbox was created by an in-dialog form (Line/Telegram credentials or the
|
||||
// Facebook page picker); close the dialog (its @close resets the form view) and
|
||||
// let the parent refetch so the connected state and real channel icons update.
|
||||
const onCreated = () => {
|
||||
dialogRef.value?.close();
|
||||
emit('connected');
|
||||
};
|
||||
|
||||
const onCardClick = channel => {
|
||||
if (channel.availability !== 'available') return;
|
||||
if (channel.form) {
|
||||
selectedChannel.value = channel;
|
||||
return;
|
||||
}
|
||||
// WhatsApp uses Meta's embedded-signup popup, not the redirect OAuth flow.
|
||||
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
|
||||
connectWhatsapp();
|
||||
return;
|
||||
}
|
||||
// Facebook swaps to an in-dialog page picker (FB.login → choose a Page).
|
||||
if (channel.type === CHANNEL_TYPES.FACEBOOK) {
|
||||
selectedChannel.value = channel;
|
||||
return;
|
||||
}
|
||||
connectViaOAuth(OAUTH_PROVIDERS[channel.type]);
|
||||
};
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
selectedChannel.value
|
||||
? t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_TITLE', {
|
||||
name: t(selectedChannel.value.labelKey),
|
||||
})
|
||||
: t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.TITLE')
|
||||
);
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
if (!selectedChannel.value) {
|
||||
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SUBTITLE');
|
||||
}
|
||||
if (selectedChannel.value.type === CHANNEL_TYPES.FACEBOOK) {
|
||||
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SUBTITLE');
|
||||
}
|
||||
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_SUBTITLE');
|
||||
});
|
||||
|
||||
const open = preselectType => {
|
||||
const entry = preselectType
|
||||
? channelCards.value.find(channel => channel.type === preselectType)
|
||||
: null;
|
||||
// Only jump straight into a channel's view when it's actually usable;
|
||||
// otherwise show the grid (with its muted card) rather than launching SDK
|
||||
// auth with a missing credential.
|
||||
selectedChannel.value = entry?.availability === 'available' ? entry : null;
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
const close = () => dialogRef.value?.close();
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="dialogTitle"
|
||||
:description="dialogDescription"
|
||||
width="lg"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
@close="selectedChannel = null"
|
||||
>
|
||||
<InboxFacebookForm
|
||||
v-if="selectedChannel?.type === CHANNEL_TYPES.FACEBOOK"
|
||||
@back="selectedChannel = null"
|
||||
@created="onCreated"
|
||||
/>
|
||||
<InboxChannelForm
|
||||
v-else-if="selectedChannel"
|
||||
:channel="selectedChannel"
|
||||
@back="selectedChannel = null"
|
||||
@created="onCreated"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="channel in channelCards"
|
||||
:key="channel.type"
|
||||
type="button"
|
||||
:disabled="channel.availability !== 'available'"
|
||||
class="flex items-center gap-3 p-3 rounded-xl outline outline-1 outline-n-weak shadow-[0px_1px_2px_0px_rgba(27,28,29,0.036)] transition-colors text-start"
|
||||
:class="CARD_CLASS[channel.availability]"
|
||||
@click="onCardClick(channel)"
|
||||
>
|
||||
<div
|
||||
class="size-9 rounded-[10px] outline outline-1 outline-n-weak flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<ChannelIcon
|
||||
v-if="channel.inbox"
|
||||
:inbox="channel.inbox"
|
||||
use-brand-icon
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:icon="channel.fallbackIcon"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-sm font-medium text-n-slate-12">
|
||||
{{ t(channel.labelKey) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="channel.availability === 'setupLater'"
|
||||
class="block text-xs text-n-slate-11"
|
||||
>
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SETUP_LATER') }}
|
||||
</span>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="channel.connected"
|
||||
icon="i-lucide-circle-check"
|
||||
class="size-5 text-n-teal-11"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="channel.availability === 'available'"
|
||||
icon="i-lucide-chevron-right"
|
||||
class="size-5 text-n-slate-9"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.NOTE') }}
|
||||
</p>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
import { FALLBACK_PREVIEW_CHANNELS } from './constants';
|
||||
import { useChannelConfig } from './useChannelConfig';
|
||||
|
||||
const props = defineProps({
|
||||
remainingChannels: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
defineEmits(['viewAll']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isConfigured } = useChannelConfig();
|
||||
|
||||
// Icons shown next to "View all". Defaults to the unconnected socials, but when
|
||||
// everything detected is already connected we still want a hint of what's
|
||||
// behind the dialog — fall back to a representative trio. Either way, drop
|
||||
// channels whose installation credentials are missing so we never preview an
|
||||
// icon the dialog itself hides.
|
||||
const previewChannels = computed(() =>
|
||||
(props.remainingChannels.length
|
||||
? props.remainingChannels
|
||||
: FALLBACK_PREVIEW_CHANNELS
|
||||
).filter(channel => isConfigured(channel.type))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 px-3 pt-3 pb-4">
|
||||
<Icon icon="i-lucide-info" class="size-4 text-n-slate-9 flex-shrink-0" />
|
||||
<I18nT
|
||||
keypath="ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_NOTE"
|
||||
tag="span"
|
||||
class="flex-1 min-w-0 text-body-main text-n-slate-11"
|
||||
>
|
||||
<template #email>
|
||||
<span class="text-n-slate-12">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_EMAIL') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #voice>
|
||||
<span class="text-n-slate-12">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_VOICE') }}
|
||||
</span>
|
||||
</template>
|
||||
</I18nT>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<ChannelIcon
|
||||
v-for="channel in previewChannels"
|
||||
:key="channel.type"
|
||||
:inbox="channel.inbox"
|
||||
use-brand-icon
|
||||
class="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ps-1">
|
||||
<span class="w-px h-3 bg-n-weak" />
|
||||
<button
|
||||
type="button"
|
||||
class="text-button text-n-slate-12 hover:underline"
|
||||
@click="$emit('viewAll')"
|
||||
>
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.VIEW_ALL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const emit = defineEmits(['back', 'created']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { isAuthenticating, preloadSdk, loginAndFetchPages } =
|
||||
useFacebookPageConnect();
|
||||
|
||||
// null until the login + page fetch completes.
|
||||
const pages = ref(null);
|
||||
const userAccessToken = ref('');
|
||||
const selectedPageId = ref('');
|
||||
const errored = ref(false);
|
||||
const isCreating = ref(false);
|
||||
|
||||
const hasFetched = computed(() => pages.value !== null);
|
||||
// Pages already connected as inboxes come back with `exists: true`.
|
||||
const pageOptions = computed(() =>
|
||||
(pages.value || [])
|
||||
.filter(page => !page.exists)
|
||||
.map(page => ({ value: page.id, label: page.name }))
|
||||
);
|
||||
|
||||
// Warm the SDK on open so the login click opens its popup within the gesture's
|
||||
// activation window (see useFacebookPageConnect).
|
||||
onMounted(preloadSdk);
|
||||
|
||||
const connect = async () => {
|
||||
errored.value = false;
|
||||
try {
|
||||
const result = await loginAndFetchPages();
|
||||
if (!result) return; // cancelled — stay on the connect prompt
|
||||
userAccessToken.value = result.userAccessToken;
|
||||
pages.value = result.pages;
|
||||
} catch {
|
||||
errored.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const createInbox = async () => {
|
||||
const page = (pages.value || []).find(p => p.id === selectedPageId.value);
|
||||
if (!page || isCreating.value) return;
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxes/createFBChannel', {
|
||||
user_access_token: userAccessToken.value,
|
||||
page_access_token: page.access_token,
|
||||
page_id: page.id,
|
||||
inbox_name: page.name,
|
||||
});
|
||||
useAlert(t('ONBOARDING_INBOX_SETUP.FACEBOOK_CONNECTED'));
|
||||
emit('created');
|
||||
} catch (error) {
|
||||
useAlert(parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR'));
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-if="isAuthenticating"
|
||||
class="flex items-center justify-center gap-3 py-8"
|
||||
>
|
||||
<Spinner :size="16" class="text-n-blue-10" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LOADING') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="hasFetched && pageOptions.length">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
v-model="selectedPageId"
|
||||
:options="pageOptions"
|
||||
:placeholder="
|
||||
t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NextButton
|
||||
type="button"
|
||||
slate
|
||||
faded
|
||||
class="flex-1 justify-center"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
blue
|
||||
class="flex-1 justify-center"
|
||||
:is-loading="isCreating"
|
||||
:disabled="!selectedPageId"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
|
||||
@click="createInbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="hasFetched">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES') }}
|
||||
</p>
|
||||
<div class="flex">
|
||||
<NextButton
|
||||
type="button"
|
||||
slate
|
||||
faded
|
||||
class="flex-1 justify-center"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="errored" class="text-sm text-n-ruby-11">
|
||||
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<NextButton
|
||||
type="button"
|
||||
slate
|
||||
faded
|
||||
class="flex-1 justify-center"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
blue
|
||||
class="flex-1 justify-center"
|
||||
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH')"
|
||||
@click="connect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useTimeoutPoll } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import CreationStatusRow from './CreationStatusRow.vue';
|
||||
|
||||
const POLL_INTERVAL = 5000;
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
// The web widget inbox is created asynchronously during account setup. We poll
|
||||
// the inboxes endpoint until it shows up, then stop — much simpler than the
|
||||
// event-driven help center flow.
|
||||
const websiteInboxes = useMapGetter('inboxes/getWebsiteInboxes');
|
||||
const isReady = computed(() => websiteInboxes.value.length > 0);
|
||||
|
||||
// useTimeoutPoll waits for each fetch to settle before scheduling the next (so
|
||||
// requests never overlap), fires immediately on mount, and stops on unmount.
|
||||
const { pause } = useTimeoutPoll(
|
||||
async () => {
|
||||
await store.dispatch('inboxes/get');
|
||||
if (isReady.value) pause();
|
||||
},
|
||||
POLL_INTERVAL,
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreationStatusRow
|
||||
:ready="isReady"
|
||||
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT')"
|
||||
:description="
|
||||
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_DESCRIPTION')
|
||||
"
|
||||
:status="
|
||||
isReady
|
||||
? t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_READY')
|
||||
: t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_STATUS')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -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));
|
||||
@@ -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 },
|
||||
])
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
+130
@@ -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,
|
||||
};
|
||||
}
|
||||
+27
-12
@@ -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']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,7 +21,7 @@ defineEmits(['continue']);
|
||||
<div
|
||||
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
|
||||
/>
|
||||
<div class="relative w-full max-w-[580px]">
|
||||
<div class="relative w-full max-w-[40rem]">
|
||||
<div class="relative ps-12">
|
||||
<!-- Timeline dotted line -->
|
||||
<svg
|
||||
@@ -104,16 +105,30 @@ defineEmits(['continue']);
|
||||
>
|
||||
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
|
||||
</svg>
|
||||
<NextButton
|
||||
type="submit"
|
||||
blue
|
||||
:is-loading="isLoading"
|
||||
:disabled="disabled"
|
||||
class="w-full justify-center"
|
||||
@click="$emit('continue')"
|
||||
>
|
||||
{{ continueLabel }}
|
||||
</NextButton>
|
||||
<div class="flex items-center gap-3">
|
||||
<NextButton
|
||||
type="submit"
|
||||
blue
|
||||
:is-loading="isLoading"
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
skipLabel ? 'flex-1 justify-center' : 'w-full justify-center'
|
||||
"
|
||||
@click="$emit('continue')"
|
||||
>
|
||||
{{ continueLabel }}
|
||||
</NextButton>
|
||||
<NextButton
|
||||
v-if="skipLabel"
|
||||
type="button"
|
||||
slate
|
||||
outline
|
||||
class="flex-1 justify-center"
|
||||
@click="$emit('skip')"
|
||||
>
|
||||
{{ skipLabel }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+6
-1
@@ -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 },
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -42,8 +43,12 @@ defineProps({
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
|
||||
<div
|
||||
v-if="!bare"
|
||||
class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
+186
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
+123
@@ -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:
|
||||
'<div data-test="row" :data-ready="ready">{{ status }}</div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
+59
@@ -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: '<div><slot /></div>',
|
||||
methods: { open() {}, close() {} },
|
||||
},
|
||||
InboxFacebookForm: { template: '<div data-test="fb-form" />' },
|
||||
InboxChannelForm: { template: '<div data-test="channel-form" />' },
|
||||
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);
|
||||
});
|
||||
});
|
||||
+158
@@ -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: `<button :disabled="disabled" @click="$emit('click')">{{ label }}</button>`,
|
||||
};
|
||||
const ComboBoxStub = {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div data-test="combobox" />',
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
+62
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+300
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+6
-2
@@ -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')"
|
||||
>
|
||||
<TextArea
|
||||
<Editor
|
||||
v-model="message"
|
||||
class="w-full"
|
||||
channel-type="Context::NoToolbar"
|
||||
enable-variables
|
||||
:enable-canned-responses="false"
|
||||
:show-character-count="false"
|
||||
:placeholder="
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
|
||||
@@ -10,6 +10,9 @@ export const LONGEST_WAITING = 'longest_waiting';
|
||||
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
|
||||
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
|
||||
|
||||
// Default age threshold for excluding stale unassigned conversations (7 days)
|
||||
export const DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168;
|
||||
|
||||
// Options groupings
|
||||
export const OPTIONS = {
|
||||
ORDER: [ROUND_ROBIN, BALANCED],
|
||||
|
||||
+1
@@ -106,6 +106,7 @@ const formData = computed(() => ({
|
||||
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
|
||||
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
|
||||
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
|
||||
excludeOlderThanHours: selectedPolicy.value?.excludeOlderThanHours ?? null,
|
||||
}));
|
||||
|
||||
const handleDeleteInbox = async inboxId => {
|
||||
|
||||
+52
-1
@@ -8,6 +8,8 @@ import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import {
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
EARLIEST_CREATED,
|
||||
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||
DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -28,6 +31,7 @@ const props = defineProps({
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||
}),
|
||||
},
|
||||
mode: {
|
||||
@@ -56,7 +60,6 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'submit',
|
||||
'addInbox',
|
||||
@@ -64,6 +67,9 @@ const emit = defineEmits([
|
||||
'navigateToInbox',
|
||||
'validationChange',
|
||||
]);
|
||||
// Duration limits for the stale-conversation threshold: 1 hour to 999 days (in minutes)
|
||||
const MIN_EXCLUSION_MINUTES = 60;
|
||||
const MAX_EXCLUSION_MINUTES = 1438560;
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -83,12 +89,28 @@ const state = reactive({
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||
});
|
||||
|
||||
const validationState = ref({
|
||||
isValid: false,
|
||||
});
|
||||
|
||||
const exclusionUnit = ref(DURATION_UNITS.DAYS);
|
||||
|
||||
// DurationInput works in minutes; the policy stores hours, so bridge the two
|
||||
const excludeOlderThanMinutes = computed({
|
||||
get() {
|
||||
return state.excludeOlderThanHours == null
|
||||
? null
|
||||
: state.excludeOlderThanHours * 60;
|
||||
},
|
||||
set(minutes) {
|
||||
state.excludeOlderThanHours =
|
||||
minutes == null ? null : Math.round(minutes / 60);
|
||||
},
|
||||
});
|
||||
|
||||
const createOption = (
|
||||
type,
|
||||
key,
|
||||
@@ -170,6 +192,7 @@ const resetForm = () => {
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -177,10 +200,17 @@ const handleSubmit = () => {
|
||||
emit('submit', { ...state });
|
||||
};
|
||||
|
||||
// Pick the display unit from the stored value so non-day thresholds (e.g. 25h) don't get floored
|
||||
const detectExclusionUnit = hours => {
|
||||
exclusionUnit.value =
|
||||
hours && hours % 24 !== 0 ? DURATION_UNITS.HOURS : DURATION_UNITS.DAYS;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.initialData,
|
||||
newData => {
|
||||
Object.assign(state, newData);
|
||||
detectExclusionUnit(newData.excludeOlderThanHours);
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
@@ -247,6 +277,27 @@ defineExpose({
|
||||
v-model:window-unit="state.windowUnit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 pb-2 flex-col flex gap-4">
|
||||
<div class="flex flex-col items-start gap-1 py-1">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.LABEL`) }}
|
||||
</label>
|
||||
<p class="mb-0 text-n-slate-11 text-sm">
|
||||
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<DurationInput
|
||||
v-model:unit="exclusionUnit"
|
||||
v-model:model-value="excludeOlderThanMinutes"
|
||||
:min="MIN_EXCLUSION_MINUTES"
|
||||
:max="MAX_EXCLUSION_MINUTES"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -144,8 +144,10 @@ const showWebhookSection = computed(
|
||||
() => props.healthData?.webhook_configuration !== undefined
|
||||
);
|
||||
|
||||
// Phone-level override takes precedence over WABA-level (application), so prefer it.
|
||||
const webhookUrl = computed(
|
||||
() =>
|
||||
props.healthData?.webhook_configuration?.phone_number ||
|
||||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
|
||||
props.healthData?.webhook_configuration?.application
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
@@ -123,9 +124,16 @@ const confirmPlaceHolderText = computed(() =>
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
|
||||
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1 text-lg"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="team.icon"
|
||||
:value="team.icon"
|
||||
:color="team.icon_color"
|
||||
class="size-5"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-lucide-users-round"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
<script>
|
||||
import validations from './helpers/validations';
|
||||
import FormInput from 'v3/components/Form/Input.vue';
|
||||
import { reactive } from 'vue';
|
||||
import { reactive, ref, defineAsyncComponent } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const EmojiIconPicker = defineAsyncComponent(
|
||||
() =>
|
||||
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
|
||||
);
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
FormInput,
|
||||
OnClickOutside,
|
||||
EmojiIcon,
|
||||
Icon,
|
||||
EmojiIconPicker,
|
||||
},
|
||||
props: {
|
||||
onSubmit: {
|
||||
@@ -35,19 +47,38 @@ export default {
|
||||
description = '',
|
||||
name: title = '',
|
||||
allow_auto_assign: allowAutoAssign = true,
|
||||
icon = '',
|
||||
icon_color: iconColor = '',
|
||||
} = formData;
|
||||
|
||||
const state = reactive({
|
||||
description,
|
||||
title,
|
||||
allowAutoAssign,
|
||||
icon,
|
||||
iconColor,
|
||||
});
|
||||
|
||||
const isIconPickerOpen = ref(false);
|
||||
|
||||
const rules = validations;
|
||||
const v$ = useVuelidate(rules, state);
|
||||
return { state, v$ };
|
||||
return { state, v$, isIconPickerOpen };
|
||||
},
|
||||
methods: {
|
||||
onSelectIcon({ type, value, color }) {
|
||||
this.state.icon = value;
|
||||
this.state.iconColor = type === 'icon' ? color : '';
|
||||
this.isIconPickerOpen = false;
|
||||
},
|
||||
onColorChange(color) {
|
||||
this.state.iconColor = color;
|
||||
},
|
||||
onRemoveIcon() {
|
||||
this.state.icon = '';
|
||||
this.state.iconColor = '';
|
||||
this.isIconPickerOpen = false;
|
||||
},
|
||||
handleSubmit() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
@@ -57,6 +88,8 @@ export default {
|
||||
description: this.state.description,
|
||||
name: this.state.title,
|
||||
allow_auto_assign: this.state.allowAutoAssign,
|
||||
icon: this.state.icon,
|
||||
icon_color: this.state.iconColor,
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -66,16 +99,49 @@ export default {
|
||||
<template>
|
||||
<div class="flex-shrink-0 w-full">
|
||||
<form class="mx-0 grid gap-4" @submit.prevent="handleSubmit">
|
||||
<FormInput
|
||||
v-model="state.title"
|
||||
name="title"
|
||||
spacing="compact"
|
||||
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
|
||||
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
|
||||
:has-error="v$.title.$error"
|
||||
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
|
||||
@blur="v$.title.$touch"
|
||||
/>
|
||||
<div class="relative">
|
||||
<FormInput
|
||||
v-model="state.title"
|
||||
class="!ps-12"
|
||||
name="title"
|
||||
spacing="compact"
|
||||
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
|
||||
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
|
||||
:has-error="v$.title.$error"
|
||||
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
|
||||
@blur="v$.title.$touch"
|
||||
/>
|
||||
<OnClickOutside
|
||||
class="absolute top-[1.75rem] start-0"
|
||||
@trigger="isIconPickerOpen = false"
|
||||
>
|
||||
<NextButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
class="text-lg !size-[2.5rem] !p-0 ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="isIconPickerOpen = !isIconPickerOpen"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="state.icon"
|
||||
:value="state.icon"
|
||||
:color="state.iconColor"
|
||||
class="size-5 text-xl !leading-5"
|
||||
/>
|
||||
<Icon v-else icon="i-lucide-smile-plus " class="size-4" />
|
||||
</NextButton>
|
||||
<EmojiIconPicker
|
||||
v-if="isIconPickerOpen"
|
||||
class="start-0 top-10"
|
||||
:value="state.icon"
|
||||
:color="state.iconColor"
|
||||
show-remove-button
|
||||
@select="onSelectIcon"
|
||||
@color-change="onColorChange"
|
||||
@remove="onRemoveIcon"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<FormInput
|
||||
v-model="state.description"
|
||||
name="description"
|
||||
|
||||
@@ -7,9 +7,12 @@ import { validateLoggedInRoutes } from '../helper/routeHelpers';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import AnalyticsHelper from '../helper/AnalyticsHelper';
|
||||
|
||||
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
|
||||
const ONBOARDING_STEPS = ['account_details', 'enrichment', 'inbox_setup'];
|
||||
const routes = [...dashboard.routes];
|
||||
|
||||
const onboardingPath = step =>
|
||||
step === 'inbox_setup' ? 'onboarding/inbox-setup' : 'onboarding';
|
||||
|
||||
export const router = createRouter({ history: createWebHistory(), routes });
|
||||
|
||||
export const validateAuthenticateRoutePermission = async (to, next) => {
|
||||
@@ -39,12 +42,18 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
|
||||
isActive;
|
||||
|
||||
if (to.name === 'no_accounts' || !to.name) {
|
||||
const target = needsOnboarding ? 'onboarding' : 'dashboard';
|
||||
const target = needsOnboarding
|
||||
? onboardingPath(userAccount?.onboarding_step)
|
||||
: 'dashboard';
|
||||
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
|
||||
}
|
||||
|
||||
if (needsOnboarding && !isOnOnboardingView(to)) {
|
||||
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
|
||||
return next(
|
||||
frontendURL(
|
||||
`accounts/${routeAccountId}/${onboardingPath(userAccount?.onboarding_step)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!needsOnboarding && isOnOnboardingView(to)) {
|
||||
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
|
||||
|
||||
@@ -208,31 +208,23 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
assignAgent: async (
|
||||
{ dispatch },
|
||||
{ conversationId, agentId, assigneeType }
|
||||
) => {
|
||||
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
|
||||
try {
|
||||
const response = await ConversationApi.assignAgent({
|
||||
conversationId,
|
||||
agentId,
|
||||
assigneeType,
|
||||
});
|
||||
dispatch('setCurrentChatAssignee', {
|
||||
conversationId,
|
||||
assignee: response.data,
|
||||
assigneeType,
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentChatAssignee(
|
||||
{ commit },
|
||||
{ conversationId, assignee, assigneeType }
|
||||
) {
|
||||
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
|
||||
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
|
||||
commit(types.ASSIGN_AGENT, { conversationId, assignee });
|
||||
},
|
||||
|
||||
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
|
||||
|
||||
+5
-9
@@ -2,14 +2,10 @@ import MessageApi from '../../../../api/inbox/message';
|
||||
|
||||
export default {
|
||||
async translateMessage(_, { conversationId, messageId, targetLanguage }) {
|
||||
try {
|
||||
await MessageApi.translateMessage(
|
||||
conversationId,
|
||||
messageId,
|
||||
targetLanguage
|
||||
);
|
||||
} catch (error) {
|
||||
// ignore error
|
||||
}
|
||||
await MessageApi.translateMessage(
|
||||
conversationId,
|
||||
messageId,
|
||||
targetLanguage
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,16 +108,10 @@ export const mutations = {
|
||||
}
|
||||
},
|
||||
|
||||
[types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
|
||||
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.meta.assignee = assignee;
|
||||
chat.meta.assignee_type = assigneeType;
|
||||
if (assigneeType === 'AgentBot') {
|
||||
chat.status = 'pending';
|
||||
} else if (assignee) {
|
||||
chat.status = 'open';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import AssignableAgentsAPI from '../../api/assignableAgents';
|
||||
import InboxesAPI from '../../api/inboxes';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
ownerRecords: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
@@ -12,7 +10,6 @@ const state = {
|
||||
export const types = {
|
||||
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
|
||||
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
|
||||
SET_INBOX_ASSIGNABLE_OWNERS: 'SET_INBOX_ASSIGNABLE_OWNERS',
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
@@ -21,7 +18,6 @@ export const getters = {
|
||||
const verifiedAgents = allAgents.filter(record => record.confirmed);
|
||||
return verifiedAgents;
|
||||
},
|
||||
getAssignableOwners: $state => inboxId => $state.ownerRecords[inboxId] || [],
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
@@ -44,15 +40,6 @@ export const actions = {
|
||||
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
async fetchAssignableOwners({ commit }, inboxId) {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await InboxesAPI.getAssignableOwners(inboxId);
|
||||
commit(types.SET_INBOX_ASSIGNABLE_OWNERS, {
|
||||
inboxId,
|
||||
owners: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
@@ -68,12 +55,6 @@ export const mutations = {
|
||||
[inboxId]: members,
|
||||
};
|
||||
},
|
||||
[types.SET_INBOX_ASSIGNABLE_OWNERS]: ($state, { inboxId, owners }) => {
|
||||
$state.ownerRecords = {
|
||||
...$state.ownerRecords,
|
||||
[inboxId]: owners,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -357,12 +357,11 @@ describe('#actions', () => {
|
||||
});
|
||||
await actions.assignAgent(
|
||||
{ dispatch },
|
||||
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
|
||||
{ conversationId: 1, agentId: 1 }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
|
||||
conversationId: 1,
|
||||
assignee: { id: 1, name: 'User' },
|
||||
assigneeType: 'AgentBot',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -372,7 +371,6 @@ describe('#actions', () => {
|
||||
const payload = {
|
||||
conversationId: 1,
|
||||
assignee: { id: 1, name: 'User' },
|
||||
assigneeType: 'AgentBot',
|
||||
};
|
||||
await actions.setCurrentChatAssignee({ commit }, payload);
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -699,11 +699,11 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
describe('#ASSIGN_AGENT', () => {
|
||||
it('should assign agent bot to the correct conversation by ID', () => {
|
||||
it('should assign agent to the correct conversation by ID', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, meta: {}, status: 'open' },
|
||||
{ id: 1, meta: {} },
|
||||
{ id: 2, meta: {} },
|
||||
],
|
||||
selectedChatId: 2,
|
||||
@@ -712,28 +712,10 @@ describe('#mutations', () => {
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
assigneeType: 'AgentBot',
|
||||
});
|
||||
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
|
||||
expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
|
||||
expect(state.allConversations[0].status).toEqual('pending');
|
||||
expect(state.allConversations[1].meta.assignee).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should open the conversation when assigning a user', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [{ id: 1, meta: {}, status: 'pending' }],
|
||||
};
|
||||
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
assigneeType: 'User',
|
||||
});
|
||||
|
||||
expect(state.allConversations[0].status).toEqual('open');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ASSIGN_PRIORITY', () => {
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import { actions, types } from '../../inboxAssignableAgents';
|
||||
import agentsData from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
vi.mock('dashboard/api/inboxes');
|
||||
|
||||
describe('#actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('#fetch', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
@@ -39,20 +33,4 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#fetchAssignableOwners', () => {
|
||||
it('stores assignable owners for the inbox', async () => {
|
||||
InboxesAPI.getAssignableOwners.mockResolvedValue({
|
||||
data: { payload: agentsData },
|
||||
});
|
||||
|
||||
await actions.fetchAssignableOwners({ commit }, 1);
|
||||
|
||||
expect(InboxesAPI.getAssignableOwners).toHaveBeenCalledWith(1);
|
||||
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_OWNERS, {
|
||||
inboxId: 1,
|
||||
owners: agentsData,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user