mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-18 06:52:01 -05:00
feat: add browser language auto-selection
This commit is contained in:
@@ -86,6 +86,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
variables: true,
|
||||
type: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
|
||||
@@ -4924,6 +4924,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
languages: [],
|
||||
triggers: [],
|
||||
showLanguageSwitch: false,
|
||||
autoSelectLanguage: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
|
||||
@@ -226,6 +226,7 @@ const baseSurveyProperties = {
|
||||
},
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
autoSelectLanguage: null,
|
||||
attributeFilters: [],
|
||||
...commonMockProperties,
|
||||
};
|
||||
|
||||
@@ -58,6 +58,7 @@ export const selectSurvey = {
|
||||
singleUse: true,
|
||||
pin: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
customHeadScripts: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as nextHeaders from "next/headers";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { findMatchingLocale } from "./locale";
|
||||
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "./locale";
|
||||
|
||||
// Mock the Next.js headers function
|
||||
vi.mock("next/headers", () => ({
|
||||
@@ -36,6 +36,26 @@ describe("locale", () => {
|
||||
expect(nextHeaders.headers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ignores Accept-Language quality values when matching locales", async () => {
|
||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||
get: vi.fn().mockReturnValue("de-DE;q=0.9,en-US;q=0.8"),
|
||||
} as any);
|
||||
|
||||
const result = await findMatchingLocale();
|
||||
|
||||
expect(result).toBe("de-DE");
|
||||
});
|
||||
|
||||
test("returns browser language codes without quality values", async () => {
|
||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||
get: vi.fn().mockReturnValue("es-MX,es;q=0.9,en-US;q=0.8"),
|
||||
} as any);
|
||||
|
||||
const result = await findMatchingBrowserLanguageCodes();
|
||||
|
||||
expect(result).toEqual(["es-MX", "es", "en-US"]);
|
||||
});
|
||||
|
||||
test("returns normalized match when available", async () => {
|
||||
// Assuming we have 'en-US' in AVAILABLE_LOCALES but not 'en-GB'
|
||||
const availableLocale = AVAILABLE_LOCALES.find((locale) => locale.startsWith("en-"));
|
||||
|
||||
@@ -2,11 +2,25 @@ import { headers } from "next/headers";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
|
||||
|
||||
const getAcceptedLanguageCodesFromHeader = (acceptLanguage: string | null): string[] => {
|
||||
return (
|
||||
acceptLanguage
|
||||
?.split(",")
|
||||
.map((language) => language.trim().split(";")[0].trim())
|
||||
.filter(Boolean) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
export const findMatchingBrowserLanguageCodes = async (): Promise<string[]> => {
|
||||
const headersList = await headers();
|
||||
return getAcceptedLanguageCodesFromHeader(headersList.get("accept-language"));
|
||||
};
|
||||
|
||||
export const findMatchingLocale = async (): Promise<TUserLocale> => {
|
||||
const headersList = await headers();
|
||||
const acceptLanguage = headersList.get("accept-language");
|
||||
const userLocales = acceptLanguage?.split(",");
|
||||
if (!userLocales) {
|
||||
const userLocales = getAcceptedLanguageCodesFromHeader(acceptLanguage);
|
||||
if (!userLocales.length) {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
// First, try to find an exact match without normalization
|
||||
|
||||
@@ -1370,6 +1370,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.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Automatically close survey after <autoCloseInput /> seconds after trigger if no response.",
|
||||
"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.",
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
|
||||
styling: true,
|
||||
projectOverwrites: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
})
|
||||
.partial({
|
||||
redirectUrl: true,
|
||||
@@ -63,6 +64,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
|
||||
styling: true,
|
||||
projectOverwrites: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
inlineTriggers: true,
|
||||
displayPercentage: true,
|
||||
})
|
||||
|
||||
@@ -39,6 +39,7 @@ export const selectSurvey = {
|
||||
singleUse: true,
|
||||
pin: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
recaptcha: true,
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive
|
||||
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
|
||||
import { TEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
|
||||
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
|
||||
import { getSurveyLanguageCode } from "@/modules/survey/link/lib/utils";
|
||||
|
||||
interface SurveyRendererProps {
|
||||
survey: TSurvey;
|
||||
@@ -36,6 +37,7 @@ interface SurveyRendererProps {
|
||||
// New props - pre-fetched in parent
|
||||
environmentContext: TEnvironmentContextForLinkSurvey;
|
||||
locale: TUserLocale;
|
||||
browserLanguageCodes?: string[];
|
||||
responseCount?: number;
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ export const renderSurvey = async ({
|
||||
isPreview,
|
||||
environmentContext,
|
||||
locale,
|
||||
browserLanguageCodes = [],
|
||||
responseCount,
|
||||
}: SurveyRendererProps) => {
|
||||
const langParam = searchParams.lang;
|
||||
@@ -108,7 +111,7 @@ export const renderSurvey = async ({
|
||||
<VerifyEmail
|
||||
survey={survey}
|
||||
isErrorComponent={true}
|
||||
languageCode={getLanguageCode(langParam, survey)}
|
||||
languageCode={getSurveyLanguageCode(langParam, survey, browserLanguageCodes)}
|
||||
styling={project.styling}
|
||||
locale={locale}
|
||||
/>
|
||||
@@ -118,7 +121,7 @@ export const renderSurvey = async ({
|
||||
<VerifyEmail
|
||||
singleUseId={searchParams.suId ?? ""}
|
||||
survey={survey}
|
||||
languageCode={getLanguageCode(langParam, survey)}
|
||||
languageCode={getSurveyLanguageCode(langParam, survey, browserLanguageCodes)}
|
||||
styling={project.styling}
|
||||
locale={locale}
|
||||
/>
|
||||
@@ -127,7 +130,7 @@ export const renderSurvey = async ({
|
||||
|
||||
// Compute final styling based on project and survey settings
|
||||
const styling = computeStyling(project.styling, survey.styling);
|
||||
const languageCode = getLanguageCode(langParam, survey);
|
||||
const languageCode = getSurveyLanguageCode(langParam, survey, browserLanguageCodes);
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
// Handle PIN-protected surveys
|
||||
@@ -194,24 +197,3 @@ function computeStyling(
|
||||
}
|
||||
return surveyStyling?.overwriteThemeStyling ? surveyStyling : projectStyling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the language code to use for the survey.
|
||||
* Checks URL parameter against available survey languages and returns
|
||||
* "default" if language is not found or disabled.
|
||||
*/
|
||||
function getLanguageCode(langParam: string | undefined, survey: TSurvey): string {
|
||||
if (!langParam) return "default";
|
||||
|
||||
const selectedLanguage = survey.languages.find((surveyLanguage) => {
|
||||
return (
|
||||
surveyLanguage.language.code.toLowerCase() === langParam.toLowerCase() ||
|
||||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
|
||||
return "default";
|
||||
}
|
||||
return selectedLanguage.language.code;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { verifyContactSurveyToken } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
|
||||
@@ -137,9 +137,10 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
}
|
||||
|
||||
// Parallel fetch of environment context and locale
|
||||
const [environmentContext, locale, singleUseResponse] = await Promise.all([
|
||||
const [environmentContext, locale, browserLanguageCodes, singleUseResponse] = await Promise.all([
|
||||
getEnvironmentContextForLinkSurvey(survey.environmentId),
|
||||
findMatchingLocale(),
|
||||
findMatchingBrowserLanguageCodes(),
|
||||
// Fetch existing response for this contact
|
||||
getExistingContactResponse(survey.id, contactId)(),
|
||||
]);
|
||||
@@ -158,6 +159,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
singleUseResponse,
|
||||
environmentContext,
|
||||
locale,
|
||||
browserLanguageCodes,
|
||||
responseCount,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -58,6 +58,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
styling: true,
|
||||
surveyClosedMessage: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@ import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getElementsFromSurveyBlocks, getWebAppLocale, isRTL, isRTLLanguage } from "./utils";
|
||||
import {
|
||||
getElementsFromSurveyBlocks,
|
||||
getSurveyLanguageCode,
|
||||
getWebAppLocale,
|
||||
isRTL,
|
||||
isRTLLanguage,
|
||||
} from "./utils";
|
||||
|
||||
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
|
||||
({
|
||||
@@ -45,6 +51,7 @@ const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
showLanguageSwitch: null,
|
||||
autoSelectLanguage: null,
|
||||
recaptcha: null,
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
@@ -98,6 +105,58 @@ describe("getWebAppLocale", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSurveyLanguageCode", () => {
|
||||
const language = (code: string, overrides: Partial<TSurvey["languages"][number]> = {}) => ({
|
||||
language: {
|
||||
id: `lang-${code}`,
|
||||
code,
|
||||
alias: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "p1",
|
||||
},
|
||||
default: false,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("uses the URL language parameter before browser language auto-selection", () => {
|
||||
const survey = {
|
||||
...createMockSurvey([language("en", { default: true }), language("de")]),
|
||||
autoSelectLanguage: true,
|
||||
};
|
||||
|
||||
expect(getSurveyLanguageCode("de", survey, ["en-US"])).toBe("de");
|
||||
});
|
||||
|
||||
test("matches browser language exactly when auto-selection is enabled", () => {
|
||||
const survey = {
|
||||
...createMockSurvey([language("en", { default: true }), language("de-DE")]),
|
||||
autoSelectLanguage: true,
|
||||
};
|
||||
|
||||
expect(getSurveyLanguageCode(undefined, survey, ["de-DE", "en-US"])).toBe("de-DE");
|
||||
});
|
||||
|
||||
test("matches browser language by base language when exact variant is unavailable", () => {
|
||||
const survey = {
|
||||
...createMockSurvey([language("en", { default: true }), language("es-ES")]),
|
||||
autoSelectLanguage: true,
|
||||
};
|
||||
|
||||
expect(getSurveyLanguageCode(undefined, survey, ["es-MX", "en-US"])).toBe("es-ES");
|
||||
});
|
||||
|
||||
test("falls back to default language when auto-selection is disabled or unmatched", () => {
|
||||
const survey = createMockSurvey([language("en", { default: true }), language("de")]);
|
||||
|
||||
expect(getSurveyLanguageCode(undefined, survey, ["de-DE"])).toBe("default");
|
||||
expect(getSurveyLanguageCode(undefined, { ...survey, autoSelectLanguage: true }, ["fr-FR"])).toBe(
|
||||
"default"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRTL", () => {
|
||||
test("detects RTL characters", () => {
|
||||
expect(isRTL("مرحبا")).toBe(true);
|
||||
|
||||
@@ -55,6 +55,79 @@ export function isRTLLanguage(survey: TJsEnvironmentStateSurvey, languageCode: s
|
||||
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";
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps survey language codes to web app locale codes.
|
||||
* Falls back to "en-US" if the language is not available in web app locales.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
|
||||
@@ -101,9 +101,10 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
|
||||
}
|
||||
|
||||
// Stage 2: Parallel fetch of all remaining data
|
||||
const [environmentContext, locale, singleUseResponse] = await Promise.all([
|
||||
const [environmentContext, locale, browserLanguageCodes, singleUseResponse] = await Promise.all([
|
||||
getEnvironmentContextForLinkSurvey(survey.environmentId),
|
||||
findMatchingLocale(),
|
||||
findMatchingBrowserLanguageCodes(),
|
||||
// Only fetch single-use response if we have a validated ID
|
||||
isSingleUseSurvey && singleUseId
|
||||
? getResponseBySingleUseId(survey.id, singleUseId)()
|
||||
@@ -124,6 +125,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
|
||||
isPreview,
|
||||
environmentContext,
|
||||
locale,
|
||||
browserLanguageCodes,
|
||||
responseCount,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -232,6 +232,10 @@ export const LanguageView = ({
|
||||
setLocalSurvey({ ...localSurvey, showLanguageSwitch: !localSurvey.showLanguageSwitch });
|
||||
};
|
||||
|
||||
const handleAutoSelectLanguageToggle = () => {
|
||||
setLocalSurvey({ ...localSurvey, autoSelectLanguage: !localSurvey.autoSelectLanguage });
|
||||
};
|
||||
|
||||
const openTranslationModal = (code: string) => {
|
||||
setActiveLanguageCode(code);
|
||||
setTranslationModalOpen(true);
|
||||
@@ -456,6 +460,16 @@ export const LanguageView = ({
|
||||
)}
|
||||
childBorder={true}
|
||||
/>
|
||||
<AdvancedOptionToggle
|
||||
customContainerClass="px-0 pt-0"
|
||||
htmlId="autoSelectLanguage"
|
||||
disabled={enabledLanguages.length <= 1}
|
||||
isChecked={!!localSurvey.autoSelectLanguage}
|
||||
onToggle={handleAutoSelectLanguageToggle}
|
||||
title={t("environments.surveys.edit.auto_select_browser_language")}
|
||||
description={t("environments.surveys.edit.auto_select_browser_language_description")}
|
||||
childBorder={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
autoSelectLanguage: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
variables: [],
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Survey" ADD COLUMN "autoSelectLanguage" BOOLEAN;
|
||||
@@ -401,6 +401,7 @@ model Survey {
|
||||
displayPercentage Decimal?
|
||||
languages SurveyLanguage[]
|
||||
showLanguageSwitch Boolean?
|
||||
autoSelectLanguage Boolean?
|
||||
followUps SurveyFollowUp[]
|
||||
/// [SurveyRecaptcha]
|
||||
recaptcha Json? @default("{\"enabled\": false, \"threshold\":0.1}")
|
||||
|
||||
@@ -55,6 +55,10 @@ const ZSurveyBase = z.object({
|
||||
status: z.enum(SurveyStatus).describe("The status of the survey"),
|
||||
thankYouMessage: z.string().nullable().describe("The thank you message of the survey"),
|
||||
showLanguageSwitch: z.boolean().nullable().describe("Whether to show the language switch"),
|
||||
autoSelectLanguage: z
|
||||
.boolean()
|
||||
.nullable()
|
||||
.describe("Whether to automatically select the survey language from the respondent's browser"),
|
||||
showThankYouMessage: z.boolean().nullable().describe("Whether to show the thank you message"),
|
||||
welcomeCard: z
|
||||
.object({
|
||||
|
||||
@@ -22,6 +22,7 @@ export const mockConfig: TConfig = {
|
||||
variables: [],
|
||||
type: "app", // "link" or "app"
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: false,
|
||||
endings: [],
|
||||
autoClose: 5,
|
||||
status: "inProgress", // whatever statuses you use
|
||||
|
||||
@@ -482,6 +482,30 @@ describe("utils.ts", () => {
|
||||
expect(getLanguageCode(survey, "fr")).toBe("fr");
|
||||
expect(getLanguageCode(survey, "fr-FR")).toBe("fr");
|
||||
});
|
||||
|
||||
test("returns a loose variant match for the selected language", () => {
|
||||
const survey = {
|
||||
languages: [
|
||||
{ language: { code: "en" }, default: true, enabled: true },
|
||||
{ language: { code: "es-ES" }, default: false, enabled: true },
|
||||
],
|
||||
} as unknown as TEnvironmentStateSurvey;
|
||||
|
||||
expect(getLanguageCode(survey, "es-MX")).toBe("es-ES");
|
||||
});
|
||||
|
||||
test("uses fallback languages only when auto-select is enabled", () => {
|
||||
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, undefined, ["de-DE", "en-US"])).toBe("de");
|
||||
expect(getLanguageCode({ ...survey, autoSelectLanguage: false }, undefined, ["de-DE"])).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
@@ -176,27 +176,79 @@ export const getDefaultLanguageCode = (survey: TEnvironmentStateSurvey): string
|
||||
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
|
||||
};
|
||||
|
||||
export const getLanguageCode = (survey: TEnvironmentStateSurvey, language?: string): string | undefined => {
|
||||
const availableLanguageCodes = survey.languages.map((surveyLanguage) => surveyLanguage.language.code);
|
||||
if (!language) return "default";
|
||||
const normalizeLanguageCode = (languageCode: string): string =>
|
||||
languageCode.trim().split(";")[0].replace("_", "-").toLowerCase();
|
||||
|
||||
const getBaseLanguageCode = (languageCode: string): string =>
|
||||
normalizeLanguageCode(languageCode).split("-")[0];
|
||||
|
||||
const getSelectableLanguageCode = (surveyLanguage: TEnvironmentStateSurvey["languages"][number]) => {
|
||||
if (surveyLanguage.default) {
|
||||
return "default";
|
||||
}
|
||||
if (!surveyLanguage.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
return surveyLanguage.language.code;
|
||||
};
|
||||
|
||||
const findExactLanguageMatch = (survey: TEnvironmentStateSurvey, language: string): string | undefined => {
|
||||
const normalizedLanguageCode = normalizeLanguageCode(language);
|
||||
|
||||
const selectedLanguage = survey.languages.find((surveyLanguage) => {
|
||||
return (
|
||||
surveyLanguage.language.code.toLowerCase() === language.toLowerCase() ||
|
||||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
|
||||
normalizeLanguageCode(surveyLanguage.language.code) === normalizedLanguageCode ||
|
||||
(surveyLanguage.language.alias
|
||||
? normalizeLanguageCode(surveyLanguage.language.alias) === normalizedLanguageCode
|
||||
: false)
|
||||
);
|
||||
});
|
||||
if (selectedLanguage?.default) {
|
||||
return "default";
|
||||
|
||||
return selectedLanguage ? getSelectableLanguageCode(selectedLanguage) : undefined;
|
||||
};
|
||||
|
||||
const findLooseLanguageMatch = (survey: TEnvironmentStateSurvey, 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;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!selectedLanguage ||
|
||||
!selectedLanguage.enabled ||
|
||||
!availableLanguageCodes.includes(selectedLanguage.language.code)
|
||||
) {
|
||||
return undefined;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getLanguageCode = (
|
||||
survey: TEnvironmentStateSurvey,
|
||||
language?: string,
|
||||
fallbackLanguages: string[] = []
|
||||
): string | undefined => {
|
||||
if (language) {
|
||||
return findExactLanguageMatch(survey, language) ?? findLooseLanguageMatch(survey, language);
|
||||
}
|
||||
return selectedLanguage.language.code;
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
export const getSecureRandom = (): number => {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const mockSurvey: TEnvironmentStateSurvey = {
|
||||
},
|
||||
type: "app", // "link" or "app"
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: false,
|
||||
endings: [],
|
||||
autoClose: 5,
|
||||
status: "inProgress", // whatever statuses you use
|
||||
|
||||
@@ -18,6 +18,15 @@ import { type TTrackProperties } from "@/types/survey";
|
||||
|
||||
let isSurveyRunning = false;
|
||||
|
||||
const getBrowserLanguageCodes = (): string[] => {
|
||||
if (typeof navigator === "undefined") return [];
|
||||
return navigator.languages?.length
|
||||
? [...navigator.languages]
|
||||
: navigator.language
|
||||
? [navigator.language]
|
||||
: [];
|
||||
};
|
||||
|
||||
export const setIsSurveyRunning = (value: boolean): void => {
|
||||
isSurveyRunning = value;
|
||||
};
|
||||
@@ -91,7 +100,11 @@ export const renderWidget = async (
|
||||
let languageCode = "default";
|
||||
|
||||
if (isMultiLanguageSurvey) {
|
||||
const displayLanguage = getLanguageCode(survey, language);
|
||||
const displayLanguage = getLanguageCode(
|
||||
survey,
|
||||
language,
|
||||
survey.autoSelectLanguage ? getBrowserLanguageCodes() : []
|
||||
);
|
||||
//if survey is not available in selected language, survey wont be shown
|
||||
if (!displayLanguage) {
|
||||
logger.debug(`Survey "${survey.id}" is not available in specified language.`);
|
||||
|
||||
@@ -10,6 +10,7 @@ export type TEnvironmentStateSurvey = Pick<
|
||||
| "variables"
|
||||
| "type"
|
||||
| "showLanguageSwitch"
|
||||
| "autoSelectLanguage"
|
||||
| "endings"
|
||||
| "autoClose"
|
||||
| "status"
|
||||
|
||||
@@ -15,6 +15,7 @@ export const ZJsEnvironmentStateSurvey = ZSurveyBase.pick({
|
||||
variables: true,
|
||||
type: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
languages: true,
|
||||
endings: true,
|
||||
autoClose: true,
|
||||
|
||||
@@ -907,6 +907,7 @@ export const ZSurveyBase = z.object({
|
||||
projectOverwrites: ZSurveyProjectOverwrites.nullable(),
|
||||
styling: ZSurveyStyling.nullable(),
|
||||
showLanguageSwitch: z.boolean().nullable(),
|
||||
autoSelectLanguage: z.boolean().nullish(),
|
||||
surveyClosedMessage: ZSurveyClosedMessage.nullable(),
|
||||
segment: ZSegment.nullable(),
|
||||
singleUse: ZSurveySingleUse.nullable(),
|
||||
|
||||
Reference in New Issue
Block a user