diff --git a/AGENTS.md b/AGENTS.md
index 2430fae2b..82acfcc25 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -85,8 +85,8 @@
## Project-Specific
- **Translations**:
- - Only update `en.yml` and `en.json`
- - Other languages are handled by the community
+ - For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
+ - Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
diff --git a/Gemfile.lock b/Gemfile.lock
index 68674155e..46a1c0449 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -211,13 +211,14 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
- datadog (2.19.0)
- datadog-ruby_core_source (~> 3.4, >= 3.4.1)
- libdatadog (~> 18.1.0.1.0)
- libddwaf (~> 1.24.1.0.3)
+ datadog (2.38.0)
+ cgi
+ datadog-ruby_core_source (~> 3.5, >= 3.5.3)
+ libdatadog (~> 36.0.0.1.0)
+ libddwaf (~> 1.30.0.0.0)
logger
msgpack
- datadog-ruby_core_source (3.4.1)
+ datadog-ruby_core_source (3.5.3)
date (3.5.1)
debug (1.8.0)
irb (>= 1.5.0)
@@ -522,15 +523,16 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
- libdatadog (18.1.0.1.0)
- libdatadog (18.1.0.1.0-x86_64-linux)
- libddwaf (1.24.1.0.3)
+ libdatadog (36.0.0.1.0)
+ libdatadog (36.0.0.1.0-arm64-darwin)
+ libdatadog (36.0.0.1.0-x86_64-linux)
+ libddwaf (1.30.0.0.2)
ffi (~> 1.0)
- libddwaf (1.24.1.0.3-arm64-darwin)
+ libddwaf (1.30.0.0.2-arm64-darwin)
ffi (~> 1.0)
- libddwaf (1.24.1.0.3-x86_64-darwin)
+ libddwaf (1.30.0.0.2-x86_64-darwin)
ffi (~> 1.0)
- libddwaf (1.24.1.0.3-x86_64-linux)
+ libddwaf (1.30.0.0.2-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
@@ -547,7 +549,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
- loofah (2.23.1)
+ loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -740,8 +742,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.6.1)
- loofah (~> 2.21)
+ rails-html-sanitizer (1.7.1)
+ loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
diff --git a/LICENSE b/LICENSE
index 8f80f35be..d0425a707 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2017-2024 Chatwoot Inc.
+Copyright (c) 2017-2026 Chatwoot Inc.
Portions of this software are licensed as follows:
diff --git a/VERSION_CW b/VERSION_CW
index fb0557132..ecbc3b030 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.15.1
+4.16.0
diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb
index de3d10081..3f0309024 100644
--- a/app/controllers/api/v1/accounts/agent_bots_controller.rb
+++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
diff --git a/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb
new file mode 100644
index 000000000..54f976b79
--- /dev/null
+++ b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb
@@ -0,0 +1,28 @@
+class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
+ before_action :check_admin_authorization?
+
+ def show
+ set_branded_email_layout
+ end
+
+ def update
+ unless Current.account.feature_enabled?(:branded_email_templates)
+ render_could_not_create_error('Branded email templates feature is not enabled')
+ return
+ end
+
+ branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
+ EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
+ set_branded_email_layout
+ rescue ActiveRecord::RecordInvalid => e
+ render_could_not_create_error(e.record.errors.full_messages.join(', '))
+ end
+
+ private
+
+ def set_branded_email_layout
+ @branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
+ end
+end
+
+Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
index 482b001d6..3b7fafad5 100644
--- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb
+++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
diff --git a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb
index f4ac05d6e..915ade8a3 100644
--- a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb
@@ -1,6 +1,17 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
+ include DeviseTokenAuth::Concerns::SetUserByToken
+ include RequestExceptionHandler
+ include AccessTokenAuthHelper
include EnsureCurrentAccountHelper
+
+ skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
+
+ around_action :handle_with_exception
+ before_action :authenticate_access_token!, if: :authenticate_by_access_token?
+ before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
+ before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :current_account
+ before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :conversation
def create
@@ -11,6 +22,16 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
private
+ def authenticate_by_access_token?
+ request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
+ end
+
+ def validate_token_api_access
+ return if Current.account.api_and_webhooks_enabled?
+
+ render json: { error: 'API access is not enabled for this account' }, status: :forbidden
+ end
+
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index bb9ca2a70..822fce7db 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -45,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def update
- inbox_params = permitted_params.except(:channel, :csat_config)
- inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
- @inbox.update!(inbox_params)
- update_inbox_working_hours
- update_channel if channel_update_required?
+ continue_update = false
+
+ ActiveRecord::Base.transaction do
+ continue_update = update_branded_email_layout
+ raise ActiveRecord::Rollback unless continue_update
+
+ inbox_params = permitted_params.except(:channel, :csat_config)
+ inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
+ @inbox.update!(inbox_params)
+ update_inbox_working_hours
+ update_channel if channel_update_required?
+ end
+
+ return unless continue_update
end
def agent_bot
@@ -155,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
formatted['template'] = config['template'] if config['template'].present?
end
+ def update_branded_email_layout
+ return true unless params.key?(:branded_email_layout)
+
+ branded_email_layout = normalized_branded_email_layout
+
+ unless Current.account.feature_enabled?(:branded_email_templates)
+ return true if branded_email_layout.blank?
+
+ render_could_not_create_error('Branded email templates feature is not enabled')
+ return false
+ end
+
+ unless @inbox.email?
+ return true if branded_email_layout.blank?
+
+ render_could_not_create_error('Branded email layout is only supported for email inboxes')
+ return false
+ end
+
+ @inbox.update_branded_email_layout!(branded_email_layout)
+ true
+ rescue ActiveRecord::RecordInvalid => e
+ render_could_not_create_error(e.record.errors.full_messages.join(', '))
+ false
+ end
+
+ def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
+
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb
index 6889d30a4..f678fee42 100644
--- a/app/controllers/api/v1/accounts/labels_controller.rb
+++ b/app/controllers/api/v1/accounts/labels_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :fetch_label, except: [:index, :create]
before_action :check_authorization
diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb
index c74c0ecfc..bdaf82f6c 100644
--- a/app/controllers/api/v1/accounts/portals_controller.rb
+++ b/app/controllers/api/v1/accounts/portals_controller.rb
@@ -81,7 +81,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
- { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
+ { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
+ { popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
)
end
@@ -89,6 +90,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.dig(:portal, :config, :locale_translations)&.keys || []
end
+ def popular_content_keys
+ params.dig(:portal, :config, :popular_content)&.keys || []
+ end
+
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
index db94113d9..46d89a1ba 100644
--- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
@@ -1,4 +1,7 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
+ before_action :ensure_embedded_signup_enabled
+ # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
+ before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -16,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
private
+ def ensure_embedded_signup_enabled
+ return unless ChatwootApp.chatwoot_cloud?
+ return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
+
+ raise Pundit::NotAuthorizedError
+ end
+
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
@@ -31,7 +41,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
end
def validate_reauthorization_required
- return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
+ return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
render json: {
success: false,
@@ -39,9 +49,11 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
- def can_upgrade_to_embedded_signup?
+ def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
+ return true if ChatwootApp.chatwoot_cloud?
+ return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
end
diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb
index ea36a48f2..7ed39fa98 100644
--- a/app/controllers/concerns/ensure_current_account_helper.rb
+++ b/app/controllers/concerns/ensure_current_account_helper.rb
@@ -14,6 +14,8 @@ module EnsureCurrentAccountHelper
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
+ else
+ render_unauthorized(I18n.t('errors.account.not_authorized'))
end
account
end
@@ -21,7 +23,7 @@ module EnsureCurrentAccountHelper
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
- render_unauthorized('You are not authorized to access this account') unless @current_account_user
+ render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
end
def account_accessible_for_bot?(account)
diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb
index 633071301..71eaad081 100644
--- a/app/controllers/concerns/portal_home_data.rb
+++ b/app/controllers/concerns/portal_home_data.rb
@@ -4,17 +4,53 @@ module PortalHomeData
private
def load_home_data
- base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
+ load_recommended_content
+ # The classic hero only needs the recommendations above; the rest is
+ # documentation-layout home data (also used on custom-domain home pages).
+ return unless @portal.layout == 'documentation'
+
@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)
+ @popular_topics = @recommended_categories.presence || @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
+ def load_recommended_content
+ @recommended_categories = recommended_categories
+ @recommended_articles = recommended_articles
+ end
+
+ def base_articles
+ @base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
+ end
+
+ # Admin-recommended categories for the locale, in the chosen order. Unlike the
+ # position-based fallback, published articles aren't required: the admin's pick wins.
+ def recommended_categories
+ ids = @portal.popular_category_ids(@locale)
+ ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
+ end
+
+ # Admin-recommended articles for the locale, in the chosen order, limited to
+ # published articles that still exist.
+ def recommended_articles
+ ids = @portal.popular_article_ids(@locale)
+ ordered_by_ids(base_articles.where(id: ids), ids)
+ end
+
+ # Loads the scope and returns its records ordered to match `ids`, dropping any
+ # that no longer exist. Skips the query entirely when `ids` is blank.
+ def ordered_by_ids(scope, ids)
+ return [] if ids.blank?
+
+ by_id = scope.index_by(&:id)
+ ids.filter_map { |id| by_id[id] }
+ end
+
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index a369830b6..a72687b42 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -64,10 +64,8 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
- if @portal.layout == 'documentation'
- request.variant = :documentation
- load_home_data
- end
+ request.variant = :documentation if @portal.layout == 'documentation'
+ load_home_data
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb
index 323440304..7b592fbd8 100644
--- a/app/controllers/public/api/v1/portals/base_controller.rb
+++ b/app/controllers/public/api/v1/portals/base_controller.rb
@@ -24,7 +24,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_view_variant
- request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
+ request.variant = if @is_plain_layout_enabled
+ :plain
+ elsif @portal_layout == 'documentation'
+ :documentation
+ end
end
def portal
@@ -65,6 +69,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
def render_404
portal
+ # set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
+ set_view_variant
render 'public/api/v1/portals/error/404', status: :not_found
end
diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb
index 4982278d7..da7d9e6a5 100644
--- a/app/controllers/public/api/v1/portals_controller.rb
+++ b/app/controllers/public/api/v1/portals_controller.rb
@@ -7,7 +7,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
- before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
+ before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
layout 'portal'
def show
diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb
index ee71f3c92..8fd18678c 100644
--- a/app/controllers/webhooks/whatsapp_controller.rb
+++ b/app/controllers/webhooks/whatsapp_controller.rb
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
- phone_number = normalized_phone_number(metadata[:display_phone_number])
- phone_number_id = metadata[:phone_number_id]
- channel = Channel::Whatsapp.find_by(phone_number: phone_number)
-
- return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
- end
-
- def normalized_phone_number(phone_number)
- return if phone_number.blank?
-
- phone_number = phone_number.to_s
- phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
+ Whatsapp::WebhookChannelFinderService.new(
+ display_phone_number: metadata[:display_phone_number],
+ phone_number_id: metadata[:phone_number_id]
+ ).perform
end
def inactive_whatsapp_number?
diff --git a/app/drops/conversation_drop.rb b/app/drops/conversation_drop.rb
index d62642885..f25435ff0 100644
--- a/app/drops/conversation_drop.rb
+++ b/app/drops/conversation_drop.rb
@@ -1,6 +1,10 @@
class ConversationDrop < BaseDrop
include MessageFormatHelper
+ def id
+ @obj.try(:display_id)
+ end
+
def display_id
@obj.try(:display_id)
end
diff --git a/app/drops/inbox_drop.rb b/app/drops/inbox_drop.rb
index 563916d5f..29698fb4b 100644
--- a/app/drops/inbox_drop.rb
+++ b/app/drops/inbox_drop.rb
@@ -2,4 +2,18 @@ class InboxDrop < BaseDrop
def name
@obj.try(:name)
end
+
+ def business_name
+ @obj.try(:sanitized_business_name)
+ end
+
+ def avatar_url
+ @obj.try(:avatar_url)
+ end
+
+ def email
+ return unless @obj.try(:email?)
+
+ @obj.try(:email_address).presence || @obj.try(:channel).try(:email)
+ end
end
diff --git a/app/javascript/dashboard/api/calls.js b/app/javascript/dashboard/api/calls.js
new file mode 100644
index 000000000..aa220bdee
--- /dev/null
+++ b/app/javascript/dashboard/api/calls.js
@@ -0,0 +1,14 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+class CallsAPI extends ApiClient {
+ constructor() {
+ super('calls', { accountScoped: true });
+ }
+
+ get(params = {}) {
+ return axios.get(this.url, { params });
+ }
+}
+
+export default new CallsAPI();
diff --git a/app/javascript/dashboard/api/captain/agentSessions.js b/app/javascript/dashboard/api/captain/agentSessions.js
new file mode 100644
index 000000000..a557b5938
--- /dev/null
+++ b/app/javascript/dashboard/api/captain/agentSessions.js
@@ -0,0 +1,9 @@
+import ApiClient from '../ApiClient';
+
+class CaptainAgentSessions extends ApiClient {
+ constructor() {
+ super('captain/agent_sessions', { accountScoped: true });
+ }
+}
+
+export default new CaptainAgentSessions();
diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js
index 1fc17798d..5af6110ab 100644
--- a/app/javascript/dashboard/api/captain/assistant.js
+++ b/app/javascript/dashboard/api/captain/assistant.js
@@ -26,15 +26,18 @@ class CaptainAssistant extends ApiClient {
});
}
- getStats({ assistantId, range }) {
- return axios.get(`${this.url}/${assistantId}/stats`, {
+ getStats({ assistantId, range, signal }) {
+ const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
- });
+ };
+ if (signal) requestConfig.signal = signal;
+
+ return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
}
- getSummary({ assistantId, range }) {
+ getSummary({ assistantId, range, stats }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
- params: { range, timezone_offset: getTimezoneOffset() },
+ params: { range, timezone_offset: getTimezoneOffset(), stats },
});
}
diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
index ec24aae34..d458c1e5d 100644
--- a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
+++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
@@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
- initiate(conversationId, sdpOffer) {
+ // Either conversationId, or contactId + inboxId to let the BE resolve the conversation.
+ initiate({ conversationId, contactId, inboxId }, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
+ contact_id: contactId,
+ inbox_id: inboxId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js
index 55b620d1a..610ba1e57 100644
--- a/app/javascript/dashboard/api/helpCenter/articles.js
+++ b/app/javascript/dashboard/api/helpCenter/articles.js
@@ -17,6 +17,7 @@ class ArticlesAPI extends PortalsAPI {
categorySlug,
sort,
query,
+ signal,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -30,7 +31,7 @@ class ArticlesAPI extends PortalsAPI {
host: this.url,
});
- return axios.get(url);
+ return axios.get(url, { signal });
}
searchArticles({ portalSlug, query }) {
diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js
index b40613739..9f7052ed5 100644
--- a/app/javascript/dashboard/api/specs/article.spec.js
+++ b/app/javascript/dashboard/api/specs/article.spec.js
@@ -37,7 +37,8 @@ describe('#PortalAPI', () => {
authorId: '1',
});
expect(axiosMock.get).toHaveBeenCalledWith(
- '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
+ '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1',
+ { signal: undefined }
);
});
});
diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue
new file mode 100644
index 000000000..dd2b3af37
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue
@@ -0,0 +1,240 @@
+
+
+
+
+
+
+
+ {{ contactName }}
+
+
+
+
+ {{ call.conversation.displayId }}
+
+
+
+
+
+
+ {{ agentActionLabel }}
+
+
+
+ {{ call.agent.name }}
+
+
+
+ {{ resultLabel }}
+
+
+
+
+ {{ call.inbox.name }}
+
+
+ {{ createdAtLabel }}
+
+
+
+
+
+ {{ createdAtLabel }}
+
+
+
+
+
+
+
+
+ {{ contactName }}
+
+
+
+
+
+
+
+ {{ agentActionLabel }}
+
+
+
+
+ {{ call.agent.name }}
+
+
+
+
+ {{ resultLabel }}
+
+
+
+
+
+
+
+ {{ call.inbox.name }}
+
+
+
+
+ {{ call.conversation.displayId }}
+
+
+
+ {{ createdAtLabel }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue
new file mode 100644
index 000000000..6eaa81b7b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+ {{ displayedTime }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue
new file mode 100644
index 000000000..b0da9c41e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+ {{
+ t(`CALLS_PAGE.STATUS.${kind.toUpperCase()}`)
+ }}
+
+
diff --git a/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue
new file mode 100644
index 000000000..f57d154eb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue
new file mode 100644
index 000000000..4d01b0500
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+ {{
+ totalCount === null
+ ? t('CALLS_PAGE.ALL_CALLS')
+ : t('CALLS_PAGE.ALL_CALLS_COUNT', { count: totalCount })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Calls/constants.js b/app/javascript/dashboard/components-next/Calls/constants.js
new file mode 100644
index 000000000..f7399bfef
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/constants.js
@@ -0,0 +1,51 @@
+import {
+ VOICE_CALL_STATUS,
+ VOICE_CALL_DIRECTION,
+} from 'dashboard/components-next/message/constants';
+
+export const CALL_KIND = {
+ ONGOING: 'ongoing',
+ INCOMING: 'incoming',
+ OUTGOING: 'outgoing',
+ MISSED: 'missed',
+ NO_REPLY: 'no_reply',
+ FAILED: 'failed',
+};
+
+// The API returns display values: status (ringing/in-progress/completed/
+// no-answer/failed) and direction (inbound/outbound). The list UI presents
+// them as a single "kind" per row.
+export const getCallKind = call => {
+ if (
+ [VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
+ call.status
+ )
+ ) {
+ return CALL_KIND.ONGOING;
+ }
+ if (
+ [VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status)
+ ) {
+ return CALL_KIND.FAILED;
+ }
+ const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND;
+ if (call.status === VOICE_CALL_STATUS.NO_ANSWER) {
+ return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY;
+ }
+ return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING;
+};
+
+// Filter chips map to the status/direction params supported by CallFinder.
+export const CALL_ACTIVITY_PARAMS = {
+ missed: {
+ status: VOICE_CALL_STATUS.NO_ANSWER,
+ direction: VOICE_CALL_DIRECTION.INBOUND,
+ },
+ no_reply: {
+ status: VOICE_CALL_STATUS.NO_ANSWER,
+ direction: VOICE_CALL_DIRECTION.OUTBOUND,
+ },
+ incoming: { direction: VOICE_CALL_DIRECTION.INBOUND },
+ outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND },
+ in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS },
+};
diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
index 7e2b6f0c4..5c7ec691d 100644
--- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
+++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
@@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
-import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -83,39 +82,18 @@ const navigateToConversation = conversationId => {
const whatsappCallSession = useWhatsappCallSession();
-// Find the most recent open conversation for this contact in the picked inbox.
-// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
-// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
-// contacts whose latest WhatsApp conversation falls outside the 20 most recent
-// across all inboxes would be treated as having no conversation.
-const findWhatsappConversationId = async inboxId => {
- const { data } = await ContactAPI.getConversations(props.contactId, {
- inboxId,
- });
- const conversations = data?.payload || [];
- const match = [...conversations].sort(
- (a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
- )[0];
- return match?.id || null;
-};
-
const startWhatsappCall = async (inboxId, conversationIdHint) => {
- // WhatsApp /initiate is conversation-scoped, so we must hand it a
- // conversation. Use the caller's hint when given (in-conversation flow);
- // otherwise pick the most recent one in the inbox.
- const conversationId =
- conversationIdHint || (await findWhatsappConversationId(inboxId));
- if (!conversationId) {
- useAlert(t('CONTACT_PANEL.CALL_FAILED'));
- return;
- }
-
- const response =
- await whatsappCallSession.initiateOutboundCall(conversationId);
+ const response = await whatsappCallSession.initiateOutboundCall(
+ conversationIdHint
+ ? { conversationId: conversationIdHint }
+ : { contactId: props.contactId, inboxId }
+ );
// The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than
// claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
+
+ const conversationId = response?.conversation_id || conversationIdHint;
if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of
diff --git a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue
index 259328bec..a27fbdac0 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue
@@ -56,6 +56,9 @@ const localeMenuLabels = computed(() => ({
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
+ 'select-popular-content': t(
+ 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT'
+ ),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
index 66d389ead..ba2ffa22e 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
@@ -2,6 +2,7 @@
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
+import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -26,6 +27,7 @@ const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
+const popularContentDialogRef = ref(null);
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
@@ -154,6 +156,8 @@ const handleAction = ({ action }, localeCode) => {
publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
+ } else if (action === 'select-popular-content') {
+ popularContentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -174,5 +178,6 @@ const handleAction = ({ action }, localeCode) => {
@action="handleAction($event, locale.code)"
/>
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue
new file mode 100644
index 000000000..487b657bb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue
@@ -0,0 +1,235 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
index 02a00c703..2446e0e2b 100644
--- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
@@ -234,6 +234,7 @@ onMounted(() => resetContacts());
ref="popoverRef"
:align="align"
:show-content-border="false"
+ :close-on-scroll="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
diff --git a/app/javascript/dashboard/components-next/audio/AudioPlayer.vue b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue
new file mode 100644
index 000000000..c716ffbfb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+ {{ displayedTime }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
index cf66a0a2f..9a68b71ce 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
@@ -9,6 +9,7 @@ const props = defineProps({
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
+ loading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -45,7 +46,11 @@ const onActivate = () => {
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
-
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
index df336a7e4..5e868956f 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
@@ -9,6 +9,10 @@ const props = defineProps({
type: String,
default: '30',
},
+ stats: {
+ type: Object,
+ default: null,
+ },
});
const route = useRoute();
@@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId);
const welcomeMarkdown = ref('');
const isLoading = ref(false);
+// Increments on every fetch so a slow response for a superseded
+// range/stats/assistant can't overwrite the latest request's state.
+let fetchToken = 0;
+
const fetchSummary = async () => {
+ fetchToken += 1;
+ const token = fetchToken;
+
+ if (!props.stats) {
+ welcomeMarkdown.value = '';
+ isLoading.value = false;
+ return;
+ }
+
isLoading.value = true;
+ let message = '';
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
+ stats: props.stats,
});
- welcomeMarkdown.value = data.message ?? '';
+ message = data.message ?? '';
} catch {
- welcomeMarkdown.value = '';
- } finally {
- isLoading.value = false;
+ message = '';
}
+
+ if (token !== fetchToken) return;
+ welcomeMarkdown.value = message;
+ isLoading.value = false;
};
-watch([() => props.range, assistantId], fetchSummary, { immediate: true });
+watch([() => props.range, () => props.stats, assistantId], fetchSummary, {
+ immediate: true,
+});
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
diff --git a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
index 1ab9e9503..2737cb353 100644
--- a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
+++ b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
@@ -1,6 +1,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.label }}
+
+
+ {{ row.subtitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js b/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js
new file mode 100644
index 000000000..b07868863
--- /dev/null
+++ b/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js
@@ -0,0 +1,224 @@
+import { mount } from '@vue/test-utils';
+import { h } from 'vue';
+import ReorderableMultiSelect from '../ReorderableMultiSelect.vue';
+
+const OPTIONS = [
+ { value: 1, label: 'Getting started', subtitle: 'Guides' },
+ { value: 2, label: 'Billing', subtitle: 'Payments' },
+ { value: 3, label: 'Security' },
+ { value: 4, label: 'API', icon: '🔌', iconColor: '#000' },
+];
+
+// A findable dropdown stub that exposes the `focus()` the component calls on open.
+const ComboBoxDropdownStub = {
+ name: 'ComboBoxDropdown',
+ props: [
+ 'open',
+ 'options',
+ 'searchValue',
+ 'searchPlaceholder',
+ 'emptyState',
+ 'loading',
+ ],
+ emits: ['select', 'update:searchValue'],
+ methods: { focus() {} },
+ template: '',
+};
+
+// Renders a real
-
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
index 34eb1eaff..1a127d9e6 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
if (!canCallBack.value || isInitiatingCall.value) return;
try {
if (isWhatsapp.value) {
- const response = await whatsappCallSession.initiateOutboundCall(
- conversationId.value
- );
+ const response = await whatsappCallSession.initiateOutboundCall({
+ conversationId: conversationId.value,
+ });
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
diff --git a/app/javascript/dashboard/components-next/popover/Popover.vue b/app/javascript/dashboard/components-next/popover/Popover.vue
index 5b67e572f..9d369133f 100644
--- a/app/javascript/dashboard/components-next/popover/Popover.vue
+++ b/app/javascript/dashboard/components-next/popover/Popover.vue
@@ -1,7 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('CALLS_PAGE.EMPTY_STATE') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/calls/routes.js b/app/javascript/dashboard/routes/dashboard/calls/routes.js
new file mode 100644
index 000000000..fa952a749
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/calls/routes.js
@@ -0,0 +1,22 @@
+import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
+import {
+ CONVERSATION_PERMISSIONS,
+ ROLES,
+} from 'dashboard/constants/permissions';
+import { frontendURL } from '../../../helper/URLHelper';
+import CallsIndex from './pages/CallsIndex.vue';
+
+export const routes = [
+ {
+ path: frontendURL('accounts/:accountId/calls'),
+ name: 'calls_dashboard_index',
+ component: CallsIndex,
+ meta: {
+ permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
+ installationTypes: [
+ INSTALLATION_TYPES.CLOUD,
+ INSTALLATION_TYPES.ENTERPRISE,
+ ],
+ },
+ },
+];
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
index 29411e56d..ba31bc05d 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
@@ -1,5 +1,5 @@
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index 222d238a8..5e7f320f3 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -390,6 +390,11 @@ export default {
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
+ (!this.isOnChatwootCloud ||
+ this.isFeatureEnabledonAccount(
+ this.accountId,
+ FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
+ )) &&
this.inbox.reauthorization_required
);
},
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue
index ee7abe9a9..253a41cd4 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue
@@ -1,23 +1,15 @@