diff --git a/frontend-new/src/_test_utilities/envServiceMock.ts b/frontend-new/src/_test_utilities/envServiceMock.ts
index 1e7d013f1..84f1b99ff 100644
--- a/frontend-new/src/_test_utilities/envServiceMock.ts
+++ b/frontend-new/src/_test_utilities/envServiceMock.ts
@@ -23,4 +23,8 @@ jest.mock("src/envService", () => ({
getAppIconUrl: jest.fn(() => "mock-app-icon-url"),
getThemeCssVariables: jest.fn(() => "{}"),
getRegistrationCodeDisabled: jest.fn(() => "false"),
+ getTargetEnvironmentName: jest.fn(),
+ getSentryDSN: jest.fn(),
+ getSentryEnabled: jest.fn(),
+ getSentryConfig: jest.fn(),
}));
diff --git a/frontend-new/src/_test_utilities/i18nMock.ts b/frontend-new/src/_test_utilities/i18nMock.ts
index 148bea29c..e9c13aae4 100644
--- a/frontend-new/src/_test_utilities/i18nMock.ts
+++ b/frontend-new/src/_test_utilities/i18nMock.ts
@@ -116,8 +116,12 @@ jest.mock("react-i18next", () => {
// Mock the initialized instance module so app code can import it without side effects
jest.mock("src/i18n/i18n", () => {
+ const { Locale } = require("src/i18n/constants");
+
const mock = {
+ language: Locale.EN_GB,
t: stableT,
+ changeLanguage: jest.fn(),
use: jest.fn().mockReturnThis(),
init: jest.fn().mockReturnThis(),
};
diff --git a/frontend-new/src/app/ProtectedRoute/ProtectedRoute.test.tsx b/frontend-new/src/app/ProtectedRoute/ProtectedRoute.test.tsx
index 525c7128f..e32bcf771 100644
--- a/frontend-new/src/app/ProtectedRoute/ProtectedRoute.test.tsx
+++ b/frontend-new/src/app/ProtectedRoute/ProtectedRoute.test.tsx
@@ -9,10 +9,10 @@ import { routerPaths } from "src/app/routerPaths";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
import {
- Language,
SensitivePersonalDataRequirement,
UserPreference,
} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
// mock the FirebaseSocialAuthentication service
jest.mock("src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service", () => {
@@ -74,7 +74,7 @@ const getUserPreferences = (
) => {
const givenPreferences: UserPreference = {
user_id: "user1",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: acceptedTC,
has_sensitive_personal_data: hasSensitiveData,
sensitive_personal_data_requirement: sensitiveDataRequirement,
diff --git a/frontend-new/src/app/ProtectedRoute/util.test.ts b/frontend-new/src/app/ProtectedRoute/util.test.ts
index 237ab0d54..472fbd878 100644
--- a/frontend-new/src/app/ProtectedRoute/util.test.ts
+++ b/frontend-new/src/app/ProtectedRoute/util.test.ts
@@ -1,8 +1,6 @@
import { isAcceptedTCValid, isSensitiveDataValid } from "src/app/ProtectedRoute/util";
-import {
- Language,
- SensitivePersonalDataRequirement,
-} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
describe("protected route util", () => {
describe("isSensitiveDataValid", () => {
@@ -10,7 +8,7 @@ describe("protected route util", () => {
// GIVEN a user required to provide sensitive personal data but does not have it
const userPreferences = {
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -30,7 +28,7 @@ describe("protected route util", () => {
// GIVEN a user required to provide sensitive personal data and has it
const userPreferences = {
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: true,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -50,7 +48,7 @@ describe("protected route util", () => {
// GIVEN a user not required to provide sensitive personal data
const userPreferences = {
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_AVAILABLE,
@@ -72,7 +70,7 @@ describe("protected route util", () => {
// GIVEN a user without accepted_tc
const userPreferences = {
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: undefined,
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_AVAILABLE,
@@ -92,7 +90,7 @@ describe("protected route util", () => {
// GIVEN a user with an invalid accepted_tc
const userPreferences = {
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: "invalid date",
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_AVAILABLE,
@@ -112,7 +110,7 @@ describe("protected route util", () => {
// GIVEN a user with a valid accepted_tc
const userPreferences = {
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_AVAILABLE,
diff --git a/frontend-new/src/app/index.test.tsx b/frontend-new/src/app/index.test.tsx
index 207243209..7dac779bf 100644
--- a/frontend-new/src/app/index.test.tsx
+++ b/frontend-new/src/app/index.test.tsx
@@ -9,15 +9,16 @@ import * as AuthenticationFactoryModule from "src/auth/services/Authentication.s
import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service";
import { PersistentStorageService } from "./PersistentStorageService/PersistentStorageService";
import {
- Language,
SensitivePersonalDataRequirement,
UserPreference,
} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import { DATA_TEST_ID as BACKDROP_DATA_TEST_ID } from "src/theme/Backdrop/Backdrop";
import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks";
import AuthenticationStateService from "src/auth/services/AuthenticationState.service";
import { routerPaths } from "src/app/routerPaths";
import { AuthBroadcastChannel, AuthChannelMessage } from "src/auth/services/authBroadcastChannel/authBroadcastChannel";
+import LocaleSyncService from "src/i18n/LocaleSyncService";
// mock the snackbar
jest.mock("src/theme/SnackbarProvider/SnackbarProvider", () => {
@@ -93,6 +94,7 @@ describe("index", () => {
// As a good practice, we should the mock*Once() methods to avoid side effects between tests
// As a precaution, we reset all method mocks to ensure that no side effects are carried over between tests
resetAllMethodMocks(UserPreferencesService.getInstance());
+ resetAllMethodMocks(LocaleSyncService.getInstance());
unmockBrowserIsOnLine();
// Mock the authenticationServiceFactory to return a mock instance of the authentication service
@@ -221,7 +223,7 @@ describe("index", () => {
const mockPreferences = {
user_id: "foo",
- language: Language.en,
+ language: Locale.EN_GB,
sessions: [123],
accepted_tc: new Date(),
has_sensitive_personal_data: false,
@@ -257,7 +259,7 @@ describe("index", () => {
const mockPreferences = {
user_id: "foo",
- language: Language.en,
+ language: Locale.EN_GB,
sessions: [123],
user_feedback_answered_questions: {},
accepted_tc: new Date(),
@@ -291,6 +293,44 @@ describe("index", () => {
expect(console.error).not.toHaveBeenCalled();
expect(console.warn).not.toHaveBeenCalled();
});
+
+ test("should apply backend locale on refresh", async () => {
+ // GIVEN a user with preferences containing a specific locale
+ jest.spyOn(AuthenticationStateService.getInstance(), "getToken").mockReturnValue("valid-token");
+
+ const applyBackendLocaleFn = jest
+ .spyOn(LocaleSyncService.getInstance(), "applyBackendLocale")
+ .mockResolvedValue();
+
+ const mockPreferences = {
+ user_id: "foo",
+ language: Locale.EN_US,
+ sessions: [123],
+ accepted_tc: new Date(),
+ has_sensitive_personal_data: false,
+ getActiveSessionId: jest.fn().mockReturnValue(123),
+ user_feedback_answered_questions: {},
+ sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
+ experiments: {},
+ };
+
+ jest.spyOn(UserPreferencesService.getInstance(), "getUserPreferences").mockResolvedValue(mockPreferences);
+
+ // WHEN the app is rendered
+ render();
+
+ // THEN wait for the app to finish loading
+ await waitFor(() => {
+ expect(screen.queryByTestId(BACKDROP_DATA_TEST_ID.BACKDROP_CONTAINER)).not.toBeInTheDocument();
+ });
+
+ // AND expect no errors or warnings
+ expect(console.error).not.toHaveBeenCalled();
+ expect(console.warn).not.toHaveBeenCalled();
+
+ // AND expect the backend locale to be applied to the frontend
+ expect(applyBackendLocaleFn).toHaveBeenCalledWith(mockPreferences.language);
+ });
});
describe("register listeners", () => {
diff --git a/frontend-new/src/app/index.tsx b/frontend-new/src/app/index.tsx
index 90cd4a6ed..309bec363 100644
--- a/frontend-new/src/app/index.tsx
+++ b/frontend-new/src/app/index.tsx
@@ -19,6 +19,7 @@ import UserPreferencesService from "src/userPreferences/UserPreferencesService/u
import { AuthenticationError } from "src/error/commonErrors";
import { RestAPIError } from "src/error/restAPIError/RestAPIError";
import { StatusCodes } from "http-status-codes";
+import LocaleSyncService from "src/i18n/LocaleSyncService";
import { lazyWithPreload } from "src/utils/preloadableComponent/PreloadableComponent";
import { TokenValidationFailureCause } from "src/auth/services/Authentication.service";
import { AuthBroadcastChannel, AuthChannelMessage } from "src/auth/services/authBroadcastChannel/authBroadcastChannel";
@@ -148,6 +149,10 @@ const App = () => {
}
UserPreferencesStateService.getInstance().setUserPreferences(preferences);
+
+ // On page refresh, apply backend locale to frontend i18n
+ // This ensures the UI language matches the user's stored preference
+ await LocaleSyncService.getInstance().applyBackendLocale(preferences.language);
} catch (error) {
console.error(new AuthenticationError("Error initializing authentication and user preferences state", error));
await AuthenticationServiceFactory.resetAuthenticationState();
diff --git a/frontend-new/src/auth/components/SocialAuth/SocialAuth.test.tsx b/frontend-new/src/auth/components/SocialAuth/SocialAuth.test.tsx
index da0520b2b..682794ffa 100644
--- a/frontend-new/src/auth/components/SocialAuth/SocialAuth.test.tsx
+++ b/frontend-new/src/auth/components/SocialAuth/SocialAuth.test.tsx
@@ -8,10 +8,8 @@ import { mockBrowserIsOnLine, unmockBrowserIsOnLine } from "src/_test_utilities/
import FirebaseSocialAuthenticationService from "src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service";
import authStateService from "src/auth/services/AuthenticationState.service";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
-import {
- SensitivePersonalDataRequirement,
- Language,
-} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import { TabiyaUser } from "src/auth/auth.types";
import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider";
import * as EnvServiceModule from "src/envService";
@@ -111,7 +109,7 @@ describe("SocialAuth tests", () => {
// AND the user preferences exist for the user
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: givenUser.id,
- language: Language.en,
+ language: Locale.EN_GB,
sessions: [1],
user_feedback_answered_questions: {},
accepted_tc: tc,
@@ -617,7 +615,7 @@ describe("SocialAuth tests", () => {
const createUserPreferencesSpy = jest.spyOn(UserPreferencesService.getInstance(), "createUserPreferences");
const mockPreferences = {
user_id: givenUser.id,
- language: Language.en,
+ language: Locale.EN_GB,
invitation_code: "valid-code",
sessions: [],
user_feedback_answered_questions: {},
@@ -660,7 +658,7 @@ describe("SocialAuth tests", () => {
expect(createUserPreferencesSpy).toHaveBeenCalledWith({
user_id: givenUser.id,
invitation_code: "valid-code",
- language: Language.en,
+ language: Locale.EN_GB,
});
});
@@ -697,7 +695,7 @@ describe("SocialAuth tests", () => {
require("src/userPreferences/UserPreferencesService/userPreferences.service").default;
const mockPreferences = {
user_id: givenUser.id,
- language: Language.en,
+ language: Locale.EN_GB,
invitation_code: "modal-code",
sessions: [],
user_feedback_answered_questions: {},
diff --git a/frontend-new/src/auth/components/SocialAuth/SocialAuth.tsx b/frontend-new/src/auth/components/SocialAuth/SocialAuth.tsx
index d6b9a10cf..77c9d626d 100644
--- a/frontend-new/src/auth/components/SocialAuth/SocialAuth.tsx
+++ b/frontend-new/src/auth/components/SocialAuth/SocialAuth.tsx
@@ -15,8 +15,10 @@ import RegistrationCodeFormModal, {
import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service";
import { invitationsService } from "src/auth/services/invitationsService/invitations.service";
import { InvitationStatus, InvitationType } from "src/auth/services/invitationsService/invitations.types";
-import { Language, UserPreference } from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { UserPreference } from "src/userPreferences/UserPreferencesService/userPreferences.types";
import { getRegistrationCodeDisabled, getRegistrationDisabled } from "src/envService";
+import { validateLocale } from "src/i18n/constants";
+import i18n from "src/i18n/i18n";
const uniqueId = "f0324e97-83fd-49e6-95c3-1043751fa1db";
export const DATA_TEST_ID = {
@@ -96,10 +98,13 @@ const SocialAuth: React.FC> = ({
throw new Error("Something went wrong: No user found");
}
+ // Use the current i18n locale instead of a hardcoded default
+ const currentLocale = validateLocale(i18n.language);
+
if (!registrationCode) {
prefs = await UserPreferencesService.getInstance().createUserPreferences({
user_id: _user.id,
- language: Language.en,
+ language: currentLocale,
});
} else {
const invitation = await invitationsService.checkInvitationCodeStatus(registrationCode);
@@ -115,7 +120,7 @@ const SocialAuth: React.FC> = ({
prefs = await UserPreferencesService.getInstance().createUserPreferences({
user_id: _user.id,
invitation_code: invitation.invitation_code,
- language: Language.en,
+ language: currentLocale,
});
}
diff --git a/frontend-new/src/auth/pages/Register/Register.test.tsx b/frontend-new/src/auth/pages/Register/Register.test.tsx
index a75b72a3e..8beeda87e 100644
--- a/frontend-new/src/auth/pages/Register/Register.test.tsx
+++ b/frontend-new/src/auth/pages/Register/Register.test.tsx
@@ -13,9 +13,9 @@ import { InvitationStatus, InvitationType } from "src/auth/services/invitationsS
import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service";
import {
SensitivePersonalDataRequirement,
- Language,
UserPreference,
} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import authStateService from "src/auth/services/AuthenticationState.service";
import { INVITATIONS_PARAM_NAME, TabiyaUser } from "src/auth/auth.types";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
@@ -301,7 +301,7 @@ describe("Testing Register component", () => {
// AND the user preferences service is mocked to succeed
jest.spyOn(UserPreferencesService.getInstance(), "createUserPreferences").mockResolvedValueOnce({
user_id: "foo-bar-id",
- language: Language.en,
+ language: Locale.EN_GB,
sessions: [],
user_feedback_answered_questions: {},
accepted_tc: new Date(),
diff --git a/frontend-new/src/auth/services/Authentication.service.test.ts b/frontend-new/src/auth/services/Authentication.service.test.ts
index 7cbaea068..261c323e8 100644
--- a/frontend-new/src/auth/services/Authentication.service.test.ts
+++ b/frontend-new/src/auth/services/Authentication.service.test.ts
@@ -3,7 +3,7 @@ import AuthenticationService, { CLOCK_TOLERANCE, TokenValidationFailureCause } f
import { jwtDecode } from "jwt-decode";
import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService";
import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service";
-import { Language, UserPreference } from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { UserPreference } from "src/userPreferences/UserPreferencesService/userPreferences.types";
import { TabiyaUser, Token, TokenHeader } from "src/auth/auth.types";
import AuthenticationStateService from "./AuthenticationState.service";
@@ -12,6 +12,17 @@ import { RestAPIError } from "src/error/restAPIError/RestAPIError";
import { StatusCodes } from "http-status-codes";
import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks";
import { nanoid } from "nanoid";
+import { Locale } from "src/i18n/constants";
+
+// Mock LocaleSyncService to prevent actual backend calls
+jest.mock("src/i18n/LocaleSyncService", () => ({
+ __esModule: true,
+ default: {
+ getInstance: jest.fn().mockReturnValue({
+ syncOnLogin: jest.fn().mockResolvedValue(undefined),
+ }),
+ },
+}));
// Mock jwt-decode
jest.mock("jwt-decode", () => ({
@@ -123,6 +134,7 @@ describe("AuthenticationService", () => {
const givenUserPreferences: UserPreference = {
user_id: "foo-id",
sessions: [],
+ language: Locale.EN_GB,
} as unknown as UserPreference;
jest
.spyOn(UserPreferencesService.getInstance(), "getUserPreferences")
@@ -236,6 +248,7 @@ describe("AuthenticationService", () => {
const givenReturnedPrefs: UserPreference = {
user_id: "foo-id",
sessions: [],
+ language: Locale.EN_GB,
} as unknown as UserPreference;
jest
.spyOn(UserPreferencesService.getInstance(), "createUserPreferences")
@@ -254,7 +267,7 @@ describe("AuthenticationService", () => {
expect(UserPreferencesService.getInstance().createUserPreferences).toHaveBeenCalledWith({
user_id: givenUser.id,
invitation_code: givenRegistrationCode,
- language: Language.en,
+ language: Locale.EN_GB,
});
// AND the preferences return from the service should be set in the state
@@ -294,6 +307,7 @@ describe("AuthenticationService", () => {
const givenReturnedPrefs: UserPreference = {
user_id: "foo-id",
sessions: [],
+ language: Locale.EN_GB,
} as unknown as UserPreference;
jest
.spyOn(UserPreferencesService.getInstance(), "createUserPreferences")
@@ -312,7 +326,7 @@ describe("AuthenticationService", () => {
expect(UserPreferencesService.getInstance().createUserPreferences).toHaveBeenCalledWith({
user_id: givenUser.id,
invitation_code: undefined,
- language: Language.en,
+ language: Locale.EN_GB,
});
// AND the preferences returned from the service should be set in the state
@@ -359,7 +373,7 @@ describe("AuthenticationService", () => {
expect(UserPreferencesService.getInstance().createUserPreferences).toHaveBeenCalledWith({
user_id: givenUser.id,
invitation_code: givenRegistrationCode,
- language: Language.en,
+ language: Locale.EN_GB,
});
// AND the preferences returned from the service should be set in the state
diff --git a/frontend-new/src/auth/services/Authentication.service.ts b/frontend-new/src/auth/services/Authentication.service.ts
index 170d45fd2..4f9134cca 100644
--- a/frontend-new/src/auth/services/Authentication.service.ts
+++ b/frontend-new/src/auth/services/Authentication.service.ts
@@ -1,12 +1,14 @@
import AuthenticationStateService from "./AuthenticationState.service";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service";
-import { Language } from "src/userPreferences/UserPreferencesService/userPreferences.types";
import { TabiyaUser, Token, TokenHeader } from "src/auth/auth.types";
+import { validateLocale } from "src/i18n/constants";
+import i18n from "src/i18n/i18n";
import { jwtDecode } from "jwt-decode";
import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService";
import { RestAPIError } from "src/error/restAPIError/RestAPIError";
import { StatusCodes } from "http-status-codes";
+import LocaleSyncService from "src/i18n/LocaleSyncService";
export const CLOCK_TOLERANCE = 10; // 10 second buffer for expiration and issuance time, in case of clock skew
@@ -121,8 +123,14 @@ abstract class AuthenticationService {
throw error;
}
if (prefs !== null) {
- // set the local preferences "state" ( for lack of a better word )
+ // set the local preferences "state" (for lack of a better word)
UserPreferencesStateService.getInstance().setUserPreferences(prefs);
+
+ // Sync locale on login: if the frontend locale differs from the backend, update backend
+ // If a user logs in, we need to update the backend with the language they are using, so that it follows.
+ // It might be a bad idea to start on compass in English and realize you are having a conversation in Spanish
+ const frontendLocale = validateLocale(i18n.language);
+ await LocaleSyncService.getInstance().syncOnLogin(prefs.language, frontendLocale);
}
}
@@ -139,10 +147,12 @@ abstract class AuthenticationService {
this.authenticationStateService.setToken(token);
// create user preferences for the first time.
// in order to do this, there needs to be a logged-in user in the persistent storage
+ // Use the current i18n locale instead of a hardcoded default
+ const currentLocale = validateLocale(i18n.language);
const prefs = await UserPreferencesService.getInstance().createUserPreferences({
user_id: user.id,
invitation_code: registrationCode,
- language: Language.en,
+ language: currentLocale,
});
UserPreferencesStateService.getInstance().setUserPreferences(prefs);
}
diff --git a/frontend-new/src/auth/services/FirebaseAuthenticationService/emailAuth/FirebaseEmailAuthentication.service.test.ts b/frontend-new/src/auth/services/FirebaseAuthenticationService/emailAuth/FirebaseEmailAuthentication.service.test.ts
index 45302d6e5..0d31ceb02 100644
--- a/frontend-new/src/auth/services/FirebaseAuthenticationService/emailAuth/FirebaseEmailAuthentication.service.test.ts
+++ b/frontend-new/src/auth/services/FirebaseAuthenticationService/emailAuth/FirebaseEmailAuthentication.service.test.ts
@@ -19,6 +19,16 @@ import { PersistentStorageService } from "src/app/PersistentStorageService/Persi
import { TokenValidationFailureCause } from "src/auth/services/Authentication.service";
import { jwtDecode } from "jwt-decode";
+// Mock LocaleSyncService to prevent actual backend calls
+jest.mock("src/i18n/LocaleSyncService", () => ({
+ __esModule: true,
+ default: {
+ getInstance: jest.fn().mockReturnValue({
+ syncOnLogin: jest.fn().mockResolvedValue(undefined),
+ }),
+ },
+}));
+
jest.mock("jwt-decode", () => ({
jwtDecode: jest.fn(),
}));
@@ -588,6 +598,19 @@ describe("AuthService class tests", () => {
// AND the user is currently logged in
jest.spyOn(firebase.auth(), "currentUser", "get").mockReturnValue(mockAnonymousUser as any);
+ // AND getUser returns a valid user
+ const givenUser = { id: "123", name: givenUserName, email: givenEmail };
+ jest.spyOn(authService, "getUser").mockReturnValue(givenUser);
+
+ // AND the user has some preferences
+ const givenUserPreferences: UserPreference = {
+ user_id: "foo-id",
+ sessions: [],
+ } as unknown as UserPreference;
+ jest
+ .spyOn(UserPreferencesService.getInstance(), "getUserPreferences")
+ .mockResolvedValueOnce(givenUserPreferences);
+
// WHEN linking the anonymous account
const actualToken = await authService.linkAnonymousAccount(givenEmail, givenPassword, givenUserName);
diff --git a/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthentication.service.test.ts b/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthentication.service.test.ts
index 976dfc964..7325c1259 100644
--- a/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthentication.service.test.ts
+++ b/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthentication.service.test.ts
@@ -6,10 +6,10 @@ import { invitationsService } from "src/auth/services/invitationsService/invitat
import { Invitation, InvitationStatus, InvitationType } from "src/auth/services/invitationsService/invitations.types";
import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service";
import {
- Language,
SensitivePersonalDataRequirement,
UserPreference,
} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks";
import AuthenticationStateService from "src/auth/services/AuthenticationState.service";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
@@ -92,6 +92,9 @@ describe("AuthService class tests", () => {
jest
.spyOn(UserPreferencesService.getInstance(), "getUserPreferences")
.mockResolvedValueOnce(givenReturnedPreferences);
+ jest
+ .spyOn(UserPreferencesService.getInstance(), "updateUserPreferences")
+ .mockResolvedValueOnce(givenReturnedPreferences);
// AND the token is decoded into a user
jest.spyOn(authService, "getUser").mockReturnValue(givenUser);
@@ -118,7 +121,7 @@ describe("AuthService class tests", () => {
expect(UserPreferencesService.getInstance().createUserPreferences).toHaveBeenCalledWith({
user_id: givenUser.id,
invitation_code: givenInvitationCode,
- language: Language.en,
+ language: Locale.EN_GB,
});
// AND the user preferences should be set in the user preferences state
diff --git a/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthenticationService.ts b/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthenticationService.ts
index 8cf9ba070..5bb8c60ab 100644
--- a/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthenticationService.ts
+++ b/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthenticationService.ts
@@ -10,11 +10,12 @@ import { AuthenticationServices, TabiyaUser } from "src/auth/auth.types";
import { invitationsService } from "src/auth/services/invitationsService/invitations.service";
import { InvitationStatus, InvitationType } from "src/auth/services/invitationsService/invitations.types";
import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service";
-import { Language } from "src/userPreferences/UserPreferencesService/userPreferences.types";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
import AuthenticationService from "src/auth/services/Authentication.service";
import { formatTokenForLogging } from "src/auth/utils/formatTokenForLogging";
import { TokenError } from "src/error/commonErrors";
+import { validateLocale } from "src/i18n/constants";
+import i18n from "src/i18n/i18n";
class FirebaseInvitationCodeAuthenticationService extends AuthenticationService {
private static instance: FirebaseInvitationCodeAuthenticationService;
@@ -92,10 +93,12 @@ class FirebaseInvitationCodeAuthenticationService extends AuthenticationService
this.authenticationStateService.setToken(token);
// create user preferences for the first time.
+ // Use the current i18n locale instead of a hardcoded default
+ const currentLocale = validateLocale(i18n.language);
const prefs = await UserPreferencesService.getInstance().createUserPreferences({
user_id: _user.id,
invitation_code: invitation.invitation_code,
- language: Language.en,
+ language: currentLocale,
});
UserPreferencesStateService.getInstance().setUserPreferences(prefs);
diff --git a/frontend-new/src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service.test.ts b/frontend-new/src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service.test.ts
index 5f6d65ca2..66f19fb89 100644
--- a/frontend-new/src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service.test.ts
+++ b/frontend-new/src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service.test.ts
@@ -11,6 +11,16 @@ import StdFirebaseAuthenticationService from "src/auth/services/FirebaseAuthenti
import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService";
import { AuthChannelMessage } from "src/auth/services/authBroadcastChannel/authBroadcastChannel";
+// Mock LocaleSyncService to prevent actual backend calls
+jest.mock("src/i18n/LocaleSyncService", () => ({
+ __esModule: true,
+ default: {
+ getInstance: jest.fn().mockReturnValue({
+ syncOnLogin: jest.fn().mockResolvedValue(undefined),
+ }),
+ },
+}));
+
jest.mock("firebase/compat/app", () => {
const mockAuth = {
initializeApp: jest.fn(),
diff --git a/frontend-new/src/chat/Chat.stories.tsx b/frontend-new/src/chat/Chat.stories.tsx
index 9fc3d1a0c..f32f00778 100644
--- a/frontend-new/src/chat/Chat.stories.tsx
+++ b/frontend-new/src/chat/Chat.stories.tsx
@@ -11,10 +11,10 @@ import ExperienceService from "src/experiences/experienceService/experienceServi
import { SkillsRankingService } from "src/features/skillsRanking/skillsRankingService/skillsRankingService";
import cvService from "src/CV/CVService/CVService";
import {
- Language,
SensitivePersonalDataRequirement,
UserPreference,
} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import { ConversationPhase } from "src/chat/chatProgressbar/types";
import { ConversationMessageSender, ConversationResponse } from "./ChatService/ChatService.types";
import { nanoid } from "nanoid";
@@ -36,7 +36,7 @@ const meta: Meta = {
status: 201,
response: {
user_id: nanoid(),
- language: Language.en,
+ language: Locale.EN_GB,
sessions: [1],
user_feedback_answered_questions: {},
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
@@ -124,7 +124,7 @@ const meta: Meta = {
const mockUserPreferencesStateService = UserPreferencesStateService.getInstance();
const defaultUserPreferences: UserPreference = {
user_id: "1",
- language: Language.en,
+ language: Locale.EN_GB,
sessions: [123],
user_feedback_answered_questions: {},
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
@@ -146,7 +146,7 @@ const meta: Meta = {
// @ts-ignore
mockUserPreferencesService.getNewSession = async (userId: string) => ({
user_id: userId,
- language: Language.en,
+ language: Locale.EN_GB,
sessions: [123],
user_feedback_answered_questions: {},
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
diff --git a/frontend-new/src/chat/Chat.test.tsx b/frontend-new/src/chat/Chat.test.tsx
index 6f9c49655..3aa3cf328 100644
--- a/frontend-new/src/chat/Chat.test.tsx
+++ b/frontend-new/src/chat/Chat.test.tsx
@@ -27,10 +27,10 @@ import ExperiencesDrawer, {
} from "src/experiences/experiencesDrawer/ExperiencesDrawer";
import { DiveInPhase, WorkType } from "src/experiences/experienceService/experiences.types";
import {
- Language,
SensitivePersonalDataRequirement,
UserPreference,
} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import ConfirmModalDialog, {
DATA_TEST_ID as CONFIRM_MODAL_DIALOG_DATA_TEST_ID,
} from "src/theme/confirmModalDialog/ConfirmModalDialog";
@@ -263,7 +263,7 @@ describe("Chat", () => {
user_id: givenUser.id,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
- language: Language.en,
+ language: Locale.EN_GB,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
sessions: givenSessionId !== null ? [givenSessionId] : [],
user_feedback_answered_questions: {},
@@ -804,7 +804,7 @@ describe("Chat", () => {
user_id: givenUser.id,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
- language: Language.en,
+ language: Locale.EN_GB,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
sessions: [givenNewSessionId],
user_feedback_answered_questions: {},
@@ -929,7 +929,7 @@ describe("Chat", () => {
sessions: [givenNewSessionId],
user_feedback_answered_questions: {},
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
- language: Language.en,
+ language: Locale.EN_GB,
experiments: {},
};
jest.spyOn(UserPreferencesService.getInstance(), "getNewSession").mockResolvedValueOnce(givenUserPreferences);
@@ -988,7 +988,7 @@ describe("Chat", () => {
const givenNewSessionId = 123;
const givenUserPreferences: UserPreference = {
user_id: givenUser.id,
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sessions: [givenNewSessionId],
@@ -1201,7 +1201,9 @@ describe("Chat", () => {
});
// THEN expect the send message method to be called with the user's message
- expect(ChatService.getInstance().sendMessage).toHaveBeenCalledWith(givenActiveSessionId, givenMessage);
+ await waitFor(() => {
+ expect(ChatService.getInstance().sendMessage).toHaveBeenCalledWith(givenActiveSessionId, givenMessage);
+ });
// AND expect the user's message and a typing indicator to be shown in the chat
await waitFor(() => {
assertMessagesAreShown(
@@ -1428,7 +1430,9 @@ describe("Chat", () => {
});
// THEN expect the send message method to be called with the user's message
- expect(ChatService.getInstance().sendMessage).toHaveBeenCalledWith(givenActiveSessionId, givenMessage);
+ await waitFor(() => {
+ expect(ChatService.getInstance().sendMessage).toHaveBeenCalledWith(givenActiveSessionId, givenMessage);
+ });
// AND expect the user's message and a typing indicator to be shown in the chat
await waitFor(() => {
assertMessagesAreShown(
@@ -1628,8 +1632,14 @@ describe("Chat", () => {
});
// THEN expect the send message method to be called
- expect(ChatService.getInstance().sendMessage).toHaveBeenCalledTimes(1);
- expect(ChatService.getInstance().sendMessage).toHaveBeenLastCalledWith(givenActiveSessionId, givenMessage);
+ await waitFor(() => {
+ expect(ChatService.getInstance().sendMessage).toHaveBeenCalledTimes(1);
+ });
+
+ await waitFor(() => {
+ expect(ChatService.getInstance().sendMessage).toHaveBeenLastCalledWith(givenActiveSessionId, givenMessage);
+ });
+
// AND expect an error to have been logged
await waitFor(() => {
expect(console.error).toHaveBeenCalledWith(new ChatError("Failed to send message:", givenError));
@@ -1840,7 +1850,7 @@ describe("Chat", () => {
user_id: givenUser.id,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
- language: Language.en,
+ language: Locale.EN_GB,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
sessions: [givenNewSessionId, givenActiveSessionId],
user_feedback_answered_questions: {},
diff --git a/frontend-new/src/chat/Chat.tsx b/frontend-new/src/chat/Chat.tsx
index d9caa3115..b059a4d53 100644
--- a/frontend-new/src/chat/Chat.tsx
+++ b/frontend-new/src/chat/Chat.tsx
@@ -21,6 +21,7 @@ import ChatMessageField from "./ChatMessageField/ChatMessageField";
import { useNavigate } from "react-router-dom";
import { routerPaths } from "src/app/routerPaths";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
+import LocaleSyncService from "src/i18n/LocaleSyncService";
import { ConversationMessage, ConversationMessageSender } from "./ChatService/ChatService.types";
import { Backdrop } from "src/theme/Backdrop/Backdrop";
import ExperiencesDrawer from "src/experiences/experiencesDrawer/ExperiencesDrawer";
@@ -222,7 +223,11 @@ export const Chat: React.FC> = ({
} finally {
setIsLoading(false);
}
- }, [enqueueSnackbar, activeSessionId, t]);
+ // Intentionally omitting `t` from dependencies:
+ // - This effect should only run when activeSessionId changes (conversation switching)
+ // - Including `t` would cause unnecessary conversation reloads on language change
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [enqueueSnackbar, activeSessionId]);
// Opens the experiences drawer and get experiences if needed
const handleOpenExperiencesDrawer = useCallback(async () => {
@@ -567,6 +572,11 @@ export const Chat: React.FC> = ({
addMessageToChat(message);
}
+ // Ensure the locale is synced before sending a message
+ // If there is a pending update of the locale, wait for that first.
+ // This is that we have real-time like when they change language and how it is applied at the backend.
+ await LocaleSyncService.getInstance().waitForPendingSync();
+
try {
// Send the user's message
const response = await ChatService.getInstance().sendMessage(sessionId, userMessage);
diff --git a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx
index 5cc0f4c08..3822eb630 100644
--- a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx
+++ b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx
@@ -9,10 +9,8 @@ import {
import AuthenticationStateService from "src/auth/services/AuthenticationState.service";
import { TabiyaUser } from "src/auth/auth.types";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
-import {
- SensitivePersonalDataRequirement,
- Language,
-} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService";
import { getBackendUrl } from "src/envService";
import { FeedbackStatus } from "src/feedback/overallFeedback/feedbackForm/FeedbackForm";
@@ -75,7 +73,7 @@ const StorybookWrapper = ({
UserPreferencesStateService.getInstance().setUserPreferences({
sessions: [1],
user_id: "test-user",
- language: Language.en,
+ language: Locale.EN_GB,
user_feedback_answered_questions: {
1: answeredQuestions,
},
diff --git a/frontend-new/src/chat/issueNewSession.test.ts b/frontend-new/src/chat/issueNewSession.test.ts
index b00f48abe..02092d348 100644
--- a/frontend-new/src/chat/issueNewSession.test.ts
+++ b/frontend-new/src/chat/issueNewSession.test.ts
@@ -3,10 +3,10 @@ import "src/_test_utilities/consoleMock";
import { issueNewSession } from "src/chat/issueNewSession";
import {
- Language,
SensitivePersonalDataRequirement,
UserPreference,
} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service";
import { SessionError } from "src/error/commonErrors";
@@ -23,7 +23,7 @@ describe("issueNewSession", () => {
// AND the user preferences service instance will return a new session for the given user ID and new session ID
const givenUserPreferences: UserPreference = {
user_id: givenUserId,
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
sessions: [givenNewSessionId],
user_feedback_answered_questions: {},
@@ -54,7 +54,7 @@ describe("issueNewSession", () => {
// GIVEN some user preferences
const givenUserPreferences: UserPreference = {
user_id: "foo",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
sessions: [123],
user_feedback_answered_questions: {},
diff --git a/frontend-new/src/consent/components/consentPage/Consent.test.tsx b/frontend-new/src/consent/components/consentPage/Consent.test.tsx
index 233752e2a..a42e3aa3c 100644
--- a/frontend-new/src/consent/components/consentPage/Consent.test.tsx
+++ b/frontend-new/src/consent/components/consentPage/Consent.test.tsx
@@ -16,9 +16,9 @@ import { mockBrowserIsOnLine } from "src/_test_utilities/mockBrowserIsOnline";
import { routerPaths } from "src/app/routerPaths";
import {
SensitivePersonalDataRequirement,
- Language,
UserPreference,
} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import { RestAPIError } from "src/error/restAPIError/RestAPIError";
import { DATA_TEST_ID as BACKDROP_TEST_ID } from "src/theme/Backdrop/Backdrop";
import { AuthenticationError } from "src/error/commonErrors";
@@ -151,7 +151,7 @@ describe("Testing Consent Page", () => {
// GIVEN the user preferences state service is mocked to set the user preferences
const givenUserPreferences: UserPreference = {
user_id: "foo-id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
sessions: [],
user_feedback_answered_questions: {},
@@ -478,7 +478,7 @@ describe("Testing Consent Page", () => {
// GIVEN the user preferences state service is mocked to set the user preferences
jest.spyOn(UserPreferencesService.getInstance(), "updateUserPreferences").mockResolvedValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_AVAILABLE,
@@ -527,7 +527,7 @@ describe("Testing Consent Page", () => {
// GIVEN the user preferences state service is mocked to set the user preferences.
jest.spyOn(UserPreferencesService.getInstance(), "updateUserPreferences").mockResolvedValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
@@ -546,7 +546,7 @@ describe("Testing Consent Page", () => {
// AND the user requires sensitive personal data.
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
diff --git a/frontend-new/src/consent/components/consentPage/Consent.tsx b/frontend-new/src/consent/components/consentPage/Consent.tsx
index f969f5e13..6ca4a54e3 100644
--- a/frontend-new/src/consent/components/consentPage/Consent.tsx
+++ b/frontend-new/src/consent/components/consentPage/Consent.tsx
@@ -2,7 +2,7 @@ import React, { useCallback, useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { Box, Container, Checkbox, styled, Typography, useTheme, FormControlLabel, useMediaQuery } from "@mui/material";
import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider";
-import { Language, UpdateUserPreferencesSpec } from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { UpdateUserPreferencesSpec } from "src/userPreferences/UserPreferencesService/userPreferences.types";
import { getUserFriendlyErrorMessage, RestAPIError } from "src/error/restAPIError/RestAPIError";
import PrimaryButton from "src/theme/PrimaryButton/PrimaryButton";
import { useNavigate } from "react-router-dom";
@@ -122,7 +122,6 @@ const Consent: React.FC = () => {
const newUserPreferenceSpecs: UpdateUserPreferencesSpec = {
user_id: user.id,
- language: Language.en,
accepted_tc: new Date(),
};
setIsAccepting(true);
diff --git a/frontend-new/src/experiences/report/config/getConfig.test.ts b/frontend-new/src/experiences/report/config/getConfig.test.ts
index 348d374d5..924e67c19 100644
--- a/frontend-new/src/experiences/report/config/getConfig.test.ts
+++ b/frontend-new/src/experiences/report/config/getConfig.test.ts
@@ -1,14 +1,11 @@
import "src/_test_utilities/consoleMock";
+import "src/_test_utilities/envServiceMock";
import * as envService from "src/envService";
import { defaultSkillsReportOutputConfig } from "./default";
import { DownloadFormat } from "./types";
import { getSkillsReportOutputConfig } from "./getConfig";
-jest.mock("src/envService", () => ({
- getSkillsReportOutputConfigEnvVar: jest.fn(),
-}));
-
const defaultValue = defaultSkillsReportOutputConfig;
describe("getConfig", () => {
diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx
index 9132aafa9..1177d7a76 100644
--- a/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx
+++ b/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx
@@ -8,10 +8,8 @@ import { act, fireEvent, waitFor } from "@testing-library/react";
import FeedbackFormContent from "src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider";
-import {
- SensitivePersonalDataRequirement,
- Language,
-} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import OverallFeedbackService from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service";
import { FeedbackError } from "src/error/commonErrors";
import { FeedbackResponse } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types";
@@ -159,7 +157,7 @@ describe("FeedbackForm", () => {
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
accepted_tc: new Date(),
user_id: "0001",
- language: Language.en,
+ language: Locale.EN_GB,
sessions: [],
user_feedback_answered_questions: {},
has_sensitive_personal_data: false,
diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.test.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.test.tsx
index bb076e8c1..392c9e815 100644
--- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.test.tsx
+++ b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.test.tsx
@@ -14,10 +14,10 @@ import UserPreferencesStateService from "src/userPreferences/UserPreferencesStat
import OverallFeedbackService from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service";
import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider";
import {
- Language,
SensitivePersonalDataRequirement,
UserPreference,
} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import { mockBrowserIsOnLine } from "src/_test_utilities/mockBrowserIsOnline";
import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks";
import {
@@ -53,7 +53,7 @@ jest.mock("src/feedback/overallFeedback/feedbackForm/components/customRating/Cus
const mockUserPreferences: UserPreference = {
sessions: [123],
user_id: "test-user",
- language: Language.en,
+ language: Locale.EN_GB,
user_feedback_answered_questions: {},
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
has_sensitive_personal_data: false,
diff --git a/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.test.ts b/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.test.ts
new file mode 100644
index 000000000..9d7eec070
--- /dev/null
+++ b/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.test.ts
@@ -0,0 +1,403 @@
+// mock the console logs
+import "src/_test_utilities/consoleMock";
+
+import LocaleSyncService from "./LocaleSyncService";
+import { Locale, FALL_BACK_LOCALE, SupportedLocales } from "src/i18n/constants";
+import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
+import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service";
+import {
+ SensitivePersonalDataRequirement,
+ UserPreference,
+} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import i18n from "src/i18n/i18n";
+
+// Mock UserPreferencesService
+jest.mock("src/userPreferences/UserPreferencesService/userPreferences.service");
+
+function getMockUserPreference(overrides: Partial = {}): UserPreference {
+ return {
+ user_id: "test-user-id",
+ language: Locale.EN_GB,
+ sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
+ has_sensitive_personal_data: true,
+ accepted_tc: new Date(),
+ sessions: [1, 2, 3],
+ user_feedback_answered_questions: {},
+ experiments: {},
+ ...overrides,
+ };
+}
+
+describe("LocaleSyncService", () => {
+ let service: LocaleSyncService;
+ let mockUpdateUserPreferences: jest.Mock;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Reset i18n mock state
+ (i18n as any).language = "en-GB";
+
+ // Clear the UserPreferencesStateService state
+ UserPreferencesStateService.getInstance().clearUserPreferences();
+
+ // Setup UserPreferencesService mock
+ mockUpdateUserPreferences = jest.fn().mockResolvedValue(getMockUserPreference());
+ (UserPreferencesService.getInstance as jest.Mock).mockReturnValue({
+ updateUserPreferences: mockUpdateUserPreferences,
+ });
+
+ // Get fresh instance reference (singleton persists, but we reset mocks)
+ service = LocaleSyncService.getInstance();
+ });
+
+ describe("getInstance", () => {
+ test("should return a singleton instance", () => {
+ // WHEN getInstance is called multiple times
+ const firstInstance = LocaleSyncService.getInstance();
+ const secondInstance = LocaleSyncService.getInstance();
+
+ // THEN both should be the same instance
+ expect(firstInstance).toBe(secondInstance);
+ });
+ });
+
+ describe("syncLocaleToBackend", () => {
+ test("should skip sync when user is not authenticated", async () => {
+ // GIVEN the user is not authenticated (no preferences)
+ expect(UserPreferencesStateService.getInstance().getUserPreferences()).toBeNull();
+
+ // WHEN syncLocaleToBackend is called
+ await service.syncLocaleToBackend(Locale.ES_ES);
+
+ // THEN no API call should be made
+ expect(mockUpdateUserPreferences).not.toHaveBeenCalled();
+ });
+
+ test("should sync locale to backend when user is authenticated", async () => {
+ // GIVEN the user is authenticated
+ const mockPrefs = getMockUserPreference();
+ UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs);
+
+ const updatedPrefs = getMockUserPreference({ language: Locale.ES_ES });
+ mockUpdateUserPreferences.mockResolvedValue(updatedPrefs);
+
+ // WHEN syncLocaleToBackend is called
+ await service.syncLocaleToBackend(Locale.ES_ES);
+
+ // THEN the API should be called with the correct parameters
+ expect(mockUpdateUserPreferences).toHaveBeenCalledWith({
+ user_id: mockPrefs.user_id,
+ language: Locale.ES_ES,
+ });
+ });
+
+ test("should update local state with response from backend", async () => {
+ // GIVEN the user is authenticated
+ const mockPrefs = getMockUserPreference();
+ UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs);
+
+ const updatedPrefs = getMockUserPreference({ language: Locale.ES_ES });
+ mockUpdateUserPreferences.mockResolvedValue(updatedPrefs);
+
+ // WHEN syncLocaleToBackend is called
+ await service.syncLocaleToBackend(Locale.ES_ES);
+
+ // THEN the local state should be updated
+ const currentPrefs = UserPreferencesStateService.getInstance().getUserPreferences();
+ expect(currentPrefs?.language).toBe(Locale.ES_ES);
+ });
+
+ test("should handle API errors gracefully without throwing", async () => {
+ // GIVEN the user is authenticated
+ const mockPrefs = getMockUserPreference();
+ UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs);
+
+ // AND the API will fail
+ const apiError = new Error("API error");
+ mockUpdateUserPreferences.mockRejectedValue(apiError);
+
+ // WHEN syncLocaleToBackend is called
+ // THEN it should not throw
+ await expect(service.syncLocaleToBackend(Locale.ES_ES)).resolves.not.toThrow();
+
+ // AND the error should be logged (console is mocked)
+ expect(console.error).toHaveBeenCalled();
+ });
+
+ describe("race condition handling", () => {
+ test("should queue subsequent requests while one is pending", async () => {
+ // GIVEN the user is authenticated
+ const mockPrefs = getMockUserPreference();
+ UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs);
+
+ // AND the API has a delay
+ let resolveFirst: () => void;
+ const firstCallPromise = new Promise((resolve) => {
+ resolveFirst = resolve;
+ });
+
+ let callCount = 0;
+ mockUpdateUserPreferences.mockImplementation(async (params: { language: Locale }) => {
+ callCount++;
+ if (callCount === 1) {
+ await firstCallPromise;
+ }
+ return getMockUserPreference({ language: params.language });
+ });
+
+ // WHEN multiple sync calls are made rapidly
+ const promise1 = service.syncLocaleToBackend(Locale.ES_ES);
+ const promise2 = service.syncLocaleToBackend(Locale.EN_US);
+ const promise3 = service.syncLocaleToBackend(Locale.ES_AR);
+
+ // Resolve the first call
+ resolveFirst!();
+
+ await Promise.all([promise1, promise2, promise3]);
+
+ // THEN only 2 API calls should be made (first + last queued)
+ // The intermediate locale (EN_US) should be skipped
+ expect(mockUpdateUserPreferences).toHaveBeenCalledTimes(2);
+ expect(mockUpdateUserPreferences).toHaveBeenNthCalledWith(1, {
+ user_id: mockPrefs.user_id,
+ language: Locale.ES_ES,
+ });
+ expect(mockUpdateUserPreferences).toHaveBeenNthCalledWith(2, {
+ user_id: mockPrefs.user_id,
+ language: Locale.ES_AR,
+ });
+ });
+ });
+ });
+
+ describe("syncOnLogin", () => {
+ test("should not sync when locales match", async () => {
+ // GIVEN the user is authenticated
+ const mockPrefs = getMockUserPreference({ language: Locale.EN_GB });
+ UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs);
+
+ // WHEN syncOnLogin is called with matching locales
+ await service.syncOnLogin(Locale.EN_GB, Locale.EN_GB);
+
+ // THEN no API call should be made
+ expect(mockUpdateUserPreferences).not.toHaveBeenCalled();
+ });
+
+ test("should sync to backend when locales differ", async () => {
+ // GIVEN the user is authenticated with EN_GB locale
+ const mockPrefs = getMockUserPreference({ language: Locale.EN_GB });
+ UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs);
+
+ mockUpdateUserPreferences.mockResolvedValue(getMockUserPreference({ language: Locale.ES_ES }));
+
+ // WHEN syncOnLogin is called with different locales
+ await service.syncOnLogin(Locale.EN_GB, Locale.ES_ES);
+
+ // THEN the API should be called with the frontend locale
+ expect(mockUpdateUserPreferences).toHaveBeenCalledWith({
+ user_id: mockPrefs.user_id,
+ language: Locale.ES_ES,
+ });
+ });
+
+ test("should handle backend locale as string", async () => {
+ // GIVEN the user is authenticated
+ const mockPrefs = getMockUserPreference({ language: Locale.EN_GB });
+ UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs);
+
+ mockUpdateUserPreferences.mockResolvedValue(getMockUserPreference({ language: Locale.ES_ES }));
+
+ // WHEN syncOnLogin is called with backend locale as string
+ await service.syncOnLogin("en-GB", Locale.ES_ES);
+
+ // THEN the API should be called
+ expect(mockUpdateUserPreferences).toHaveBeenCalledWith({
+ user_id: mockPrefs.user_id,
+ language: Locale.ES_ES,
+ });
+ });
+ });
+
+ describe("applyBackendLocale", () => {
+ test("should change i18n language when backend locale differs from current", async () => {
+ // GIVEN the current i18n language is EN_GB
+ (i18n as any).language = Locale.EN_GB;
+
+ // WHEN applyBackendLocale is called with a different locale
+ await service.applyBackendLocale(Locale.ES_ES);
+
+ // THEN i18n.changeLanguage should be called
+ expect(i18n.changeLanguage).toHaveBeenCalledWith(Locale.ES_ES);
+ });
+
+ test("should not change i18n language when backend locale matches current", async () => {
+ // GIVEN the current i18n language is EN_GB
+ (i18n as any).language = Locale.EN_GB;
+
+ // WHEN applyBackendLocale is called with the same locale
+ await service.applyBackendLocale(Locale.EN_GB);
+
+ // THEN i18n.changeLanguage should not be called
+ expect(i18n.changeLanguage).not.toHaveBeenCalled();
+ });
+
+ test("should fall back to FALL_BACK_LOCALE when backend locale is not supported", async () => {
+ // GIVEN the current i18n language is EN_GB
+ (i18n as any).language = Locale.EN_GB;
+
+ // WHEN applyBackendLocale is called with an unsupported locale
+ await service.applyBackendLocale("invalid-locale");
+
+ // THEN i18n.changeLanguage should be called with the fallback locale
+ expect(i18n.changeLanguage).toHaveBeenCalledWith(FALL_BACK_LOCALE);
+
+ // AND an warning should be logged
+ expect(console.warn).toHaveBeenCalled();
+ });
+
+ test("should validate all supported locales correctly", async () => {
+ // Test each supported locale to ensure they're all accepted
+ for (const locale of SupportedLocales) {
+ jest.clearAllMocks();
+ (i18n as any).language = "different-locale";
+
+ // WHEN applyBackendLocale is called with a supported locale
+ await service.applyBackendLocale(locale);
+
+ // THEN i18n.changeLanguage should be called with that locale
+ expect(i18n.changeLanguage).toHaveBeenCalledWith(locale);
+
+ // AND no error should be logged
+ expect(console.error).not.toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe("waitForPendingSync", () => {
+ test("should resolve immediately if no sync is pending", async () => {
+ // WHEN waitForPendingSync is called
+ const promise = service.waitForPendingSync();
+
+ // THEN it should resolve
+ await expect(promise).resolves.not.toThrow();
+ });
+
+ test("should wait for pending sync to complete", async () => {
+ // GIVEN the user is authenticated
+ const mockPrefs = getMockUserPreference();
+ UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs);
+
+ // AND a sync is pending (delayed)
+ let resolveSync: () => void;
+ const syncPromise = new Promise((resolve) => {
+ resolveSync = resolve;
+ });
+
+ mockUpdateUserPreferences.mockImplementation(async () => {
+ await syncPromise;
+ return getMockUserPreference();
+ });
+
+ // Start sync
+ const syncCall = service.syncLocaleToBackend(Locale.ES_ES);
+
+ // WHEN waitForPendingSync is called
+ let waitResolved = false;
+ const waitPromise = service.waitForPendingSync().then(() => {
+ waitResolved = true;
+ });
+
+ // THEN it should not resolve yet
+ await new Promise((r) => setTimeout(r, 0));
+ expect(waitResolved).toBe(false);
+
+ // WHEN sync completes
+ resolveSync!();
+ await syncCall;
+
+ // THEN waitForPendingSync should resolve
+ await waitPromise;
+ expect(waitResolved).toBe(true);
+ });
+
+ test("should wait for queued sync to complete", async () => {
+ // GIVEN the user is authenticated
+ const mockPrefs = getMockUserPreference();
+ UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs);
+
+ // AND a sync is pending (delayed)
+ let resolveFirstSync: () => void;
+ const firstSyncPromise = new Promise((resolve) => {
+ resolveFirstSync = resolve;
+ });
+
+ let resolveSecondSync: () => void;
+ const secondSyncPromise = new Promise((resolve) => {
+ resolveSecondSync = resolve;
+ });
+
+ let callCount = 0;
+ mockUpdateUserPreferences.mockImplementation(async (params) => {
+ callCount++;
+ if (callCount === 1) {
+ await firstSyncPromise;
+ } else if (callCount === 2) {
+ await secondSyncPromise;
+ }
+ return getMockUserPreference({ language: params.language });
+ });
+
+ // Start first sync
+ const syncCall1 = service.syncLocaleToBackend(Locale.ES_ES);
+
+ // Queue second sync
+ const syncCall2 = service.syncLocaleToBackend(Locale.ES_AR);
+
+ // WHEN waitForPendingSync is called
+ let waitResolved = false;
+ const waitPromise = service.waitForPendingSync().then(() => {
+ waitResolved = true;
+ });
+
+ // Resolve first sync
+ resolveFirstSync!();
+
+ // Wait a bit for microtasks
+ await new Promise((r) => setTimeout(r, 0));
+
+ // Resolve second sync
+ resolveSecondSync!();
+ await Promise.all([syncCall1, syncCall2]);
+
+ // THEN waitForPendingSync should resolve
+ await waitPromise;
+ expect(waitResolved).toBe(true);
+ expect(callCount).toBe(2);
+ });
+ });
+
+ describe("isAuthenticated", () => {
+ test("should return false when user preferences are not set", () => {
+ // GIVEN no user preferences are set
+ UserPreferencesStateService.getInstance().clearUserPreferences();
+
+ // WHEN isAuthenticated is called
+ const result = service.isAuthenticated();
+
+ // THEN it should return false
+ expect(result).toBe(false);
+ });
+
+ test("should return true when user preferences are set", () => {
+ // GIVEN user preferences are set
+ const mockPrefs = getMockUserPreference();
+ UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs);
+
+ // WHEN isAuthenticated is called
+ const result = service.isAuthenticated();
+
+ // THEN it should return true
+ expect(result).toBe(true);
+ });
+ });
+});
diff --git a/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.ts b/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.ts
new file mode 100644
index 000000000..8aa074b50
--- /dev/null
+++ b/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.ts
@@ -0,0 +1,181 @@
+import { Locale, SupportedLocales, FALL_BACK_LOCALE } from "src/i18n/constants";
+import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service";
+import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
+import i18n from "src/i18n/i18n";
+
+/**
+ * LocaleSyncService is responsible for synchronizing the frontend i18n locale
+ * with the backend user preferences.
+ *
+ * Responsibilities:
+ * - Sync locale changes to backend when a user changes language in UI
+ * - Apply backend locale to frontend on page refresh (for authenticated users)
+ * - Sync locale to backend on login if frontend and backend differ
+ * - Handle race conditions when rapid locale changes occur
+ *
+ * This service is a singleton to ensure consistent state management across the app.
+ */
+export default class LocaleSyncService {
+ private static instance: LocaleSyncService;
+ private pendingSync: Promise | null = null;
+ private queuedLocale: Locale | null = null;
+
+ private constructor() {}
+
+ /**
+ * Get the singleton instance of LocaleSyncService.
+ * @returns {LocaleSyncService} The singleton instance.
+ */
+ public static getInstance(): LocaleSyncService {
+ if (!LocaleSyncService.instance) {
+ LocaleSyncService.instance = new LocaleSyncService();
+ }
+ return LocaleSyncService.instance;
+ }
+
+ /**
+ * Sync the current locale to the backend.
+ * Handles race conditions by tracking pending requests and queuing the latest locale.
+ *
+ * @param {Locale} locale - The locale to sync to backend.
+ * @returns {Promise}
+ */
+ public async syncLocaleToBackend(locale: Locale): Promise {
+ // If there's already a pending sync, queue this locale and wait
+ if (this.pendingSync) {
+ this.queuedLocale = locale;
+ await this.pendingSync;
+ // After pending completing, if this was the queued locale, process it
+ if (this.queuedLocale === locale) {
+ this.queuedLocale = null;
+ return this.executeSyncToBackend(locale);
+ }
+ // Otherwise, a newer locale was queued, so skip this one
+ return;
+ }
+
+ return this.executeSyncToBackend(locale);
+ }
+
+ /**
+ * Execute the actual sync to backend.
+ * @param {Locale} locale - The locale to sync.
+ * @returns {Promise}
+ */
+ private async executeSyncToBackend(locale: Locale): Promise {
+ const userPreferences = UserPreferencesStateService.getInstance().getUserPreferences();
+
+ // Guard: Don't sync if not authenticated
+ if (!userPreferences) {
+ console.debug("LocaleSyncService: Skipping sync, user not authenticated");
+ return;
+ }
+
+ this.pendingSync = this.performBackendUpdate(userPreferences.user_id, locale);
+
+ try {
+ await this.pendingSync;
+ } finally {
+ this.pendingSync = null;
+ }
+ }
+
+ /**
+ * Perform the actual PATCH request to update locale in backend.
+ * @param {string} userId - The user ID.
+ * @param {Locale} locale - The locale to update.
+ * @returns {Promise}
+ */
+ private async performBackendUpdate(userId: string, locale: Locale): Promise {
+ try {
+ const updatedPreferences = await UserPreferencesService.getInstance().updateUserPreferences({
+ user_id: userId,
+ language: locale,
+ });
+
+ // Update local state with the response
+ UserPreferencesStateService.getInstance().setUserPreferences(updatedPreferences);
+ console.debug(`LocaleSyncService: Successfully synced locale ${locale} to backend`);
+ } catch (error) {
+ // Log error but don't throw - locale is already updated in i18n (optimistic update)
+ console.error(new Error(`LocaleSyncService: Failed to sync locale ${locale} to backend`, { cause: error }));
+ }
+ }
+
+ /**
+ * Handle locale synchronization on login.
+ * If the frontend locale differs from the backend locale, update the backend.
+ *
+ * @param {string} backendLocale - The locale stored in backend preferences.
+ * @param {Locale} frontendLocale - The current i18n locale.
+ * @returns {Promise}
+ */
+ public async syncOnLogin(backendLocale: string, frontendLocale: Locale): Promise {
+ // Validate that the comparison makes sense
+ if (backendLocale === frontendLocale) {
+ console.debug("LocaleSyncService: Locales match, no sync needed");
+ return;
+ }
+
+ console.debug(
+ `LocaleSyncService: Frontend locale (${frontendLocale}) differs from backend (${backendLocale}), syncing to backend`
+ );
+ await this.syncLocaleToBackend(frontendLocale);
+ }
+
+ /**
+ * Wait for any pending sync to complete.
+ * This ensures that the locale is synced to the backend before proceeding.
+ * Useful when performing actions that depend on the correct backend locale (e.g., sending a message).
+ *
+ * Note: Failures in the underlying sync are logged but do not prevent the wait
+ * from completing. This is intentional - we use optimistic updates for i18n,
+ * so even if backend sync fails, the frontend locale has already been updated.
+ * Backend sync failures will be retried on next login.
+ *
+ * @returns {Promise}
+ */
+ public async waitForPendingSync(): Promise {
+ try {
+ if (this.pendingSync) {
+ await this.pendingSync;
+ }
+ } catch (e) {
+ // Log error but don't propagate - see method documentation for rationale
+ console.error("LocaleSyncService: Failed to wait for pending sync", e);
+ }
+ }
+
+ /**
+ * Apply the backend locale to the frontend i18n.
+ * Used on page refresh for authenticated users.
+ * Validates that the backend locale is supported before applying.
+ *
+ * @param {string} backendLocale - The locale from backend preferences.
+ * @returns {Promise}
+ */
+ public async applyBackendLocale(backendLocale: string): Promise {
+ // Validate the backend locale is supported
+ if (!SupportedLocales.includes(backendLocale as Locale)) {
+ console.warn(
+ `LocaleSyncService: Backend locale "${backendLocale}" is not supported. Supported: ${SupportedLocales.join(", ")}. Falling back to ${FALL_BACK_LOCALE}`
+ );
+ await i18n.changeLanguage(FALL_BACK_LOCALE);
+ return;
+ }
+
+ // Only change if different from the current
+ if (i18n.language !== backendLocale) {
+ console.debug(`LocaleSyncService: Applying backend locale ${backendLocale} to frontend`);
+ await i18n.changeLanguage(backendLocale);
+ }
+ }
+
+ /**
+ * Check if a user is currently authenticated (has user preferences).
+ * @returns {boolean} True if authenticated, false otherwise.
+ */
+ public isAuthenticated(): boolean {
+ return UserPreferencesStateService.getInstance().getUserPreferences() !== null;
+ }
+}
diff --git a/frontend-new/src/i18n/LocaleSyncService/index.ts b/frontend-new/src/i18n/LocaleSyncService/index.ts
new file mode 100644
index 000000000..db9f812e7
--- /dev/null
+++ b/frontend-new/src/i18n/LocaleSyncService/index.ts
@@ -0,0 +1 @@
+export { default } from "./LocaleSyncService";
diff --git a/frontend-new/src/i18n/constants.ts b/frontend-new/src/i18n/constants.ts
index 8098ee128..a4dc088ac 100644
--- a/frontend-new/src/i18n/constants.ts
+++ b/frontend-new/src/i18n/constants.ts
@@ -19,3 +19,19 @@ export const LocalesLabels = {
export const SupportedLocales: Locale[] = [Locale.EN_GB, Locale.EN_US, Locale.ES_ES, Locale.ES_AR];
export const FALL_BACK_LOCALE = Locale.EN_GB;
+
+/**
+ * Validates and safely converts a string to a supported Locale.
+ * If the locale is not supported, falls back to FALL_BACK_LOCALE and logs a warning.
+ *
+ * @param locale - The locale string to validate (typically from i18n.language)
+ * @returns A valid Locale enum value
+ */
+export function validateLocale(locale: string): Locale {
+ const localeValue = locale as Locale;
+ if (SupportedLocales.includes(localeValue)) {
+ return localeValue;
+ }
+ console.warn(`[validateLocale] Invalid locale: ${locale}, falling back to ${FALL_BACK_LOCALE}`);
+ return FALL_BACK_LOCALE;
+}
diff --git a/frontend-new/src/i18n/languageContextMenu/LanguageContextMenu.tsx b/frontend-new/src/i18n/languageContextMenu/LanguageContextMenu.tsx
index f56cb9c7f..b326b3329 100644
--- a/frontend-new/src/i18n/languageContextMenu/LanguageContextMenu.tsx
+++ b/frontend-new/src/i18n/languageContextMenu/LanguageContextMenu.tsx
@@ -5,7 +5,10 @@ import { useTheme } from "@mui/material";
import ContextMenu from "src/theme/ContextMenu/ContextMenu";
import { useTranslation } from "react-i18next";
import { Locale, LocalesLabels } from "src/i18n/constants";
+import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
import { parseEnvSupportedLocales } from "src/i18n/languageContextMenu/parseEnvSupportedLocales";
+import LocaleSyncService from "src/i18n/LocaleSyncService";
+import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider";
const uniqueId = "f4d06e4b-0e0c-49c7-ad93-924c5ac89070";
@@ -24,6 +27,7 @@ const LanguageContextMenu: React.FC = ({ removeMargin
const theme = useTheme();
const { t, i18n } = useTranslation();
+ const { enqueueSnackbar } = useSnackbar();
const handleLocaleChange = useCallback(
(locale: Locale) => () => {
@@ -31,12 +35,26 @@ const LanguageContextMenu: React.FC = ({ removeMargin
.changeLanguage(locale)
.then(() => {
console.debug(`Language changed to ${locale}`);
+ // Sync locale to backend if user is authenticated
+ const isAuthenticated = UserPreferencesStateService.getInstance().getUserPreferences() !== null;
+ if (!isAuthenticated) return;
+
+ LocaleSyncService.getInstance()
+ .syncLocaleToBackend(locale)
+ .catch((e) => {
+ // Notify the user that preference may not persist
+ enqueueSnackbar(t("i18n.sync.errors.syncFailedWarning", { locale: LocalesLabels[locale] }), {
+ variant: "warning",
+ });
+ console.debug("Locale sync to backend failed, will retry on next login");
+ });
})
.catch((e) => {
console.error(`Failed to change language to ${locale}`, e);
+ enqueueSnackbar(t("i18n.sync.errors.changeFailed"), { variant: "error" });
});
},
- [i18n]
+ [i18n, enqueueSnackbar, t]
);
const supportedLocales = useMemo(() => parseEnvSupportedLocales(), []);
diff --git a/frontend-new/src/i18n/locales/en-GB/translation.json b/frontend-new/src/i18n/locales/en-GB/translation.json
index c1136b22a..b69530abc 100644
--- a/frontend-new/src/i18n/locales/en-GB/translation.json
+++ b/frontend-new/src/i18n/locales/en-GB/translation.json
@@ -733,6 +733,12 @@
"i18n": {
"languageContextMenu": {
"selector": "Language Selector"
+ },
+ "sync": {
+ "errors": {
+ "syncFailedWarning": "Language changed to {{locale}}, but we couldn't save your preference. It will revert on next login.",
+ "changeFailed": "Failed to change language. Please try again."
+ }
}
},
"_deprecated": {
diff --git a/frontend-new/src/i18n/locales/en-US/translation.json b/frontend-new/src/i18n/locales/en-US/translation.json
index f48745dc8..e38f98d6d 100644
--- a/frontend-new/src/i18n/locales/en-US/translation.json
+++ b/frontend-new/src/i18n/locales/en-US/translation.json
@@ -733,6 +733,12 @@
"i18n": {
"languageContextMenu": {
"selector": "Language Selector"
+ },
+ "sync": {
+ "errors": {
+ "syncFailedWarning": "Language changed to {{locale}}, but we couldn't save your preference. It will revert on next login.",
+ "changeFailed": "Failed to change language. Please try again."
+ }
}
},
"_deprecated": {
diff --git a/frontend-new/src/i18n/locales/es-AR/translation.json b/frontend-new/src/i18n/locales/es-AR/translation.json
index 064b3106f..21b3c83dd 100644
--- a/frontend-new/src/i18n/locales/es-AR/translation.json
+++ b/frontend-new/src/i18n/locales/es-AR/translation.json
@@ -733,6 +733,12 @@
"i18n": {
"languageContextMenu": {
"selector": "Selector de idioma"
+ },
+ "sync": {
+ "errors": {
+ "syncFailedWarning": "Idioma cambiado a {{locale}}, pero no pudimos guardar tu preferencia. Se revertirá en el próximo inicio de sesión.",
+ "changeFailed": "Error al cambiar el idioma. Por favor, intentá de nuevo."
+ }
}
},
"_deprecated": {
diff --git a/frontend-new/src/i18n/locales/es-ES/translation.json b/frontend-new/src/i18n/locales/es-ES/translation.json
index becf02fcc..06b8d69bd 100644
--- a/frontend-new/src/i18n/locales/es-ES/translation.json
+++ b/frontend-new/src/i18n/locales/es-ES/translation.json
@@ -733,6 +733,12 @@
"i18n": {
"languageContextMenu": {
"selector": "Selector de idioma"
+ },
+ "sync": {
+ "errors": {
+ "syncFailedWarning": "Idioma cambiado a {{locale}}, pero no pudimos guardar tu preferencia. Se revertirá en el próximo inicio de sesión.",
+ "changeFailed": "Error al cambiar el idioma. Por favor, inténtalo de nuevo."
+ }
}
},
"_deprecated": {
diff --git a/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.stories.tsx b/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.stories.tsx
index a24112c88..c8db0ecf5 100644
--- a/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.stories.tsx
+++ b/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.stories.tsx
@@ -2,10 +2,8 @@ import { Meta, StoryObj } from "@storybook/react";
import SensitiveDataForm from "src/sensitiveData/components/sensitiveDataForm/SensitiveDataForm";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
-import {
- Language,
- SensitivePersonalDataRequirement,
-} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import { getBackendUrl } from "src/envService";
const meta: Meta = {
@@ -37,7 +35,7 @@ export const Shown: StoryObj = {
sessions: [],
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
user_feedback_answered_questions: {},
- language: Language.en,
+ language: Locale.EN_GB,
experiments: {},
});
return () => {
@@ -56,7 +54,7 @@ export const ShownWhenSkipping: StoryObj = {
sessions: [],
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
user_feedback_answered_questions: {},
- language: Language.en,
+ language: Locale.EN_GB,
experiments: {},
});
return () => {
diff --git a/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.test.tsx b/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.test.tsx
index 804170f24..6774772cd 100644
--- a/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.test.tsx
+++ b/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.test.tsx
@@ -16,10 +16,8 @@ import * as RestAPIErrorModule from "src/error/restAPIError/RestAPIError";
import { RestAPIError } from "src/error/restAPIError/RestAPIError";
import AuthenticationServiceFactory from "src/auth/services/Authentication.service.factory";
import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService";
-import {
- Language,
- SensitivePersonalDataRequirement,
-} from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types";
+import { Locale } from "src/i18n/constants";
import { sensitivePersonalDataService } from "src/sensitiveData/services/sensitivePersonalDataService/sensitivePersonalData.service";
import { EncryptedDataTooLarge } from "src/sensitiveData/services/sensitivePersonalDataService/errors";
import { FieldDefinition, FieldType } from "./config/types";
@@ -153,7 +151,7 @@ const givenUserId = getTestString(10);
const SAMPLE_USER_PREFERENCES = {
user_id: givenUserId,
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
sessions: [],
user_feedback_answered_questions: {},
@@ -206,7 +204,7 @@ describe("Sensitive Data Form", () => {
// AND the user preferences state service is mocked to return user preferences with PII required
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -259,7 +257,7 @@ describe("Sensitive Data Form", () => {
// AND the user preferences state service is mocked to return user preferences with PII required
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -312,7 +310,7 @@ describe("Sensitive Data Form", () => {
// AND the user preferences state service is mocked to return user preferences with PII required
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -365,7 +363,7 @@ describe("Sensitive Data Form", () => {
// AND the user preferences state service is mocked to return user preferences with PII required
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -419,7 +417,7 @@ describe("Sensitive Data Form", () => {
// AND the user preferences state service is mocked to return user preferences with PII required
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -472,7 +470,7 @@ describe("Sensitive Data Form", () => {
// AND the user preferences state service is mocked to return user preferences with PII required
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -508,7 +506,7 @@ describe("Sensitive Data Form", () => {
// AND the user preferences state service is mocked to return user preferences with PII required
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -542,7 +540,7 @@ describe("Sensitive Data Form", () => {
// AND the user preferences state service is mocked to return user preferences with PII required
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -811,7 +809,7 @@ describe("Sensitive Data Form", () => {
// AND user preferences are set to show reject button
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: givenUserId,
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -931,7 +929,7 @@ describe("Sensitive Data Form", () => {
// GIVEN the user preferences state service is mocked to return user preferences with PII required
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
@@ -977,7 +975,7 @@ describe("Sensitive Data Form", () => {
// GIVEN the user preferences state service is mocked to return user preferences with PII not required
jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
@@ -1008,7 +1006,7 @@ describe("Sensitive Data Form", () => {
// AND the user preferences should be updated
expect(UserPreferencesStateService.getInstance().setUserPreferences).toHaveBeenCalledWith({
user_id: "given user id",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: expect.any(Date),
experiments: {},
has_sensitive_personal_data: true,
@@ -1031,7 +1029,7 @@ describe("Sensitive Data Form", () => {
// GIVEN sensitive personal data is not required
const givenUserPreferences = {
user_id: givenUserId,
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
@@ -1069,7 +1067,7 @@ describe("Sensitive Data Form", () => {
// AND the sensitive personal data is not required
const givenUserPreferences = {
user_id: givenUserId,
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
has_sensitive_personal_data: false,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED,
diff --git a/frontend-new/src/sentryInit.test.ts b/frontend-new/src/sentryInit.test.ts
index eacdd6d88..0228e6629 100644
--- a/frontend-new/src/sentryInit.test.ts
+++ b/frontend-new/src/sentryInit.test.ts
@@ -1,4 +1,5 @@
import "src/_test_utilities/consoleMock";
+import "src/_test_utilities/envServiceMock";
import * as Sentry from "@sentry/react";
import { CompressionError, initSentry, SENTRY_CONFIG_DEFAULT, SentryConfig, sentryTransport } from "./sentryInit";
@@ -29,14 +30,6 @@ jest.mock("@sentry/core", () => ({
createTransport: jest.fn(),
}));
-jest.mock("./envService", () => ({
- getBackendUrl: jest.fn(),
- getTargetEnvironmentName: jest.fn(),
- getSentryDSN: jest.fn(),
- getSentryEnabled: jest.fn(),
- getSentryConfig: jest.fn(),
-}));
-
jest.mock("brotli-wasm", () => ({
__esModule: true,
default: Promise.resolve({
diff --git a/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.test.ts b/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.test.ts
index ed61f34aa..51d33b76c 100644
--- a/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.test.ts
+++ b/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.test.ts
@@ -9,16 +9,16 @@ import ErrorConstants from "src/error/restAPIError/RestAPIError.constants";
import {
CreateUserPreferencesSpec,
SensitivePersonalDataRequirement,
- Language,
UpdateUserPreferencesSpec,
UserPreference,
} from "./userPreferences.types";
+import { Locale } from "src/i18n/constants";
import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService";
function getTestUserPreferences(): UserPreference {
return {
user_id: "1",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
sessions: [1234],
client_id: getRandomString(10),
@@ -210,7 +210,7 @@ describe("UserPreferencesService", () => {
// AND the PATCH models REST API will respond with OK and some models
const givenUserPreferences: UpdateUserPreferencesSpec = {
user_id: "1",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
};
@@ -250,7 +250,7 @@ describe("UserPreferencesService", () => {
let updateUserPreferencesCallback = async () =>
await service.updateUserPreferences({
user_id: "1",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
});
@@ -285,7 +285,7 @@ describe("UserPreferencesService", () => {
const updateUserPreferencesCallback = async () =>
await service.updateUserPreferences({
user_id: "1",
- language: Language.en,
+ language: Locale.EN_GB,
accepted_tc: new Date(),
});
@@ -300,7 +300,7 @@ describe("UserPreferencesService", () => {
// AND the POST models REST API will respond with OK and some models
const givenUserPreferences: CreateUserPreferencesSpec = {
user_id: "1",
- language: Language.en,
+ language: Locale.EN_GB,
invitation_code: "1234",
};
@@ -374,7 +374,7 @@ describe("UserPreferencesService", () => {
let createUserPreferencesCallback = async () =>
await service.createUserPreferences({
user_id: "1",
- language: Language.en,
+ language: Locale.EN_GB,
invitation_code: "1234",
});
@@ -411,7 +411,7 @@ describe("UserPreferencesService", () => {
const createUserPreferencesCallback = async () =>
await service.createUserPreferences({
user_id: "1",
- language: Language.en,
+ language: Locale.EN_GB,
invitation_code: "1234",
});
diff --git a/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.types.ts b/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.types.ts
index 1ee99e493..d5c84360b 100644
--- a/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.types.ts
+++ b/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.types.ts
@@ -1,3 +1,5 @@
+import { Locale } from "src/i18n/constants";
+
export enum SensitivePersonalDataRequirement {
REQUIRED = "REQUIRED",
NOT_REQUIRED = "NOT_REQUIRED",
@@ -14,7 +16,7 @@ export type UserPreferencesExperiments = Partial random + index);
return {
user_id: nanoid(), // ensure a unique user id
- language: Language.en,
+ language: Locale.EN_GB,
sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED,
has_sensitive_personal_data: true,
accepted_tc: new Date(),
diff --git a/frontend-new/src/userPreferences/UserPreferencesStateService.ts b/frontend-new/src/userPreferences/UserPreferencesStateService.ts
index 626708342..d00d6ec30 100644
--- a/frontend-new/src/userPreferences/UserPreferencesStateService.ts
+++ b/frontend-new/src/userPreferences/UserPreferencesStateService.ts
@@ -1,9 +1,9 @@
import {
SensitivePersonalDataRequirement,
UserPreference,
- Language,
} from "src/userPreferences/UserPreferencesService/userPreferences.types";
import { QUESTION_KEYS } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types";
+import { Locale, SupportedLocales, FALL_BACK_LOCALE } from "src/i18n/constants";
export default class UserPreferencesStateService {
private static instance: UserPreferencesStateService;
@@ -103,9 +103,18 @@ export default class UserPreferencesStateService {
if (key === "sensitive_personal_data_requirement") {
return SensitivePersonalDataRequirement[value as keyof typeof SensitivePersonalDataRequirement];
}
+
if (key === "language") {
- return Language[value as keyof typeof Language];
+ // Validate that the locale is supported, fall back to default if not
+ const locale = value as Locale;
+ if (SupportedLocales.includes(locale)) {
+ return locale;
+ }
+
+ console.warn(`Unsupported locale "${value}" received from backend, falling back to ${FALL_BACK_LOCALE}`);
+ return FALL_BACK_LOCALE;
}
+
return value;
});
}