feat(help-center): support per-locale portal title, name & header (#14642)
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. <img width="494" height="395" alt="Screenshot 2026-06-03 at 2 48 59 PM" src="https://github.com/user-attachments/assets/e55a06e8-f20f-4d8a-9352-92fde28a9a19" /> https://github.com/user-attachments/assets/f5affbd2-7ad4-415a-b376-57c759fc2aaa --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cd9192f7d1
commit
64c5aeebee
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
<DropdownMenu
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="localeMenuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const activeLocale = ref('');
|
||||
const name = ref('');
|
||||
const pageTitle = ref('');
|
||||
const headerText = ref('');
|
||||
|
||||
const localeTranslations = computed(
|
||||
() => props.portal?.config?.locale_translations || {}
|
||||
);
|
||||
|
||||
const openForLocale = localeCode => {
|
||||
const existing = localeTranslations.value[localeCode] || {};
|
||||
activeLocale.value = localeCode;
|
||||
name.value = existing.name || '';
|
||||
pageTitle.value = existing.page_title || '';
|
||||
headerText.value = existing.header_text || '';
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
const translations = { ...localeTranslations.value };
|
||||
const fields = {};
|
||||
if (name.value.trim()) fields.name = name.value.trim();
|
||||
if (pageTitle.value.trim()) fields.page_title = pageTitle.value.trim();
|
||||
if (headerText.value.trim()) fields.header_text = headerText.value.trim();
|
||||
|
||||
if (Object.keys(fields).length) {
|
||||
translations[activeLocale.value] = fields;
|
||||
} else {
|
||||
delete translations[activeLocale.value];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: { locale_translations: translations },
|
||||
});
|
||||
dialogRef.value?.close();
|
||||
useAlert(t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ openForLocale });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.DESCRIPTION')"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.NAME.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="name" :placeholder="portal.name" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.PAGE_TITLE.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="pageTitle" :placeholder="portal.page_title" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.HEADER_TEXT.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="headerText" :placeholder="portal.header_text" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -23,6 +25,8 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contentDialogRef = ref(null);
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
};
|
||||
@@ -148,6 +152,8 @@ const handleAction = ({ action }, localeCode) => {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'customize-content') {
|
||||
contentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -167,5 +173,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
@@ -159,6 +159,13 @@ export const LOCALE_MENU_ITEMS = {
|
||||
value: 'publish',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
customizeContent: {
|
||||
label:
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT',
|
||||
action: 'customize-content',
|
||||
value: 'customize-content',
|
||||
icon: 'i-lucide-pencil',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
@@ -172,20 +179,28 @@ const disableLocaleMenuItems = menuItems =>
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
return [
|
||||
...disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
]),
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
|
||||
];
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.publishLocale,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -74,37 +74,40 @@ describe('PortalHelper', () => {
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('returns disabled actions for the default locale', () => {
|
||||
it('disables other actions but keeps customize enabled for the default locale', () => {
|
||||
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
|
||||
const customize = items.find(item => item.action === 'customize-content');
|
||||
|
||||
expect(customize).toBeTruthy();
|
||||
expect(customize.disabled).toBeFalsy();
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'change-default', disabled: true }),
|
||||
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
|
||||
expect.objectContaining({ action: 'delete', disabled: true }),
|
||||
])
|
||||
);
|
||||
items
|
||||
.filter(item => item.action !== 'customize-content')
|
||||
.every(item => item.disabled)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns publish and delete actions for draft locales', () => {
|
||||
it('returns publish, customize, and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'delete']);
|
||||
).toEqual(['publish-locale', 'customize-content', 'delete']);
|
||||
});
|
||||
|
||||
it('returns default, draft, and delete actions for live locales', () => {
|
||||
it('returns default, draft, customize, and delete actions for live locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||
).toEqual([
|
||||
'change-default',
|
||||
'move-to-draft',
|
||||
'customize-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -700,9 +700,27 @@
|
||||
"MAKE_DEFAULT": "Make default",
|
||||
"MOVE_TO_DRAFT": "Move to draft",
|
||||
"PUBLISH_LOCALE": "Publish locale",
|
||||
"CUSTOMIZE_CONTENT": "Localize content",
|
||||
"DELETE": "Delete"
|
||||
}
|
||||
},
|
||||
"CONTENT_DIALOG": {
|
||||
"TITLE": "Localize content",
|
||||
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||
"NAME": {
|
||||
"LABEL": "Name"
|
||||
},
|
||||
"PAGE_TITLE": {
|
||||
"LABEL": "Page title"
|
||||
},
|
||||
"HEADER_TEXT": {
|
||||
"LABEL": "Header text"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||
}
|
||||
},
|
||||
"ADD_LOCALE_DIALOG": {
|
||||
"TITLE": "Add a new locale",
|
||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
module PortalConfigSchema
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Per-locale overrides for portal level fields. Any locale present in
|
||||
# `allowed_locales` may carry its own `name`, `page_title` and `header_text`.
|
||||
# Missing values fall back to the default locale and finally to the base column.
|
||||
LOCALE_TRANSLATION_SCHEMA = {
|
||||
'type' => 'object',
|
||||
'properties' => {
|
||||
'name' => { 'type' => %w[string null] },
|
||||
'page_title' => { 'type' => %w[string null] },
|
||||
'header_text' => { 'type' => %w[string null] }
|
||||
},
|
||||
'additionalProperties' => false
|
||||
}.freeze
|
||||
|
||||
CONFIG_PARAMS_SCHEMA = {
|
||||
'type' => 'object',
|
||||
'properties' => {
|
||||
'allowed_locales' => { 'type' => %w[array null], 'items' => { 'type' => 'string' } },
|
||||
'default_locale' => { 'type' => %w[string null] },
|
||||
'draft_locales' => { 'type' => %w[array null], 'items' => { 'type' => 'string' } },
|
||||
'layout' => { 'type' => %w[string null], 'enum' => ['classic', 'documentation', nil] },
|
||||
# TODO: unused reserved key; remove with a migration that scrubs it from existing portals' config
|
||||
'website_token' => { 'type' => %w[string null] },
|
||||
'social_profiles' => { 'type' => %w[object null] },
|
||||
'locale_translations' => {
|
||||
'type' => %w[object null],
|
||||
'additionalProperties' => LOCALE_TRANSLATION_SCHEMA
|
||||
}
|
||||
},
|
||||
'required' => [],
|
||||
'additionalProperties' => true
|
||||
}.to_json.freeze
|
||||
end
|
||||
+24
-5
@@ -26,6 +26,7 @@
|
||||
#
|
||||
class Portal < ApplicationRecord
|
||||
include Rails.application.routes.url_helpers
|
||||
include PortalConfigSchema
|
||||
|
||||
DEFAULT_COLOR = '#1f93ff'.freeze
|
||||
|
||||
@@ -43,11 +44,16 @@ class Portal < ApplicationRecord
|
||||
validates :slug, presence: true, uniqueness: true
|
||||
validates :custom_domain, uniqueness: true, allow_nil: true
|
||||
validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
|
||||
validate :config_json_format
|
||||
before_validation :normalize_config
|
||||
validate :validate_config
|
||||
validates_with JsonSchemaValidator,
|
||||
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
|
||||
attribute_resolver: ->(record) { record.config }
|
||||
|
||||
scope :active, -> { where(archived: false) }
|
||||
|
||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout].freeze
|
||||
# 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
|
||||
|
||||
def file_base_data
|
||||
{
|
||||
@@ -91,8 +97,18 @@ class Portal < ApplicationRecord
|
||||
self[:color].presence || DEFAULT_COLOR
|
||||
end
|
||||
|
||||
def display_title
|
||||
page_title.presence || name
|
||||
def display_title(locale = default_locale)
|
||||
localized_value('page_title', locale).presence || localized_value('name', locale)
|
||||
end
|
||||
|
||||
# Resolves a portal level field for a locale, falling back to the default
|
||||
# locale's value (its override or the base column) when the locale has no
|
||||
# override of its own.
|
||||
def localized_value(field, locale = default_locale)
|
||||
translations = config_value('locale_translations') || {}
|
||||
translations.dig(locale.to_s, field).presence ||
|
||||
translations.dig(default_locale, field).presence ||
|
||||
self[field]
|
||||
end
|
||||
|
||||
def layout
|
||||
@@ -105,11 +121,14 @@ class Portal < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def config_json_format
|
||||
def normalize_config
|
||||
self.config = persisted_config.merge((config || {}).deep_stringify_keys)
|
||||
config['allowed_locales'] = allowed_locale_codes
|
||||
config['default_locale'] = default_locale
|
||||
config['draft_locales'] = draft_locale_codes
|
||||
end
|
||||
|
||||
def validate_config
|
||||
denied_keys = config.keys - CONFIG_JSON_KEYS
|
||||
errors.add(:config, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
|
||||
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
|
||||
|
||||
@@ -18,6 +18,7 @@ json.config do
|
||||
json.default_locale portal.default_locale
|
||||
json.layout portal.layout
|
||||
json.social_profiles portal.social_profiles
|
||||
json.locale_translations portal.config['locale_translations'] || {}
|
||||
end
|
||||
|
||||
if portal.channel_web_widget
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<% if content_for?(:head) %>
|
||||
<%= yield(:head) %>
|
||||
<% else %>
|
||||
<title><%= @portal.display_title %></title>
|
||||
<title><%= @portal.display_title(@locale) %></title>
|
||||
<% end %>
|
||||
|
||||
<% if @portal.logo.present? %>
|
||||
|
||||
@@ -89,5 +89,9 @@ html.light {
|
||||
};
|
||||
</script>
|
||||
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
|
||||
<script>
|
||||
window.chatwootSettings = window.chatwootSettings || {};
|
||||
window.chatwootSettings.locale = '<%= @locale %>';
|
||||
</script>
|
||||
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
|
||||
<% end %>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<% if @portal.logo.present? %>
|
||||
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
|
||||
<% end %>
|
||||
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.name %></span>
|
||||
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.localized_value('name', @locale) %></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
<% if @portal.logo.present? %>
|
||||
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
|
||||
<% end %>
|
||||
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span>
|
||||
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.localized_value('name', @locale) %></span>
|
||||
</a>
|
||||
|
||||
<!-- Mobile Menu Component -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<% content_for :head do %>
|
||||
<title><%= @portal.display_title %></title>
|
||||
<meta name="title" content="<%= @portal.display_title %>">
|
||||
<title><%= @portal.display_title(@locale) %></title>
|
||||
<meta name="title" content="<%= @portal.display_title(@locale) %>">
|
||||
|
||||
<% if @og_image_url.present? %>
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
@@ -13,9 +13,9 @@
|
||||
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
|
||||
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
|
||||
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.name %></span>
|
||||
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.localized_value('name', @locale) %></span>
|
||||
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
|
||||
<%= portal.header_text %>
|
||||
<%= portal.localized_value('header_text', @locale) %>
|
||||
</h1>
|
||||
<p class="text-slate-600 dark:text-slate-200 text-start text-lg leading-normal pt-2 pb-4"><%= I18n.t('public_portal.hero.sub_title') %></p>
|
||||
<div id="search-wrap" class="w-full relative z-30"></div>
|
||||
|
||||
@@ -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') %>
|
||||
</a>
|
||||
<span>/</span>
|
||||
<span>/</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= @article.title %> | <%= @portal.display_title %></title>
|
||||
<title><%= @article.title %> | <%= @portal.display_title(@locale) %></title>
|
||||
<% if @article.meta["title"].present? %>
|
||||
<meta name="title" content="<%= @article.meta["title"] %>">
|
||||
<meta property="og:title" content="<%= @article.meta["title"] %>">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
<section class="bg-slate-50 dark:bg-slate-800 py-16 flex flex-col items-center justify-center">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-4xl text-slate-900 dark:text-white font-semibold leading-relaxed text-center"><%= portal.header_text %></h1>
|
||||
<h1 class="text-4xl text-slate-900 dark:text-white font-semibold leading-relaxed text-center"><%= portal.localized_value('header_text', @locale) %></h1>
|
||||
<p class="text-slate-700 dark:text-slate-100 py-2 text-center"><%= I18n.t('public_portal.hero.sub_title') %></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= @category.name %> | <%= @portal.display_title %></title>
|
||||
<meta name="title" content="<%= @category.name %> | <%= @portal.display_title %>">
|
||||
<title><%= @category.name %> | <%= @portal.display_title(@locale) %></title>
|
||||
<meta name="title" content="<%= @category.name %> | <%= @portal.display_title(@locale) %>">
|
||||
<% if @category.description.present? %>
|
||||
<meta name="description" content="<%= @category.description %>">
|
||||
<meta property="og:description" content="<%= @category.description %>">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="relative w-full px-6 md:px-10 py-12 md:py-16">
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-4xl md:text-5xl leading-tight font-620 tracking-tight text-n-slate-12 text-balance">
|
||||
<%= portal.header_text.presence || 'How can we help?' %>
|
||||
<%= portal.localized_value('header_text', @locale).presence || 'How can we help?' %>
|
||||
</h1>
|
||||
<p class="mt-4 text-lg leading-normal text-n-slate-11 text-pretty">
|
||||
<%= I18n.t('public_portal.hero.sub_title') %>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<% if portal.logo.present? %>
|
||||
<img src="<%= url_for(portal.logo) %>" class="w-8 h-8 rounded-full" draggable="false" />
|
||||
<% end %>
|
||||
<span class="text-base font-semibold tracking-tight text-n-slate-12 truncate"><%= portal.name %></span>
|
||||
<span class="text-base font-semibold tracking-tight text-n-slate-12 truncate"><%= portal.localized_value('name', locale) %></span>
|
||||
<span class="hidden sm:inline-block w-px h-5 bg-n-weak mx-1"></span>
|
||||
<span class="hidden sm:inline-block text-sm font-medium text-n-slate-11 truncate"><%= I18n.t('public_portal.sidebar.help_center') %></span>
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<title><%= article.title %> | <%= portal.display_title %></title>
|
||||
<title><%= article.title %> | <%= portal.display_title(article.locale) %></title>
|
||||
<% if article.meta["title"].present? %>
|
||||
<meta name="title" content="<%= article.meta["title"] %>">
|
||||
<meta property="og:title" content="<%= article.meta["title"] %>">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<title><%= category.name %> | <%= portal.display_title %></title>
|
||||
<meta name="title" content="<%= category.name %> | <%= portal.display_title %>">
|
||||
<title><%= category.name %> | <%= portal.display_title(category.locale) %></title>
|
||||
<meta name="title" content="<%= category.name %> | <%= portal.display_title(category.locale) %>">
|
||||
<% if category.description.present? %>
|
||||
<meta name="description" content="<%= category.description %>">
|
||||
<meta property="og:description" content="<%= category.description %>">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %></title>
|
||||
<% end %>
|
||||
|
||||
<div class="px-6 md:px-10 py-8 md:py-10">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %></title>
|
||||
<% 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' %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user