+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationPreferences.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationPreferences.vue
index 215b46923..6ab2b34d4 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationPreferences.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationPreferences.vue
@@ -75,10 +75,34 @@ export default {
onRegistrationSuccess() {
this.hasEnabledPushPermissions = true;
},
- onRequestPermissions() {
- requestPushPermissions({
- onSuccess: this.onRegistrationSuccess,
- });
+ onRequestPermissions(value) {
+ if (value) {
+ // Enable / re-enable push notifications
+ requestPushPermissions({
+ onSuccess: this.onRegistrationSuccess,
+ });
+ } else {
+ // Disable push notifications
+ this.disablePushPermissions();
+ }
+ },
+ disablePushPermissions() {
+ verifyServiceWorkerExistence(registration =>
+ registration.pushManager
+ .getSubscription()
+ .then(subscription => {
+ if (subscription) {
+ return subscription.unsubscribe();
+ }
+ return null;
+ })
+ .finally(() => {
+ this.hasEnabledPushPermissions = false;
+ })
+ .catch(() => {
+ // error
+ })
+ );
},
getPushSubscription() {
verifyServiceWorkerExistence(registration =>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue
index 7e0b9be8f..9b966c8fe 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue
@@ -59,7 +59,7 @@ const defaulSpanRender = cellProps =>
cellProps.getValue()
);
-const columns = [
+const columns = computed(() => [
columnHelper.accessor('name', {
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
width: 300,
@@ -90,7 +90,7 @@ const columns = [
width: 200,
cell: defaulSpanRender,
}),
-];
+]);
const renderAvgTime = value => (value ? formatTime(value) : '--');
@@ -142,7 +142,9 @@ const table = useVueTable({
get data() {
return tableData.value;
},
- columns,
+ get columns() {
+ return columns.value;
+ },
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
});
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index defaed811..d70ce4b66 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -9,29 +9,7 @@ import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
-
-const buildInboxData = inboxParams => {
- const formData = new FormData();
- const { channel = {}, ...inboxProperties } = inboxParams;
- Object.keys(inboxProperties).forEach(key => {
- formData.append(key, inboxProperties[key]);
- });
- const { selectedFeatureFlags, ...channelParams } = channel;
- // selectedFeatureFlags needs to be empty when creating a website channel
- if (selectedFeatureFlags) {
- if (selectedFeatureFlags.length) {
- selectedFeatureFlags.forEach(featureFlag => {
- formData.append(`channel[selected_feature_flags][]`, featureFlag);
- });
- } else {
- formData.append('channel[selected_feature_flags][]', '');
- }
- }
- Object.keys(channelParams).forEach(key => {
- formData.append(`channel[${key}]`, channel[key]);
- });
- return formData;
-};
+import { channelActions, buildInboxData } from './inboxes/channelActions';
export const state = {
records: [],
@@ -253,6 +231,12 @@ export const actions = {
throw new Error(error);
}
},
+ ...channelActions,
+ // TODO: Extract other create channel methods to separate files to reduce file size
+ // - createChannel
+ // - createWebsiteChannel
+ // - createTwilioChannel
+ // - createFBChannel
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
try {
diff --git a/app/javascript/dashboard/store/modules/inboxes/channelActions.js b/app/javascript/dashboard/store/modules/inboxes/channelActions.js
new file mode 100644
index 000000000..9975d8d1f
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/inboxes/channelActions.js
@@ -0,0 +1,52 @@
+import * as types from '../../mutation-types';
+import InboxesAPI from '../../../api/inboxes';
+import AnalyticsHelper from '../../../helper/AnalyticsHelper';
+import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
+
+export const buildInboxData = inboxParams => {
+ const formData = new FormData();
+ const { channel = {}, ...inboxProperties } = inboxParams;
+ Object.keys(inboxProperties).forEach(key => {
+ formData.append(key, inboxProperties[key]);
+ });
+ const { selectedFeatureFlags, ...channelParams } = channel;
+ // selectedFeatureFlags needs to be empty when creating a website channel
+ if (selectedFeatureFlags) {
+ if (selectedFeatureFlags.length) {
+ selectedFeatureFlags.forEach(featureFlag => {
+ formData.append(`channel[selected_feature_flags][]`, featureFlag);
+ });
+ } else {
+ formData.append('channel[selected_feature_flags][]', '');
+ }
+ }
+ Object.keys(channelParams).forEach(key => {
+ formData.append(`channel[${key}]`, channel[key]);
+ });
+ return formData;
+};
+
+const sendAnalyticsEvent = channelType => {
+ AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
+ channelType,
+ });
+};
+
+export const channelActions = {
+ createVoiceChannel: async ({ commit }, params) => {
+ try {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
+ const response = await InboxesAPI.create({
+ name: params.name,
+ channel: { ...params.voice, type: 'voice' },
+ });
+ commit(types.default.ADD_INBOXES, response.data);
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
+ sendAnalyticsEvent('voice');
+ return response.data;
+ } catch (error) {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
+ throw error;
+ }
+ },
+};
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
index 8e917467a..a91addaa3 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
@@ -62,6 +62,28 @@ describe('#actions', () => {
});
});
+ describe('#createVoiceChannel', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.post.mockResolvedValue({ data: inboxList[0] });
+ await actions.createVoiceChannel({ commit }, inboxList[0]);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
+ [types.default.ADD_INBOXES, inboxList[0]],
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ it('sends correct actions if API is error', async () => {
+ axios.post.mockRejectedValue({ message: 'Incorrect header' });
+ await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
+ Error
+ );
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ });
+
describe('#createFBChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js
index 35e54d154..1fdc51276 100644
--- a/app/javascript/portal/portalHelpers.js
+++ b/app/javascript/portal/portalHelpers.js
@@ -112,11 +112,14 @@ export const InitializationHelpers = {
},
setDirectionAttribute: () => {
- const portalElement = document.getElementById('portal');
- if (!portalElement) return;
+ const htmlElement = document.querySelector('html');
+ // If direction is already applied through props, do not apply again (iframe case)
+ const hasDirApplied = htmlElement.getAttribute('data-dir-applied');
+ if (!htmlElement || hasDirApplied) return;
- const locale = document.querySelector('.locale-switcher')?.value;
- portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
+ const localeFromHtml = htmlElement.lang;
+ htmlElement.dir =
+ localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
},
initializeThemesInPortal: initializeTheme,
diff --git a/app/javascript/shared/components/IframeLoader.vue b/app/javascript/shared/components/IframeLoader.vue
index b3c91b8f0..0a99816b4 100644
--- a/app/javascript/shared/components/IframeLoader.vue
+++ b/app/javascript/shared/components/IframeLoader.vue
@@ -1,64 +1,56 @@
-
@@ -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 @@