diff --git a/AGENTS.md b/AGENTS.md
index 2430fae2b..82acfcc25 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -85,8 +85,8 @@
## Project-Specific
- **Translations**:
- - Only update `en.yml` and `en.json`
- - Other languages are handled by the community
+ - For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
+ - Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
diff --git a/Gemfile.lock b/Gemfile.lock
index 68674155e..46a1c0449 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -211,13 +211,14 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
- datadog (2.19.0)
- datadog-ruby_core_source (~> 3.4, >= 3.4.1)
- libdatadog (~> 18.1.0.1.0)
- libddwaf (~> 1.24.1.0.3)
+ datadog (2.38.0)
+ cgi
+ datadog-ruby_core_source (~> 3.5, >= 3.5.3)
+ libdatadog (~> 36.0.0.1.0)
+ libddwaf (~> 1.30.0.0.0)
logger
msgpack
- datadog-ruby_core_source (3.4.1)
+ datadog-ruby_core_source (3.5.3)
date (3.5.1)
debug (1.8.0)
irb (>= 1.5.0)
@@ -522,15 +523,16 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
- libdatadog (18.1.0.1.0)
- libdatadog (18.1.0.1.0-x86_64-linux)
- libddwaf (1.24.1.0.3)
+ libdatadog (36.0.0.1.0)
+ libdatadog (36.0.0.1.0-arm64-darwin)
+ libdatadog (36.0.0.1.0-x86_64-linux)
+ libddwaf (1.30.0.0.2)
ffi (~> 1.0)
- libddwaf (1.24.1.0.3-arm64-darwin)
+ libddwaf (1.30.0.0.2-arm64-darwin)
ffi (~> 1.0)
- libddwaf (1.24.1.0.3-x86_64-darwin)
+ libddwaf (1.30.0.0.2-x86_64-darwin)
ffi (~> 1.0)
- libddwaf (1.24.1.0.3-x86_64-linux)
+ libddwaf (1.30.0.0.2-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
@@ -547,7 +549,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
- loofah (2.23.1)
+ loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -740,8 +742,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.6.1)
- loofah (~> 2.21)
+ rails-html-sanitizer (1.7.1)
+ loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
diff --git a/LICENSE b/LICENSE
index 8f80f35be..d0425a707 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2017-2024 Chatwoot Inc.
+Copyright (c) 2017-2026 Chatwoot Inc.
Portions of this software are licensed as follows:
diff --git a/VERSION_CW b/VERSION_CW
index fb0557132..ecbc3b030 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.15.1
+4.16.0
diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb
index de3d10081..3f0309024 100644
--- a/app/controllers/api/v1/accounts/agent_bots_controller.rb
+++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb
index 4a8363fdd..439ba3e31 100644
--- a/app/controllers/api/v1/accounts/articles_controller.rb
+++ b/app/controllers/api/v1/accounts/articles_controller.rb
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def update
- @article.update!(article_params) if params[:article].present?
- render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
+ persist_article_changes if params[:article].present?
+ render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
end
def destroy
@@ -67,12 +67,26 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
+ # Draft-only autosaves must not bump the public-facing updated_at, so write
+ # them with update_columns (which skips the timestamp). update_columns also
+ # skips validations, so assign and validate first to avoid persisting content
+ # that exceeds the column length limit.
+ def persist_article_changes
+ keys = article_params.to_h.keys
+ if keys.any? && (keys - %w[draft_title draft_content]).empty?
+ @article.assign_attributes(article_params)
+ @article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
+ else
+ @article.update!(article_params)
+ end
+ end
+
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
- :locale, meta: [:title,
- :description,
- { tags: [] }]
+ :locale, :draft_title, :draft_content, meta: [:title,
+ :description,
+ { tags: [] }]
)
end
diff --git a/app/controllers/api/v1/accounts/base_controller.rb b/app/controllers/api/v1/accounts/base_controller.rb
index e30effc59..f08b87e60 100644
--- a/app/controllers/api/v1/accounts/base_controller.rb
+++ b/app/controllers/api/v1/accounts/base_controller.rb
@@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
+ before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
+
+ private
+
+ def validate_token_api_access
+ return if Current.account.api_and_webhooks_enabled?
+
+ render json: { error: 'API access is not enabled for this account' }, status: :forbidden
+ end
end
diff --git a/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb
new file mode 100644
index 000000000..54f976b79
--- /dev/null
+++ b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb
@@ -0,0 +1,28 @@
+class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
+ before_action :check_admin_authorization?
+
+ def show
+ set_branded_email_layout
+ end
+
+ def update
+ unless Current.account.feature_enabled?(:branded_email_templates)
+ render_could_not_create_error('Branded email templates feature is not enabled')
+ return
+ end
+
+ branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
+ EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
+ set_branded_email_layout
+ rescue ActiveRecord::RecordInvalid => e
+ render_could_not_create_error(e.record.errors.full_messages.join(', '))
+ end
+
+ private
+
+ def set_branded_email_layout
+ @branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
+ end
+end
+
+Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
index 482b001d6..3b7fafad5 100644
--- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb
+++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
diff --git a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb
index f4ac05d6e..915ade8a3 100644
--- a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb
@@ -1,6 +1,17 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
+ include DeviseTokenAuth::Concerns::SetUserByToken
+ include RequestExceptionHandler
+ include AccessTokenAuthHelper
include EnsureCurrentAccountHelper
+
+ skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
+
+ around_action :handle_with_exception
+ before_action :authenticate_access_token!, if: :authenticate_by_access_token?
+ before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
+ before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :current_account
+ before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :conversation
def create
@@ -11,6 +22,16 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
private
+ def authenticate_by_access_token?
+ request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
+ end
+
+ def validate_token_api_access
+ return if Current.account.api_and_webhooks_enabled?
+
+ render json: { error: 'API access is not enabled for this account' }, status: :forbidden
+ end
+
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index bb9ca2a70..822fce7db 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -45,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def update
- inbox_params = permitted_params.except(:channel, :csat_config)
- inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
- @inbox.update!(inbox_params)
- update_inbox_working_hours
- update_channel if channel_update_required?
+ continue_update = false
+
+ ActiveRecord::Base.transaction do
+ continue_update = update_branded_email_layout
+ raise ActiveRecord::Rollback unless continue_update
+
+ inbox_params = permitted_params.except(:channel, :csat_config)
+ inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
+ @inbox.update!(inbox_params)
+ update_inbox_working_hours
+ update_channel if channel_update_required?
+ end
+
+ return unless continue_update
end
def agent_bot
@@ -155,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
formatted['template'] = config['template'] if config['template'].present?
end
+ def update_branded_email_layout
+ return true unless params.key?(:branded_email_layout)
+
+ branded_email_layout = normalized_branded_email_layout
+
+ unless Current.account.feature_enabled?(:branded_email_templates)
+ return true if branded_email_layout.blank?
+
+ render_could_not_create_error('Branded email templates feature is not enabled')
+ return false
+ end
+
+ unless @inbox.email?
+ return true if branded_email_layout.blank?
+
+ render_could_not_create_error('Branded email layout is only supported for email inboxes')
+ return false
+ end
+
+ @inbox.update_branded_email_layout!(branded_email_layout)
+ true
+ rescue ActiveRecord::RecordInvalid => e
+ render_could_not_create_error(e.record.errors.full_messages.join(', '))
+ false
+ end
+
+ def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
+
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
diff --git a/app/controllers/api/v1/accounts/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 db94113d9..46d89a1ba 100644
--- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
@@ -1,4 +1,7 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
+ before_action :ensure_embedded_signup_enabled
+ # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
+ before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -16,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
private
+ def ensure_embedded_signup_enabled
+ return unless ChatwootApp.chatwoot_cloud?
+ return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
+
+ raise Pundit::NotAuthorizedError
+ end
+
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
@@ -31,7 +41,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
end
def validate_reauthorization_required
- return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
+ return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
render json: {
success: false,
@@ -39,9 +49,11 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
- def can_upgrade_to_embedded_signup?
+ def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
+ return true if ChatwootApp.chatwoot_cloud?
+ return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index fb991949a..9438a1660 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
+ before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
+ def validate_token_api_access
+ return if @account.api_and_webhooks_enabled?
+
+ render json: { error: 'API access is not enabled for this account' }, status: :forbidden
+ end
+
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb
index ea36a48f2..7ed39fa98 100644
--- a/app/controllers/concerns/ensure_current_account_helper.rb
+++ b/app/controllers/concerns/ensure_current_account_helper.rb
@@ -14,6 +14,8 @@ module EnsureCurrentAccountHelper
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
+ else
+ render_unauthorized(I18n.t('errors.account.not_authorized'))
end
account
end
@@ -21,7 +23,7 @@ module EnsureCurrentAccountHelper
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
- render_unauthorized('You are not authorized to access this account') unless @current_account_user
+ render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
end
def account_accessible_for_bot?(account)
diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb
index 633071301..71eaad081 100644
--- a/app/controllers/concerns/portal_home_data.rb
+++ b/app/controllers/concerns/portal_home_data.rb
@@ -4,17 +4,53 @@ module PortalHomeData
private
def load_home_data
- base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
+ load_recommended_content
+ # The classic hero only needs the recommendations above; the rest is
+ # documentation-layout home data (also used on custom-domain home pages).
+ return unless @portal.layout == 'documentation'
+
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
- @popular_topics = @visible_categories.first(3)
+ @popular_topics = @recommended_categories.presence || @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
+ def load_recommended_content
+ @recommended_categories = recommended_categories
+ @recommended_articles = recommended_articles
+ end
+
+ def base_articles
+ @base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
+ end
+
+ # Admin-recommended categories for the locale, in the chosen order. Unlike the
+ # position-based fallback, published articles aren't required: the admin's pick wins.
+ def recommended_categories
+ ids = @portal.popular_category_ids(@locale)
+ ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
+ end
+
+ # Admin-recommended articles for the locale, in the chosen order, limited to
+ # published articles that still exist.
+ def recommended_articles
+ ids = @portal.popular_article_ids(@locale)
+ ordered_by_ids(base_articles.where(id: ids), ids)
+ end
+
+ # Loads the scope and returns its records ordered to match `ids`, dropping any
+ # that no longer exist. Skips the query entirely when `ids` is blank.
+ def ordered_by_ids(scope, ids)
+ return [] if ids.blank?
+
+ by_id = scope.index_by(&:id)
+ ids.filter_map { |id| by_id[id] }
+ end
+
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index a369830b6..a72687b42 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -64,10 +64,8 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
- if @portal.layout == 'documentation'
- request.variant = :documentation
- load_home_data
- end
+ request.variant = :documentation if @portal.layout == 'documentation'
+ load_home_data
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb
index 323440304..7b592fbd8 100644
--- a/app/controllers/public/api/v1/portals/base_controller.rb
+++ b/app/controllers/public/api/v1/portals/base_controller.rb
@@ -24,7 +24,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_view_variant
- request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
+ request.variant = if @is_plain_layout_enabled
+ :plain
+ elsif @portal_layout == 'documentation'
+ :documentation
+ end
end
def portal
@@ -65,6 +69,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
def render_404
portal
+ # set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
+ set_view_variant
render 'public/api/v1/portals/error/404', status: :not_found
end
diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb
index 4982278d7..da7d9e6a5 100644
--- a/app/controllers/public/api/v1/portals_controller.rb
+++ b/app/controllers/public/api/v1/portals_controller.rb
@@ -7,7 +7,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
- before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
+ before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
layout 'portal'
def show
diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb
index ee71f3c92..8fd18678c 100644
--- a/app/controllers/webhooks/whatsapp_controller.rb
+++ b/app/controllers/webhooks/whatsapp_controller.rb
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
- phone_number = normalized_phone_number(metadata[:display_phone_number])
- phone_number_id = metadata[:phone_number_id]
- channel = Channel::Whatsapp.find_by(phone_number: phone_number)
-
- return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
- end
-
- def normalized_phone_number(phone_number)
- return if phone_number.blank?
-
- phone_number = phone_number.to_s
- phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
+ Whatsapp::WebhookChannelFinderService.new(
+ display_phone_number: metadata[:display_phone_number],
+ phone_number_id: metadata[:phone_number_id]
+ ).perform
end
def inactive_whatsapp_number?
diff --git a/app/drops/conversation_drop.rb b/app/drops/conversation_drop.rb
index d62642885..f25435ff0 100644
--- a/app/drops/conversation_drop.rb
+++ b/app/drops/conversation_drop.rb
@@ -1,6 +1,10 @@
class ConversationDrop < BaseDrop
include MessageFormatHelper
+ def id
+ @obj.try(:display_id)
+ end
+
def display_id
@obj.try(:display_id)
end
diff --git a/app/drops/inbox_drop.rb b/app/drops/inbox_drop.rb
index 563916d5f..29698fb4b 100644
--- a/app/drops/inbox_drop.rb
+++ b/app/drops/inbox_drop.rb
@@ -2,4 +2,18 @@ class InboxDrop < BaseDrop
def name
@obj.try(:name)
end
+
+ def business_name
+ @obj.try(:sanitized_business_name)
+ end
+
+ def avatar_url
+ @obj.try(:avatar_url)
+ end
+
+ def email
+ return unless @obj.try(:email?)
+
+ @obj.try(:email_address).presence || @obj.try(:channel).try(:email)
+ end
end
diff --git a/app/javascript/dashboard/api/calls.js b/app/javascript/dashboard/api/calls.js
new file mode 100644
index 000000000..aa220bdee
--- /dev/null
+++ b/app/javascript/dashboard/api/calls.js
@@ -0,0 +1,14 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+class CallsAPI extends ApiClient {
+ constructor() {
+ super('calls', { accountScoped: true });
+ }
+
+ get(params = {}) {
+ return axios.get(this.url, { params });
+ }
+}
+
+export default new CallsAPI();
diff --git a/app/javascript/dashboard/api/captain/agentSessions.js b/app/javascript/dashboard/api/captain/agentSessions.js
new file mode 100644
index 000000000..a557b5938
--- /dev/null
+++ b/app/javascript/dashboard/api/captain/agentSessions.js
@@ -0,0 +1,9 @@
+import ApiClient from '../ApiClient';
+
+class CaptainAgentSessions extends ApiClient {
+ constructor() {
+ super('captain/agent_sessions', { accountScoped: true });
+ }
+}
+
+export default new CaptainAgentSessions();
diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js
index 1fc17798d..5af6110ab 100644
--- a/app/javascript/dashboard/api/captain/assistant.js
+++ b/app/javascript/dashboard/api/captain/assistant.js
@@ -26,15 +26,18 @@ class CaptainAssistant extends ApiClient {
});
}
- getStats({ assistantId, range }) {
- return axios.get(`${this.url}/${assistantId}/stats`, {
+ getStats({ assistantId, range, signal }) {
+ const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
- });
+ };
+ if (signal) requestConfig.signal = signal;
+
+ return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
}
- getSummary({ assistantId, range }) {
+ getSummary({ assistantId, range, stats }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
- params: { range, timezone_offset: getTimezoneOffset() },
+ params: { range, timezone_offset: getTimezoneOffset(), stats },
});
}
diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
index ec24aae34..d458c1e5d 100644
--- a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
+++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
@@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
- initiate(conversationId, sdpOffer) {
+ // Either conversationId, or contactId + inboxId to let the BE resolve the conversation.
+ initiate({ conversationId, contactId, inboxId }, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
+ contact_id: contactId,
+ inbox_id: inboxId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js
index 55b620d1a..610ba1e57 100644
--- a/app/javascript/dashboard/api/helpCenter/articles.js
+++ b/app/javascript/dashboard/api/helpCenter/articles.js
@@ -17,6 +17,7 @@ class ArticlesAPI extends PortalsAPI {
categorySlug,
sort,
query,
+ signal,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -30,7 +31,7 @@ class ArticlesAPI extends PortalsAPI {
host: this.url,
});
- return axios.get(url);
+ return axios.get(url, { signal });
}
searchArticles({ portalSlug, query }) {
diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js
index b40613739..9f7052ed5 100644
--- a/app/javascript/dashboard/api/specs/article.spec.js
+++ b/app/javascript/dashboard/api/specs/article.spec.js
@@ -37,7 +37,8 @@ describe('#PortalAPI', () => {
authorId: '1',
});
expect(axiosMock.get).toHaveBeenCalledWith(
- '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
+ '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1',
+ { signal: undefined }
);
});
});
diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue
new file mode 100644
index 000000000..dd2b3af37
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue
@@ -0,0 +1,240 @@
+
+
+
+
+ {{
+ t(
+ 'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION'
+ )
+ }}
+
+ {{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.TITLE') }}
+
+
+ +
++ +
++ {{ row.label }} +
++ {{ row.subtitle }} +
+
+
+
+ {{ reasoning }} +
+'); + expect(formattedMessage).toContain(''); + }); }); describe('#sanitize', () => { 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/channels/whatsapp/templates_sync_scheduler_job.rb b/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb index 6a5e6cb2b..8cbc5a0ff 100644 --- a/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb +++ b/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb @@ -2,7 +2,9 @@ class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob queue_as :low def perform - Channel::Whatsapp.order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC')) + Channel::Whatsapp.joins(:account) + .merge(Account.active) + .order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC')) .where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL', 3.hours.ago) .limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT) .each do |channel| 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/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb index c64b36ca0..bb44649dd 100644 --- a/app/listeners/webhook_listener.rb +++ b/app/listeners/webhook_listener.rb @@ -108,6 +108,8 @@ class WebhookListener < BaseListener end def deliver_account_webhooks(payload, account) + return unless account.api_and_webhooks_enabled? + account.webhooks.account_type.each do |webhook| next unless webhook.subscriptions.include?(payload[:event]) 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/account.rb b/app/models/account.rb index 4295b162e..00201739a 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -154,6 +154,10 @@ class Account < ApplicationRecord } end + def api_and_webhooks_enabled? + true + end + def locale_english_name # the locale can also be something like pt_BR, en_US, fr_FR, etc. # the format is `
_ ` diff --git a/app/models/article.rb b/app/models/article.rb index 9d1247e8b..eb843493a 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -5,6 +5,8 @@ # id :bigint not null, primary key # content :text # description :text +# draft_content :text +# draft_title :string # locale :string default("en"), not null # meta :jsonb # position :integer diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 7a109c455..87ff199d5 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -101,6 +101,13 @@ class Channel::Whatsapp < ApplicationRecord end end + # Whether the pending (unsaved) provider_config change drops the embedded_signup + # source marker, i.e. this save is an embedded signup → manual setup transfer. + def embedded_to_manual_transfer_pending? + before, after = provider_config_change + before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup' + end + def mark_message_templates_updated # rubocop:disable Rails/SkipsModelValidations update_column(:message_templates_last_updated, Time.zone.now) @@ -130,13 +137,14 @@ class Channel::Whatsapp < ApplicationRecord errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config? end - # Logs only credential changes, so config-only saves (e.g. calling toggles) stay silent. + # Logs only the embedded signup → manual migration (the save drops the + # embedded_signup source marker), so credential rotations on inboxes that are + # already manual stay silent. def log_credentials_transfer before, after = saved_change_to_provider_config - keys = %w[api_key phone_number_id business_account_id] - return if before.nil? || before.values_at(*keys) == after.values_at(*keys) + return unless before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup' - Rails.logger.info("[WHATSAPP_MANUAL_TRANSFER] success account_id=#{account_id} channel_id=#{id}") + Rails.logger.info("[WHATSAPP_EMBEDDED_TO_MANUAL] success account_id=#{account_id} channel_id=#{id}") end def perform_webhook_setup 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/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/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb index 2e8a5028e..495d36975 100644 --- a/app/services/whatsapp/embedded_signup_service.rb +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -47,7 +47,7 @@ class Whatsapp::EmbeddedSignupService account: @account, inbox_id: @inbox_id, phone_number_id: @phone_number_id, - business_id: @business_id + waba_id: @waba_id ).perform(access_token, phone_info) else waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] } 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/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 373e47b3c..d65c6cc62 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -94,12 +94,12 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi private - # Only credential updates on existing channels are transfer attempts; creation failures are regular setup errors. Returns false. + # Only saves dropping the embedded_signup source marker are transfer attempts; creation/rotation failures are setup errors. Returns false. def log_transfer_failure(check, response) - return false unless whatsapp_channel.persisted? && whatsapp_channel.provider_config_changed? + return false unless whatsapp_channel.embedded_to_manual_transfer_pending? error_message = response.parsed_response.is_a?(Hash) ? response.parsed_response.dig('error', 'message') : nil - Rails.logger.warn("[WHATSAPP_MANUAL_TRANSFER] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \ + Rails.logger.warn("[WHATSAPP_EMBEDDED_TO_MANUAL] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \ "check=#{check} http_status=#{response.code} meta_error=#{error_message}") false end diff --git a/app/services/whatsapp/reauthorization_service.rb b/app/services/whatsapp/reauthorization_service.rb index 141417886..81be85f50 100644 --- a/app/services/whatsapp/reauthorization_service.rb +++ b/app/services/whatsapp/reauthorization_service.rb @@ -1,9 +1,9 @@ class Whatsapp::ReauthorizationService - def initialize(account:, inbox_id:, phone_number_id:, business_id:) + def initialize(account:, inbox_id:, phone_number_id:, waba_id:) @account = account @inbox_id = inbox_id @phone_number_id = phone_number_id - @business_id = business_id + @waba_id = waba_id end def perform(access_token, phone_info) @@ -33,7 +33,7 @@ class Whatsapp::ReauthorizationService channel.provider_config = current_config.merge( 'api_key' => access_token, 'phone_number_id' => resolved_phone_number_id, - 'business_account_id' => @business_id, + 'business_account_id' => @waba_id, 'source' => 'embedded_signup' ) channel.save! 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/articles/_article.json.jbuilder b/app/views/api/v1/accounts/articles/_article.json.jbuilder index a6eb7a551..426b24afe 100644 --- a/app/views/api/v1/accounts/articles/_article.json.jbuilder +++ b/app/views/api/v1/accounts/articles/_article.json.jbuilder @@ -4,6 +4,8 @@ json.title article.title json.content article.content json.description article.description json.status article.status +json.draft_title article.draft_title +json.draft_content article.draft_content json.position article.position json.account_id article.account_id json.updated_at article.updated_at.to_i 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/api/v1/models/_user.json.jbuilder b/app/views/api/v1/models/_user.json.jbuilder index e856031c5..e4f795566 100644 --- a/app/views/api/v1/models/_user.json.jbuilder +++ b/app/views/api/v1/models/_user.json.jbuilder @@ -1,4 +1,4 @@ -json.access_token resource.access_token.token +json.access_token resource.accounts.any?(&:api_and_webhooks_enabled?) ? resource.access_token.token : '' json.account_id resource.active_account_user&.account_id json.available_name resource.available_name json.avatar_url resource.avatar_url @@ -31,6 +31,7 @@ json.accounts do # availability derived from presence json.availability_status account_user.availability_status json.auto_offline account_user.auto_offline + json.api_and_webhooks account_user.account.feature_enabled?('api_and_webhooks') json.partial! 'api/v1/models/account_user', account_user: account_user if ChatwootApp.enterprise? end end 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); +}