Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
950ebcacd4 | ||
|
|
b9834c3a18 | ||
|
|
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
|
||||
before_action :ensure_api_inbox, only: :update
|
||||
before_action :ensure_agent_bot_takeover, only: :create
|
||||
|
||||
def index
|
||||
@messages = message_finder.perform
|
||||
@@ -72,6 +73,21 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
message.translations.present? && message.translations[permitted_params[:target_language]].present?
|
||||
end
|
||||
|
||||
def ensure_agent_bot_takeover
|
||||
return unless Current.user.is_a?(User) && @conversation.assignee_agent_bot_id.present?
|
||||
return if private_message? || incoming_message?
|
||||
|
||||
render json: { error: 'Conversation is assigned to an Agent Bot. Take over the conversation before replying.' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def private_message?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:private])
|
||||
end
|
||||
|
||||
def incoming_message?
|
||||
params[:message_type] == 'incoming'
|
||||
end
|
||||
|
||||
# API inbox check
|
||||
def ensure_api_inbox
|
||||
# Only API inboxes can update messages
|
||||
|
||||
@@ -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?
|
||||
|
||||
+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(() => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,9 @@ export default {
|
||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.isAgentBotOwned) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
this.currentChat.can_reply ||
|
||||
this.isAWhatsAppChannel ||
|
||||
@@ -197,10 +200,14 @@ export default {
|
||||
},
|
||||
isReplyRestricted() {
|
||||
return (
|
||||
!this.currentChat?.can_reply &&
|
||||
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.isAgentBotOwned ||
|
||||
(!this.currentChat?.can_reply &&
|
||||
!(this.isAWhatsAppChannel || this.isAPIInbox))
|
||||
);
|
||||
},
|
||||
isAgentBotOwned() {
|
||||
return this.currentChat?.meta?.assignee_type === 'AgentBot';
|
||||
},
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
},
|
||||
@@ -462,6 +469,11 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isAgentBotOwned) {
|
||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||
return;
|
||||
}
|
||||
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
|
||||
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
||||
} else {
|
||||
@@ -501,6 +513,9 @@ export default {
|
||||
|
||||
mounted() {
|
||||
this.getFromDraft();
|
||||
if (this.isAgentBotOwned) {
|
||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||
}
|
||||
// Don't use the keyboard listener mixin here as the events here are supposed to be
|
||||
// working even if the editor is focussed.
|
||||
document.addEventListener('paste', this.onPaste);
|
||||
@@ -921,6 +936,9 @@ export default {
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||
if (this.isAgentBotOwned && mode === REPLY_EDITOR_MODES.REPLY) {
|
||||
return;
|
||||
}
|
||||
// Clear attachments when switching between private note and reply modes
|
||||
// This is to prevent from breaking the upload rules
|
||||
if (this.attachedFiles.length > 0) this.attachedFiles = [];
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
|
||||
@@ -34,6 +33,7 @@ const assignedAgent = computed({
|
||||
store.dispatch('setCurrentChatAssignee', {
|
||||
conversationId: currentChat.value?.id,
|
||||
assignee: agent,
|
||||
assigneeType: agent ? 'User' : null,
|
||||
});
|
||||
store.dispatch('assignAgent', {
|
||||
conversationId: currentChat.value?.id,
|
||||
@@ -42,9 +42,8 @@ const assignedAgent = computed({
|
||||
},
|
||||
});
|
||||
|
||||
const isUserTyping = computed(
|
||||
() => props.message !== '' && !props.isOnPrivateNote
|
||||
);
|
||||
const hasMessage = computed(() => props.message !== '');
|
||||
const isUserTyping = computed(() => hasMessage.value && !props.isOnPrivateNote);
|
||||
const isUnassigned = computed(() => !assignedAgent.value);
|
||||
const isAssignedToOtherAgent = computed(
|
||||
() => assignedAgent.value?.id !== currentUser.value?.id
|
||||
@@ -56,11 +55,11 @@ const showSelfAssignBanner = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const showBotHandoffBanner = computed(
|
||||
() =>
|
||||
isUserTyping.value &&
|
||||
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
|
||||
);
|
||||
const showBotHandoffBanner = computed(() => {
|
||||
return (
|
||||
hasMessage.value && currentChat.value?.meta?.assignee_type === 'AgentBot'
|
||||
);
|
||||
});
|
||||
|
||||
const botHandoffActionLabel = computed(() => {
|
||||
return assignedAgent.value?.id === currentUser.value?.id
|
||||
@@ -89,7 +88,7 @@ const onClickSelfAssign = async () => {
|
||||
const reopenConversation = async () => {
|
||||
await store.dispatch('toggleStatus', {
|
||||
conversationId: currentChat.value?.id,
|
||||
status: wootConstants.STATUS_TYPE.OPEN,
|
||||
status: 'open',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +440,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 +800,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.",
|
||||
|
||||
@@ -252,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`));
|
||||
|
||||
@@ -223,8 +223,11 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
|
||||
commit(types.ASSIGN_AGENT, { conversationId, assignee });
|
||||
setCurrentChatAssignee(
|
||||
{ commit },
|
||||
{ conversationId, assignee, assigneeType }
|
||||
) {
|
||||
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
|
||||
},
|
||||
|
||||
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
|
||||
|
||||
@@ -108,10 +108,13 @@ export const mutations = {
|
||||
}
|
||||
},
|
||||
|
||||
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
|
||||
[types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.meta.assignee = assignee;
|
||||
if (assigneeType !== undefined) {
|
||||
chat.meta.assignee_type = assigneeType;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -716,6 +716,21 @@ describe('#mutations', () => {
|
||||
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
|
||||
expect(state.allConversations[1].meta.assignee).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update assignee type when provided', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [{ id: 1, meta: { assignee_type: 'AgentBot' } }],
|
||||
};
|
||||
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
assigneeType: 'User',
|
||||
});
|
||||
|
||||
expect(state.allConversations[0].meta.assignee_type).toEqual('User');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ASSIGN_PRIORITY', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToggle } from '@vueuse/core';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -37,6 +38,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'Search',
|
||||
},
|
||||
showEmojiIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
@@ -96,8 +101,18 @@ const hasIcon = computed(() => {
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
v-if="hasValue && hasIcon && showEmojiIcon"
|
||||
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
|
||||
>
|
||||
<EmojiIcon
|
||||
:value="selectedItem.icon"
|
||||
:color="selectedItem.icon_color"
|
||||
class="size-3.5 !text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="hasValue && hasIcon"
|
||||
v-else-if="hasValue && hasIcon"
|
||||
:icon="selectedItem.icon"
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
@@ -124,6 +139,7 @@ const hasIcon = computed(() => {
|
||||
:has-thumbnail="hasThumbnail"
|
||||
:input-placeholder="inputPlaceholder"
|
||||
:no-search-result="noSearchResult"
|
||||
:show-emoji-icon="showEmojiIcon"
|
||||
@select="onClickSelectItem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
@@ -11,6 +12,7 @@ export default {
|
||||
WootDropdownMenu,
|
||||
Avatar,
|
||||
Icon,
|
||||
EmojiIcon,
|
||||
NextButton,
|
||||
},
|
||||
|
||||
@@ -35,6 +37,10 @@ export default {
|
||||
type: String,
|
||||
default: 'No results found',
|
||||
},
|
||||
showEmojiIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['select'],
|
||||
|
||||
@@ -116,8 +122,18 @@ export default {
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
v-if="option.icon && showEmojiIcon"
|
||||
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
|
||||
>
|
||||
<EmojiIcon
|
||||
:value="option.icon"
|
||||
:color="option.icon_color"
|
||||
class="size-3.5 !text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="option.icon"
|
||||
v-else-if="option.icon"
|
||||
:icon="option.icon"
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
const DYTE_MEETING_LINK = 'https://app.dyte.io/v2/meeting';
|
||||
const DYTE_MEETING_LINK = 'https://examples.realtime.cloudflare.com/meeting/';
|
||||
|
||||
export const buildDyteURL = dyteAuthToken => {
|
||||
return `${DYTE_MEETING_LINK}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`;
|
||||
const params = new URLSearchParams({
|
||||
authToken: dyteAuthToken,
|
||||
showSetupScreen: true,
|
||||
disableVideoBackground: true,
|
||||
});
|
||||
|
||||
return `${DYTE_MEETING_LINK}?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ class Account < ApplicationRecord
|
||||
store_accessor :settings, :captain_models, :captain_features
|
||||
store_accessor :settings, :reporting_timezone
|
||||
store_accessor :settings, :keep_pending_on_bot_failure
|
||||
store_accessor :settings, :captain_auto_resolve_mode
|
||||
store_accessor :settings, :captain_auto_resolve_mode, :captain_false_promise_harness_enabled
|
||||
include AccountCaptainAutoResolve
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# conversation_priority :integer default("earliest_created"), not null
|
||||
# description :text
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# exclude_older_than_hours :integer default(168)
|
||||
# fair_distribution_limit :integer default(100), not null
|
||||
# fair_distribution_window :integer default(3600), not null
|
||||
# name :string(255) not null
|
||||
@@ -28,6 +29,7 @@ class AssignmentPolicy < ApplicationRecord
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :fair_distribution_limit, numericality: { greater_than: 0 }
|
||||
validates :fair_distribution_window, numericality: { greater_than: 0 }
|
||||
validates :exclude_older_than_hours, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
||||
|
||||
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
module AccountSettingsSchema
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
CAPTAIN_MODEL_PROPERTIES = Llm::Models.feature_keys.index_with { { 'type': %w[string null] } }.freeze
|
||||
CAPTAIN_FEATURE_PROPERTIES = Llm::Models.feature_keys.index_with { { 'type': %w[boolean null] } }.freeze
|
||||
|
||||
SETTINGS_PARAMS_SCHEMA = {
|
||||
'type': 'object',
|
||||
'properties':
|
||||
@@ -12,32 +15,19 @@ module AccountSettingsSchema
|
||||
'auto_resolve_label': { 'type': %w[string null] },
|
||||
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
|
||||
'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] },
|
||||
'captain_false_promise_harness_enabled': { 'type': %w[boolean null] },
|
||||
'conversation_required_attributes': {
|
||||
'type': %w[array null],
|
||||
'items': { 'type': 'string' }
|
||||
},
|
||||
'captain_models': {
|
||||
'type': %w[object null],
|
||||
'properties': {
|
||||
'editor': { 'type': %w[string null] },
|
||||
'assistant': { 'type': %w[string null] },
|
||||
'copilot': { 'type': %w[string null] },
|
||||
'label_suggestion': { 'type': %w[string null] },
|
||||
'audio_transcription': { 'type': %w[string null] },
|
||||
'help_center_search': { 'type': %w[string null] }
|
||||
},
|
||||
'properties': CAPTAIN_MODEL_PROPERTIES,
|
||||
'additionalProperties': false
|
||||
},
|
||||
'captain_features': {
|
||||
'type': %w[object null],
|
||||
'properties': {
|
||||
'editor': { 'type': %w[boolean null] },
|
||||
'assistant': { 'type': %w[boolean null] },
|
||||
'copilot': { 'type': %w[boolean null] },
|
||||
'label_suggestion': { 'type': %w[boolean null] },
|
||||
'audio_transcription': { 'type': %w[boolean null] },
|
||||
'help_center_search': { 'type': %w[boolean null] }
|
||||
},
|
||||
'properties': CAPTAIN_FEATURE_PROPERTIES,
|
||||
'additionalProperties': false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ module CaptainFeaturable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_validation :normalize_captain_models
|
||||
validate :validate_captain_models
|
||||
|
||||
# Dynamically define accessor methods for each captain feature
|
||||
@@ -30,14 +31,8 @@ module CaptainFeaturable
|
||||
private
|
||||
|
||||
def captain_models_with_defaults
|
||||
stored_models = captain_models || {}
|
||||
Llm::Models.feature_keys.each_with_object({}) do |feature_key, result|
|
||||
stored_value = stored_models[feature_key]
|
||||
result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value)
|
||||
stored_value
|
||||
else
|
||||
Llm::Models.default_model_for(feature_key)
|
||||
end
|
||||
Llm::Models.feature_keys.index_with do |feature_key|
|
||||
Llm::FeatureRouter.resolve(feature: feature_key, account: self)[:model]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -52,11 +47,27 @@ module CaptainFeaturable
|
||||
return if captain_models.blank?
|
||||
|
||||
captain_models.each do |feature_key, model_name|
|
||||
next if model_name.blank?
|
||||
unless Llm::Models.feature?(feature_key)
|
||||
errors.add(:captain_models, "'#{feature_key}' is not a known feature")
|
||||
next
|
||||
end
|
||||
|
||||
next if Llm::Models.valid_model_for?(feature_key, model_name)
|
||||
|
||||
allowed_models = Llm::Models.models_for(feature_key)
|
||||
errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}")
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_captain_models
|
||||
return unless captain_models.is_a?(Hash)
|
||||
|
||||
normalized_models = captain_models.each_with_object({}) do |(feature_key, model_name), result|
|
||||
next if model_name.blank?
|
||||
|
||||
result[feature_key.to_s] = model_name.to_s
|
||||
end
|
||||
|
||||
self.captain_models = normalized_models.presence
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,6 +30,7 @@ class Integrations::Hook < ApplicationRecord
|
||||
validate :validate_settings_json_schema
|
||||
validate :ensure_feature_enabled
|
||||
validate :validate_openai_api_key, if: :validate_openai_api_key?
|
||||
validate :validate_cloudflare_realtimekit_credentials, if: :validate_cloudflare_realtimekit_credentials?
|
||||
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
|
||||
|
||||
# TODO: This seems to be only used for slack at the moment
|
||||
@@ -61,6 +62,10 @@ class Integrations::Hook < ApplicationRecord
|
||||
app_id == 'openai'
|
||||
end
|
||||
|
||||
def dyte?
|
||||
app_id == 'dyte'
|
||||
end
|
||||
|
||||
def notion?
|
||||
app_id == 'notion'
|
||||
end
|
||||
@@ -96,6 +101,7 @@ class Integrations::Hook < ApplicationRecord
|
||||
|
||||
def validate_settings_json_schema
|
||||
return if app.blank? || app.params[:settings_json_schema].blank?
|
||||
return if legacy_dyte_settings_unchanged?
|
||||
|
||||
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
|
||||
end
|
||||
@@ -106,18 +112,57 @@ class Integrations::Hook < ApplicationRecord
|
||||
openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?)
|
||||
end
|
||||
|
||||
def validate_cloudflare_realtimekit_credentials?
|
||||
dyte? && enabled? && !legacy_dyte_settings_unchanged? &&
|
||||
(new_record? || cloudflare_realtimekit_credentials_changed? || will_save_change_to_status?)
|
||||
end
|
||||
|
||||
def openai_api_key_changed?
|
||||
settings_api_key(settings) != settings_api_key(settings_in_database)
|
||||
end
|
||||
|
||||
def cloudflare_realtimekit_credentials_changed?
|
||||
settings_cloudflare_realtimekit_credentials(settings) != settings_cloudflare_realtimekit_credentials(settings_in_database)
|
||||
end
|
||||
|
||||
def legacy_dyte_settings_unchanged?
|
||||
dyte? && persisted? && !will_save_change_to_settings? && legacy_dyte_settings?(settings_in_database)
|
||||
end
|
||||
|
||||
def legacy_dyte_settings?(value)
|
||||
return false if value.blank?
|
||||
|
||||
%w[organization_id api_key].any? { |key| settings_value(value, key).present? } &&
|
||||
%w[account_id app_id api_token].none? { |key| settings_value(value, key).present? }
|
||||
end
|
||||
|
||||
def validate_openai_api_key
|
||||
return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings))
|
||||
|
||||
errors.add(:base, I18n.t('errors.openai.invalid_api_key'))
|
||||
end
|
||||
|
||||
def validate_cloudflare_realtimekit_credentials
|
||||
result = Integrations::Cloudflare::RealtimeKitCredentialsValidator.validate(*settings_cloudflare_realtimekit_credentials(settings))
|
||||
return if result.success?
|
||||
|
||||
errors.add(:base, I18n.t("errors.cloudflare.realtimekit.#{result.error}"))
|
||||
end
|
||||
|
||||
def settings_api_key(value)
|
||||
value&.dig('api_key') || value&.dig(:api_key)
|
||||
settings_value(value, 'api_key')
|
||||
end
|
||||
|
||||
def settings_cloudflare_realtimekit_credentials(value)
|
||||
[
|
||||
settings_value(value, 'account_id'),
|
||||
settings_value(value, 'app_id'),
|
||||
settings_value(value, 'api_token')
|
||||
]
|
||||
end
|
||||
|
||||
def settings_value(value, key)
|
||||
value&.dig(key) || value&.dig(key.to_sym)
|
||||
end
|
||||
|
||||
def trigger_setup_if_crm
|
||||
|
||||
+5
-1
@@ -5,6 +5,8 @@
|
||||
# id :bigint not null, primary key
|
||||
# allow_auto_assign :boolean default(TRUE)
|
||||
# description :text
|
||||
# icon :string default("")
|
||||
# icon_color :string default("")
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -62,7 +64,9 @@ class Team < ApplicationRecord
|
||||
def push_event_data
|
||||
{
|
||||
id: id,
|
||||
name: name
|
||||
name: name,
|
||||
icon: icon,
|
||||
icon_color: icon_color
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,8 +35,11 @@ class AutoAssignment::AssignmentService
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations.unassigned.open
|
||||
|
||||
# Apply conversation priority using assignment policy if available
|
||||
# Skip stale backlog with no activity beyond the policy's age threshold (defaults to 7 days)
|
||||
policy = inbox.assignment_policy
|
||||
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
|
||||
|
||||
# Apply conversation priority using assignment policy if available
|
||||
scope = if policy&.longest_waiting?
|
||||
scope.reorder(last_activity_at: :asc, created_at: :asc)
|
||||
else
|
||||
@@ -46,6 +49,16 @@ class AutoAssignment::AssignmentService
|
||||
scope.limit(limit)
|
||||
end
|
||||
|
||||
def apply_age_exclusions(scope, hours_threshold)
|
||||
return scope if hours_threshold.blank?
|
||||
|
||||
hours = hours_threshold.to_i
|
||||
return scope unless hours.positive?
|
||||
|
||||
# Use last_activity_at so reopened/active conversations aren't excluded by their original created_at
|
||||
scope.where('conversations.last_activity_at >= ?', hours.hours.ago)
|
||||
end
|
||||
|
||||
def find_available_agent(conversation = nil)
|
||||
agents = filter_agents_by_team(inbox.available_agents, conversation)
|
||||
return nil if agents.nil?
|
||||
|
||||
@@ -31,6 +31,14 @@ class UserSessionTrackingService
|
||||
private
|
||||
|
||||
def session_attributes
|
||||
client_headers = mobile_client_headers
|
||||
if client_headers
|
||||
return client_headers.merge(
|
||||
ip_address: @request.remote_ip,
|
||||
user_agent: @request.user_agent
|
||||
)
|
||||
end
|
||||
|
||||
browser = Browser.new(@request.user_agent)
|
||||
|
||||
attrs = {
|
||||
@@ -46,6 +54,30 @@ class UserSessionTrackingService
|
||||
patch_for_legacy_mobile(attrs)
|
||||
end
|
||||
|
||||
def mobile_client_headers
|
||||
name = @request.headers['X-Chatwoot-Client-Name']
|
||||
return nil if name.blank?
|
||||
|
||||
platform = @request.headers['X-Chatwoot-Platform']
|
||||
model = @request.headers['X-Chatwoot-Device-Model']
|
||||
|
||||
{
|
||||
browser_name: name,
|
||||
browser_version: @request.headers['X-Chatwoot-Client-Version'],
|
||||
device_name: device_name_for_icon(platform, model),
|
||||
platform_name: model,
|
||||
platform_version: @request.headers['X-Chatwoot-Platform-Version']
|
||||
}
|
||||
end
|
||||
|
||||
def device_name_for_icon(platform, model)
|
||||
normalized_platform = platform.to_s.downcase
|
||||
return 'iPad' if normalized_platform == 'ios' && model.to_s.include?('iPad')
|
||||
return 'iPhone' if normalized_platform == 'ios'
|
||||
|
||||
'Android'
|
||||
end
|
||||
|
||||
def patch_for_legacy_mobile(attrs)
|
||||
return attrs unless attrs[:browser_name] == 'Unknown Browser'
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
class Whatsapp::FacebookApiClient
|
||||
BASE_URI = 'https://graph.facebook.com'.freeze
|
||||
# Base webhook fields resent on every subscribe so Meta won't reset to defaults. `calls` is added by callers only when voice is enabled.
|
||||
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze
|
||||
|
||||
def initialize(access_token = nil)
|
||||
@access_token = access_token
|
||||
@@ -60,48 +62,62 @@ class Whatsapp::FacebookApiClient
|
||||
data['code_verification_status'] == 'VERIFIED'
|
||||
end
|
||||
|
||||
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze
|
||||
def subscribe_phone_number_webhook(waba_id, phone_number_id, callback_url, verify_token, subscribed_fields: nil)
|
||||
# Subscribe app to WABA first — Meta requires it before any callback override (issue #13097).
|
||||
# subscribed_fields (incl. `calls` when voice is enabled) is declared here; the phone-level POST has no such field.
|
||||
subscribe_app_to_waba(waba_id, subscribed_fields: subscribed_fields || WEBHOOK_DEFAULT_FIELDS)
|
||||
|
||||
def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
|
||||
# Step 1: Subscribe app to WABA first (required before override)
|
||||
# Meta requires the app to be subscribed before using override_callback_uri
|
||||
# See: https://github.com/chatwoot/chatwoot/issues/13097
|
||||
subscribe_app_to_waba(waba_id)
|
||||
|
||||
# Step 2: Override callback URL for this specific WABA
|
||||
override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
|
||||
# Phone-level override takes precedence over WABA-level, so numbers on one WABA can route to different URLs.
|
||||
override_phone_number_callback(phone_number_id, callback_url, verify_token)
|
||||
end
|
||||
|
||||
def subscribe_app_to_waba(waba_id)
|
||||
def subscribe_app_to_waba(waba_id, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers
|
||||
headers: request_headers,
|
||||
body: { subscribed_fields: subscribed_fields }.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'App subscription to WABA failed')
|
||||
end
|
||||
|
||||
def override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
|
||||
def override_phone_number_callback(phone_number_id, callback_url, verify_token)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
|
||||
headers: request_headers,
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token,
|
||||
subscribed_fields: subscribed_fields
|
||||
webhook_configuration: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token
|
||||
}
|
||||
}.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Webhook callback override failed')
|
||||
handle_response(response, 'Phone number webhook callback override failed')
|
||||
end
|
||||
|
||||
def unsubscribe_waba_webhook(waba_id)
|
||||
def clear_phone_number_callback_override(phone_number_id)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
|
||||
headers: request_headers,
|
||||
body: {
|
||||
webhook_configuration: {
|
||||
override_callback_uri: ''
|
||||
}
|
||||
}.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Phone number webhook callback clear failed')
|
||||
end
|
||||
|
||||
# Fully removes this app's WABA subscription (last inbox deleted) so Meta stops delivering webhooks.
|
||||
def unsubscribe_app_from_waba(waba_id)
|
||||
response = HTTParty.delete(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers
|
||||
)
|
||||
|
||||
handle_response(response, 'Webhook unsubscription failed')
|
||||
handle_response(response, 'WABA app unsubscription failed')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -27,9 +27,12 @@ class Whatsapp::ReauthorizationService
|
||||
|
||||
def update_channel_config(channel, access_token, phone_info)
|
||||
current_config = channel.provider_config || {}
|
||||
# Legacy clients may omit phone_number_id; fall back to the value just fetched from Meta.
|
||||
resolved_phone_number_id = @phone_number_id.presence || phone_info[:phone_number_id]
|
||||
|
||||
channel.provider_config = current_config.merge(
|
||||
'api_key' => access_token,
|
||||
'phone_number_id' => @phone_number_id,
|
||||
'phone_number_id' => resolved_phone_number_id,
|
||||
'business_account_id' => @business_id,
|
||||
'source' => 'embedded_signup'
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ class Whatsapp::WebhookSetupService
|
||||
raise ArgumentError, 'Channel is required' if @channel.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
raise ArgumentError, 'Phone number ID is required' if @channel.provider_config['phone_number_id'].blank?
|
||||
end
|
||||
|
||||
def register_phone_number
|
||||
@@ -58,8 +59,9 @@ class Whatsapp::WebhookSetupService
|
||||
def setup_webhook
|
||||
callback_url = build_callback_url
|
||||
verify_token = @channel.provider_config['webhook_verify_token']
|
||||
phone_number_id = @channel.provider_config['phone_number_id']
|
||||
|
||||
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
|
||||
@api_client.subscribe_phone_number_webhook(@waba_id, phone_number_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
|
||||
raise "Webhook setup failed: #{e.message}"
|
||||
@@ -68,10 +70,24 @@ class Whatsapp::WebhookSetupService
|
||||
# Subscribe to `calls` only when voice calling is enabled on the inbox
|
||||
def subscribed_fields
|
||||
fields = %w[messages smb_message_echoes]
|
||||
fields << 'calls' if @channel.provider_config['calling_enabled']
|
||||
fields << 'calls' if calls_enabled_on_waba?
|
||||
fields
|
||||
end
|
||||
|
||||
# `subscribed_fields` is a WABA-wide app subscription, so keep `calls` whenever this inbox or
|
||||
# any sibling on the same WABA has voice on — otherwise a non-calling sibling's setup would
|
||||
# rewrite the shared subscription and drop calls for a calling-enabled sibling.
|
||||
def calls_enabled_on_waba?
|
||||
return true if @channel.provider_config['calling_enabled']
|
||||
|
||||
Channel::Whatsapp
|
||||
.where(provider: 'whatsapp_cloud')
|
||||
.where.not(id: @channel.id)
|
||||
.where("provider_config->>'business_account_id' = ?", @waba_id)
|
||||
.where("provider_config->>'calling_enabled' = 'true'")
|
||||
.exists?
|
||||
end
|
||||
|
||||
def build_callback_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
phone_number = @channel.phone_number
|
||||
|
||||
@@ -6,42 +6,53 @@ class Whatsapp::WebhookTeardownService
|
||||
def perform
|
||||
return unless should_teardown_webhook?
|
||||
|
||||
teardown_webhook
|
||||
api_client = Whatsapp::FacebookApiClient.new(provider_config['api_key'])
|
||||
|
||||
clear_phone_number_override(api_client)
|
||||
unsubscribe_app_if_last_inbox(api_client)
|
||||
rescue StandardError => e
|
||||
handle_webhook_teardown_error(e)
|
||||
# before_destroy must never block a channel delete — log and move on.
|
||||
Rails.logger.error "[WHATSAPP] Webhook teardown failed for channel #{@channel&.id}: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provider_config
|
||||
@channel.provider_config || {}
|
||||
end
|
||||
|
||||
def should_teardown_webhook?
|
||||
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
|
||||
@channel.provider == 'whatsapp_cloud' &&
|
||||
provider_config['source'] == 'embedded_signup' &&
|
||||
provider_config['api_key'].present? &&
|
||||
(provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?)
|
||||
end
|
||||
|
||||
def whatsapp_cloud_provider?
|
||||
@channel.provider == 'whatsapp_cloud'
|
||||
def clear_phone_number_override(api_client)
|
||||
phone_number_id = provider_config['phone_number_id']
|
||||
return if phone_number_id.blank?
|
||||
|
||||
api_client.clear_phone_number_callback_override(phone_number_id)
|
||||
Rails.logger.info "[WHATSAPP] Phone-level webhook override cleared for channel #{@channel.id}"
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}"
|
||||
end
|
||||
|
||||
def embedded_signup_source?
|
||||
@channel.provider_config['source'] == 'embedded_signup'
|
||||
# The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one.
|
||||
def unsubscribe_app_if_last_inbox(api_client)
|
||||
waba_id = provider_config['business_account_id']
|
||||
return if waba_id.blank?
|
||||
return if waba_sibling_exists?(waba_id)
|
||||
|
||||
api_client.unsubscribe_app_from_waba(waba_id)
|
||||
Rails.logger.info "[WHATSAPP] WABA app subscription removed for channel #{@channel.id}"
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP] WABA app unsubscribe failed for channel #{@channel.id}: #{e.message}"
|
||||
end
|
||||
|
||||
def webhook_config_present?
|
||||
@channel.provider_config['business_account_id'].present? &&
|
||||
@channel.provider_config['api_key'].present?
|
||||
end
|
||||
|
||||
def teardown_webhook
|
||||
waba_id = @channel.provider_config['business_account_id']
|
||||
access_token = @channel.provider_config['api_key']
|
||||
api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
|
||||
api_client.unsubscribe_waba_webhook(waba_id)
|
||||
Rails.logger.info "[WHATSAPP] Webhook unsubscribed successfully for channel #{@channel.id}"
|
||||
end
|
||||
|
||||
def handle_webhook_teardown_error(error)
|
||||
Rails.logger.error "[WHATSAPP] Webhook teardown failed: #{error.message}"
|
||||
# Don't raise the error to prevent channel deletion from failing
|
||||
# Failed webhook teardown shouldn't block deletion
|
||||
def waba_sibling_exists?(waba_id)
|
||||
Channel::Whatsapp
|
||||
.where.not(id: @channel.id)
|
||||
.exists?(["provider_config ->> 'business_account_id' = ?", waba_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@ json.assignment_order assignment_policy.assignment_order
|
||||
json.conversation_priority assignment_policy.conversation_priority
|
||||
json.fair_distribution_limit assignment_policy.fair_distribution_limit
|
||||
json.fair_distribution_window assignment_policy.fair_distribution_window
|
||||
json.exclude_older_than_hours assignment_policy.exclude_older_than_hours
|
||||
json.enabled assignment_policy.enabled
|
||||
json.assigned_inbox_count assignment_policy.inboxes.count
|
||||
json.created_at assignment_policy.created_at.to_i
|
||||
|
||||
@@ -2,5 +2,7 @@ json.id resource.id
|
||||
json.name resource.name
|
||||
json.description resource.description
|
||||
json.allow_auto_assign resource.allow_auto_assign
|
||||
json.icon resource.icon
|
||||
json.icon_color resource.icon_color
|
||||
json.account_id resource.account_id
|
||||
json.is_member Current.user.teams.include?(resource)
|
||||
|
||||
@@ -253,6 +253,12 @@
|
||||
display_title: 'Cloud Plans'
|
||||
value:
|
||||
description: 'Config to store stripe plans for cloud'
|
||||
- name: MARKETING_CONVERSION_TRACKING_CONFIG
|
||||
value:
|
||||
display_title: 'Marketing Conversion Tracking Config'
|
||||
description: 'JSON config for Chatwoot Cloud signup and plan activation conversion tracking'
|
||||
locked: true
|
||||
type: code
|
||||
- name: CHATWOOT_CLOUD_PLAN_FEATURES
|
||||
display_title: 'Planwise Features List'
|
||||
value:
|
||||
|
||||
@@ -215,28 +215,35 @@ dyte:
|
||||
'type': 'object',
|
||||
'properties':
|
||||
{
|
||||
'api_key': { 'type': 'string' },
|
||||
'organization_id': { 'type': 'string' },
|
||||
'account_id': { 'type': 'string' },
|
||||
'app_id': { 'type': 'string' },
|
||||
'api_token': { 'type': 'string' },
|
||||
},
|
||||
'required': ['api_key', 'organization_id'],
|
||||
'required': ['account_id', 'app_id', 'api_token'],
|
||||
'additionalProperties': false,
|
||||
}
|
||||
settings_form_schema:
|
||||
[
|
||||
{
|
||||
'label': 'Organization ID',
|
||||
'label': 'Cloudflare Account ID',
|
||||
'type': 'text',
|
||||
'name': 'organization_id',
|
||||
'name': 'account_id',
|
||||
'validation': 'required',
|
||||
},
|
||||
{
|
||||
'label': 'API Key',
|
||||
'label': 'RealtimeKit App ID',
|
||||
'type': 'text',
|
||||
'name': 'api_key',
|
||||
'name': 'app_id',
|
||||
'validation': 'required',
|
||||
},
|
||||
{
|
||||
'label': 'Cloudflare API Token',
|
||||
'type': 'text',
|
||||
'name': 'api_token',
|
||||
'validation': 'required',
|
||||
},
|
||||
]
|
||||
visible_properties: ['organization_id']
|
||||
visible_properties: ['account_id', 'app_id']
|
||||
|
||||
shopify:
|
||||
id: shopify
|
||||
|
||||
+49
-5
@@ -1,4 +1,4 @@
|
||||
aproviders:
|
||||
providers:
|
||||
openai:
|
||||
display_name: 'OpenAI'
|
||||
anthropic:
|
||||
@@ -59,6 +59,10 @@ models:
|
||||
provider: openai
|
||||
display_name: 'Whisper'
|
||||
credit_multiplier: 1
|
||||
gpt-4o-mini-transcribe:
|
||||
provider: openai
|
||||
display_name: 'GPT-4o Mini Transcribe'
|
||||
credit_multiplier: 1
|
||||
text-embedding-3-small:
|
||||
provider: openai
|
||||
display_name: 'Text Embedding 3 Small'
|
||||
@@ -82,6 +86,7 @@ features:
|
||||
assistant:
|
||||
models:
|
||||
[
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
@@ -91,10 +96,11 @@ features:
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.1
|
||||
default: gpt-4.1
|
||||
copilot:
|
||||
models:
|
||||
[
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
@@ -104,14 +110,52 @@ features:
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.1
|
||||
default: gpt-4.1
|
||||
label_suggestion:
|
||||
models:
|
||||
[gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5]
|
||||
default: gpt-4.1-mini
|
||||
document_faq_generation:
|
||||
models:
|
||||
[
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-4.1-mini
|
||||
pdf_faq_generation:
|
||||
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
|
||||
default: gpt-4.1-mini
|
||||
help_center_article_generation:
|
||||
models:
|
||||
[
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.2
|
||||
onboarding_content_generation:
|
||||
models:
|
||||
[gpt-4.1, gpt-4.1-mini, gpt-5-mini, gpt-5.1, gpt-5.2]
|
||||
default: gpt-4.1
|
||||
help_center_query_translation:
|
||||
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini]
|
||||
default: gpt-4.1-nano
|
||||
audio_transcription:
|
||||
models: [whisper-1]
|
||||
default: whisper-1
|
||||
models: [gpt-4o-mini-transcribe, whisper-1]
|
||||
default: gpt-4o-mini-transcribe
|
||||
help_center_search:
|
||||
models: [text-embedding-3-small]
|
||||
default: text-embedding-3-small
|
||||
|
||||
+33
-2
@@ -113,6 +113,15 @@ en:
|
||||
unique: should be unique in the category and portal
|
||||
dyte:
|
||||
invalid_message_type: 'Invalid message type. Action not permitted'
|
||||
realtimekit_credentials_required: 'Cloudflare RealtimeKit credentials are required. Delete the existing Dyte integration and create it again with Cloudflare RealtimeKit credentials.'
|
||||
cloudflare:
|
||||
realtimekit:
|
||||
invalid_credentials: 'Cloudflare RealtimeKit credentials are invalid. Please check the Account ID, RealtimeKit App ID, and API token permissions.'
|
||||
missing_credentials: 'Cloudflare Account ID, RealtimeKit App ID, and API token are required.'
|
||||
invalid_api_token: 'Cloudflare API token is invalid or inactive. Please create a new token with Realtime Admin permissions.'
|
||||
invalid_account_or_permissions: 'Cloudflare Account ID is invalid, or the API token does not have Realtime Admin access to this account.'
|
||||
app_not_found: 'RealtimeKit App ID was not found in this Cloudflare account. Please check the app you selected.'
|
||||
verification_failed: 'Could not verify Cloudflare RealtimeKit credentials right now. Please try again.'
|
||||
slack:
|
||||
invalid_channel_id: 'Invalid slack channel. Please try again'
|
||||
whatsapp:
|
||||
@@ -350,9 +359,9 @@ en:
|
||||
name: 'Dashboard Apps'
|
||||
description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
|
||||
dyte:
|
||||
name: 'Dyte'
|
||||
name: 'Cloudflare RealtimeKit'
|
||||
short_description: 'Start video/voice calls with customers directly from Chatwoot.'
|
||||
description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
|
||||
description: 'Cloudflare RealtimeKit lets your agents start video/voice calls with your customers directly from Chatwoot.'
|
||||
meeting_name: '%{agent_name} has started a meeting'
|
||||
slack:
|
||||
name: 'Slack'
|
||||
@@ -565,6 +574,28 @@ en:
|
||||
ssl_status:
|
||||
custom_domain_not_configured: 'Custom domain is not configured'
|
||||
super_admin:
|
||||
captain_model_overrides:
|
||||
form:
|
||||
helper_text: 'Leave a model blank to use the YAML default for that AI feature.'
|
||||
use_default: 'Use default: %{model} (%{model_id})'
|
||||
show:
|
||||
summary: 'View model routing'
|
||||
provider: 'Provider'
|
||||
model: 'Model'
|
||||
sources:
|
||||
account_override: 'Account override'
|
||||
default: 'Default'
|
||||
features:
|
||||
editor: 'Editor'
|
||||
assistant: 'Assistant'
|
||||
copilot: 'Copilot'
|
||||
label_suggestion: 'Label suggestion'
|
||||
document_faq_generation: 'Document FAQ generation'
|
||||
help_center_article_generation: 'Help center article generation'
|
||||
onboarding_content_generation: 'Onboarding content generation'
|
||||
help_center_query_translation: 'Help center query translation'
|
||||
audio_transcription: 'Audio transcription'
|
||||
help_center_search: 'Help center search'
|
||||
push_diagnostics:
|
||||
user_not_found: 'User not found.'
|
||||
no_subscriptions_to_test: 'Select at least one subscription to test.'
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddIconToTeams < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :teams, :icon, :string, default: '' unless column_exists?(:teams, :icon)
|
||||
add_column :teams, :icon_color, :string, default: '' unless column_exists?(:teams, :icon_color)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddExcludeOlderThanHoursToAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
# Default 168 hours (7 days); nil disables the age exclusion for the policy
|
||||
add_column :assignment_policies, :exclude_older_than_hours, :integer, default: 168
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class BackfillRejectedCallStatus < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
execute("UPDATE calls SET status = 'rejected' WHERE status = 'failed' AND end_reason = 'agent_rejected'")
|
||||
end
|
||||
|
||||
def down
|
||||
execute("UPDATE calls SET status = 'failed' WHERE status = 'rejected' AND end_reason = 'agent_rejected'")
|
||||
end
|
||||
end
|
||||
@@ -205,6 +205,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "exclude_older_than_hours", default: 168
|
||||
t.index ["account_id", "name"], name: "index_assignment_policies_on_account_id_and_name", unique: true
|
||||
t.index ["account_id"], name: "index_assignment_policies_on_account_id"
|
||||
t.index ["enabled"], name: "index_assignment_policies_on_enabled"
|
||||
@@ -1265,6 +1266,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "icon", default: ""
|
||||
t.string "icon_color", default: ""
|
||||
t.index ["account_id"], name: "index_teams_on_account_id"
|
||||
t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true
|
||||
end
|
||||
|
||||
@@ -74,9 +74,9 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
rejected = call.with_lock do
|
||||
next false unless agent_rejecting_before_pickup?(call)
|
||||
|
||||
call.update!(status: 'failed', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id)
|
||||
call.update!(status: 'rejected', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id)
|
||||
true
|
||||
end
|
||||
Voice::CallMessageBuilder.new(call).update_status!(status: 'failed', agent: Current.user) if rejected
|
||||
Voice::CallMessageBuilder.new(call).update_status!(status: 'rejected', agent: Current.user) if rejected
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,27 @@ module Enterprise::Api::V1::Accounts::OnboardingsController
|
||||
|
||||
private
|
||||
|
||||
def create_onboarding_inboxes
|
||||
super
|
||||
create_help_center
|
||||
end
|
||||
|
||||
def complete_inbox_setup
|
||||
# Drop the onboarding-only generation pointer; the OSS method's save! persists both deletions.
|
||||
@account.custom_attributes.delete('help_center_generation_id')
|
||||
super
|
||||
end
|
||||
|
||||
def create_help_center
|
||||
return if website.blank?
|
||||
|
||||
Onboarding::HelpCenterCreationService.new(@account, Current.user).perform
|
||||
end
|
||||
|
||||
def website
|
||||
custom_attributes_params[:website]
|
||||
end
|
||||
|
||||
def help_center_generation_status
|
||||
generation_id = help_center_generation_id
|
||||
return super if generation_id.blank?
|
||||
|
||||
@@ -35,9 +35,9 @@ module Enterprise::SuperAdmin::AppConfigsController
|
||||
|
||||
def internal_config_options
|
||||
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY CLOUD_ANALYTICS_TOKEN CLEARBIT_API_KEY CONTEXT_DEV_API_KEY DASHBOARD_SCRIPTS
|
||||
INACTIVE_WHATSAPP_NUMBERS SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL
|
||||
CHATWOOT_INSTANCE_ADMIN_EMAIL OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS
|
||||
OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
|
||||
INACTIVE_WHATSAPP_NUMBERS SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS MARKETING_CONVERSION_TRACKING_CONFIG
|
||||
ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY
|
||||
CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
|
||||
end
|
||||
|
||||
def captain_config_options
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
require 'administrate/field/base'
|
||||
|
||||
class CaptainModelOverridesField < Administrate::Field::Base
|
||||
def feature_rows
|
||||
Llm::Models.feature_keys.map do |feature_key|
|
||||
route = Llm::FeatureRouter.resolve(feature: feature_key, account: resource)
|
||||
|
||||
{
|
||||
key: feature_key,
|
||||
name: feature_name(feature_key),
|
||||
provider: provider_label(route[:provider]),
|
||||
provider_id: route[:provider],
|
||||
model: model_label(route[:model]),
|
||||
model_id: route[:model],
|
||||
default_model: model_label(default_model_id(feature_key)),
|
||||
default_model_id: default_model_id(feature_key),
|
||||
source: route[:source],
|
||||
source_label: source_label(route[:source]),
|
||||
selected_override: selected_override(feature_key),
|
||||
options: model_options(feature_key)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def selected_override(feature_key)
|
||||
resource.captain_models&.[](feature_key).presence
|
||||
end
|
||||
|
||||
def default_model_id(feature_key)
|
||||
Llm::Models.default_model_for(feature_key)
|
||||
end
|
||||
|
||||
def model_options(feature_key)
|
||||
Llm::Models.feature_config(feature_key)[:models].map do |model|
|
||||
[model[:display_name] || model[:id], model[:id]]
|
||||
end
|
||||
end
|
||||
|
||||
def model_label(model_id)
|
||||
Llm::Models.model_config(model_id)&.dig('display_name') || model_id
|
||||
end
|
||||
|
||||
def provider_label(provider_id)
|
||||
Llm::Models.providers.dig(provider_id, 'display_name') || provider_id
|
||||
end
|
||||
|
||||
def feature_name(feature_key)
|
||||
I18n.t("super_admin.captain_model_overrides.features.#{feature_key}", default: feature_key.humanize)
|
||||
end
|
||||
|
||||
def source_label(source)
|
||||
I18n.t("super_admin.captain_model_overrides.sources.#{source}")
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
include Captain::Conversation::V1ActionClassifier
|
||||
include Captain::Conversation::V1FalsePromiseHandler
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
@@ -38,6 +39,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
message_history: message_history
|
||||
)
|
||||
classify_v1_response_action(message_history) if conversation_pending?
|
||||
repair_v1_false_promise_response(message_history) if conversation_pending?
|
||||
process_response
|
||||
end
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user