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/builders/agent_builder.rb b/app/builders/agent_builder.rb
index d2715011c..af68eefc5 100644
--- a/app/builders/agent_builder.rb
+++ b/app/builders/agent_builder.rb
@@ -2,6 +2,14 @@
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
+ LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
+
+ class LimitExceededError < StandardError
+ def initialize
+ super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
+ end
+ end
+
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
@@ -14,15 +22,23 @@ class AgentBuilder
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
- ActiveRecord::Base.transaction do
- @user = find_or_create_user
- create_account_user
+ account.with_lock do
+ raise LimitExceededError unless can_add_agent?
+
+ ActiveRecord::Base.transaction do
+ @user = find_or_create_user
+ create_account_user
+ end
end
@user
end
private
+ def can_add_agent?
+ account.usage_limits[:agents] > account.account_users.count
+ end
+
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
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/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb
index 438944f04..864c50bb4 100644
--- a/app/controllers/api/v1/accounts/agents_controller.rb
+++ b/app/controllers/api/v1/accounts/agents_controller.rb
@@ -1,8 +1,6 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create]
before_action :check_authorization
- before_action :validate_limit, only: [:create]
- before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index
@agents = agents
@@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
)
@agent = builder.perform
+ rescue AgentBuilder::LimitExceededError => e
+ render_payment_required(e.message)
end
def update
@@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def bulk_create
emails = params[:emails]
- emails.each do |email|
- builder = AgentBuilder.new(
- email: email,
- name: email.split('@').first,
- inviter: current_user,
- account: Current.account
- )
- begin
- builder.perform
- rescue ActiveRecord::RecordInvalid => e
- Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
- end
- end
-
+ bulk_create_agents(emails)
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
- Current.account.custom_attributes.delete('onboarding_step')
- Current.account.save!
+ clear_onboarding_step
head :ok
+ rescue AgentBuilder::LimitExceededError => e
+ render_payment_required(e.message)
end
private
@@ -87,22 +75,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end
- def validate_limit_for_bulk_create
- limit_available = params[:emails].count <= available_agent_count
+ def bulk_create_agents(emails)
+ Current.account.with_lock do
+ raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
- render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
+ emails.each { |email| create_agent_from_email(email) }
+ end
end
- def validate_limit
- render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
+ def create_agent_from_email(email)
+ builder = AgentBuilder.new(
+ email: email,
+ name: email.split('@').first,
+ inviter: current_user,
+ account: Current.account
+ )
+ builder.perform
+ rescue ActiveRecord::RecordInvalid => e
+ Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
+ end
+
+ def clear_onboarding_step
+ Current.account.custom_attributes.delete('onboarding_step')
+ Current.account.save!
end
def available_agent_count
- Current.account.usage_limits[:agents] - agents.count
- end
-
- def can_add_agent?
- available_agent_count.positive?
+ Current.account.usage_limits[:agents] - Current.account.account_users.count
end
def delete_user_record(agent)
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/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/integrations/base_controller.rb b/app/controllers/api/v1/accounts/integrations/base_controller.rb
new file mode 100644
index 000000000..ef1ebb713
--- /dev/null
+++ b/app/controllers/api/v1/accounts/integrations/base_controller.rb
@@ -0,0 +1,9 @@
+class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
+ private
+
+ # Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
+ # Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
+ def check_authorization
+ authorize(:hook)
+ end
+end
diff --git a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb
index 087a9b78d..aec105930 100644
--- a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb
+++ b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb
@@ -1,4 +1,4 @@
-class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
+class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_hook, except: [:create]
before_action :check_authorization
@@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
@hook = Current.account.hooks.find(params[:id])
end
- def check_authorization
- authorize(:hook)
- end
-
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end
diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb
index 9ca0c72fd..8ae3109b9 100644
--- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb
+++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb
@@ -1,6 +1,7 @@
-class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
+class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
+ before_action :check_authorization, only: [:destroy]
def destroy
revoke_linear_token
diff --git a/app/controllers/api/v1/accounts/integrations/notion_controller.rb b/app/controllers/api/v1/accounts/integrations/notion_controller.rb
index ecf6bae6e..29343e4f5 100644
--- a/app/controllers/api/v1/accounts/integrations/notion_controller.rb
+++ b/app/controllers/api/v1/accounts/integrations/notion_controller.rb
@@ -1,5 +1,6 @@
-class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
+class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_hook, only: [:destroy]
+ before_action :check_authorization, only: [:destroy]
def destroy
@hook.destroy!
diff --git a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb
index 7fe31889b..c847a85df 100644
--- a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb
+++ b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb
@@ -1,7 +1,8 @@
-class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
+class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
+ before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders]
def auth
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 580ae77c6..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,5 @@
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? }
@@ -18,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,
@@ -44,8 +52,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
-
- # Reconfiguring a live embedded-signup channel requires the feature flag.
+ return true if ChatwootApp.chatwoot_cloud?
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
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_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/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..806b45bb2 100644
--- a/app/javascript/dashboard/api/captain/assistant.js
+++ b/app/javascript/dashboard/api/captain/assistant.js
@@ -26,15 +26,25 @@ class CaptainAssistant extends ApiClient {
});
}
- getStats({ assistantId, range }) {
- return axios.get(`${this.url}/${assistantId}/stats`, {
+ getMetrics({ assistantId, range, signal }) {
+ const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
- });
+ };
+ if (signal) requestConfig.signal = signal;
+
+ return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
}
- getSummary({ assistantId, range }) {
+ getFaqStats({ assistantId, signal }) {
+ const requestConfig = {};
+ if (signal) requestConfig.signal = signal;
+
+ return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
+ }
+
+ 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..124b7c924 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/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
index 3e822f796..6a4a361b0 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
@@ -8,12 +8,12 @@ import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { useAccount } from 'dashboard/composables/useAccount';
-import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
-const { isOnChatwootCloud } = useAccount();
+const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
@@ -24,10 +24,6 @@ const PROVIDER_TYPES = {
THREE_SIXTY_DIALOG: '360dialog',
};
-const isWhatsappEmbeddedSignupRestricted = computed(() => {
- return isOnChatwootCloud.value;
-});
-
const hasWhatsappAppId = computed(() => {
return (
window.chatwootConfig?.whatsappAppId &&
@@ -41,6 +37,15 @@ const showProviderSelection = computed(() => !selectedProvider.value);
const showConfiguration = computed(() => Boolean(selectedProvider.value));
+const shouldShowWhatsappEmbeddedSignup = computed(() => {
+ return (
+ selectedProvider.value === PROVIDER_TYPES.WHATSAPP &&
+ hasWhatsappAppId.value &&
+ (!isOnChatwootCloud.value ||
+ isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW))
+ );
+});
+
const availableProviders = computed(() => [
{
key: PROVIDER_TYPES.WHATSAPP,
@@ -67,7 +72,8 @@ const selectProvider = providerValue => {
const shouldShowCloudWhatsapp = provider => {
return (
provider === PROVIDER_TYPES.WHATSAPP_MANUAL ||
- (provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value)
+ (provider === PROVIDER_TYPES.WHATSAPP &&
+ !shouldShowWhatsappEmbeddedSignup.value)
);
};
@@ -102,17 +108,8 @@ const handleManualLinkClick = () => {
-
-
-
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
index 0972e4b95..3dda0ad8e 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
@@ -7,7 +7,6 @@ import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
-import Banner from 'next/banner/Banner.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
@@ -18,22 +17,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
- isDisabled: {
- type: Boolean,
- default: false,
- },
- showRestrictionAlert: {
- type: Boolean,
- default: false,
- },
- restrictionStatusUrl: {
- type: String,
- default: '',
- },
- restrictionWarningText: {
- type: String,
- default: '',
- },
});
const store = useStore();
@@ -98,8 +81,6 @@ const handleSignupSuccess = async inboxData => {
};
const launchEmbeddedSignup = async () => {
- if (props.isDisabled) return;
-
let credentials;
try {
credentials = await runEmbeddedSignup();
@@ -193,33 +174,9 @@ const launchEmbeddedSignup = async () => {
-
-
-
-
- {{
- restrictionWarningText ||
- $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
- }}
-
- {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
-
-
-
-
-
[
{
@@ -30,7 +32,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
preview: {
senderName: 'Smith',
- businessName: 'Chatwoot',
+ businessName: replaceInstallationName('Chatwoot'),
email: '',
},
},
@@ -40,7 +42,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
preview: {
senderName: '',
- businessName: 'Chatwoot',
+ businessName: replaceInstallationName('Chatwoot'),
email: '',
},
},
@@ -51,7 +53,7 @@ const isKeyOptionFriendly = key => key === 'friendly';
const userName = keyOption =>
isKeyOptionFriendly(keyOption.key)
? keyOption.preview.senderName
- : keyOption.preview.businessName;
+ : props.businessName || keyOption.preview.businessName;
const toggleSenderNameType = key => {
emit('update', key);
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
index 61038cec1..1e9e2f704 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
@@ -56,6 +56,7 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
+ isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
@@ -65,7 +66,9 @@ export default {
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
- FEATURE_FLAGS.WHATSAPP_RECONFIGURE
+ this.isOnChatwootCloud
+ ? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
+ : FEATURE_FLAGS.WHATSAPP_RECONFIGURE
)
);
},
diff --git a/app/javascript/dashboard/store/captain/agentSessions.js b/app/javascript/dashboard/store/captain/agentSessions.js
new file mode 100644
index 000000000..e72d2e58c
--- /dev/null
+++ b/app/javascript/dashboard/store/captain/agentSessions.js
@@ -0,0 +1,62 @@
+import CaptainAgentSessionsAPI from 'dashboard/api/captain/agentSessions';
+import camelcaseKeys from 'camelcase-keys';
+
+const SET_SESSION = 'SET_SESSION';
+const SET_FETCHING = 'SET_FETCHING';
+
+// Session capture runs right after the message is broadcast (and well after,
+// for handoff notes created mid-run), so a 404 on a fresh message may just
+// mean the session isn't written yet. Skip caching those so a later
+// hover/click retries; older misses are permanent (V1 messages, failed runs).
+const RECENT_MESSAGE_WINDOW_SECONDS = 60;
+
+// Caches Captain agent-session metadata per message id. A missing session
+// (404) is cached as null so the UI shows an empty state without refetching.
+export default {
+ namespaced: true,
+ state: {
+ sessions: {},
+ fetchingIds: [],
+ },
+ getters: {
+ getSessionByMessageId: state => messageId => state.sessions[messageId],
+ isFetching: state => messageId => state.fetchingIds.includes(messageId),
+ hasFetched: state => messageId => messageId in state.sessions,
+ },
+ actions: {
+ fetch: async ({ state, commit }, { messageId, createdAt }) => {
+ if (messageId in state.sessions) return;
+ if (state.fetchingIds.includes(messageId)) return;
+
+ commit(SET_FETCHING, { messageId, isFetching: true });
+ try {
+ const { data } = await CaptainAgentSessionsAPI.show(messageId);
+ commit(SET_SESSION, {
+ messageId,
+ session: camelcaseKeys(data, { deep: true }),
+ });
+ } catch (error) {
+ const isRecentMessage =
+ createdAt &&
+ Date.now() / 1000 - createdAt < RECENT_MESSAGE_WINDOW_SECONDS;
+ // Only a 404 means "no session exists"; transient failures (5xx,
+ // network errors) stay uncached so a later hover retries.
+ if (error.response?.status === 404 && !isRecentMessage) {
+ commit(SET_SESSION, { messageId, session: null });
+ }
+ } finally {
+ commit(SET_FETCHING, { messageId, isFetching: false });
+ }
+ },
+ },
+ mutations: {
+ [SET_SESSION](state, { messageId, session }) {
+ state.sessions = { ...state.sessions, [messageId]: session };
+ },
+ [SET_FETCHING](state, { messageId, isFetching }) {
+ state.fetchingIds = isFetching
+ ? [...state.fetchingIds, messageId]
+ : state.fetchingIds.filter(id => id !== messageId);
+ },
+ },
+};
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index 23685bfad..d0c8e5002 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -50,6 +50,7 @@ import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
+import captainAgentSessions from './captain/agentSessions';
import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document';
import captainResponses from './captain/response';
@@ -115,6 +116,7 @@ export default createStore({
teams,
userNotificationSettings,
webhooks,
+ captainAgentSessions,
captainAssistants,
captainDocuments,
captainResponses,
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index ce24ef653..64a29c215 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -7,6 +7,7 @@ import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import WhatsappChannel from '../../api/channel/whatsappChannel';
import { throwErrorMessage } from '../utils/api';
+import { isSendableTemplate } from '@chatwoot/utils';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
@@ -67,45 +68,8 @@ export const getters = {
return [];
}
- return templates.filter(template => {
- // Ensure template has required properties
- if (!template || !template.status || !template.components) {
- return false;
- }
-
- // Only show approved templates
- if (template.status.toLowerCase() !== 'approved') {
- return false;
- }
-
- // Filter out authentication templates
- if (template.category === 'AUTHENTICATION') {
- return false;
- }
-
- // Filter out CSAT templates (customer_satisfaction_survey and its versions)
- if (
- template.name &&
- template.name.startsWith('customer_satisfaction_survey')
- ) {
- return false;
- }
-
- // Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
- const hasUnsupportedComponents = template.components.some(
- component =>
- ['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
- component.type
- ) ||
- (component.type === 'HEADER' && component.format === 'LOCATION')
- );
-
- if (hasUnsupportedComponents) {
- return false;
- }
-
- return true;
- });
+ // Sendable-template filtering is shared with the mobile app via @chatwoot/utils.
+ return templates.filter(isSendableTemplate);
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
diff --git a/app/javascript/dashboard/stores/callHistory.js b/app/javascript/dashboard/stores/callHistory.js
new file mode 100644
index 000000000..c0a2c0fa9
--- /dev/null
+++ b/app/javascript/dashboard/stores/callHistory.js
@@ -0,0 +1,40 @@
+import camelcaseKeys from 'camelcase-keys';
+import CallsAPI from 'dashboard/api/calls';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+import { defineStore } from 'pinia';
+
+export const useCallHistoryStore = defineStore('callHistory', {
+ state: () => ({
+ records: [],
+ meta: { count: 0, currentPage: 1, totalPages: 0 },
+ uiFlags: { isFetching: false },
+ fetchRequestToken: 0,
+ }),
+
+ actions: {
+ async fetchCalls(params = {}) {
+ this.uiFlags.isFetching = true;
+ this.fetchRequestToken += 1;
+ const requestToken = this.fetchRequestToken;
+ try {
+ const { data } = await CallsAPI.get(params);
+ // A newer fetch (filter/page change) superseded this one; drop the result.
+ if (this.fetchRequestToken !== requestToken) return this.records;
+ this.records = camelcaseKeys(data.payload, { deep: true });
+ this.meta = camelcaseKeys(data.meta);
+ return this.records;
+ } catch (error) {
+ // Don't surface errors from a fetch that a newer request already replaced.
+ if (this.fetchRequestToken !== requestToken) return this.records;
+ // Drop the previous results so stale rows aren't shown under the new view.
+ this.records = [];
+ this.meta = { count: 0, currentPage: 1, totalPages: 0 };
+ return throwErrorMessage(error);
+ } finally {
+ if (this.fetchRequestToken === requestToken) {
+ this.uiFlags.isFetching = false;
+ }
+ }
+ },
+ },
+});
diff --git a/app/javascript/dashboard/stores/specs/callHistory.spec.js b/app/javascript/dashboard/stores/specs/callHistory.spec.js
new file mode 100644
index 000000000..834ae8ab5
--- /dev/null
+++ b/app/javascript/dashboard/stores/specs/callHistory.spec.js
@@ -0,0 +1,117 @@
+import { setActivePinia, createPinia } from 'pinia';
+import CallsAPI from 'dashboard/api/calls';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+import { useCallHistoryStore } from '../callHistory';
+
+vi.mock('dashboard/api/calls', () => ({
+ default: {
+ get: vi.fn(),
+ },
+}));
+
+vi.mock('dashboard/store/utils/api', () => ({
+ throwErrorMessage: vi.fn(error => error),
+}));
+
+const createDeferred = () => {
+ let resolve;
+ const promise = new Promise(res => {
+ resolve = res;
+ });
+
+ return { promise, resolve };
+};
+
+const buildResponse = (payload, meta) => ({ data: { payload, meta } });
+
+describe('callHistory store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ vi.clearAllMocks();
+ });
+
+ it('fetches calls and stores camelized records and meta', async () => {
+ CallsAPI.get.mockResolvedValue(
+ buildResponse(
+ [{ id: 1, recording_url: 'rec.mp3', contact: { phone_number: '+1' } }],
+ { count: 44, current_page: 1, total_pages: 2 }
+ )
+ );
+ const store = useCallHistoryStore();
+
+ await store.fetchCalls({ page: 1, status: 'no-answer' });
+
+ expect(CallsAPI.get).toHaveBeenCalledWith({ page: 1, status: 'no-answer' });
+ expect(store.records).toEqual([
+ { id: 1, recordingUrl: 'rec.mp3', contact: { phoneNumber: '+1' } },
+ ]);
+ expect(store.meta).toEqual({ count: 44, currentPage: 1, totalPages: 2 });
+ expect(store.uiFlags.isFetching).toBe(false);
+ });
+
+ it('drops a superseded response that resolves after the latest one', async () => {
+ const firstRequest = createDeferred();
+ const secondRequest = createDeferred();
+ CallsAPI.get
+ .mockImplementationOnce(() => firstRequest.promise)
+ .mockImplementationOnce(() => secondRequest.promise);
+ const store = useCallHistoryStore();
+
+ const staleFetch = store.fetchCalls({ page: 1 });
+ const currentFetch = store.fetchCalls({ page: 2 });
+
+ secondRequest.resolve(
+ buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 })
+ );
+ await currentFetch;
+
+ firstRequest.resolve(
+ buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 })
+ );
+ await staleFetch;
+
+ expect(store.records).toEqual([{ id: 2 }]);
+ expect(store.meta.count).toBe(1);
+ expect(store.uiFlags.isFetching).toBe(false);
+ });
+
+ it('keeps fetching state when a superseded response resolves first', async () => {
+ const firstRequest = createDeferred();
+ const secondRequest = createDeferred();
+ CallsAPI.get
+ .mockImplementationOnce(() => firstRequest.promise)
+ .mockImplementationOnce(() => secondRequest.promise);
+ const store = useCallHistoryStore();
+
+ const staleFetch = store.fetchCalls({ page: 1 });
+ const currentFetch = store.fetchCalls({ page: 2 });
+
+ firstRequest.resolve(
+ buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 })
+ );
+ await staleFetch;
+
+ expect(store.records).toEqual([]);
+ expect(store.uiFlags.isFetching).toBe(true);
+
+ secondRequest.resolve(
+ buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 })
+ );
+ await currentFetch;
+
+ expect(store.records).toEqual([{ id: 2 }]);
+ expect(store.uiFlags.isFetching).toBe(false);
+ });
+
+ it('surfaces the error and resets fetching state on failure', async () => {
+ const error = new Error('Request failed');
+ CallsAPI.get.mockRejectedValue(error);
+ const store = useCallHistoryStore();
+
+ await store.fetchCalls();
+
+ expect(throwErrorMessage).toHaveBeenCalledWith(error);
+ expect(store.records).toEqual([]);
+ expect(store.uiFlags.isFetching).toBe(false);
+ });
+});
diff --git a/app/javascript/dashboard/stores/companies.spec.js b/app/javascript/dashboard/stores/specs/companies.spec.js
similarity index 99%
rename from app/javascript/dashboard/stores/companies.spec.js
rename to app/javascript/dashboard/stores/specs/companies.spec.js
index 1c44d4292..98d9fd9b6 100644
--- a/app/javascript/dashboard/stores/companies.spec.js
+++ b/app/javascript/dashboard/stores/specs/companies.spec.js
@@ -1,6 +1,6 @@
import { setActivePinia, createPinia } from 'pinia';
import CompanyAPI from 'dashboard/api/companies';
-import { useCompaniesStore } from './companies';
+import { useCompaniesStore } from '../companies';
vi.mock('dashboard/api/companies', () => ({
default: {
diff --git a/app/javascript/shared/components/ui/MultiselectDropdown.vue b/app/javascript/shared/components/ui/MultiselectDropdown.vue
index 0f775c679..92043c12b 100644
--- a/app/javascript/shared/components/ui/MultiselectDropdown.vue
+++ b/app/javascript/shared/components/ui/MultiselectDropdown.vue
@@ -68,6 +68,10 @@ const isAgentBot = computed(
() => props.selectedItem?.assignee_type === 'AgentBot'
);
+const selectedItemName = computed(() =>
+ !props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
+);
+
const selectedThumbnail = computed(
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
);
@@ -95,16 +99,16 @@ const selectedThumbnail = computed(
- {{ selectedItem.name }}
+ {{ selectedItemName }}
{
- return option.name.toLowerCase().includes(this.search.toLowerCase());
+ return (option.name || '')
+ .toLowerCase()
+ .includes(this.search.toLowerCase());
});
},
noResult() {
diff --git a/app/javascript/shared/composables/specs/useBranding.spec.js b/app/javascript/shared/composables/specs/useBranding.spec.js
index 6f3f06b67..4477ac052 100644
--- a/app/javascript/shared/composables/specs/useBranding.spec.js
+++ b/app/javascript/shared/composables/specs/useBranding.spec.js
@@ -73,13 +73,13 @@ describe('useBranding', () => {
expect(result).toBe('Welcome to our platform');
});
- it('should be case-sensitive for "Chatwoot"', () => {
+ it('should replace "Chatwoot" regardless of casing', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
- 'Welcome to chatwoot and CHATWOOT'
+ 'Welcome to chatwoot, Chatwoot and CHATWOOT'
);
- expect(result).toBe('Welcome to chatwoot and CHATWOOT');
+ expect(result).toBe('Welcome to MyCompany, MyCompany and MyCompany');
});
it('should handle special characters in installation name', () => {
diff --git a/app/javascript/shared/composables/useBranding.js b/app/javascript/shared/composables/useBranding.js
index fccdcd32b..d9be3e696 100644
--- a/app/javascript/shared/composables/useBranding.js
+++ b/app/javascript/shared/composables/useBranding.js
@@ -7,7 +7,8 @@ import { useMapGetter } from 'dashboard/composables/store.js';
export function useBranding() {
const globalConfig = useMapGetter('globalConfig/get');
/**
- * Replaces "Chatwoot" in text with the installation name from global config
+ * Replaces "Chatwoot" (any casing) in text with the installation name from
+ * global config
* @param {string} text - The text to process
* @returns {string} - Text with "Chatwoot" replaced by installation name
*/
@@ -17,7 +18,7 @@ export function useBranding() {
const installationName = globalConfig.value?.installationName;
if (!installationName) return text;
- return text.replace(/Chatwoot/g, installationName);
+ return text.replace(/chatwoot/gi, installationName);
};
return {
diff --git a/app/javascript/shared/helpers/specs/timeHelper.spec.js b/app/javascript/shared/helpers/specs/timeHelper.spec.js
index d12a42bc8..8a4b50b17 100644
--- a/app/javascript/shared/helpers/specs/timeHelper.spec.js
+++ b/app/javascript/shared/helpers/specs/timeHelper.spec.js
@@ -1,11 +1,12 @@
import {
- messageStamp,
- messageTimestamp,
- dynamicTime,
dateFormat,
- shortTimestamp,
+ dynamicTime,
getDayDifferenceFromNow,
hasOneDayPassed,
+ messageStamp,
+ messageTimestamp,
+ relativeDayTimestamp,
+ shortTimestamp,
} from 'shared/helpers/timeHelper';
beforeEach(() => {
@@ -37,6 +38,33 @@ describe('#messageTimestamp', () => {
});
});
+describe('#relativeDayTimestamp', () => {
+ // System time is mocked to May 5, 2023 00:00 UTC.
+ const toUnix = date => Math.floor(date / 1000);
+
+ it('returns the time for timestamps from today', () => {
+ const today = toUnix(Date.UTC(2023, 4, 5, 15, 35, 0));
+ expect(relativeDayTimestamp(today, 'Yesterday')).toEqual('3:35 PM');
+ });
+
+ it('returns the supplied label for timestamps from yesterday', () => {
+ const yesterday = toUnix(Date.UTC(2023, 4, 4, 9, 0, 0));
+ expect(relativeDayTimestamp(yesterday, 'Yesterday')).toEqual('Yesterday');
+ });
+
+ it('returns a day and month for older timestamps in the current year', () => {
+ const earlierThisYear = toUnix(Date.UTC(2023, 1, 10, 12, 0, 0));
+ expect(relativeDayTimestamp(earlierThisYear, 'Yesterday')).toEqual(
+ 'Feb 10'
+ );
+ });
+
+ it('returns a full date for timestamps from a previous year', () => {
+ const lastYear = toUnix(Date.UTC(2021, 1, 10, 12, 0, 0));
+ expect(relativeDayTimestamp(lastYear, 'Yesterday')).toEqual('Feb 10, 2021');
+ });
+});
+
describe('#dynamicTime', () => {
it('returns correct value', () => {
Date.now = vi.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf());
diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js
index db5609d89..cde5ccf89 100644
--- a/app/javascript/shared/helpers/timeHelper.js
+++ b/app/javascript/shared/helpers/timeHelper.js
@@ -1,6 +1,9 @@
import {
format,
isSameYear,
+ isThisYear,
+ isToday,
+ isYesterday,
fromUnixTime,
formatDistanceToNow,
differenceInDays,
@@ -33,6 +36,22 @@ export const messageTimestamp = (time, dateFormat = 'MMM d, yyyy') => {
return messageDate;
};
+/**
+ * Formats a Unix timestamp relative to today: the time for today, a caller-
+ * supplied label for yesterday, and a date otherwise. The yesterday label is
+ * passed in so the caller keeps ownership of translation.
+ * @param {number} time - Unix timestamp.
+ * @param {string} yesterdayLabel - Localized label shown for yesterday.
+ * @returns {string} Formatted timestamp string.
+ */
+export const relativeDayTimestamp = (time, yesterdayLabel) => {
+ const date = fromUnixTime(time);
+ if (isToday(date)) return format(date, 'h:mm a');
+ if (isYesterday(date)) return yesterdayLabel;
+ if (isThisYear(date)) return format(date, 'MMM d');
+ return format(date, 'MMM d, yyyy');
+};
+
/**
* Converts a Unix timestamp to a relative time string (e.g., 3 hours ago).
* @param {number} time - Unix timestamp.
diff --git a/app/javascript/survey/i18n/index.js b/app/javascript/survey/i18n/index.js
index 1a9ea1a59..908d3e628 100644
--- a/app/javascript/survey/i18n/index.js
+++ b/app/javascript/survey/i18n/index.js
@@ -35,6 +35,7 @@ import ta from './locale/ta.json';
import th from './locale/th.json';
import tr from './locale/tr.json';
import uk from './locale/uk.json';
+import uz from './locale/uz.json';
import vi from './locale/vi.json';
import zh_CN from './locale/zh_CN.json';
import zh_TW from './locale/zh_TW.json';
@@ -77,6 +78,7 @@ export default {
th,
tr,
uk,
+ uz,
vi,
zh_CN,
zh_TW,
diff --git a/app/javascript/survey/i18n/locale/uz.json b/app/javascript/survey/i18n/locale/uz.json
new file mode 100644
index 000000000..2d8359d79
--- /dev/null
+++ b/app/javascript/survey/i18n/locale/uz.json
@@ -0,0 +1,19 @@
+{
+ "SURVEY": {
+ "DESCRIPTION": "Hurmatli mijoz 👋, {inboxName} bilan bo'lgan suhbatingiz haqida fikr bildirish uchun bir necha daqiqa vaqt ajrating.",
+ "RATING": {
+ "LABEL": "Suhbatingizni baholang",
+ "SUCCESS_MESSAGE": "Baho berganingiz uchun rahmat"
+ },
+ "FEEDBACK": {
+ "LABEL": "Bildirmoqchi bo'lgan fikr-mulohazalaringiz bormi?",
+ "PLACEHOLDER": "Fikringiz (ixtiyoriy)",
+ "BUTTON_TEXT": "Fikrni yuborish"
+ },
+ "API": {
+ "SUCCESS_MESSAGE": "So'rovnoma muvaffaqiyatli yangilandi",
+ "ERROR_MESSAGE": "Woot serveriga ulanib bo'lmadi, iltimos, keyinroq qayta urinib ko'ring"
+ }
+ },
+ "POWERED_BY": "Chatwoot yordamida ishlaydi"
+}
diff --git a/app/javascript/widget/components/ChatFooter.vue b/app/javascript/widget/components/ChatFooter.vue
index c85727a2b..2cad4d177 100755
--- a/app/javascript/widget/components/ChatFooter.vue
+++ b/app/javascript/widget/components/ChatFooter.vue
@@ -11,6 +11,8 @@ import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import { emitter } from 'shared/helpers/mitt';
+const TRANSCRIPT_COOLDOWN_MS = 15000;
+
export default {
components: {
ChatInputWrap,
@@ -24,6 +26,9 @@ export default {
data() {
return {
inReplyTo: null,
+ isSendingTranscript: false,
+ transcriptCooldown: false,
+ transcriptCooldownTimer: null,
};
},
computed: {
@@ -57,6 +62,9 @@ export default {
mounted() {
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
},
+ beforeUnmount() {
+ clearTimeout(this.transcriptCooldownTimer);
+ },
methods: {
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
...mapActions('conversationAttributes', ['getAttributes']),
@@ -90,19 +98,35 @@ export default {
toggleReplyTo(message) {
this.inReplyTo = message;
},
+ startTranscriptCooldown() {
+ this.transcriptCooldown = true;
+ clearTimeout(this.transcriptCooldownTimer);
+ this.transcriptCooldownTimer = setTimeout(() => {
+ this.transcriptCooldown = false;
+ }, TRANSCRIPT_COOLDOWN_MS);
+ },
async sendTranscript() {
- if (this.hasEmail) {
- try {
- await sendEmailTranscript();
- emitter.emit(BUS_EVENTS.SHOW_ALERT, {
- message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
- type: 'success',
- });
- } catch (error) {
- emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
- message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
- });
- }
+ if (
+ !this.hasEmail ||
+ this.isSendingTranscript ||
+ this.transcriptCooldown
+ ) {
+ return;
+ }
+ this.isSendingTranscript = true;
+ try {
+ await sendEmailTranscript();
+ this.startTranscriptCooldown();
+ emitter.emit(BUS_EVENTS.SHOW_ALERT, {
+ message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
+ type: 'success',
+ });
+ } catch (error) {
+ emitter.emit(BUS_EVENTS.SHOW_ALERT, {
+ message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
+ });
+ } finally {
+ this.isSendingTranscript = false;
}
},
},
@@ -144,6 +168,7 @@ export default {
v-if="showEmailTranscriptButton"
type="clear"
class="font-normal"
+ :disabled="isSendingTranscript || transcriptCooldown"
@click="sendTranscript"
>
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
diff --git a/app/javascript/widget/i18n/index.js b/app/javascript/widget/i18n/index.js
index 93a371391..ee7d355cf 100644
--- a/app/javascript/widget/i18n/index.js
+++ b/app/javascript/widget/i18n/index.js
@@ -35,6 +35,7 @@ import ta from './locale/ta.json';
import th from './locale/th.json';
import tr from './locale/tr.json';
import uk from './locale/uk.json';
+import uz from './locale/uz.json';
import vi from './locale/vi.json';
import zh_CN from './locale/zh_CN.json';
import zh_TW from './locale/zh_TW.json';
@@ -77,6 +78,7 @@ export default {
th,
tr,
uk,
+ uz,
vi,
zh_CN,
zh_TW,
diff --git a/app/javascript/widget/i18n/locale/am.json b/app/javascript/widget/i18n/locale/am.json
index c3d6ddfc1..82271f4de 100644
--- a/app/javascript/widget/i18n/locale/am.json
+++ b/app/javascript/widget/i18n/locale/am.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/ar.json b/app/javascript/widget/i18n/locale/ar.json
index 1424b7418..9d7e7daa1 100644
--- a/app/javascript/widget/i18n/locale/ar.json
+++ b/app/javascript/widget/i18n/locale/ar.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "لا يوجد إيموجي يطابق بحثك",
"ARIA_LABEL": "أداة اختيار الايموجي"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "لا يوجد إيموجي يطابق بحثك"
+ },
"CSAT": {
"TITLE": "قيم محادثتك",
"SUBMITTED_TITLE": "شكرا لك على تقييم المحادثة",
diff --git a/app/javascript/widget/i18n/locale/az.json b/app/javascript/widget/i18n/locale/az.json
index c3d6ddfc1..82271f4de 100644
--- a/app/javascript/widget/i18n/locale/az.json
+++ b/app/javascript/widget/i18n/locale/az.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/bg.json b/app/javascript/widget/i18n/locale/bg.json
index 369caa46a..ccaf944f5 100644
--- a/app/javascript/widget/i18n/locale/bg.json
+++ b/app/javascript/widget/i18n/locale/bg.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Няма намерени емотикони",
"ARIA_LABEL": "Избор на емотикони"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Оценете този разговор",
"SUBMITTED_TITLE": "Благодарим ви, че оценихте разговора",
diff --git a/app/javascript/widget/i18n/locale/bn.json b/app/javascript/widget/i18n/locale/bn.json
index c3d6ddfc1..82271f4de 100644
--- a/app/javascript/widget/i18n/locale/bn.json
+++ b/app/javascript/widget/i18n/locale/bn.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/ca.json b/app/javascript/widget/i18n/locale/ca.json
index ce3b99da7..4553c4c6b 100644
--- a/app/javascript/widget/i18n/locale/ca.json
+++ b/app/javascript/widget/i18n/locale/ca.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Cap emoji coincideix amb la teva cerca",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Cap emoji coincideix amb la teva cerca"
+ },
"CSAT": {
"TITLE": "Valora la teva conversa",
"SUBMITTED_TITLE": "Gràcies per enviar la qualificació",
diff --git a/app/javascript/widget/i18n/locale/cs.json b/app/javascript/widget/i18n/locale/cs.json
index 61bebf777..e2aa28472 100644
--- a/app/javascript/widget/i18n/locale/cs.json
+++ b/app/javascript/widget/i18n/locale/cs.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Žádné emoji neodpovídají vašemu hledání",
"ARIA_LABEL": "Výběr emoji"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Ohodnoťte svou konverzaci",
"SUBMITTED_TITLE": "Děkujeme Vám za odeslání hodnocení",
diff --git a/app/javascript/widget/i18n/locale/da.json b/app/javascript/widget/i18n/locale/da.json
index ef7adfc51..b663ee089 100644
--- a/app/javascript/widget/i18n/locale/da.json
+++ b/app/javascript/widget/i18n/locale/da.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Ingen emoji matcher din søgning",
"ARIA_LABEL": "Emoji vælger"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Ingen emoji matcher din søgning"
+ },
"CSAT": {
"TITLE": "Bedøm din samtale",
"SUBMITTED_TITLE": "Tak for din bedømmelse",
diff --git a/app/javascript/widget/i18n/locale/de.json b/app/javascript/widget/i18n/locale/de.json
index fd6de1f2e..b51f879af 100644
--- a/app/javascript/widget/i18n/locale/de.json
+++ b/app/javascript/widget/i18n/locale/de.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Kein Emoji entspricht Ihrer Suche",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Kein Emoji entspricht deiner Suche"
+ },
"CSAT": {
"TITLE": "Bewerten Sie Ihre Konversation",
"SUBMITTED_TITLE": "Danke, dass Sie die Bewertung eingereicht haben",
diff --git a/app/javascript/widget/i18n/locale/el.json b/app/javascript/widget/i18n/locale/el.json
index 64bb71e74..c3613fe58 100644
--- a/app/javascript/widget/i18n/locale/el.json
+++ b/app/javascript/widget/i18n/locale/el.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Κανένα emoji δεν ταιριάζει με την αναζήτησή σας",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Κανένα emoji δεν ταιριάζει με την αναζήτησή σας"
+ },
"CSAT": {
"TITLE": "Αξιολογήστε τη συνομιλία σας",
"SUBMITTED_TITLE": "Ευχαριστούμε για την υποβολή της αξιολόγησης",
diff --git a/app/javascript/widget/i18n/locale/es.json b/app/javascript/widget/i18n/locale/es.json
index 3ced9aeab..8119a674a 100644
--- a/app/javascript/widget/i18n/locale/es.json
+++ b/app/javascript/widget/i18n/locale/es.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Ningún emoji coincide con tu búsqueda",
"ARIA_LABEL": "Selector de emoji"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Buscar emoji…",
+ "FREQUENTLY_USED": "Usado con frecuencia",
+ "NO_EMOJI": "Ningún emoji coincide con tu búsqueda"
+ },
"CSAT": {
"TITLE": "Califica tu conversación",
"SUBMITTED_TITLE": "Gracias por enviar la valoración",
diff --git a/app/javascript/widget/i18n/locale/et.json b/app/javascript/widget/i18n/locale/et.json
index e4a1f135f..e856e3f89 100644
--- a/app/javascript/widget/i18n/locale/et.json
+++ b/app/javascript/widget/i18n/locale/et.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Ühtegi emotikoni ei leitud",
"ARIA_LABEL": "Emotikonide valija"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Hinda oma vestlust",
"SUBMITTED_TITLE": "Täname hinnangu eest",
diff --git a/app/javascript/widget/i18n/locale/fa.json b/app/javascript/widget/i18n/locale/fa.json
index c4d1bcc1c..17eac8c8e 100644
--- a/app/javascript/widget/i18n/locale/fa.json
+++ b/app/javascript/widget/i18n/locale/fa.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "هیچ اموجی با جستجوی شما مطابقت ندارد",
"ARIA_LABEL": "انتخاب اموجی"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "هیچ اموجی با جستجوی شما مطابقت ندارد"
+ },
"CSAT": {
"TITLE": "به گفتگوی خود امتیاز دهید",
"SUBMITTED_TITLE": "با تشکر از شما برای ثبت امتیاز",
diff --git a/app/javascript/widget/i18n/locale/fi.json b/app/javascript/widget/i18n/locale/fi.json
index b11170020..c7655af29 100644
--- a/app/javascript/widget/i18n/locale/fi.json
+++ b/app/javascript/widget/i18n/locale/fi.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Yksikään emoji ei vastaa hakuasi",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Anna arvio palvelusta",
"SUBMITTED_TITLE": "Kiitos, että lähetit arvion",
diff --git a/app/javascript/widget/i18n/locale/fr.json b/app/javascript/widget/i18n/locale/fr.json
index 90275e02a..9f6a649f1 100644
--- a/app/javascript/widget/i18n/locale/fr.json
+++ b/app/javascript/widget/i18n/locale/fr.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Aucun émoji ne correspond à votre recherche",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Aucun émoji ne correspond à votre recherche"
+ },
"CSAT": {
"TITLE": "Évaluer votre conversation",
"SUBMITTED_TITLE": "Merci d'avoir soumis votre évaluation",
diff --git a/app/javascript/widget/i18n/locale/he.json b/app/javascript/widget/i18n/locale/he.json
index 70ea66e83..0fdfa0fd3 100644
--- a/app/javascript/widget/i18n/locale/he.json
+++ b/app/javascript/widget/i18n/locale/he.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "אין אמוג'י שתואם את החיפוש שלך",
"ARIA_LABEL": "בוחר אימוג'י"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "אין אמוג'י שתואם את החיפוש שלך"
+ },
"CSAT": {
"TITLE": "דרג את השיחה שלך",
"SUBMITTED_TITLE": "תודה על שליחת הדירוג",
diff --git a/app/javascript/widget/i18n/locale/hi.json b/app/javascript/widget/i18n/locale/hi.json
index c3d6ddfc1..82271f4de 100644
--- a/app/javascript/widget/i18n/locale/hi.json
+++ b/app/javascript/widget/i18n/locale/hi.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/hr.json b/app/javascript/widget/i18n/locale/hr.json
index 1ae76f16f..f10e4eef4 100644
--- a/app/javascript/widget/i18n/locale/hr.json
+++ b/app/javascript/widget/i18n/locale/hr.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Nema emojija koji odgovaraju vašoj pretrazi",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Ocijenite vaš razgovor",
"SUBMITTED_TITLE": "Hvala vam na ocjeni",
diff --git a/app/javascript/widget/i18n/locale/hu.json b/app/javascript/widget/i18n/locale/hu.json
index 5ea7c095a..2bca22079 100644
--- a/app/javascript/widget/i18n/locale/hu.json
+++ b/app/javascript/widget/i18n/locale/hu.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Nem található emoji",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Nem található emoji"
+ },
"CSAT": {
"TITLE": "Értékeld a beszélgetést",
"SUBMITTED_TITLE": "Köszönjük, hogy elküldted az értékelést",
diff --git a/app/javascript/widget/i18n/locale/hy.json b/app/javascript/widget/i18n/locale/hy.json
index c3d6ddfc1..82271f4de 100644
--- a/app/javascript/widget/i18n/locale/hy.json
+++ b/app/javascript/widget/i18n/locale/hy.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/id.json b/app/javascript/widget/i18n/locale/id.json
index 59f86956f..d65558010 100644
--- a/app/javascript/widget/i18n/locale/id.json
+++ b/app/javascript/widget/i18n/locale/id.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Tidak ada emoji yang cocok dengan penelusuran anda",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Tidak ditemukan emoji yang sesuai dengan pencarian Anda"
+ },
"CSAT": {
"TITLE": "Nilai percakapan anda",
"SUBMITTED_TITLE": "Terima kasih telah mengirimkan penilaian",
diff --git a/app/javascript/widget/i18n/locale/is.json b/app/javascript/widget/i18n/locale/is.json
index 0ab53118a..14d5e585b 100644
--- a/app/javascript/widget/i18n/locale/is.json
+++ b/app/javascript/widget/i18n/locale/is.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Enginn emoji fannst",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Gefðu samtalinu einkunn",
"SUBMITTED_TITLE": "Takk fyrir að gefa samtalinu einkunn",
diff --git a/app/javascript/widget/i18n/locale/it.json b/app/javascript/widget/i18n/locale/it.json
index d5ec381d2..5d3c37acd 100644
--- a/app/javascript/widget/i18n/locale/it.json
+++ b/app/javascript/widget/i18n/locale/it.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Nessuna emoji corrisponde alla tua ricerca",
"ARIA_LABEL": "Selettore emoji"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Nessuna emoji corrisponde alla tua ricerca"
+ },
"CSAT": {
"TITLE": "Valuta la conversazione",
"SUBMITTED_TITLE": "Grazie per aver inviato la valutazione",
diff --git a/app/javascript/widget/i18n/locale/ja.json b/app/javascript/widget/i18n/locale/ja.json
index b8300fbab..a915271f9 100644
--- a/app/javascript/widget/i18n/locale/ja.json
+++ b/app/javascript/widget/i18n/locale/ja.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "検索条件に一致する絵文字がありません",
"ARIA_LABEL": "絵文字選択"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "検索条件に一致する絵文字が見つかりません"
+ },
"CSAT": {
"TITLE": "会話の評価",
"SUBMITTED_TITLE": "評価を送信いただきありがとうございます",
diff --git a/app/javascript/widget/i18n/locale/ka.json b/app/javascript/widget/i18n/locale/ka.json
index ce5f10c07..e9ec1ea08 100644
--- a/app/javascript/widget/i18n/locale/ka.json
+++ b/app/javascript/widget/i18n/locale/ka.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/ko.json b/app/javascript/widget/i18n/locale/ko.json
index d9f213da4..379cba948 100644
--- a/app/javascript/widget/i18n/locale/ko.json
+++ b/app/javascript/widget/i18n/locale/ko.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "검색과 일치하는 이모지 없음",
"ARIA_LABEL": "이모지 선택기"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "검색과 일치하는 이모지가 없습니다"
+ },
"CSAT": {
"TITLE": "대화를 평가해주세요.",
"SUBMITTED_TITLE": "평가해주셔서 감사합니다!",
diff --git a/app/javascript/widget/i18n/locale/lt.json b/app/javascript/widget/i18n/locale/lt.json
index a11db6919..5d07721e5 100644
--- a/app/javascript/widget/i18n/locale/lt.json
+++ b/app/javascript/widget/i18n/locale/lt.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Jūsų paieška neatitinka jokių jaustukų",
"ARIA_LABEL": "Jaustukų pasirinkimas"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Jūsų paieška neatitinka jokių emodži"
+ },
"CSAT": {
"TITLE": "Įvertinkite savo pokalbį",
"SUBMITTED_TITLE": "Dėkojame, kad pateikėte įvertinimą",
diff --git a/app/javascript/widget/i18n/locale/lv.json b/app/javascript/widget/i18n/locale/lv.json
index 702fb041f..8d5b93417 100644
--- a/app/javascript/widget/i18n/locale/lv.json
+++ b/app/javascript/widget/i18n/locale/lv.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Neviena emocijzīme neatbilst jūsu meklēšanas vaicājumam",
"ARIA_LABEL": "Emocijzīmju atlasītājs"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Neviena emocijzīme neatbilst jūsu meklēšanas vaicājumam"
+ },
"CSAT": {
"TITLE": "Novērtējiet Jūsu sarunu",
"SUBMITTED_TITLE": "Paldies, ka iesniedzāt novērtējumu",
diff --git a/app/javascript/widget/i18n/locale/ml.json b/app/javascript/widget/i18n/locale/ml.json
index 96a650cb4..5fecfb403 100644
--- a/app/javascript/widget/i18n/locale/ml.json
+++ b/app/javascript/widget/i18n/locale/ml.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "നിങ്ങളുടെ സംഭാഷണം റേറ്റുചെയ്യുക",
"SUBMITTED_TITLE": "റേറ്റിംഗ് സമർപ്പിച്ചതിന് നന്ദി",
diff --git a/app/javascript/widget/i18n/locale/ms.json b/app/javascript/widget/i18n/locale/ms.json
index d813e4eb1..3fd8aa653 100644
--- a/app/javascript/widget/i18n/locale/ms.json
+++ b/app/javascript/widget/i18n/locale/ms.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/ne.json b/app/javascript/widget/i18n/locale/ne.json
index 20120afd9..f1959ec1e 100644
--- a/app/javascript/widget/i18n/locale/ne.json
+++ b/app/javascript/widget/i18n/locale/ne.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/nl.json b/app/javascript/widget/i18n/locale/nl.json
index 2f123f80a..271d9feed 100644
--- a/app/javascript/widget/i18n/locale/nl.json
+++ b/app/javascript/widget/i18n/locale/nl.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Er zijn geen overeenkomende emoji's gevonden",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Er zijn geen overeenkomende emoji's gevonden"
+ },
"CSAT": {
"TITLE": "Beoordeel uw gesprek",
"SUBMITTED_TITLE": "Bedankt voor het indienen van een beoordeling",
diff --git a/app/javascript/widget/i18n/locale/no.json b/app/javascript/widget/i18n/locale/no.json
index 9034df45b..92742d443 100644
--- a/app/javascript/widget/i18n/locale/no.json
+++ b/app/javascript/widget/i18n/locale/no.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Ingen emojier samsvarer søket ditt",
"ARIA_LABEL": "Emoji velger"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Vurder samtalen din",
"SUBMITTED_TITLE": "Takk for at du ga din vurdering",
diff --git a/app/javascript/widget/i18n/locale/pl.json b/app/javascript/widget/i18n/locale/pl.json
index c2095692f..7677a5ea2 100644
--- a/app/javascript/widget/i18n/locale/pl.json
+++ b/app/javascript/widget/i18n/locale/pl.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Nie znaleziono emoji pasującego do wyszukiwania",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Nie znaleziono emoji pasującego do wyszukiwania"
+ },
"CSAT": {
"TITLE": "Oceń udzielone Ci wsparcie",
"SUBMITTED_TITLE": "Dziękujemy za przesłanie oceny",
diff --git a/app/javascript/widget/i18n/locale/pt.json b/app/javascript/widget/i18n/locale/pt.json
index 300ffb59f..0dfebb81e 100644
--- a/app/javascript/widget/i18n/locale/pt.json
+++ b/app/javascript/widget/i18n/locale/pt.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Nenhum emoji corresponde à sua pesquisa",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Nenhum emoji corresponde à sua pesquisa"
+ },
"CSAT": {
"TITLE": "Avalie a sua conversa",
"SUBMITTED_TITLE": "Obrigado pela sua avaliação",
diff --git a/app/javascript/widget/i18n/locale/pt_BR.json b/app/javascript/widget/i18n/locale/pt_BR.json
index c5c85a251..7c9422df9 100644
--- a/app/javascript/widget/i18n/locale/pt_BR.json
+++ b/app/javascript/widget/i18n/locale/pt_BR.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Nenhum emoji corresponde à sua pesquisa",
"ARIA_LABEL": "Seletor de emoji"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Pesquisar emoji…",
+ "FREQUENTLY_USED": "Usados com frequência",
+ "NO_EMOJI": "Nenhum emoji corresponde à sua pesquisa"
+ },
"CSAT": {
"TITLE": "Avalie sua conversa",
"SUBMITTED_TITLE": "Obrigado por enviar a classificação",
diff --git a/app/javascript/widget/i18n/locale/ro.json b/app/javascript/widget/i18n/locale/ro.json
index cd77700b8..bfd5ca849 100644
--- a/app/javascript/widget/i18n/locale/ro.json
+++ b/app/javascript/widget/i18n/locale/ro.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Niciun emoji nu corespunde căutării tale",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Niciun emoji nu corespunde căutării tale"
+ },
"CSAT": {
"TITLE": "Evaluează conversația ta",
"SUBMITTED_TITLE": "Vă mulțumim pentru trimiterea de rating",
diff --git a/app/javascript/widget/i18n/locale/ru.json b/app/javascript/widget/i18n/locale/ru.json
index a2d1005c1..397088273 100644
--- a/app/javascript/widget/i18n/locale/ru.json
+++ b/app/javascript/widget/i18n/locale/ru.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "По Вашему запросу не было найдено эмодзи",
"ARIA_LABEL": "Выбор эмодзи"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Эмодзи не соответствуют вашему запросу"
+ },
"CSAT": {
"TITLE": "Оцените разговор",
"SUBMITTED_TITLE": "Спасибо за оценку",
diff --git a/app/javascript/widget/i18n/locale/sh.json b/app/javascript/widget/i18n/locale/sh.json
index c3d6ddfc1..82271f4de 100644
--- a/app/javascript/widget/i18n/locale/sh.json
+++ b/app/javascript/widget/i18n/locale/sh.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/sk.json b/app/javascript/widget/i18n/locale/sk.json
index b7ac2a763..49865cd03 100644
--- a/app/javascript/widget/i18n/locale/sk.json
+++ b/app/javascript/widget/i18n/locale/sk.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Ohodnoťte konverzáciu",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/sl.json b/app/javascript/widget/i18n/locale/sl.json
index 0c95f1268..b06eaeeaf 100644
--- a/app/javascript/widget/i18n/locale/sl.json
+++ b/app/javascript/widget/i18n/locale/sl.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Noben emoji ne ustreza vašemu iskanju",
"ARIA_LABEL": "Iskalnik emoji-jev"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Z vašim iskanjem se ne ujema noben emoji"
+ },
"CSAT": {
"TITLE": "Ocenite svoj pogovor",
"SUBMITTED_TITLE": "Hvala za oddano oceno",
diff --git a/app/javascript/widget/i18n/locale/sq.json b/app/javascript/widget/i18n/locale/sq.json
index 3efbe543e..299cf6999 100644
--- a/app/javascript/widget/i18n/locale/sq.json
+++ b/app/javascript/widget/i18n/locale/sq.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/sr.json b/app/javascript/widget/i18n/locale/sr.json
index c0eae2c59..b0e1e8205 100644
--- a/app/javascript/widget/i18n/locale/sr.json
+++ b/app/javascript/widget/i18n/locale/sr.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Ocenite razgovor",
"SUBMITTED_TITLE": "Hvala vam na dostavljenoj oceni",
diff --git a/app/javascript/widget/i18n/locale/sv.json b/app/javascript/widget/i18n/locale/sv.json
index 4d6e1723c..3ee701e1d 100644
--- a/app/javascript/widget/i18n/locale/sv.json
+++ b/app/javascript/widget/i18n/locale/sv.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Ingen emoji matchar din sökning",
"ARIA_LABEL": "Emoji-väljare"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Ingen emoji matchar din sökning"
+ },
"CSAT": {
"TITLE": "Betygsätt din konversation",
"SUBMITTED_TITLE": "Tack för att du lämnat in omdömet",
diff --git a/app/javascript/widget/i18n/locale/ta.json b/app/javascript/widget/i18n/locale/ta.json
index 91ccddf96..922f50d91 100644
--- a/app/javascript/widget/i18n/locale/ta.json
+++ b/app/javascript/widget/i18n/locale/ta.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/th.json b/app/javascript/widget/i18n/locale/th.json
index fcfed1fde..1c4781380 100644
--- a/app/javascript/widget/i18n/locale/th.json
+++ b/app/javascript/widget/i18n/locale/th.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "ให้คะแนนการสนทนาครั้งนี้",
"SUBMITTED_TITLE": "ขอบคุณที่ร่วมมือให้คะแนนการสนทนากับเรา",
diff --git a/app/javascript/widget/i18n/locale/tl.json b/app/javascript/widget/i18n/locale/tl.json
index c3d6ddfc1..82271f4de 100644
--- a/app/javascript/widget/i18n/locale/tl.json
+++ b/app/javascript/widget/i18n/locale/tl.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/tr.json b/app/javascript/widget/i18n/locale/tr.json
index 7c552eac5..d3f716523 100644
--- a/app/javascript/widget/i18n/locale/tr.json
+++ b/app/javascript/widget/i18n/locale/tr.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "İfade bulunamadı",
"ARIA_LABEL": "Emoji seçimi"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Aramanızla eşleşen emoji yok"
+ },
"CSAT": {
"TITLE": "Görüşmenizi değerlendirin",
"SUBMITTED_TITLE": "Değerlendirmeniz için teşekkürler",
diff --git a/app/javascript/widget/i18n/locale/uk.json b/app/javascript/widget/i18n/locale/uk.json
index d31df8ab0..4c028be58 100644
--- a/app/javascript/widget/i18n/locale/uk.json
+++ b/app/javascript/widget/i18n/locale/uk.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Немає емодзі, що відповідають пошуковому запиту",
"ARIA_LABEL": "Вибір емодзі"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "Немає смайликів, які відповідають вашому запиту"
+ },
"CSAT": {
"TITLE": "Оцініть вашу бесіду",
"SUBMITTED_TITLE": "Дякуємо за оцінку",
diff --git a/app/javascript/widget/i18n/locale/ur.json b/app/javascript/widget/i18n/locale/ur.json
index 7b03483a4..16cf1968f 100644
--- a/app/javascript/widget/i18n/locale/ur.json
+++ b/app/javascript/widget/i18n/locale/ur.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/ur_IN.json b/app/javascript/widget/i18n/locale/ur_IN.json
index c3d6ddfc1..82271f4de 100644
--- a/app/javascript/widget/i18n/locale/ur_IN.json
+++ b/app/javascript/widget/i18n/locale/ur_IN.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
diff --git a/app/javascript/widget/i18n/locale/uz.json b/app/javascript/widget/i18n/locale/uz.json
new file mode 100644
index 000000000..3e0b06a5d
--- /dev/null
+++ b/app/javascript/widget/i18n/locale/uz.json
@@ -0,0 +1,158 @@
+{
+ "COMPONENTS": {
+ "FILE_BUBBLE": {
+ "DOWNLOAD": "Yuklab olish",
+ "UPLOADING": "Yuklanmoqda..."
+ },
+ "FORM_BUBBLE": {
+ "SUBMIT": "Yuborish"
+ },
+ "MESSAGE_BUBBLE": {
+ "RETRY": "Xabarni qayta yuborish",
+ "ERROR_MESSAGE": "Yuborib bo'lmadi, qayta urinib ko'ring"
+ }
+ },
+ "THUMBNAIL": {
+ "AUTHOR": {
+ "NOT_AVAILABLE": "Mavjud emas"
+ }
+ },
+ "TEAM_AVAILABILITY": {
+ "ONLINE": "Biz onlaynmiz",
+ "OFFLINE": "Hozircha javob bera olmaymiz",
+ "BACK_AS_SOON_AS_POSSIBLE": "Imkon qadar tezroq qaytamiz"
+ },
+ "REPLY_TIME": {
+ "IN_A_FEW_MINUTES": "Odatda bir necha daqiqada javob beramiz",
+ "IN_A_FEW_HOURS": "Odatda bir necha soatda javob beramiz",
+ "IN_A_DAY": "Odatda bir kun ichida javob beramiz",
+ "BACK_IN_HOURS": "{n} soatdan keyin onlayn bo'lamiz | {n} soatdan keyin onlayn bo'lamiz",
+ "BACK_IN_MINUTES": "{time} daqiqadan keyin onlayn bo'lamiz",
+ "BACK_AT_TIME": "Soat {time} da onlayn bo'lamiz",
+ "BACK_ON_DAY": "{day} kuni onlayn bo'lamiz",
+ "BACK_TOMORROW": "Ertaga onlayn bo'lamiz",
+ "BACK_IN_SOME_TIME": "Birozdan keyin onlayn bo'lamiz"
+ },
+ "DAY_NAMES": {
+ "SUNDAY": "Yakshanba",
+ "MONDAY": "Dushanba",
+ "TUESDAY": "Seshanba",
+ "WEDNESDAY": "Chorshanba",
+ "THURSDAY": "Payshanba",
+ "FRIDAY": "Juma",
+ "SATURDAY": "Shanba"
+ },
+ "START_CONVERSATION": "Suhbatni boshlash",
+ "END_CONVERSATION": "Suhbatni tugatish",
+ "CONTINUE_CONVERSATION": "Suhbatni davom ettirish",
+ "YOU": "Siz",
+ "START_NEW_CONVERSATION": "Yangi suhbatni boshlash",
+ "VIEW_UNREAD_MESSAGES": "Sizda o'qilmagan xabarlar bor",
+ "UNREAD_VIEW": {
+ "VIEW_MESSAGES_BUTTON": "Yangi xabarlarni ko'rish",
+ "CLOSE_MESSAGES_BUTTON": "Yopish",
+ "COMPANY_FROM": "tomonidan",
+ "BOT": "Bot"
+ },
+ "BUBBLE": {
+ "LABEL": "Biz bilan suhbatlashing"
+ },
+ "POWERED_BY": "Chatwoot yordamida ishlaydi",
+ "EMAIL_PLACEHOLDER": "Iltimos, emailingizni kiriting",
+ "CHAT_PLACEHOLDER": "Xabaringizni yozing",
+ "TODAY": "Bugun",
+ "YESTERDAY": "Kecha",
+ "PRE_CHAT_FORM": {
+ "FIELDS": {
+ "FULL_NAME": {
+ "LABEL": "To'liq ism",
+ "PLACEHOLDER": "Iltimos, to'liq ismingizni kiriting",
+ "REQUIRED_ERROR": "To'liq ism kiritilishi shart"
+ },
+ "EMAIL_ADDRESS": {
+ "LABEL": "Email manzili",
+ "PLACEHOLDER": "Iltimos, email manzilingizni kiriting",
+ "REQUIRED_ERROR": "Email manzilini kiritish shart",
+ "VALID_ERROR": "Iltimos, to'g'ri email manzilini kiriting"
+ },
+ "PHONE_NUMBER": {
+ "LABEL": "Telefon raqami",
+ "PLACEHOLDER": "Iltimos, telefon raqamingizni kiriting",
+ "REQUIRED_ERROR": "Telefon raqami kiritilishi shart",
+ "DIAL_CODE_VALID_ERROR": "Iltimos, mamlakat kodini tanlang",
+ "VALID_ERROR": "Iltimos, to'g'ri telefon raqamini kiriting",
+ "DROPDOWN_EMPTY": "Natija topilmadi",
+ "DROPDOWN_SEARCH": "Mamlakatni qidirish"
+ },
+ "MESSAGE": {
+ "LABEL": "Xabar",
+ "PLACEHOLDER": "Iltimos, xabaringizni kiriting",
+ "ERROR": "Xabar juda qisqa"
+ }
+ },
+ "CAMPAIGN_HEADER": "Suhbatni boshlashdan oldin ism va emailingizni kiriting",
+ "IS_REQUIRED": "kiritilishi shart",
+ "REQUIRED": "Majburiy",
+ "REGEX_ERROR": "Iltimos, to'g'ri ma'lumot kiriting"
+ },
+ "FILE_SIZE_LIMIT": "Fayl {MAXIMUM_FILE_UPLOAD_SIZE} biriktirma chegarasidan oshib ketdi",
+ "CHAT_FORM": {
+ "INVALID": {
+ "FIELD": "Noto'g'ri maydon"
+ }
+ },
+ "EMOJI": {
+ "PLACEHOLDER": "Emoji qidirish",
+ "NOT_FOUND": "Qidiruvga mos emoji topilmadi",
+ "ARIA_LABEL": "Emoji tanlagich"
+ },
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Emojilarni qidirish…",
+ "FREQUENTLY_USED": "Ko'p ishlatiladiganlar",
+ "NO_EMOJI": "Qidiruvga mos emoji topilmadi"
+ },
+ "CSAT": {
+ "TITLE": "Suhbatingizni baholang",
+ "SUBMITTED_TITLE": "Baho berganingiz uchun rahmat",
+ "PLACEHOLDER": "Batafsil aytib bering..."
+ },
+ "EMAIL_TRANSCRIPT": {
+ "BUTTON_TEXT": "Suhbat tarixini so'rash",
+ "SEND_EMAIL_SUCCESS": "Suhbat tarixi muvaffaqiyatli yuborildi",
+ "SEND_EMAIL_ERROR": "Xatolik yuz berdi, qayta urinib ko'ring"
+ },
+ "INTEGRATIONS": {
+ "DYTE": {
+ "CLICK_HERE_TO_JOIN": "Qo'shilish uchun shu yerni bosing",
+ "LEAVE_THE_ROOM": "Qo'ng'iroqni tark etish"
+ }
+ },
+ "PORTAL": {
+ "POPULAR_ARTICLES": "Ommabop maqolalar",
+ "VIEW_ALL_ARTICLES": "Barcha maqolalarni ko'rish",
+ "IFRAME_LOAD_ERROR": "Maqolani yuklashda xatolik yuz berdi, sahifani yangilab qayta urinib ko'ring."
+ },
+ "ATTACHMENTS": {
+ "image": {
+ "CONTENT": "Rasmli xabar"
+ },
+ "audio": {
+ "CONTENT": "Audio xabar"
+ },
+ "video": {
+ "CONTENT": "Video xabar"
+ },
+ "file": {
+ "CONTENT": "Fayl biriktirmasi"
+ },
+ "location": {
+ "CONTENT": "Joylashuv"
+ },
+ "fallback": {
+ "CONTENT": "havola ulashdi"
+ }
+ },
+ "FOOTER_REPLY_TO": {
+ "REPLY_TO": "Javob berilmoqda:"
+ }
+}
diff --git a/app/javascript/widget/i18n/locale/vi.json b/app/javascript/widget/i18n/locale/vi.json
index 312883388..318316f3f 100644
--- a/app/javascript/widget/i18n/locale/vi.json
+++ b/app/javascript/widget/i18n/locale/vi.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "Không có biểu tượng cảm xúc nào phù hợp với tìm kiếm của bạn",
"ARIA_LABEL": "Emoji picker"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "No emoji match your search"
+ },
"CSAT": {
"TITLE": "Đánh giá hội thoại",
"SUBMITTED_TITLE": "Cảm ơn vì đã đánh giá",
diff --git a/app/javascript/widget/i18n/locale/zh_CN.json b/app/javascript/widget/i18n/locale/zh_CN.json
index dc4a1cebe..ac30579e8 100644
--- a/app/javascript/widget/i18n/locale/zh_CN.json
+++ b/app/javascript/widget/i18n/locale/zh_CN.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "没有适合你的搜索结果",
"ARIA_LABEL": "Emoji选择器"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "没有适合你的搜索结果"
+ },
"CSAT": {
"TITLE": "评价您的对话",
"SUBMITTED_TITLE": "感谢您提交评分",
diff --git a/app/javascript/widget/i18n/locale/zh_TW.json b/app/javascript/widget/i18n/locale/zh_TW.json
index bd401a8ca..bfc737d91 100644
--- a/app/javascript/widget/i18n/locale/zh_TW.json
+++ b/app/javascript/widget/i18n/locale/zh_TW.json
@@ -106,6 +106,11 @@
"NOT_FOUND": "查無相符的 emoji",
"ARIA_LABEL": "表情選擇"
},
+ "EMOJI_ICON_PICKER": {
+ "SEARCH_EMOJI": "Search emoji…",
+ "FREQUENTLY_USED": "Frequently used",
+ "NO_EMOJI": "找不到符合的表情符號"
+ },
"CSAT": {
"TITLE": "為此對話評分",
"SUBMITTED_TITLE": "感謝您提交的評分",
diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb
index 14429e61c..f81ce242b 100644
--- a/app/jobs/webhooks/whatsapp_events_job.rb
+++ b/app/jobs/webhooks/whatsapp_events_job.rb
@@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
end
def get_channel_from_wb_payload(wb_params)
- phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
- phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
- channel = Channel::Whatsapp.find_by(phone_number: phone_number)
- # validate to ensure the phone number id matches the whatsapp channel
- return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
+ metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
+ Whatsapp::WebhookChannelFinderService.new(
+ display_phone_number: metadata[:display_phone_number],
+ phone_number_id: metadata[:phone_number_id]
+ ).perform
end
end
diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb
index 20e194fed..22b176421 100644
--- a/app/mailers/conversation_reply_mailer.rb
+++ b/app/mailers/conversation_reply_mailer.rb
@@ -69,6 +69,8 @@ class ConversationReplyMailer < ApplicationMailer
@agent = @conversation.assignee
@inbox = @conversation.inbox
@channel = @inbox.channel
+ Current.account = @account
+ Current.inbox = @inbox
end
def should_use_conversation_email_address?
@@ -200,8 +202,24 @@ class ConversationReplyMailer < ApplicationMailer
end
def choose_layout
+ return 'mailer/base' if branded_email_layout_action?
return false if action_name == 'reply_without_summary' || action_name == 'email_reply'
'mailer/base'
end
+
+ def branded_email_layout_action?
+ return false unless action_name.in?(%w[email_reply reply_without_summary])
+ return @inbox.branded_email_layout_available? if @inbox&.email?
+
+ @account&.feature_enabled?(:branded_email_templates) && EmailTemplate.account_branded_layout_template_for(@account).present?
+ end
+
+ def liquid_droppables
+ super.merge({
+ agent: current_message&.sender || @agent,
+ contact: @contact,
+ message: @message || @messages&.last
+ })
+ end
end
diff --git a/app/models/concerns/inbox_branded_email_layoutable.rb b/app/models/concerns/inbox_branded_email_layoutable.rb
new file mode 100644
index 000000000..196ada3e2
--- /dev/null
+++ b/app/models/concerns/inbox_branded_email_layoutable.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module InboxBrandedEmailLayoutable
+ extend ActiveSupport::Concern
+
+ def branded_email_layout
+ branded_email_layout_template&.body
+ end
+
+ def branded_email_layout_template
+ email_templates.find_by(name: EmailTemplate::BRANDED_LAYOUT_NAME, template_type: :layout, locale: EmailTemplate::DEFAULT_LOCALE)
+ end
+
+ def effective_branded_email_layout_template(locale = I18n.locale)
+ EmailTemplate.branded_layout_for(inbox: self, account: account, locale: locale)
+ end
+
+ def branded_email_layout_available?
+ email? && account.feature_enabled?(:branded_email_templates) && effective_branded_email_layout_template.present?
+ end
+
+ def update_branded_email_layout!(body)
+ if body.blank?
+ branded_email_layout_template&.destroy!
+ return
+ end
+
+ template = branded_email_layout_template || email_templates.new(
+ name: EmailTemplate::BRANDED_LAYOUT_NAME,
+ template_type: :layout,
+ locale: EmailTemplate::DEFAULT_LOCALE,
+ account: account
+ )
+ template.update!(body: body)
+ end
+end
diff --git a/app/models/concerns/portal_config_schema.rb b/app/models/concerns/portal_config_schema.rb
index de338b830..7e506a076 100644
--- a/app/models/concerns/portal_config_schema.rb
+++ b/app/models/concerns/portal_config_schema.rb
@@ -14,6 +14,18 @@ module PortalConfigSchema
'additionalProperties' => false
}.freeze
+ # Per-locale recommended content for the portal home page: an ordered list of
+ # `category_ids` (the hero's "Recommended topics" pills) and `article_ids` (the
+ # "Recommended" articles section). When empty, the portal uses its defaults.
+ POPULAR_CONTENT_SCHEMA = {
+ 'type' => 'object',
+ 'properties' => {
+ 'category_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } },
+ 'article_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } }
+ },
+ 'additionalProperties' => false
+ }.freeze
+
CONFIG_PARAMS_SCHEMA = {
'type' => 'object',
'properties' => {
@@ -27,6 +39,10 @@ module PortalConfigSchema
'locale_translations' => {
'type' => %w[object null],
'additionalProperties' => LOCALE_TRANSLATION_SCHEMA
+ },
+ 'popular_content' => {
+ 'type' => %w[object null],
+ 'additionalProperties' => POPULAR_CONTENT_SCHEMA
}
},
'required' => [],
diff --git a/app/models/email_template.rb b/app/models/email_template.rb
index 57db7a94c..dd7f20f6e 100644
--- a/app/models/email_template.rb
+++ b/app/models/email_template.rb
@@ -10,19 +10,111 @@
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
+# inbox_id :integer
#
# Indexes
#
-# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE
+# index_email_templates_on_account_scope (account_id,name,template_type,locale) UNIQUE WHERE ((account_id IS NOT NULL) AND (inbox_id IS NULL))
+# index_email_templates_on_inbox_id (inbox_id)
+# index_email_templates_on_inbox_scope (inbox_id,name,template_type,locale) UNIQUE WHERE (inbox_id IS NOT NULL)
+# index_email_templates_on_installation_scope (name,template_type,locale) UNIQUE WHERE ((account_id IS NULL) AND (inbox_id IS NULL))
#
class EmailTemplate < ApplicationRecord
+ BRANDED_LAYOUT_NAME = 'base'.freeze
+ DEFAULT_LOCALE = 'en'.freeze
+ CONTENT_FOR_LAYOUT_PATTERN = /\{\{\s*content_for_layout\s*\}\}/
+
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
enum :template_type, { layout: 0, content: 1 }
belongs_to :account, optional: true
+ belongs_to :inbox, optional: true
- validates :name, uniqueness: { scope: :account }
+ validates :name,
+ uniqueness: { scope: %i[template_type locale], conditions: -> { where(account_id: nil, inbox_id: nil) } },
+ if: :installation_scoped?
+ validates :name, uniqueness: { scope: %i[account_id template_type locale], conditions: -> { where(inbox_id: nil) } }, if: :account_scoped?
+ validates :name, uniqueness: { scope: %i[inbox_id template_type locale] }, if: :inbox_scoped?
+ validate :validate_inbox_account
+ validate :validate_liquid_body
+ validate :validate_layout_slot, if: :layout?
def self.resolver(options = {})
::EmailTemplates::DbResolverService.using self, options
end
+
+ def self.branded_layout_for(inbox:, account:, locale: I18n.locale)
+ layout_template_for_scope(inbox: inbox, account: account, locale: locale)
+ end
+
+ def self.account_branded_layout_template_for(account)
+ find_by(account: account, inbox: nil, name: BRANDED_LAYOUT_NAME, template_type: :layout, locale: DEFAULT_LOCALE)
+ end
+
+ def self.update_account_branded_layout!(account:, body:)
+ if body.blank?
+ account_branded_layout_template_for(account)&.destroy!
+ return
+ end
+
+ template = account_branded_layout_template_for(account) || new(
+ account: account,
+ name: BRANDED_LAYOUT_NAME,
+ template_type: :layout,
+ locale: DEFAULT_LOCALE
+ )
+ template.update!(body: body)
+ end
+
+ def self.locale_candidates(locale)
+ candidate = locale.to_s
+ ([candidate] + [DEFAULT_LOCALE]).select { |locale_key| locales.key?(locale_key) }.uniq
+ end
+
+ def self.layout_template_for_scope(inbox:, account:, locale:)
+ scoped_relations = []
+ scoped_relations << where(inbox: inbox) if inbox.present?
+ scoped_relations << where(account: account, inbox: nil) if account.present?
+ scoped_relations << where(account: nil, inbox: nil)
+
+ scoped_relations.each do |relation|
+ locale_candidates(locale).each do |locale_key|
+ template = relation.find_by(name: BRANDED_LAYOUT_NAME, template_type: :layout, locale: locale_key)
+ return template if template.present?
+ end
+ end
+ nil
+ end
+
+ private
+
+ def installation_scoped?
+ account_id.nil? && inbox_id.nil?
+ end
+
+ def account_scoped?
+ account_id.present? && inbox_id.nil?
+ end
+
+ def inbox_scoped?
+ inbox_id.present?
+ end
+
+ def validate_inbox_account
+ return if inbox.blank? || account.blank?
+ return if inbox.account_id == account_id
+
+ errors.add(:account, 'must match inbox account')
+ end
+
+ def validate_liquid_body
+ Liquid::Template.parse(body.to_s)
+ rescue Liquid::Error => e
+ errors.add(:body, "has invalid Liquid syntax: #{e.message}")
+ end
+
+ def validate_layout_slot
+ return if body.to_s.match?(CONTENT_FOR_LAYOUT_PATTERN)
+
+ errors.add(:body, 'must include {{ content_for_layout }}')
+ end
end
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index 1b0cfc587..69f41180a 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -45,6 +45,7 @@ class Inbox < ApplicationRecord
include OutOfOffisable
include AccountCacheRevalidator
include InboxAgentAvailability
+ include InboxBrandedEmailLayoutable
# Not allowing characters:
validates :name, presence: true
@@ -67,6 +68,7 @@ class Inbox < ApplicationRecord
has_many :members, through: :inbox_members, source: :user
has_many :conversations, dependent: :destroy_async
has_many :messages, dependent: :destroy_async
+ has_many :email_templates, dependent: :destroy_async
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
diff --git a/app/models/portal.rb b/app/models/portal.rb
index 9d2da6965..b1f387b49 100644
--- a/app/models/portal.rb
+++ b/app/models/portal.rb
@@ -53,7 +53,12 @@ class Portal < ApplicationRecord
scope :active, -> { where(archived: false) }
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
- CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze
+ CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations
+ popular_content].freeze
+
+ # Max number of recommended categories/articles shown per locale.
+ POPULAR_CATEGORY_LIMIT = 3
+ POPULAR_ARTICLE_LIMIT = 6
def file_base_data
{
@@ -115,6 +120,14 @@ class Portal < ApplicationRecord
config_value('layout').presence || 'classic'
end
+ def popular_category_ids(locale = default_locale)
+ Array(config.dig('popular_content', locale.to_s, 'category_ids')).first(POPULAR_CATEGORY_LIMIT)
+ end
+
+ def popular_article_ids(locale = default_locale)
+ Array(config.dig('popular_content', locale.to_s, 'article_ids')).first(POPULAR_ARTICLE_LIMIT)
+ end
+
def social_profiles
config_value('social_profiles') || {}
end
diff --git a/app/services/data_imports/intercom/retry_service.rb b/app/services/data_imports/intercom/retry_service.rb
index 6d0cb5c9d..57289d917 100644
--- a/app/services/data_imports/intercom/retry_service.rb
+++ b/app/services/data_imports/intercom/retry_service.rb
@@ -8,14 +8,15 @@ class DataImports::Intercom::RetryService
def perform
@account.with_lock do
- @data_import.reload
- next :not_stalled unless @data_import.stalled?
- next :active_import_exists if another_active_import?
- next :access_token_missing if @data_import.access_token.blank?
+ @data_import.with_lock do
+ next :not_stalled unless @data_import.stalled?
+ next :active_import_exists if another_active_import?
+ next :access_token_missing if @data_import.access_token.blank?
- @data_import.assign_active_intercom_import_run_id
- @data_import.update!(status: :pending)
- :enqueue
+ @data_import.assign_active_intercom_import_run_id
+ @data_import.update!(status: :pending)
+ :enqueue
+ end
end
end
diff --git a/app/services/email_templates/db_resolver_service.rb b/app/services/email_templates/db_resolver_service.rb
index 42222fe75..0a4621aa9 100644
--- a/app/services/email_templates/db_resolver_service.rb
+++ b/app/services/email_templates/db_resolver_service.rb
@@ -29,12 +29,14 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
end
# rubocop:enable Metrics/ParameterLists
- # the function has to accept(name, prefix, partial, _details, _locals = [])
- # _details contain local info which we can leverage in future
+ # the function has to accept(name, prefix, partial, details, locals = [])
+ # details contain local info which we can leverage in future
# cause of codeclimate issue with 4 args, relying on (*args)
- def find_templates(name, prefix, partial, *_args)
+ def find_templates(name, prefix, partial, *args)
@template_name = name
- @template_type = prefix.include?('layout') ? 'layout' : 'content'
+ @template_type = prefix.to_s.include?('layout') ? 'layout' : 'content'
+ @prefix = prefix
+ @details = args.first if args.first.is_a?(Hash)
@db_template = find_db_template
return [] if @db_template.blank?
@@ -54,17 +56,62 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
private
def find_db_template
- find_account_template || find_installation_template
+ find_inbox_template || find_account_template || find_installation_template
+ end
+
+ def find_inbox_template
+ return unless email_inbox_layout_lookup? && branded_email_templates_enabled?
+
+ find_template_for(@@model.where(inbox: Current.inbox))
end
def find_account_template
return unless Current.account
+ return if account_layout_lookup? && !branded_email_templates_enabled?
- @@model.find_by(name: @template_name, template_type: @template_type, account: Current.account)
+ find_template_for(@@model.where(account: Current.account, inbox: nil))
end
def find_installation_template
- @@model.find_by(name: @template_name, template_type: @template_type, account: nil)
+ find_template_for(@@model.where(account: nil, inbox: nil))
+ end
+
+ def account_layout_lookup?
+ @template_type == 'layout' && Current.account.present?
+ end
+
+ def email_inbox_layout_lookup?
+ account_layout_lookup? && Current.inbox&.email?
+ end
+
+ def branded_email_templates_enabled?
+ Current.account&.feature_enabled?(:branded_email_templates)
+ end
+
+ def find_template_for(relation)
+ locale_candidates.each do |locale|
+ template_names.each do |name|
+ template = relation.find_by(name: name, template_type: @template_type, locale: locale)
+ return template if template.present?
+ end
+ end
+
+ nil
+ end
+
+ def locale_candidates
+ locale = Array(@details&.dig(:locale)).first
+ EmailTemplate.locale_candidates(locale.presence || EmailTemplate::DEFAULT_LOCALE)
+ end
+
+ def template_names
+ [db_template_name, @template_name].uniq
+ end
+
+ def db_template_name
+ return @template_name if @template_type == 'layout'
+
+ build_path(@prefix)
end
# Build path with eventual prefix
diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb
index 00936ff15..09632c5b1 100644
--- a/app/services/whatsapp/incoming_message_base_service.rb
+++ b/app/services/whatsapp/incoming_message_base_service.rb
@@ -113,12 +113,14 @@ class Whatsapp::IncomingMessageBaseService
end
def set_conversation
+ # Scope reuse to the contact across all its contact_inboxes in this inbox: WhatsApp coexistence
+ # gives one contact multiple source_ids (phone + BSUID), so reopen must not be limited to a single contact_inbox.
+ conversations = @contact.conversations.where(inbox_id: @inbox.id)
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
@conversation = if @inbox.lock_to_single_conversation
- @contact_inbox.conversations.last
+ conversations.last
else
- @contact_inbox.conversations
- .where.not(status: :resolved).last
+ conversations.where.not(status: :resolved).last
end
return if @conversation
diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb
index 20419c0cd..b8de35d1c 100644
--- a/app/services/whatsapp/send_on_whatsapp_service.rb
+++ b/app/services/whatsapp/send_on_whatsapp_service.rb
@@ -6,12 +6,10 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end
def perform_reply
- should_send_template_message = template_params.present? || !message.conversation.can_reply?
- if should_send_template_message
- send_template_message
- else
- send_session_message
- end
+ return send_template_message if template_params.present?
+ return send_session_message if message.conversation.can_reply?
+
+ message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
end
def send_template_message
diff --git a/app/services/whatsapp/webhook_channel_finder_service.rb b/app/services/whatsapp/webhook_channel_finder_service.rb
new file mode 100644
index 000000000..32fe8356f
--- /dev/null
+++ b/app/services/whatsapp/webhook_channel_finder_service.rb
@@ -0,0 +1,35 @@
+# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's
+# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil
+# omits the mobile 9, Argentina adds a digit after the country code), so we try the
+# raw digits first and then a normalized fallback, accepting only a candidate whose
+# phone_number_id matches.
+class Whatsapp::WebhookChannelFinderService
+ def initialize(display_phone_number:, phone_number_id:)
+ @display_phone_number = display_phone_number
+ @phone_number_id = phone_number_id
+ end
+
+ def perform
+ return if digits.blank?
+
+ candidates = [
+ Channel::Whatsapp.find_by(phone_number: "+#{digits}"),
+ channel_by_normalized_number
+ ]
+ candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id }
+ end
+
+ private
+
+ def digits
+ @digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '')
+ end
+
+ def channel_by_normalized_number
+ normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
+ .lazy.map(&:new).find { |n| n.handles_country?(digits) }
+ return unless normalizer
+
+ Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}")
+ end
+end
diff --git a/app/views/api/v1/accounts/branded_email_layouts/show.json.jbuilder b/app/views/api/v1/accounts/branded_email_layouts/show.json.jbuilder
new file mode 100644
index 000000000..7a30a0b14
--- /dev/null
+++ b/app/views/api/v1/accounts/branded_email_layouts/show.json.jbuilder
@@ -0,0 +1 @@
+json.branded_email_layout @branded_email_layout
diff --git a/app/views/api/v1/accounts/branded_email_layouts/update.json.jbuilder b/app/views/api/v1/accounts/branded_email_layouts/update.json.jbuilder
new file mode 100644
index 000000000..7a30a0b14
--- /dev/null
+++ b/app/views/api/v1/accounts/branded_email_layouts/update.json.jbuilder
@@ -0,0 +1 @@
+json.branded_email_layout @branded_email_layout
diff --git a/app/views/api/v1/accounts/inboxes/show.json.jbuilder b/app/views/api/v1/accounts/inboxes/show.json.jbuilder
index 2ad94ff82..4837c8c02 100644
--- a/app/views/api/v1/accounts/inboxes/show.json.jbuilder
+++ b/app/views/api/v1/accounts/inboxes/show.json.jbuilder
@@ -1 +1 @@
-json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
+json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox, with_branded_email_layout: true
diff --git a/app/views/api/v1/accounts/inboxes/update.json.jbuilder b/app/views/api/v1/accounts/inboxes/update.json.jbuilder
index 2ad94ff82..4837c8c02 100644
--- a/app/views/api/v1/accounts/inboxes/update.json.jbuilder
+++ b/app/views/api/v1/accounts/inboxes/update.json.jbuilder
@@ -1 +1 @@
-json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
+json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox, with_branded_email_layout: true
diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
index 93626ee36..2e4e78f1c 100644
--- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder
+++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
@@ -19,6 +19,7 @@ json.config do
json.layout portal.layout
json.social_profiles portal.social_profiles
json.locale_translations portal.config['locale_translations'] || {}
+ json.popular_content portal.config['popular_content'] || {}
end
if portal.channel_web_widget
diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder
index 677e4fe9e..216f7db76 100644
--- a/app/views/api/v1/models/_inbox.json.jbuilder
+++ b/app/views/api/v1/models/_inbox.json.jbuilder
@@ -81,6 +81,10 @@ if resource.email?
json.email resource.channel.try(:email)
json.forwarding_enabled ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present?
json.forward_to_email resource.channel.try(:forward_to_email) if ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present?
+ if Current.account_user&.administrator? && defined?(with_branded_email_layout) && with_branded_email_layout.present? &&
+ Current.account.feature_enabled?(:branded_email_templates)
+ json.branded_email_layout resource.branded_email_layout
+ end
## IMAP
if Current.account_user&.administrator?
diff --git a/app/views/layouts/_portal_scripts.html.erb b/app/views/layouts/_portal_scripts.html.erb
index b1479cace..2df4a02fd 100644
--- a/app/views/layouts/_portal_scripts.html.erb
+++ b/app/views/layouts/_portal_scripts.html.erb
@@ -67,6 +67,11 @@ html.light {
#category-block:hover #category-name {
color: var(--dynamic-hover-color);
}
+/* Recommended topic pills in the classic hero */
+.recommended-pill:hover {
+ border-color: var(--dynamic-hover-color);
+ color: var(--dynamic-hover-color);
+}