diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index a8977522bb..af4be01e6a 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -631,6 +631,7 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody; const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null; const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null; + const hasMultipleEnabledLanguages = (languages ?? []).filter((language) => language.enabled).length > 1; const actionClasses = await getActionClasses(parsedWorkspaceId); @@ -641,6 +642,8 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat publishOn: normalizedPublishOn, status: restSurveyBody.status ?? "draft", }), + autoSelectLanguage: + restSurveyBody.autoSelectLanguage ?? (hasMultipleEnabledLanguages ? true : undefined), // @ts-expect-error - languages would be undefined in case of empty array languages: languages?.length ? languages : undefined, triggers: restSurveyBody.triggers diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 07fc792d59..63af3a514f 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -2833,8 +2833,8 @@ "auto_save_disabled": "Auto-save disabled", "auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.", "auto_save_on": "Auto-save on", - "auto_select_browser_language": "Auto-select browser language", - "auto_select_browser_language_description": "Use the respondent's browser language when the survey has a matching active language. Falls back to the default language.", + "auto_select_browser_language": "Use browser language by default", + "auto_select_browser_language_description": "Automatically open the survey in the respondent's browser language when that language is active. Falls back to the default language.", "automatically_close_survey_after": "Automatically close survey after", "automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.", "automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.", diff --git a/apps/web/modules/survey/link/lib/utils.test.ts b/apps/web/modules/survey/link/lib/utils.test.ts index 15e3c9297d..2695e34ed2 100644 --- a/apps/web/modules/survey/link/lib/utils.test.ts +++ b/apps/web/modules/survey/link/lib/utils.test.ts @@ -147,6 +147,28 @@ describe("getSurveyLanguageCode", () => { expect(getSurveyLanguageCode(undefined, survey, ["es-MX", "en-US"])).toBe("es-ES"); }); + test("uses aliases and ignores disabled languages", () => { + const survey = { + ...createMockSurvey([ + language("en", { default: true }), + language("de", { enabled: false }), + language("fr-FR", { + language: { + id: "lang-fr-FR", + code: "fr-FR", + alias: "fr", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "p1", + }, + }), + ]), + autoSelectLanguage: true, + }; + + expect(getSurveyLanguageCode(undefined, survey, ["de-DE", "fr-CA"])).toBe("fr-FR"); + }); + test("falls back to default language when auto-selection is disabled or unmatched", () => { const survey = createMockSurvey([language("en", { default: true }), language("de")]); diff --git a/apps/web/modules/survey/link/lib/utils.ts b/apps/web/modules/survey/link/lib/utils.ts index 40ba843441..ebc581d875 100644 --- a/apps/web/modules/survey/link/lib/utils.ts +++ b/apps/web/modules/survey/link/lib/utils.ts @@ -1,6 +1,7 @@ import { TJsWorkspaceStateSurvey } from "@formbricks/types/js"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; import { TSurveyElement } from "@formbricks/types/surveys/elements"; +import { resolveSurveyLanguage } from "@formbricks/types/surveys/language"; import { TSurvey } from "@formbricks/types/surveys/types"; export function isRTL(text: string): boolean { @@ -55,77 +56,20 @@ export function isRTLLanguage(survey: TJsWorkspaceStateSurvey, languageCode: str export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] => blocks.flatMap((block) => block.elements); -const normalizeLanguageCode = (languageCode: string): string => - languageCode.trim().split(";")[0].replace("_", "-").toLowerCase(); - -const getBaseLanguageCode = (languageCode: string): string => - normalizeLanguageCode(languageCode).split("-")[0]; - -const getSelectableLanguageCode = (surveyLanguage: TSurvey["languages"][number]): string | undefined => { - if (surveyLanguage.default) return "default"; - if (!surveyLanguage.enabled) return undefined; - return surveyLanguage.language.code; -}; - -const findExactLanguageMatch = (survey: TSurvey, languageCode: string): string | undefined => { - const normalizedLanguageCode = normalizeLanguageCode(languageCode); - - const selectedLanguage = survey.languages.find((surveyLanguage) => { - return ( - normalizeLanguageCode(surveyLanguage.language.code) === normalizedLanguageCode || - (surveyLanguage.language.alias - ? normalizeLanguageCode(surveyLanguage.language.alias) === normalizedLanguageCode - : false) - ); - }); - - return selectedLanguage ? getSelectableLanguageCode(selectedLanguage) : undefined; -}; - -const findLooseLanguageMatch = (survey: TSurvey, languageCode: string): string | undefined => { - const baseLanguageCode = getBaseLanguageCode(languageCode); - - for (const surveyLanguage of survey.languages) { - const selectableLanguageCode = getSelectableLanguageCode(surveyLanguage); - if (!selectableLanguageCode) continue; - - const languageBaseCode = getBaseLanguageCode(surveyLanguage.language.code); - const aliasBaseCode = surveyLanguage.language.alias - ? getBaseLanguageCode(surveyLanguage.language.alias) - : undefined; - - if (languageBaseCode === baseLanguageCode || aliasBaseCode === baseLanguageCode) { - return selectableLanguageCode; - } - } - - return undefined; -}; - export const getSurveyLanguageCode = ( langParam: string | undefined, survey: TSurvey, browserLanguageCodes: string[] = [] ): string => { - if (langParam) { - return findExactLanguageMatch(survey, langParam) ?? "default"; - } - - if (!survey.autoSelectLanguage) { - return "default"; - } - - for (const browserLanguageCode of browserLanguageCodes) { - const exactMatch = findExactLanguageMatch(survey, browserLanguageCode); - if (exactMatch) return exactMatch; - } - - for (const browserLanguageCode of browserLanguageCodes) { - const looseMatch = findLooseLanguageMatch(survey, browserLanguageCode); - if (looseMatch) return looseMatch; - } - - return "default"; + return ( + resolveSurveyLanguage({ + languages: survey.languages, + explicitLanguageCode: langParam, + browserLanguageCodes, + autoSelectLanguage: survey.autoSelectLanguage, + unmatchedExplicitLanguageBehavior: "fallback", + }) ?? "default" + ); }; /** diff --git a/apps/web/modules/survey/multi-language-surveys/components/language-view.tsx b/apps/web/modules/survey/multi-language-surveys/components/language-view.tsx index c9cb3e8d90..4dc8fcda09 100644 --- a/apps/web/modules/survey/multi-language-surveys/components/language-view.tsx +++ b/apps/web/modules/survey/multi-language-surveys/components/language-view.tsx @@ -149,7 +149,7 @@ export const LanguageView = ({ buttonVariant: "destructive", onConfirm: () => { // Strip all non-default language keys from the survey data - let cleanedSurvey = localSurvey; + let cleanedSurvey = { ...localSurvey, autoSelectLanguage: false }; for (const lang of localSurvey.languages) { if (!lang.default) { cleanedSurvey = removeLanguageKeysFromSurvey(cleanedSurvey, lang.language.code); @@ -172,6 +172,7 @@ export const LanguageView = ({ const language = workspaceLanguages.find((lang) => lang.code === languageCode); if (!language) return; + const isNewMultiLanguageSurvey = localSurvey.languages.length === 0; let languageExists = false; const newLanguages = localSurvey.languages.map((lang) => { @@ -187,7 +188,11 @@ export const LanguageView = ({ } setConfirmationModalInfo((prev) => ({ ...prev, open: false })); - setLocalSurvey({ ...localSurvey, languages: newLanguages }); + setLocalSurvey({ + ...localSurvey, + languages: newLanguages, + autoSelectLanguage: isNewMultiLanguageSurvey ? true : localSurvey.autoSelectLanguage, + }); }; const handleToggleLanguage = (code: string) => { @@ -254,7 +259,7 @@ export const LanguageView = ({ buttonText: t("workspace.surveys.edit.remove_translations"), buttonVariant: "destructive", onConfirm: () => { - updateSurveyTranslations(localSurvey, []); + updateSurveyTranslations({ ...localSurvey, autoSelectLanguage: false }, []); setIsMultiLanguageActivated(false); setConfirmationModalInfo((prev) => ({ ...prev, open: false })); }, diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index 2bc9eb2434..74b41d865f 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -328,6 +328,10 @@ "example": null, "type": "boolean" }, + "autoSelectLanguage": { + "example": null, + "type": "boolean" + }, "delay": { "example": 0, "type": "integer" @@ -4367,6 +4371,7 @@ { "autoClose": null, "autoComplete": null, + "autoSelectLanguage": null, "createdAt": "2024-08-05T11:08:27.042Z", "createdBy": "clfv1zvij0000ru0gunwpy43a", "delay": 0, @@ -4551,6 +4556,7 @@ "value": { "autoClose": null, "autoComplete": null, + "autoSelectLanguage": null, "createdBy": null, "delay": 0, "displayLimit": null, @@ -4771,6 +4777,7 @@ "data": { "autoClose": null, "autoComplete": null, + "autoSelectLanguage": null, "createdAt": "2024-08-05T11:08:27.042Z", "createdBy": "clfv1zvij0000ru0gunwpy43a", "delay": 0, @@ -5037,6 +5044,7 @@ "data": { "autoClose": null, "autoComplete": null, + "autoSelectLanguage": null, "createdAt": "2024-08-05T11:08:27.042Z", "createdBy": "clfv1zvij0000ru0gunwpy43a", "delay": 0, @@ -5290,6 +5298,7 @@ "data": { "autoClose": null, "autoComplete": null, + "autoSelectLanguage": null, "createdAt": "2024-08-05T11:08:27.042Z", "createdBy": "clfv1zvij0000ru0gunwpy43a", "delay": 0, @@ -5493,6 +5502,7 @@ "data": { "autoClose": null, "autoComplete": null, + "autoSelectLanguage": null, "createdAt": "2024-08-05T11:08:27.042Z", "createdBy": "clfv1zvij0000ru0gunwpy43a", "delay": 0, diff --git a/docs/xm-and-surveys/surveys/general-features/multi-language-surveys.mdx b/docs/xm-and-surveys/surveys/general-features/multi-language-surveys.mdx index f45eab46f8..b6c0ffa870 100644 --- a/docs/xm-and-surveys/surveys/general-features/multi-language-surveys.mdx +++ b/docs/xm-and-surveys/surveys/general-features/multi-language-surveys.mdx @@ -37,12 +37,14 @@ How to deliver a specific language depends on the survey type (app or link surve ![Add Multiple Languages to your Workspace](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/add-languages.webp) You can come back to this page anytime to add more languages or remove existing ones. + Return to the dashboard to create a new survey or edit an existing one: ![Survey Overview](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/surveys-home.webp) + @@ -52,19 +54,26 @@ How to deliver a specific language depends on the survey type (app or link surve Choose a **Default Language** for your survey. + New multi-language surveys use the respondent's browser language by default when a matching active language is + available. You can change this anytime with the **Use browser language by default** toggle. This setting is + separate from **Show language switch**; you can automatically open the right language without showing respondents a + manual language menu. + Changing the default language will reset all the translations you have made for the survey. + Add the languages from the dropdown that you want to support in your survey: ![Enable Multi-language for a survey](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/add-language-in-survey.webp) + You can now see the survey in the selected language by clicking on the language dropdown in any of the questions. - + Now you can translate all survey content, including questions, options, and button placeholders, into the selected language. @@ -74,6 +83,17 @@ How to deliver a specific language depends on the survey type (app or link surve +## Language Selection Order + +Formbricks selects the survey language in this order: + +1. An explicit `lang` URL parameter for link surveys, or an explicit SDK/user language for app surveys. +2. The respondent's browser language when **Use browser language by default** is enabled. +3. The survey's default language. + +Language matching first checks the exact identifier or alias. If there is no exact match, Formbricks checks language +variants with the same base language, for example `es-MX` can match an active `es-ES` translation. + ## App Surveys Configuration @@ -91,8 +111,11 @@ How to deliver a specific language depends on the survey type (app or link surve If a user has a language assigned, a survey has multi-language activated and it is missing a translation in - the language of the user, the survey will not be displayed. + the language of the user, the survey will not be displayed. When no user language is assigned and **Use browser + language by default** is enabled, Formbricks uses the browser language and falls back to the survey default if + there is no match. + @@ -104,7 +127,9 @@ How to deliver a specific language depends on the survey type (app or link surve ## Link Surveys Configuration -For link surveys, the translation delivery is dependent on the `lang` URL parameter. +For link surveys, the `lang` URL parameter always takes priority. If no `lang` parameter is present and **Use browser +language by default** is enabled, Formbricks uses the respondent's browser language when a matching active language is +available. @@ -122,7 +147,10 @@ For link surveys, the translation delivery is dependent on the `lang` URL parame - German: [https://app.Formbricks.com/s/clptfos2i1pj516pvhxqyu3bn?lang=de](https://app.Formbricks.com/s/clptfos2i1pj516pvhxqyu3bn?lang=de) - Without the `lang` parameter, Formbricks will show the survey in the default language you have set. + Without the `lang` parameter, Formbricks will use the browser language when **Use browser language by default** is + enabled and a matching active language exists. Otherwise, it will show the survey in the default language you have + set. + @@ -150,14 +178,13 @@ Formbricks fully supports Right-to-Left (RTL) languages such as Arabic, Hebrew, Add an RTL language (like Arabic or Hebrew) in the **Survey Languages** settings - - Create translations for your survey content in the RTL language - +Create translations for your survey content in the RTL language The survey will automatically display in RTL format when that language is selected ![RTL Language Support](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/rtl-support.webp) + diff --git a/packages/js-core/package.json b/packages/js-core/package.json index 1a6e2ea3b7..8a958b7cec 100644 --- a/packages/js-core/package.json +++ b/packages/js-core/package.json @@ -41,6 +41,9 @@ "test": "vitest run", "test:coverage": "vitest run --coverage" }, + "dependencies": { + "@formbricks/types": "workspace:*" + }, "author": "Formbricks ", "devDependencies": { "@formbricks/config-typescript": "workspace:*", diff --git a/packages/js-core/src/lib/common/tests/utils.test.ts b/packages/js-core/src/lib/common/tests/utils.test.ts index d38fb79070..812bc6b7ee 100644 --- a/packages/js-core/src/lib/common/tests/utils.test.ts +++ b/packages/js-core/src/lib/common/tests/utils.test.ts @@ -456,6 +456,18 @@ describe("utils.ts", () => { expect(getLanguageCode(survey, undefined, ["de-DE", "en-US"])).toBe("de"); expect(getLanguageCode({ ...survey, autoSelectLanguage: false }, undefined, ["de-DE"])).toBe("default"); }); + + test("does not fall back to browser language when an explicit user language is unavailable", () => { + const survey = { + autoSelectLanguage: true, + languages: [ + { language: { code: "en" }, default: true, enabled: true }, + { language: { code: "de" }, default: false, enabled: true }, + ], + } as unknown as TEnvironmentStateSurvey; + + expect(getLanguageCode(survey, "fr", ["de-DE"])).toBeUndefined(); + }); }); // --------------------------------------------------------------------------------- diff --git a/packages/js-core/src/lib/common/utils.ts b/packages/js-core/src/lib/common/utils.ts index 45e774f95e..c655d4e20f 100644 --- a/packages/js-core/src/lib/common/utils.ts +++ b/packages/js-core/src/lib/common/utils.ts @@ -1,3 +1,4 @@ +import { resolveSurveyLanguage } from "@formbricks/types/surveys/language"; import { Logger } from "@/lib/common/logger"; import type { TSurveyStyling, @@ -161,79 +162,18 @@ export const getDefaultLanguageCode = (survey: TWorkspaceStateSurvey): string | if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code; }; -const normalizeLanguageCode = (languageCode: string): string => - languageCode.trim().split(";")[0].replace("_", "-").toLowerCase(); - -const getBaseLanguageCode = (languageCode: string): string => - normalizeLanguageCode(languageCode).split("-")[0]; - -const getSelectableLanguageCode = (surveyLanguage: TWorkspaceStateSurvey["languages"][number]) => { - if (surveyLanguage.default) { - return "default"; - } - if (!surveyLanguage.enabled) { - return undefined; - } - return surveyLanguage.language.code; -}; - -const findExactLanguageMatch = (survey: TWorkspaceStateSurvey, language: string): string | undefined => { - const normalizedLanguageCode = normalizeLanguageCode(language); - - const selectedLanguage = survey.languages.find((surveyLanguage) => { - return ( - normalizeLanguageCode(surveyLanguage.language.code) === normalizedLanguageCode || - (surveyLanguage.language.alias - ? normalizeLanguageCode(surveyLanguage.language.alias) === normalizedLanguageCode - : false) - ); - }); - - return selectedLanguage ? getSelectableLanguageCode(selectedLanguage) : undefined; -}; - -const findLooseLanguageMatch = (survey: TWorkspaceStateSurvey, language: string): string | undefined => { - const baseLanguageCode = getBaseLanguageCode(language); - - for (const surveyLanguage of survey.languages) { - const selectableLanguageCode = getSelectableLanguageCode(surveyLanguage); - if (!selectableLanguageCode) continue; - - const languageBaseCode = getBaseLanguageCode(surveyLanguage.language.code); - const aliasBaseCode = surveyLanguage.language.alias - ? getBaseLanguageCode(surveyLanguage.language.alias) - : undefined; - - if (languageBaseCode === baseLanguageCode || aliasBaseCode === baseLanguageCode) { - return selectableLanguageCode; - } - } - - return undefined; -}; - export const getLanguageCode = ( survey: TWorkspaceStateSurvey, language?: string, fallbackLanguages: string[] = [] ): string | undefined => { - if (language) { - return findExactLanguageMatch(survey, language) ?? findLooseLanguageMatch(survey, language); - } - - if (!survey.autoSelectLanguage) return "default"; - - for (const fallbackLanguage of fallbackLanguages) { - const exactMatch = findExactLanguageMatch(survey, fallbackLanguage); - if (exactMatch) return exactMatch; - } - - for (const fallbackLanguage of fallbackLanguages) { - const looseMatch = findLooseLanguageMatch(survey, fallbackLanguage); - if (looseMatch) return looseMatch; - } - - return "default"; + return resolveSurveyLanguage({ + languages: survey.languages, + explicitLanguageCode: language, + browserLanguageCodes: fallbackLanguages, + autoSelectLanguage: survey.autoSelectLanguage, + unmatchedExplicitLanguageBehavior: "undefined", + }); }; export const getSecureRandom = (): number => { diff --git a/packages/types/surveys/language.ts b/packages/types/surveys/language.ts new file mode 100644 index 0000000000..db934c54d4 --- /dev/null +++ b/packages/types/surveys/language.ts @@ -0,0 +1,108 @@ +type TSurveyLanguageLike = { + default?: boolean | null; + enabled?: boolean | null; + language: { + code: string; + alias?: string | null; + }; +}; + +interface ResolveSurveyLanguageInput { + languages: T[]; + explicitLanguageCode?: string; + browserLanguageCodes?: string[]; + autoSelectLanguage?: boolean | null; + unmatchedExplicitLanguageBehavior?: "fallback" | "undefined"; +} + +export const normalizeLanguageCode = (languageCode: string): string => + languageCode.trim().split(";")[0].trim().replace("_", "-").toLowerCase(); + +const getBaseLanguageCode = (languageCode: string): string => + normalizeLanguageCode(languageCode).split("-")[0]; + +const getSelectableLanguageCode = (surveyLanguage: TSurveyLanguageLike): string | undefined => { + if (surveyLanguage.default) return "default"; + if (!surveyLanguage.enabled) return undefined; + return surveyLanguage.language.code; +}; + +const findExactLanguageMatch = ( + languages: T[], + languageCode: string +): string | undefined => { + const normalizedLanguageCode = normalizeLanguageCode(languageCode); + + const selectedLanguage = languages.find((surveyLanguage) => { + return ( + normalizeLanguageCode(surveyLanguage.language.code) === normalizedLanguageCode || + (surveyLanguage.language.alias + ? normalizeLanguageCode(surveyLanguage.language.alias) === normalizedLanguageCode + : false) + ); + }); + + return selectedLanguage ? getSelectableLanguageCode(selectedLanguage) : undefined; +}; + +const findLooseLanguageMatch = ( + languages: T[], + languageCode: string +): string | undefined => { + const baseLanguageCode = getBaseLanguageCode(languageCode); + + for (const surveyLanguage of languages) { + const selectableLanguageCode = getSelectableLanguageCode(surveyLanguage); + if (!selectableLanguageCode) continue; + + const languageBaseCode = getBaseLanguageCode(surveyLanguage.language.code); + const aliasBaseCode = surveyLanguage.language.alias + ? getBaseLanguageCode(surveyLanguage.language.alias) + : undefined; + + if (languageBaseCode === baseLanguageCode || aliasBaseCode === baseLanguageCode) { + return selectableLanguageCode; + } + } + + return undefined; +}; + +export const matchSurveyLanguage = ( + languages: T[], + languageCode: string +): string | undefined => { + return findExactLanguageMatch(languages, languageCode) ?? findLooseLanguageMatch(languages, languageCode); +}; + +/** + * Resolves survey language precedence without coupling callers to a delivery channel: + * explicit language (URL or SDK/user setting) -> browser languages when enabled -> survey default. + */ +export const resolveSurveyLanguage = ({ + languages, + explicitLanguageCode, + browserLanguageCodes = [], + autoSelectLanguage, + unmatchedExplicitLanguageBehavior = "fallback", +}: ResolveSurveyLanguageInput): string | undefined => { + if (explicitLanguageCode) { + const explicitMatch = matchSurveyLanguage(languages, explicitLanguageCode); + if (explicitMatch) return explicitMatch; + return unmatchedExplicitLanguageBehavior === "undefined" ? undefined : "default"; + } + + if (!autoSelectLanguage) return "default"; + + for (const browserLanguageCode of browserLanguageCodes) { + const exactMatch = findExactLanguageMatch(languages, browserLanguageCode); + if (exactMatch) return exactMatch; + } + + for (const browserLanguageCode of browserLanguageCodes) { + const looseMatch = findLooseLanguageMatch(languages, browserLanguageCode); + if (looseMatch) return looseMatch; + } + + return "default"; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fc8123adc..2e208080e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -840,6 +840,10 @@ importers: version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/js-core: + dependencies: + '@formbricks/types': + specifier: workspace:* + version: link:../types devDependencies: '@formbricks/config-typescript': specifier: workspace:*