From 64c5aeebeeab99f9a8fbd93de5f40d88f241e787 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 4 Jun 2026 06:34:12 -0700 Subject: [PATCH 01/34] feat(help-center): support per-locale portal title, name & header (#14642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portals can now override their name, page title and header text per locale, with a fallback chain of locale override → default locale → base value. Overrides are stored under portal.config.locale_translations and validated with a JSON schema. Editing is exposed as a "Localize content" action in each non-default locale's menu on the Locale page, and all public-facing portal views (classic + documentation layouts) render the localized values. The live chat widget is also loaded in the active portal locale. Screenshot 2026-06-03 at 2 48 59 PM https://github.com/user-attachments/assets/f5affbd2-7ad4-415a-b376-57c759fc2aaa --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../api/v1/accounts/portals_controller.rb | 7 +- .../public/api/v1/portals_controller.rb | 2 +- .../HelpCenter/LocaleCard/LocaleCard.vue | 5 +- .../Pages/LocalePage/LocaleContentDialog.vue | 98 +++++++++++++++++++ .../Pages/LocalePage/LocaleList.vue | 7 ++ .../dashboard/helper/portalHelper.js | 27 +++-- .../helper/specs/portalHelper.spec.js | 35 ++++--- .../dashboard/i18n/locale/en/helpCenter.json | 18 ++++ app/models/concerns/portal_config_schema.rb | 35 +++++++ app/models/portal.rb | 29 +++++- .../v1/accounts/portals/_portal.json.jbuilder | 1 + app/views/layouts/_portal_head.html.erb | 2 +- app/views/layouts/_portal_scripts.html.erb | 4 + .../public/api/v1/portals/_header.html.erb | 4 +- .../public/api/v1/portals/_hero.html.erb | 8 +- .../api/v1/portals/articles/index.html.erb | 2 +- .../api/v1/portals/articles/show.html.erb | 2 +- .../api/v1/portals/categories/_hero.html.erb | 2 +- .../api/v1/portals/categories/show.html.erb | 4 +- .../documentation_layout/_hero.html.erb | 2 +- .../documentation_layout/_topbar.html.erb | 2 +- .../articles/_meta_head.html.erb | 2 +- .../categories/_meta_head.html.erb | 4 +- .../search/index.html+documentation.erb | 2 +- .../api/v1/portals/search/index.html.erb | 2 +- .../v1/accounts/portals_controller_spec.rb | 3 +- spec/models/portal_spec.rb | 88 +++++++++++++++++ 27 files changed, 347 insertions(+), 50 deletions(-) create mode 100644 app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue create mode 100644 app/models/concerns/portal_config_schema.rb diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 770018e3c..c74c0ecfc 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -80,10 +80,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController :id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, - { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] } + { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }, + { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] } ) end + def locale_translation_keys + params.dig(:portal, :config, :locale_translations)&.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/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index 63f44b052..57db11aec 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl layout 'portal' def show - @og_image_url = helpers.set_og_image_url('', @portal.header_text) + @og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale)) end def sitemap diff --git a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue index 8619e5b77..259328bec 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue @@ -53,6 +53,9 @@ const localeMenuLabels = computed(() => ({ 'publish-locale': t( 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE' ), + 'customize-content': t( + 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT' + ), delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'), })); @@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => { diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue new file mode 100644 index 000000000..11d38f2a2 --- /dev/null +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue @@ -0,0 +1,98 @@ + + + diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue index 62c644655..66d389ead 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue @@ -1,5 +1,7 @@ <% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %> + <%= @portal.channel_web_widget.web_widget_script.html_safe %> <% end %> diff --git a/app/views/public/api/v1/portals/_header.html.erb b/app/views/public/api/v1/portals/_header.html.erb index 3db8efa59..189371261 100644 --- a/app/views/public/api/v1/portals/_header.html.erb +++ b/app/views/public/api/v1/portals/_header.html.erb @@ -5,7 +5,7 @@ <% if @portal.logo.present? %> <% end %> - <%= @portal.name %> + <%= @portal.localized_value('name', @locale) %> @@ -106,7 +106,7 @@ <% if @portal.logo.present? %> <% end %> - <%= @portal.name %> + <%= @portal.localized_value('name', @locale) %> diff --git a/app/views/public/api/v1/portals/_hero.html.erb b/app/views/public/api/v1/portals/_hero.html.erb index c1ead0e59..1e2bf119c 100644 --- a/app/views/public/api/v1/portals/_hero.html.erb +++ b/app/views/public/api/v1/portals/_hero.html.erb @@ -1,7 +1,7 @@ <% if !@is_plain_layout_enabled %> <% content_for :head do %> - <%= @portal.display_title %> - + <%= @portal.display_title(@locale) %> + <% if @og_image_url.present? %> @@ -13,9 +13,9 @@
- <%= @portal.name %> + <%= @portal.localized_value('name', @locale) %>

- <%= portal.header_text %> + <%= portal.localized_value('header_text', @locale) %>

<%= I18n.t('public_portal.hero.sub_title') %>

diff --git a/app/views/public/api/v1/portals/articles/index.html.erb b/app/views/public/api/v1/portals/articles/index.html.erb index d040bbc24..e4dc9aa87 100644 --- a/app/views/public/api/v1/portals/articles/index.html.erb +++ b/app/views/public/api/v1/portals/articles/index.html.erb @@ -6,7 +6,7 @@ class="leading-8 text-slate-800 hover:underline" href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>" > - <%= @portal.name %> <%= I18n.t('public_portal.common.home') %> + <%= @portal.localized_value('name', @locale) %> <%= I18n.t('public_portal.common.home') %> / / diff --git a/app/views/public/api/v1/portals/articles/show.html.erb b/app/views/public/api/v1/portals/articles/show.html.erb index 784817c9c..e35b19009 100644 --- a/app/views/public/api/v1/portals/articles/show.html.erb +++ b/app/views/public/api/v1/portals/articles/show.html.erb @@ -1,5 +1,5 @@ <% content_for :head do %> - <%= @article.title %> | <%= @portal.display_title %> + <%= @article.title %> | <%= @portal.display_title(@locale) %> <% if @article.meta["title"].present? %> "> "> diff --git a/app/views/public/api/v1/portals/categories/_hero.html.erb b/app/views/public/api/v1/portals/categories/_hero.html.erb index 0c179338d..1b481617e 100644 --- a/app/views/public/api/v1/portals/categories/_hero.html.erb +++ b/app/views/public/api/v1/portals/categories/_hero.html.erb @@ -1,7 +1,7 @@
-

<%= portal.header_text %>

+

<%= portal.localized_value('header_text', @locale) %>

<%= I18n.t('public_portal.hero.sub_title') %>

diff --git a/app/views/public/api/v1/portals/categories/show.html.erb b/app/views/public/api/v1/portals/categories/show.html.erb index 6657559d0..e7179db73 100644 --- a/app/views/public/api/v1/portals/categories/show.html.erb +++ b/app/views/public/api/v1/portals/categories/show.html.erb @@ -1,6 +1,6 @@ <% content_for :head do %> - <%= @category.name %> | <%= @portal.display_title %> - + <%= @category.name %> | <%= @portal.display_title(@locale) %> + <% if @category.description.present? %> diff --git a/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb index c7df0517d..af80d46ac 100644 --- a/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb @@ -5,7 +5,7 @@

- <%= portal.header_text.presence || 'How can we help?' %> + <%= portal.localized_value('header_text', @locale).presence || 'How can we help?' %>

<%= I18n.t('public_portal.hero.sub_title') %> diff --git a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb index 46d6f3558..00f408d70 100644 --- a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb @@ -11,7 +11,7 @@ <% if portal.logo.present? %> <% end %> - <%= portal.name %> + <%= portal.localized_value('name', locale) %> diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb index a236722ae..aeed7ff6c 100644 --- a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb @@ -1,4 +1,4 @@ -<%= article.title %> | <%= portal.display_title %> +<%= article.title %> | <%= portal.display_title(article.locale) %> <% if article.meta["title"].present? %> "> "> diff --git a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb index 7c8dec8d2..ac36496df 100644 --- a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb @@ -1,5 +1,5 @@ -<%= category.name %> | <%= portal.display_title %> - +<%= category.name %> | <%= portal.display_title(category.locale) %> + <% if category.description.present? %> diff --git a/app/views/public/api/v1/portals/search/index.html+documentation.erb b/app/views/public/api/v1/portals/search/index.html+documentation.erb index 8577a5f4e..f77517618 100644 --- a/app/views/public/api/v1/portals/search/index.html+documentation.erb +++ b/app/views/public/api/v1/portals/search/index.html+documentation.erb @@ -1,5 +1,5 @@ <% content_for :head do %> - <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %> + <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %> <% end %>

diff --git a/app/views/public/api/v1/portals/search/index.html.erb b/app/views/public/api/v1/portals/search/index.html.erb index 82c29775f..b4a1e77ed 100644 --- a/app/views/public/api/v1/portals/search/index.html.erb +++ b/app/views/public/api/v1/portals/search/index.html.erb @@ -1,5 +1,5 @@ <% content_for :head do %> - <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %> + <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %> <% end %> <% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %> diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb index 860791c0e..ccb5d7449 100644 --- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb @@ -173,7 +173,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do ], 'default_locale' => 'en', 'layout' => 'classic', - 'social_profiles' => {} + 'social_profiles' => {}, + 'locale_translations' => {} } ) end diff --git a/spec/models/portal_spec.rb b/spec/models/portal_spec.rb index 38d9a7da6..c71a458fd 100644 --- a/spec/models/portal_spec.rb +++ b/spec/models/portal_spec.rb @@ -60,6 +60,94 @@ RSpec.describe Portal do portal.update(custom_domain: '') expect(portal.custom_domain).to be_nil end + + context 'with locale_translations' do + it 'allows valid locale translations' do + portal.update(config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro', 'page_title' => 'Título', 'header_text' => 'Hola' } } }) + + expect(portal).to be_valid + end + + it 'rejects unknown fields within a locale translation' do + portal.update(config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'tagline' => 'nope' } } }) + + expect(portal).not_to be_valid + end + + it 'retains a locale override after it becomes the default so it can still be edited' do + portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro' } } }) + + portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' }) + + expect(portal.config['locale_translations']).to eq({ 'es' => { 'name' => 'Centro' } }) + end + end + end + end + + describe '#localized_value' do + let!(:account) { create(:account) } + let!(:portal) do + create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme', + config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } }) + end + + it 'returns the override for the requested locale' do + expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda') + end + + it 'falls back to the base column when the locale has no override for the field' do + expect(portal.localized_value('page_title', 'es')).to eq('Help Center | Acme') + end + + it 'falls back to the base column when the locale has no overrides at all' do + expect(portal.localized_value('name', 'fr')).to eq('Help Center') + end + + it 'keeps serving the override for a locale that has become the default' do + portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' }) + + expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda') + end + + it "inherits the default locale's override for a locale without its own" do + portal.update!(config: { allowed_locales: %w[en es fr], default_locale: 'es' }) + + expect(portal.localized_value('name', 'fr')).to eq('Centro de ayuda') + end + + it 'uses the default locale when no locale is given' do + expect(portal.localized_value('name')).to eq('Help Center') + end + end + + describe '#display_title' do + let!(:account) { create(:account) } + + it 'prefers the localized page_title' do + portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme', + config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'page_title' => 'Centro | Acme' } } }) + + expect(portal.display_title('es')).to eq('Centro | Acme') + end + + it 'falls back to the localized name when no page_title is set' do + portal = create(:portal, account_id: account.id, name: 'Help Center', + config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } }) + + expect(portal.display_title('es')).to eq('Centro de ayuda') + end + + it 'uses the base values for the default locale' do + portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme') + + expect(portal.display_title).to eq('Help Center | Acme') end end end From ec43975f3f6ec64fb63b752acba571e740d57693 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:25:32 +0530 Subject: [PATCH 02/34] chore: use square avatars (#14656) --- .../components-next/Companies/CompaniesCard/CompaniesCard.vue | 3 +-- .../Companies/CompanyDetail/CompanyProfileCard.vue | 1 - .../components-next/Contacts/ContactsCard/ContactsCard.vue | 3 +-- .../dashboard/components-next/sidebar/SidebarProfileMenu.vue | 1 - .../components/widgets/conversation/ConversationHeader.vue | 1 - .../routes/dashboard/conversation/contact/ContactInfo.vue | 1 - 6 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue index fe4385bbb..d81b997c5 100644 --- a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue +++ b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue @@ -46,9 +46,8 @@ const formattedLastActivityAt = computed(() => { :src="avatarSource" class="shrink-0" :name="name" - :size="48" + :size="42" hide-offline-status - rounded-full />
diff --git a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue index 1ffbf0c18..7615328ca 100644 --- a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue +++ b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue @@ -141,7 +141,6 @@ const handleUpdateCompany = async () => { :src="avatarSource" :size="72" :allow-upload="!isAvatarBusy" - rounded-full hide-offline-status @upload="handleAvatarUpload" @delete="handleAvatarDelete" diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index ba887b46f..50af6f0c0 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -124,10 +124,9 @@ const handleAvatarHover = isHovered => {