diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb
index 32a147d34..02023559b 100644
--- a/app/controllers/public/api/v1/portals/articles_controller.rb
+++ b/app/controllers/public/api/v1/portals/articles_controller.rb
@@ -7,7 +7,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def index
@articles = @portal.articles.published.includes(:category, :author)
+
+ @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
+
@articles_count = @articles.count
+
search_articles
order_by_sort_param
limit_results
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleSearch/ArticleView.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleSearch/ArticleView.vue
index f2a2ab4d6..7c7dd0040 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleSearch/ArticleView.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleSearch/ArticleView.vue
@@ -1,6 +1,7 @@
@@ -66,6 +58,7 @@ export default {
diff --git a/app/javascript/shared/helpers/portalHelper.js b/app/javascript/shared/helpers/portalHelper.js
new file mode 100644
index 000000000..a9e92a92a
--- /dev/null
+++ b/app/javascript/shared/helpers/portalHelper.js
@@ -0,0 +1,40 @@
+/**
+ * Determine the best-matching locale from the list of locales allowed by the portal.
+ *
+ * The matching happens in the following order:
+ * 1. Exact match – the visitor-selected locale equals one in the `allowedLocales` list
+ * (e.g., `fr` ➜ `fr`).
+ * 2. Base language match – the base part of a compound locale (before the underscore)
+ * matches (e.g., `fr_CA` ➜ `fr`).
+ * 3. Variant match – when the base language is selected but a regional variant exists
+ * in the portal list (e.g., `fr` ➜ `fr_BE`).
+ *
+ * If none of these rules find a match, the function returns `null`,
+ * Don't show popular articles if locale doesn't match with allowed locales
+ *
+ * @export
+ * @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
+ * @param {string[]} allowedLocales Array of locales enabled for the portal.
+ * @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
+ */
+export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
+ // Ensure inputs are valid
+ if (
+ !selectedLocale ||
+ !Array.isArray(allowedLocales) ||
+ !allowedLocales.length
+ ) {
+ return null;
+ }
+
+ const [lang] = selectedLocale.split('_');
+
+ const priorityMatches = [
+ selectedLocale, // exact match
+ lang, // base language match
+ allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
+ ];
+
+ // Return the first match that exists in the allowed list, or null
+ return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
+};
diff --git a/app/javascript/shared/helpers/specs/portalHelper.spec.js b/app/javascript/shared/helpers/specs/portalHelper.spec.js
new file mode 100644
index 000000000..e79c9a3d9
--- /dev/null
+++ b/app/javascript/shared/helpers/specs/portalHelper.spec.js
@@ -0,0 +1,28 @@
+import { getMatchingLocale } from 'shared/helpers/portalHelper';
+
+describe('portalHelper - getMatchingLocale', () => {
+ it('returns exact match when present', () => {
+ const result = getMatchingLocale('fr', ['en', 'fr']);
+ expect(result).toBe('fr');
+ });
+
+ it('returns base language match when exact variant not present', () => {
+ const result = getMatchingLocale('fr_CA', ['en', 'fr']);
+ expect(result).toBe('fr');
+ });
+
+ it('returns variant match when base language not present', () => {
+ const result = getMatchingLocale('fr', ['en', 'fr_BE']);
+ expect(result).toBe('fr_BE');
+ });
+
+ it('returns null when no match found', () => {
+ const result = getMatchingLocale('de', ['en', 'fr']);
+ expect(result).toBeNull();
+ });
+
+ it('returns null for invalid inputs', () => {
+ expect(getMatchingLocale('', [])).toBeNull();
+ expect(getMatchingLocale(null, null)).toBeNull();
+ });
+});
diff --git a/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue b/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue
index 1d5fdc99d..e093d4316 100644
--- a/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue
+++ b/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue
@@ -7,6 +7,7 @@ import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useDarkMode } from 'widget/composables/useDarkMode';
+import { getMatchingLocale } from 'shared/helpers/portalHelper';
const store = useStore();
const router = useRouter();
@@ -20,17 +21,8 @@ const articleUiFlags = useMapGetter('article/uiFlags');
const locale = computed(() => {
const { locale: selectedLocale } = i18n;
- const {
- allowed_locales: allowedLocales,
- default_locale: defaultLocale = 'en',
- } = portal.value.config;
- // IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
- // If the exact match of a locale is available in the list of portal locales, return it
- // Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
- if (allowedLocales.includes(selectedLocale)) {
- return locale;
- }
- return defaultLocale;
+ const { allowed_locales: allowedLocales } = portal.value.config;
+ return getMatchingLocale(selectedLocale.value, allowedLocales);
});
const fetchArticles = () => {
@@ -46,6 +38,7 @@ const openArticleInArticleViewer = link => {
const params = new URLSearchParams({
show_plain_layout: 'true',
theme: prefersDarkMode.value ? 'dark' : 'light',
+ ...(locale.value && { locale: locale.value }),
});
// Combine link with query parameters
@@ -64,7 +57,8 @@ const hasArticles = computed(
() =>
!articleUiFlags.value.isFetching &&
!articleUiFlags.value.isError &&
- !!popularArticles.value.length
+ !!popularArticles.value.length &&
+ !!locale.value
);
onMounted(() => fetchArticles());
diff --git a/app/javascript/widget/store/modules/articles.js b/app/javascript/widget/store/modules/articles.js
index 27faa297b..2ad6ae1dd 100644
--- a/app/javascript/widget/store/modules/articles.js
+++ b/app/javascript/widget/store/modules/articles.js
@@ -23,6 +23,7 @@ export const actions = {
commit('setError', false);
try {
+ if (!locale) return;
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
if (cachedData) {
commit('setArticles', cachedData);
diff --git a/app/javascript/widget/views/ArticleViewer.vue b/app/javascript/widget/views/ArticleViewer.vue
index d3e1f9437..9289d0546 100644
--- a/app/javascript/widget/views/ArticleViewer.vue
+++ b/app/javascript/widget/views/ArticleViewer.vue
@@ -1,24 +1,16 @@
-
+
diff --git a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
index e6429a1de..2d8c5fcd5 100644
--- a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
+++ b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
@@ -65,6 +65,14 @@ RSpec.describe 'Public Articles API', type: :request do
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.length).to eq(2)
+ # Only count articles in the current locale (category.locale is 'en')
+ expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(3)
+ end
+
+ it 'returns articles count from all locales when locale parameter is not present' do
+ get "/hc/#{portal.slug}/articles.json"
+
+ expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(5)
end