diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb
index a8ea8ae05..1221d7155 100644
--- a/app/controllers/concerns/switch_locale.rb
+++ b/app/controllers/concerns/switch_locale.rb
@@ -4,17 +4,28 @@ module SwitchLocale
private
def switch_locale(&)
- # priority is for locale set in query string (mostly for widget/from js sdk)
+ # Priority is for locale set in query string (mostly for widget/from js sdk)
locale ||= params[:locale]
+ # Use the user's locale if available
+ locale ||= locale_from_user
+
+ # Use the locale from a custom domain if applicable
locale ||= locale_from_custom_domain
+
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
locale ||= ENV.fetch('DEFAULT_LOCALE', nil)
+
set_locale(locale, &)
end
def switch_locale_using_account_locale(&)
- locale = locale_from_account(@current_account)
+ # Get the locale from the user first
+ locale = locale_from_user
+
+ # Fallback to the account's locale if the user's locale is not set
+ locale ||= locale_from_account(@current_account)
+
set_locale(locale, &)
end
@@ -32,6 +43,12 @@ module SwitchLocale
@portal.default_locale
end
+ def locale_from_user
+ return unless @user
+
+ @user.ui_settings&.dig('locale')
+ end
+
def set_locale(locale, &)
safe_locale = validate_and_get_locale(locale)
# Ensure locale won't bleed into other requests
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index 8da7e7476..0fcb8c9fe 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -19,6 +19,7 @@ import {
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
+import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
name: 'App',
@@ -38,12 +39,14 @@ export default {
const { accountId } = useAccount();
// Use the font size composable (it automatically sets up the watcher)
const { currentFontSize } = useFontSize();
+ const { uiSettings } = useUISettings();
return {
router,
store,
currentAccountId: accountId,
currentFontSize,
+ uiSettings,
};
},
data() {
@@ -88,7 +91,10 @@ export default {
mounted() {
this.initializeColorTheme();
this.listenToThemeChanges();
- this.setLocale(window.chatwootConfig.selectedLocale);
+ // If user locale is set, use it; otherwise use account locale
+ this.setLocale(
+ this.uiSettings?.locale || window.chatwootConfig.selectedLocale
+ );
},
unmounted() {
if (this.reconnectService) {
@@ -114,7 +120,8 @@ export default {
const { locale, latest_chatwoot_version: latestChatwootVersion } =
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
- this.setLocale(locale);
+ // If user locale is set, use it; otherwise use account locale
+ this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
diff --git a/app/javascript/dashboard/composables/spec/useFontSize.spec.js b/app/javascript/dashboard/composables/spec/useFontSize.spec.js
index 52d22478f..28b927610 100644
--- a/app/javascript/dashboard/composables/spec/useFontSize.spec.js
+++ b/app/javascript/dashboard/composables/spec/useFontSize.spec.js
@@ -43,18 +43,22 @@ describe('useFontSize', () => {
it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize();
- expect(fontSizeOptions).toHaveLength(5);
- expect(fontSizeOptions[0]).toHaveProperty('value');
- expect(fontSizeOptions[0]).toHaveProperty('label');
+ expect(fontSizeOptions.value).toHaveLength(5);
+ expect(fontSizeOptions.value[0]).toHaveProperty('value');
+ expect(fontSizeOptions.value[0]).toHaveProperty('label');
// Check specific options
- expect(fontSizeOptions.find(option => option.value === '16px')).toEqual({
+ expect(
+ fontSizeOptions.value.find(option => option.value === '16px')
+ ).toEqual({
value: '16px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT',
});
- expect(fontSizeOptions.find(option => option.value === '14px')).toEqual({
+ expect(
+ fontSizeOptions.value.find(option => option.value === '14px')
+ ).toEqual({
value: '14px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
@@ -143,12 +147,12 @@ describe('useFontSize', () => {
const { fontSizeOptions } = useFontSize();
// Check that translation is applied
- expect(fontSizeOptions.find(option => option.value === '14px').label).toBe(
- 'Smaller'
- );
- expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
- 'Default'
- );
+ expect(
+ fontSizeOptions.value.find(option => option.value === '14px').label
+ ).toBe('Smaller');
+ expect(
+ fontSizeOptions.value.find(option => option.value === '16px').label
+ ).toBe('Default');
// Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith(
diff --git a/app/javascript/dashboard/composables/useFontSize.js b/app/javascript/dashboard/composables/useFontSize.js
index d7177a5fb..92d6f9e72 100644
--- a/app/javascript/dashboard/composables/useFontSize.js
+++ b/app/javascript/dashboard/composables/useFontSize.js
@@ -77,8 +77,8 @@ export const useFontSize = () => {
* Font size options for select dropdown
* @type {Array<{value: string, label: string}>}
*/
- const fontSizeOptions = FONT_SIZE_NAMES.map(name =>
- createFontSizeOption(t, name)
+ const fontSizeOptions = computed(() =>
+ FONT_SIZE_NAMES.map(name => createFontSizeOption(t, name))
);
/**
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index 5b2197a03..2e24bace4 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -51,6 +51,13 @@
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
+ },
+ "LANGUAGE": {
+ "TITLE": "Preferred Language",
+ "NOTE": "Choose the language you want to use.",
+ "UPDATE_SUCCESS": "Your Language settings have been updated successfully",
+ "UPDATE_ERROR": "There is an error while updating the language settings, please try again",
+ "USE_ACCOUNT_DEFAULT": "Use account default"
}
},
"MESSAGE_SIGNATURE_SECTION": {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
index 69342858d..c6e9a1ebb 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
@@ -7,7 +7,6 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
-import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import NextInput from 'next/input/Input.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -33,12 +32,12 @@ export default {
NextInput,
},
setup() {
- const { updateUISettings } = useUISettings();
+ const { updateUISettings, uiSettings } = useUISettings();
const { enabledLanguages } = useConfig();
const { accountId } = useAccount();
const v$ = useVuelidate();
- return { updateUISettings, v$, enabledLanguages, accountId };
+ return { updateUISettings, uiSettings, v$, enabledLanguages, accountId };
},
data() {
return {
@@ -112,7 +111,7 @@ export default {
const { name, locale, id, domain, support_email, features } =
this.getAccount(this.accountId);
- this.$root.$i18n.locale = locale;
+ this.$root.$i18n.locale = this.uiSettings?.locale || locale;
this.name = name;
this.locale = locale;
this.id = id;
@@ -137,21 +136,19 @@ export default {
domain: this.domain,
support_email: this.supportEmail,
});
- this.$root.$i18n.locale = this.locale;
+ // If user locale is set, update the locale with user locale
+ if (this.uiSettings?.locale) {
+ this.$root.$i18n.locale = this.uiSettings?.locale;
+ } else {
+ // If user locale is not set, update the locale with account locale
+ this.$root.$i18n.locale = this.locale;
+ }
this.getAccount(this.id).locale = this.locale;
- this.updateDirectionView(this.locale);
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
},
-
- updateDirectionView(locale) {
- const isRTLSupported = getLanguageDirection(locale);
- this.updateUISettings({
- rtl_view: isRTLSupported,
- });
- },
},
};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
index bbde7a1c0..ce0a480bb 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
@@ -11,6 +11,7 @@ import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
+import UserLanguageSelect from './UserLanguageSelect.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
@@ -28,6 +29,7 @@ export default {
MessageSignature,
FormSection,
FontSize,
+ UserLanguageSelect,
UserProfilePicture,
Policy,
UserBasicDetails,
@@ -230,6 +232,12 @@ export default {
"
@change="updateFontSize"
/>
+
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useAlert } from 'dashboard/composables';
+import { useConfig } from 'dashboard/composables/useConfig';
+import { useAccount } from 'dashboard/composables/useAccount';
+import { useUISettings } from 'dashboard/composables/useUISettings';
+
+import FormSelect from 'v3/components/Form/Select.vue';
+
+defineProps({
+ label: { type: String, default: '' },
+ description: { type: String, default: '' },
+});
+
+const { t, locale } = useI18n();
+const { updateUISettings, uiSettings } = useUISettings();
+const { enabledLanguages } = useConfig();
+const { currentAccount } = useAccount();
+
+const currentLanguage = computed(() => uiSettings.value?.locale ?? '');
+
+const languageOptions = computed(() => [
+ {
+ name: t(
+ 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.USE_ACCOUNT_DEFAULT'
+ ),
+ iso_639_1_code: '',
+ },
+ ...(enabledLanguages ?? []),
+]);
+
+const updateLanguage = async languageCode => {
+ try {
+ if (!languageCode) {
+ // Clear preference to use account default
+ await updateUISettings({ locale: null });
+ locale.value = currentAccount.value.locale;
+ useAlert(
+ t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_SUCCESS')
+ );
+ return;
+ }
+
+ const valid = (enabledLanguages || []).some(
+ l => l.iso_639_1_code === languageCode
+ );
+ if (!valid) {
+ throw new Error(`Invalid language code: ${languageCode}`);
+ }
+
+ await updateUISettings({ locale: languageCode });
+ // Apply immediately if the user explicitly chose a preference
+ locale.value = languageCode;
+
+ useAlert(
+ t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_SUCCESS')
+ );
+ } catch (error) {
+ useAlert(
+ t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_ERROR')
+ );
+ throw error;
+ }
+};
+
+const selectedValue = computed({
+ get: () => currentLanguage.value,
+ set: value => {
+ updateLanguage(value);
+ },
+});
+
+
+
+
+
+
+
+ {{ description }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js
index 0d5fdc748..1eef59cfe 100644
--- a/app/javascript/dashboard/store/modules/accounts.js
+++ b/app/javascript/dashboard/store/modules/accounts.js
@@ -28,12 +28,16 @@ export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
- isRTL: ($state, _, rootState) => {
- const accountId = rootState.route?.params?.accountId;
- if (!accountId) return false;
+ isRTL: ($state, _getters, rootState, rootGetters) => {
+ const accountId = Number(rootState.route?.params?.accountId);
+ const userLocale = rootGetters?.getUISettings?.locale;
+ const accountLocale =
+ accountId && findRecordById($state, accountId)?.locale;
- const { locale } = findRecordById($state, Number(accountId));
- return locale ? getLanguageDirection(locale) : false;
+ // Prefer user locale; fallback to account locale
+ const effectiveLocale = userLocale ?? accountLocale;
+
+ return effectiveLocale ? getLanguageDirection(effectiveLocale) : false;
},
isTrialAccount: $state => id => {
const account = findRecordById($state, id);
diff --git a/app/javascript/dashboard/store/modules/specs/account/getters.spec.js b/app/javascript/dashboard/store/modules/specs/account/getters.spec.js
index 77cc9b357..f354faca2 100644
--- a/app/javascript/dashboard/store/modules/specs/account/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/account/getters.spec.js
@@ -49,35 +49,74 @@ describe('#getters', () => {
});
describe('isRTL', () => {
- it('returns false when accountId is not present', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('returns false when accountId is not present and userLocale is not set', () => {
+ const state = { records: [accountData] };
const rootState = { route: { params: {} } };
- expect(getters.isRTL({}, null, rootState)).toBe(false);
+ const rootGetters = {};
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
});
- it('returns true for RTL language', () => {
- const state = {
- records: [{ id: 1, locale: 'ar' }],
- };
- const rootState = { route: { params: { accountId: '1' } } };
- vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(true);
- expect(getters.isRTL(state, null, rootState)).toBe(true);
+ it('uses userLocale when present (no accountId)', () => {
+ const state = { records: [accountData] };
+ const rootState = { route: { params: {} } };
+ const rootGetters = { getUISettings: { locale: 'ar' } };
+ const spy = vi
+ .spyOn(languageHelpers, 'getLanguageDirection')
+ .mockReturnValue(true);
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
+ expect(spy).toHaveBeenCalledWith('ar');
});
- it('returns false for LTR language', () => {
- const state = {
- records: [{ id: 1, locale: 'en' }],
- };
+ it('prefers userLocale over account locale when both are present', () => {
+ const state = { records: [{ id: 1, locale: 'en' }] };
const rootState = { route: { params: { accountId: '1' } } };
- vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(false);
- expect(getters.isRTL(state, null, rootState)).toBe(false);
+ const rootGetters = { getUISettings: { locale: 'ar' } };
+ const spy = vi
+ .spyOn(languageHelpers, 'getLanguageDirection')
+ .mockReturnValue(true);
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
+ expect(spy).toHaveBeenCalledWith('ar');
});
- it('returns false when account is not found', () => {
- const state = {
- records: [],
- };
+ it('falls back to account locale when userLocale is not provided', () => {
+ const state = { records: [{ id: 1, locale: 'ar' }] };
const rootState = { route: { params: { accountId: '1' } } };
- expect(getters.isRTL(state, null, rootState)).toBe(false);
+ const rootGetters = {};
+ const spy = vi
+ .spyOn(languageHelpers, 'getLanguageDirection')
+ .mockReturnValue(true);
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
+ expect(spy).toHaveBeenCalledWith('ar');
+ });
+
+ it('returns false for LTR language when userLocale is provided', () => {
+ const state = { records: [{ id: 1, locale: 'en' }] };
+ const rootState = { route: { params: { accountId: '1' } } };
+ const rootGetters = { getUISettings: { locale: 'en' } };
+ const spy = vi
+ .spyOn(languageHelpers, 'getLanguageDirection')
+ .mockReturnValue(false);
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
+ expect(spy).toHaveBeenCalledWith('en');
+ });
+
+ it('returns false when accountId present but user locale is null', () => {
+ const state = { records: [{ id: 1, locale: 'en' }] };
+ const rootState = { route: { params: { accountId: '1' } } };
+ const rootGetters = { getUISettings: { locale: null } };
+ const spy = vi.spyOn(languageHelpers, 'getLanguageDirection');
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
+ expect(spy).toHaveBeenCalledWith('en');
});
});
});
diff --git a/spec/models/concerns/switch_locale_spec.rb b/spec/models/concerns/switch_locale_spec.rb
index 98628b92c..421f254a0 100644
--- a/spec/models/concerns/switch_locale_spec.rb
+++ b/spec/models/concerns/switch_locale_spec.rb
@@ -29,6 +29,26 @@ RSpec.describe 'SwitchLocale Concern', type: :controller do
end
end
+ context 'when user has a locale set in ui_settings' do
+ let(:user) { create(:user, ui_settings: { 'locale' => 'es' }) }
+
+ before { controller.instance_variable_set(:@user, user) }
+
+ it 'returns the user locale' do
+ expect(controller.send(:locale_from_user)).to eq('es')
+ end
+ end
+
+ context 'when user does not have a locale set' do
+ let(:user) { create(:user, ui_settings: {}) }
+
+ before { controller.instance_variable_set(:@user, user) }
+
+ it 'returns nil' do
+ expect(controller.send(:locale_from_user)).to be_nil
+ end
+ end
+
context 'when request is from custom domain' do
before { request.host = portal.custom_domain }