mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 11:30:11 -05:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a17b839a4 | |||
| 2348812a05 | |||
| 7c1945f926 | |||
| 4af26d6be2 | |||
| 5d6c0273f4 | |||
| 5e7bd310f9 | |||
| bbada99199 | |||
| dd58cadddb |
@@ -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,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
mockActionClass,
|
||||
mockId,
|
||||
mockOrganizationOutput,
|
||||
mockSurveyLanguages,
|
||||
mockSurveyOutput,
|
||||
mockSurveyWithLogic,
|
||||
mockTransformedSurveyOutput,
|
||||
@@ -615,6 +616,33 @@ describe("Tests for createSurvey", () => {
|
||||
languages: [],
|
||||
} as TSurveyCreateInput;
|
||||
|
||||
const getMultiLanguageCreateSurveyInput = (): TSurveyCreateInput =>
|
||||
({
|
||||
...mockCreateSurveyInput,
|
||||
welcomeCard: {
|
||||
...mockCreateSurveyInput.welcomeCard,
|
||||
headline: { default: "Welcome", de: "Willkommen" },
|
||||
},
|
||||
questions: mockCreateSurveyInput.questions.map((question) => {
|
||||
if ("choices" in question && Array.isArray(question.choices)) {
|
||||
return {
|
||||
...question,
|
||||
headline: { ...question.headline, de: question.headline.default },
|
||||
choices: question.choices.map((choice) => ({
|
||||
...choice,
|
||||
label: { ...choice.label, de: choice.label.default },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...question,
|
||||
headline: { ...question.headline, de: question.headline.default },
|
||||
};
|
||||
}),
|
||||
languages: mockSurveyLanguages,
|
||||
}) as TSurveyCreateInput;
|
||||
|
||||
const mockActionClasses = [
|
||||
{
|
||||
id: "action-123",
|
||||
@@ -647,6 +675,45 @@ describe("Tests for createSurvey", () => {
|
||||
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("enables browser language auto-selection by default for new multi-language surveys", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
|
||||
prisma.survey.create.mockResolvedValueOnce({
|
||||
...mockSurveyOutput,
|
||||
});
|
||||
|
||||
await createSurvey(mockEnvironmentId, {
|
||||
...getMultiLanguageCreateSurveyInput(),
|
||||
});
|
||||
|
||||
expect(prisma.survey.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
autoSelectLanguage: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves explicit browser language auto-selection setting on create", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
|
||||
prisma.survey.create.mockResolvedValueOnce({
|
||||
...mockSurveyOutput,
|
||||
});
|
||||
|
||||
await createSurvey(mockEnvironmentId, {
|
||||
...getMultiLanguageCreateSurveyInput(),
|
||||
autoSelectLanguage: false,
|
||||
});
|
||||
|
||||
expect(prisma.survey.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
autoSelectLanguage: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("creates a private segment for app surveys", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
|
||||
prisma.survey.create.mockResolvedValueOnce({
|
||||
|
||||
@@ -58,6 +58,7 @@ export const selectSurvey = {
|
||||
singleUse: true,
|
||||
pin: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
customHeadScripts: true,
|
||||
@@ -591,9 +592,12 @@ export const createSurvey = async (
|
||||
try {
|
||||
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
|
||||
const actionClasses = await getActionClasses(parsedEnvironmentId);
|
||||
const hasMultipleEnabledLanguages = (languages ?? []).filter((language) => language.enabled).length > 1;
|
||||
|
||||
let data: Omit<Prisma.SurveyCreateInput, "environment"> = {
|
||||
...restSurveyBody,
|
||||
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
|
||||
|
||||
@@ -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": "Automatisches Speichern deaktiviert",
|
||||
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
|
||||
"auto_save_on": "Automatisches Speichern an",
|
||||
"auto_select_browser_language": "Browsersprache standardmäßig verwenden",
|
||||
"auto_select_browser_language_description": "Öffnet die Umfrage automatisch in der Browsersprache der befragten Person, wenn diese Sprache aktiv ist. Fällt auf die Standardsprache zurück.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Umfrage automatisch nach <autoCloseInput /> Sekunden nach dem Auslöser schließen, wenn keine Antwort erfolgt.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
|
||||
|
||||
@@ -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": "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_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.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "Guardado automático desactivado",
|
||||
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
|
||||
"auto_save_on": "Guardado automático activado",
|
||||
"auto_select_browser_language": "Usar el idioma del navegador por defecto",
|
||||
"auto_select_browser_language_description": "Abre automáticamente la encuesta en el idioma del navegador de la persona encuestada cuando ese idioma está activo. Si no coincide, usa el idioma predeterminado.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Cerrar automáticamente la encuesta después de <autoCloseInput /> segundos tras activarse si no hay respuesta.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "Sauvegarde automatique désactivée",
|
||||
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
|
||||
"auto_save_on": "Sauvegarde automatique activée",
|
||||
"auto_select_browser_language": "Utiliser la langue du navigateur par défaut",
|
||||
"auto_select_browser_language_description": "Ouvre automatiquement l'enquête dans la langue du navigateur de la personne interrogée lorsque cette langue est active. Sinon, la langue par défaut est utilisée.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Fermer automatiquement le sondage après <autoCloseInput /> secondes si aucune réponse n'est donnée après le déclenchement.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "Az automatikus mentés letiltva",
|
||||
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
|
||||
"auto_save_on": "Automatikus mentés bekapcsolva",
|
||||
"auto_select_browser_language": "A böngésző nyelvének használata alapértelmezésként",
|
||||
"auto_select_browser_language_description": "Automatikusan a válaszadó böngészőnyelvén nyitja meg a kérdőívet, ha ez a nyelv aktív. Ellenkező esetben az alapértelmezett nyelvre vált.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "A felmérés automatikus bezárása <autoCloseInput /> másodperc elteltével az aktiválás után, amennyiben nem érkezik válasz.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "A kérdőív automatikus lezárása egy bizonyos számú válasz után.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "A kérdőív automatikus lezárása, ha a felhasználó nem válaszol egy bizonyos másodpercnyi idő után.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "自動保存が無効",
|
||||
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
|
||||
"auto_save_on": "自動保存オン",
|
||||
"auto_select_browser_language": "ブラウザーの言語をデフォルトで使用",
|
||||
"auto_select_browser_language_description": "その言語が有効な場合、回答者のブラウザー言語でアンケートを自動的に開きます。一致しない場合はデフォルト言語に戻ります。",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "トリガー後、反応がない場合は<autoCloseInput />秒後に自動的にアンケートを閉じます。",
|
||||
"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": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
|
||||
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
|
||||
"auto_save_on": "Automatisch opslaan aan",
|
||||
"auto_select_browser_language": "Browsertaal standaard gebruiken",
|
||||
"auto_select_browser_language_description": "Opent de enquête automatisch in de browsertaal van de respondent wanneer die taal actief is. Valt terug op de standaardtaal.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Sluit de enquête automatisch na <autoCloseInput /> seconden na activatie als er geen reactie komt.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "Salvamento automático desativado",
|
||||
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
|
||||
"auto_save_on": "Salvamento automático ativado",
|
||||
"auto_select_browser_language": "Usar o idioma do navegador por padrão",
|
||||
"auto_select_browser_language_description": "Abre automaticamente a pesquisa no idioma do navegador do respondente quando esse idioma está ativo. Caso contrário, usa o idioma padrão.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente a pesquisa após <autoCloseInput /> segundos do acionamento se não houver resposta.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "Guardar automático desativado",
|
||||
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
|
||||
"auto_save_on": "Guardar automático ativado",
|
||||
"auto_select_browser_language": "Usar o idioma do navegador por predefinição",
|
||||
"auto_select_browser_language_description": "Abre automaticamente o inquérito no idioma do navegador do respondente quando esse idioma está ativo. Caso contrário, usa o idioma predefinido.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente o inquérito após <autoCloseInput /> segundos depois do acionamento se não houver resposta.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "Salvare automată dezactivată",
|
||||
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
|
||||
"auto_save_on": "Salvare automată activată",
|
||||
"auto_select_browser_language": "Folosește implicit limba browserului",
|
||||
"auto_select_browser_language_description": "Deschide automat sondajul în limba browserului respondentului atunci când această limbă este activă. Revine la limba implicită.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Închide automat sondajul după <autoCloseInput /> secunde de la declanșare dacă nu există răspuns.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "Автосохранение отключено",
|
||||
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
|
||||
"auto_save_on": "Автосохранение включено",
|
||||
"auto_select_browser_language": "Использовать язык браузера по умолчанию",
|
||||
"auto_select_browser_language_description": "Автоматически открывает опрос на языке браузера респондента, если этот язык активен. В противном случае используется язык по умолчанию.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Автоматически закрывать опрос через <autoCloseInput /> секунд после срабатывания триггера, если нет ответа.",
|
||||
"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": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "Automatisk sparning inaktiverad",
|
||||
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
|
||||
"auto_save_on": "Automatisk sparning på",
|
||||
"auto_select_browser_language": "Använd webbläsarens språk som standard",
|
||||
"auto_select_browser_language_description": "Öppnar automatiskt enkäten på respondentens webbläsarspråk när det språket är aktivt. Faller tillbaka till standardspråket.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Stäng undersökningen automatiskt efter <autoCloseInput /> sekunder efter utlösning om inget svar ges.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "Otomatik kayıt devre dışı",
|
||||
"auto_save_disabled_tooltip": "Survey'iniz yalnızca taslak durumundayken otomatik kaydedilir. Bu, yayınlanmış survey'lerin yanlışlıkla güncellenmesini önler.",
|
||||
"auto_save_on": "Otomatik kayıt açık",
|
||||
"auto_select_browser_language": "Varsayılan olarak tarayıcı dilini kullan",
|
||||
"auto_select_browser_language_description": "Bu dil aktif olduğunda anketi otomatik olarak yanıtlayıcının tarayıcı dilinde açar. Eşleşme yoksa varsayılan dile döner.",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Yanıt verilmezse anketi tetiklendikten sonra <autoCloseInput /> saniye sonra otomatik olarak kapat.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Belirli sayıda yanıt sonrasında survey'i otomatik olarak kapatın.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Kullanıcı belirli bir süre yanıt vermezse survey'i otomatik olarak kapatın.",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "自动保存已禁用",
|
||||
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
|
||||
"auto_save_on": "自动保存已启用",
|
||||
"auto_select_browser_language": "默认使用浏览器语言",
|
||||
"auto_select_browser_language_description": "当受访者的浏览器语言处于启用状态时,自动以该语言打开调查。否则回退到默认语言。",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "如果触发后无响应,则在 <autoCloseInput /> 秒后自动关闭调查。",
|
||||
"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": "用户未在一定秒数内应答时 自动关闭 问卷",
|
||||
|
||||
@@ -1370,6 +1370,8 @@
|
||||
"auto_save_disabled": "自動儲存已停用",
|
||||
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
|
||||
"auto_save_on": "自動儲存已啟用",
|
||||
"auto_select_browser_language": "預設使用瀏覽器語言",
|
||||
"auto_select_browser_language_description": "當受訪者的瀏覽器語言已啟用時,自動以該語言開啟問卷。否則會回到預設語言。",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "如果沒有回應,將在觸發後 <autoCloseInput /> 秒自動關閉問卷。",
|
||||
"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": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
|
||||
|
||||
@@ -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,80 @@ 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("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")]);
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TJsEnvironmentStateSurvey } 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,6 +56,22 @@ export function isRTLLanguage(survey: TJsEnvironmentStateSurvey, languageCode: s
|
||||
export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
|
||||
blocks.flatMap((block) => block.elements);
|
||||
|
||||
export const getSurveyLanguageCode = (
|
||||
langParam: string | undefined,
|
||||
survey: TSurvey,
|
||||
browserLanguageCodes: string[] = []
|
||||
): string => {
|
||||
return (
|
||||
resolveSurveyLanguage({
|
||||
languages: survey.languages,
|
||||
explicitLanguageCode: langParam,
|
||||
browserLanguageCodes,
|
||||
autoSelectLanguage: survey.autoSelectLanguage,
|
||||
unmatchedExplicitLanguageBehavior: "fallback",
|
||||
}) ?? "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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -122,7 +122,7 @@ export const LanguageView = ({
|
||||
buttonText: t("environments.surveys.edit.remove_translations"),
|
||||
buttonVariant: "destructive",
|
||||
onConfirm: () => {
|
||||
updateSurveyTranslations(localSurvey, []);
|
||||
updateSurveyTranslations({ ...localSurvey, autoSelectLanguage: false }, []);
|
||||
setIsMultiLanguageActivated(false);
|
||||
setConfirmationModalInfo((prev) => ({ ...prev, open: false }));
|
||||
},
|
||||
@@ -139,6 +139,7 @@ export const LanguageView = ({
|
||||
const language = projectLanguages.find((lang) => lang.code === languageCode);
|
||||
if (!language) return;
|
||||
|
||||
const isNewMultiLanguageSurvey = localSurvey.languages.length === 0;
|
||||
let languageExists = false;
|
||||
const newLanguages =
|
||||
localSurvey.languages.map((lang) => {
|
||||
@@ -154,7 +155,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) => {
|
||||
@@ -221,7 +226,7 @@ export const LanguageView = ({
|
||||
buttonText: t("environments.surveys.edit.remove_translations"),
|
||||
buttonVariant: "destructive",
|
||||
onConfirm: () => {
|
||||
updateSurveyTranslations(localSurvey, []);
|
||||
updateSurveyTranslations({ ...localSurvey, autoSelectLanguage: false }, []);
|
||||
setIsMultiLanguageActivated(false);
|
||||
setConfirmationModalInfo((prev) => ({ ...prev, open: false }));
|
||||
},
|
||||
@@ -232,6 +237,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 +465,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: [],
|
||||
|
||||
@@ -754,6 +754,10 @@
|
||||
"example": null,
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoSelectLanguage": {
|
||||
"example": null,
|
||||
"type": "boolean"
|
||||
},
|
||||
"delay": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
@@ -6156,6 +6160,7 @@
|
||||
{
|
||||
"autoClose": null,
|
||||
"autoComplete": null,
|
||||
"autoSelectLanguage": null,
|
||||
"createdAt": "2024-08-05T11:08:27.042Z",
|
||||
"createdBy": "clfv1zvij0000ru0gunwpy43a",
|
||||
"delay": 0,
|
||||
@@ -6340,6 +6345,7 @@
|
||||
"value": {
|
||||
"autoClose": null,
|
||||
"autoComplete": null,
|
||||
"autoSelectLanguage": null,
|
||||
"createdBy": null,
|
||||
"delay": 0,
|
||||
"displayLimit": null,
|
||||
@@ -6495,6 +6501,7 @@
|
||||
"data": {
|
||||
"autoClose": null,
|
||||
"autoComplete": null,
|
||||
"autoSelectLanguage": null,
|
||||
"createdAt": "2024-08-05T11:08:27.042Z",
|
||||
"createdBy": "clfv1zvij0000ru0gunwpy43a",
|
||||
"delay": 0,
|
||||
@@ -6761,6 +6768,7 @@
|
||||
"data": {
|
||||
"autoClose": null,
|
||||
"autoComplete": null,
|
||||
"autoSelectLanguage": null,
|
||||
"createdAt": "2024-08-05T11:08:27.042Z",
|
||||
"createdBy": "clfv1zvij0000ru0gunwpy43a",
|
||||
"delay": 0,
|
||||
@@ -7014,6 +7022,7 @@
|
||||
"data": {
|
||||
"autoClose": null,
|
||||
"autoComplete": null,
|
||||
"autoSelectLanguage": null,
|
||||
"createdAt": "2024-08-05T11:08:27.042Z",
|
||||
"createdBy": "clfv1zvij0000ru0gunwpy43a",
|
||||
"delay": 0,
|
||||
@@ -7217,6 +7226,7 @@
|
||||
"data": {
|
||||
"autoClose": null,
|
||||
"autoComplete": null,
|
||||
"autoSelectLanguage": null,
|
||||
"createdAt": "2024-08-05T11:08:27.042Z",
|
||||
"createdBy": "clfv1zvij0000ru0gunwpy43a",
|
||||
"delay": 0,
|
||||
|
||||
@@ -37,12 +37,14 @@ How to deliver a specific language depends on the survey type (app or link surve
|
||||

|
||||
|
||||
You can come back to this page anytime to add more languages or remove existing ones.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create or Edit Your Survey">
|
||||
Return to the dashboard to create a new survey or edit an existing one:
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Enable Multi-language Support">
|
||||
@@ -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.
|
||||
|
||||
<Note>Changing the default language will reset all the translations you have made for the survey.</Note>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Add Supported Languages">
|
||||
Add the languages from the dropdown that you want to support in your survey:
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Preview and Translate Content">
|
||||
|
||||
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.
|
||||
|
||||
</Step>
|
||||
@@ -74,6 +83,17 @@ How to deliver a specific language depends on the survey type (app or link surve
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 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
|
||||
|
||||
<Steps>
|
||||
@@ -91,8 +111,11 @@ How to deliver a specific language depends on the survey type (app or link surve
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Start Collecting Responses">
|
||||
@@ -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.
|
||||
|
||||
<Steps>
|
||||
<Step title="Publish Your Survey">
|
||||
@@ -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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Start Collecting Responses">
|
||||
@@ -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
|
||||
</Step>
|
||||
|
||||
<Step title="Create Translations">
|
||||
Create translations for your survey content in the RTL language
|
||||
</Step>
|
||||
<Step title="Create Translations">Create translations for your survey content in the RTL language</Step>
|
||||
|
||||
<Step title="Automatic RTL Display">
|
||||
The survey will automatically display in RTL format when that language is selected
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
+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({
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/types": "workspace:*"
|
||||
},
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -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,42 @@ 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");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveSurveyLanguage } from "@formbricks/types/surveys/language";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import type {
|
||||
TEnvironmentState,
|
||||
@@ -176,27 +177,18 @@ 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 selectedLanguage = survey.languages.find((surveyLanguage) => {
|
||||
return (
|
||||
surveyLanguage.language.code.toLowerCase() === language.toLowerCase() ||
|
||||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
|
||||
);
|
||||
export const getLanguageCode = (
|
||||
survey: TEnvironmentStateSurvey,
|
||||
language?: string,
|
||||
fallbackLanguages: string[] = []
|
||||
): string | undefined => {
|
||||
return resolveSurveyLanguage({
|
||||
languages: survey.languages,
|
||||
explicitLanguageCode: language,
|
||||
browserLanguageCodes: fallbackLanguages,
|
||||
autoSelectLanguage: survey.autoSelectLanguage,
|
||||
unmatchedExplicitLanguageBehavior: "undefined",
|
||||
});
|
||||
if (selectedLanguage?.default) {
|
||||
return "default";
|
||||
}
|
||||
if (
|
||||
!selectedLanguage ||
|
||||
!selectedLanguage.enabled ||
|
||||
!availableLanguageCodes.includes(selectedLanguage.language.code)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return selectedLanguage.language.code;
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { executeRecaptcha, loadRecaptchaScript } from "@/lib/common/recaptcha";
|
||||
import type * as CommonUtils from "@/lib/common/utils";
|
||||
import { filterSurveys, getLanguageCode, shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
|
||||
import { mockSurvey } from "@/lib/survey/tests/__mocks__/widget.mock";
|
||||
@@ -27,11 +28,18 @@ vi.mock("@/lib/common/logger", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/recaptcha", () => ({
|
||||
executeRecaptcha: vi.fn().mockResolvedValue("recaptcha-token"),
|
||||
loadRecaptchaScript: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockTimeoutStack = {
|
||||
add: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/lib/common/timeout-stack", () => ({
|
||||
TimeoutStack: {
|
||||
getInstance: vi.fn(() => ({
|
||||
add: vi.fn(),
|
||||
})),
|
||||
getInstance: vi.fn(() => mockTimeoutStack),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -86,6 +94,42 @@ describe("widget-file", () => {
|
||||
widget.setIsSurveyRunning(true);
|
||||
});
|
||||
|
||||
test("getBrowserLanguageCodes prefers navigator.languages and falls back to navigator.language", () => {
|
||||
const originalLanguages = navigator.languages;
|
||||
const originalLanguage = navigator.language;
|
||||
|
||||
Object.defineProperty(navigator, "languages", {
|
||||
configurable: true,
|
||||
value: ["de-DE", "en-US"],
|
||||
});
|
||||
expect(widget.getBrowserLanguageCodes()).toEqual(["de-DE", "en-US"]);
|
||||
|
||||
Object.defineProperty(navigator, "languages", {
|
||||
configurable: true,
|
||||
value: [],
|
||||
});
|
||||
Object.defineProperty(navigator, "language", {
|
||||
configurable: true,
|
||||
value: "fr-FR",
|
||||
});
|
||||
expect(widget.getBrowserLanguageCodes()).toEqual(["fr-FR"]);
|
||||
|
||||
Object.defineProperty(navigator, "language", {
|
||||
configurable: true,
|
||||
value: "",
|
||||
});
|
||||
expect(widget.getBrowserLanguageCodes()).toEqual([]);
|
||||
|
||||
Object.defineProperty(navigator, "languages", {
|
||||
configurable: true,
|
||||
value: originalLanguages,
|
||||
});
|
||||
Object.defineProperty(navigator, "language", {
|
||||
configurable: true,
|
||||
value: originalLanguage,
|
||||
});
|
||||
});
|
||||
|
||||
test("triggerSurvey skips if shouldDisplayBasedOnPercentage returns false", async () => {
|
||||
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
|
||||
(shouldDisplayBasedOnPercentage as Mock).mockReturnValueOnce(false);
|
||||
@@ -165,6 +209,64 @@ describe("widget-file", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("renderWidget enables reCAPTCHA and provides getRecaptchaToken when spam protection is active", async () => {
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
recaptchaSiteKey: "site-key",
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
contactId: "contact_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
recaptcha: { enabled: true, threshold: 0.5 },
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(loadRecaptchaScript).toHaveBeenCalledWith("site-key");
|
||||
|
||||
const renderPayload = vi.mocked(window.formbricksSurveys.renderSurvey).mock.calls[0][0];
|
||||
expect(renderPayload.isSpamProtectionEnabled).toBe(true);
|
||||
await expect(renderPayload.getRecaptchaToken()).resolves.toBe("recaptcha-token");
|
||||
expect(executeRecaptcha).toHaveBeenCalledWith("site-key");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("renderWidget short-circuits if isSurveyRunning is already true", async () => {
|
||||
widget.setIsSurveyRunning(true);
|
||||
await widget.renderWidget(mockSurvey);
|
||||
@@ -217,6 +319,72 @@ describe("widget-file", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("renderWidget passes browser languages when auto-select is enabled and no user language is set", async () => {
|
||||
const originalLanguages = navigator.languages;
|
||||
Object.defineProperty(navigator, "languages", {
|
||||
configurable: true,
|
||||
value: ["de-DE", "en-US"],
|
||||
});
|
||||
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
(getLanguageCode as Mock).mockReturnValueOnce("de");
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
const autoSelectSurvey = {
|
||||
...mockSurvey,
|
||||
autoSelectLanguage: true,
|
||||
delay: 0,
|
||||
languages: [
|
||||
{ language: { code: "en" }, default: true, enabled: true },
|
||||
{ language: { code: "de" }, default: false, enabled: true },
|
||||
],
|
||||
} as unknown as TEnvironmentStateSurvey;
|
||||
|
||||
await widget.renderWidget(autoSelectSurvey);
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(getLanguageCode).toHaveBeenCalledWith(autoSelectSurvey, undefined, ["de-DE", "en-US"]);
|
||||
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(navigator, "languages", {
|
||||
configurable: true,
|
||||
value: originalLanguages,
|
||||
});
|
||||
});
|
||||
|
||||
test("closeSurvey removes widget container, resets filtered surveys, sets isSurveyRunning=false", () => {
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
@@ -446,6 +614,121 @@ describe("widget-file", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("renderWidget callback handlers update displays and responses in config", async () => {
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
recaptchaSiteKey: undefined,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
contactId: "contact_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
(filterSurveys as Mock).mockReturnValue(["filtered"]);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
const renderPayload = vi.mocked(window.formbricksSurveys.renderSurvey).mock.calls[0][0];
|
||||
renderPayload.onDisplayCreated();
|
||||
renderPayload.onResponseCreated();
|
||||
|
||||
expect(renderPayload.getSetIsResponseSendingFinished(vi.fn())).toBeUndefined();
|
||||
expect(mockConfigValue.update).toHaveBeenCalledTimes(2);
|
||||
|
||||
const displayUpdate = mockConfigValue.update.mock.calls[0][0];
|
||||
expect(displayUpdate.user.data.displays).toHaveLength(1);
|
||||
expect(displayUpdate.user.data.displays[0].surveyId).toBe(mockSurvey.id);
|
||||
expect(displayUpdate.user.data.lastDisplayAt).toBeInstanceOf(Date);
|
||||
expect(displayUpdate.filteredSurveys).toEqual(["filtered"]);
|
||||
|
||||
const responseUpdate = mockConfigValue.update.mock.calls[1][0];
|
||||
expect(responseUpdate.user.data.responses).toEqual([mockSurvey.id]);
|
||||
expect(responseUpdate.filteredSurveys).toEqual(["filtered"]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("renderWidget adds timeout to stack when action is provided", async () => {
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
contactId: "contact_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
await widget.renderWidget(
|
||||
{
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey,
|
||||
"manual-trigger"
|
||||
);
|
||||
|
||||
expect(mockTimeoutStack.add).toHaveBeenCalledWith("manual-trigger", expect.anything());
|
||||
});
|
||||
|
||||
test("renderWidget skips survey when identification fails and survey has segment filters", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
|
||||
@@ -634,9 +917,6 @@ describe("widget-file", () => {
|
||||
|
||||
const appendChildSpy = vi.spyOn(document.head, "appendChild");
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
@@ -653,8 +933,6 @@ describe("widget-file", () => {
|
||||
});
|
||||
expect(scriptAppendCalls.length).toBe(0);
|
||||
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,13 @@ import { type TTrackProperties } from "@/types/survey";
|
||||
|
||||
let isSurveyRunning = false;
|
||||
|
||||
export const getBrowserLanguageCodes = (): string[] => {
|
||||
if (typeof navigator === "undefined") return [];
|
||||
if (navigator.languages.length > 0) return [...navigator.languages];
|
||||
if (navigator.language) return [navigator.language];
|
||||
return [];
|
||||
};
|
||||
|
||||
export const setIsSurveyRunning = (value: boolean): void => {
|
||||
isSurveyRunning = value;
|
||||
};
|
||||
@@ -91,7 +98,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,
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"clean": "rimraf node_modules .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -20,6 +22,8 @@
|
||||
"node-html-parser": "7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*"
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { matchSurveyLanguage, normalizeLanguageCode, resolveSurveyLanguage } from "./language";
|
||||
|
||||
const languages = [
|
||||
{
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
code: "en",
|
||||
alias: "English",
|
||||
},
|
||||
},
|
||||
{
|
||||
default: false,
|
||||
enabled: true,
|
||||
language: {
|
||||
code: "es-ES",
|
||||
alias: "es",
|
||||
},
|
||||
},
|
||||
{
|
||||
default: false,
|
||||
enabled: false,
|
||||
language: {
|
||||
code: "de",
|
||||
alias: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("survey language helpers", () => {
|
||||
test("normalizes quality values, whitespace, underscores, and case", () => {
|
||||
expect(normalizeLanguageCode(" PT_BR ;q=0.9 ")).toBe("pt-br");
|
||||
});
|
||||
|
||||
test("matches exact codes, aliases, and loose variants while ignoring disabled languages", () => {
|
||||
expect(matchSurveyLanguage(languages, "es")).toBe("es-ES");
|
||||
expect(matchSurveyLanguage(languages, "es-MX")).toBe("es-ES");
|
||||
expect(matchSurveyLanguage(languages, "de-DE")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("resolves explicit language before browser language", () => {
|
||||
expect(
|
||||
resolveSurveyLanguage({
|
||||
languages,
|
||||
explicitLanguageCode: "es-MX",
|
||||
browserLanguageCodes: ["en-US"],
|
||||
autoSelectLanguage: true,
|
||||
})
|
||||
).toBe("es-ES");
|
||||
});
|
||||
|
||||
test("uses browser language only when enabled and falls back to default", () => {
|
||||
expect(
|
||||
resolveSurveyLanguage({
|
||||
languages,
|
||||
browserLanguageCodes: ["es-MX"],
|
||||
autoSelectLanguage: true,
|
||||
})
|
||||
).toBe("es-ES");
|
||||
|
||||
expect(
|
||||
resolveSurveyLanguage({
|
||||
languages,
|
||||
browserLanguageCodes: ["es-MX"],
|
||||
autoSelectLanguage: false,
|
||||
})
|
||||
).toBe("default");
|
||||
|
||||
expect(
|
||||
resolveSurveyLanguage({
|
||||
languages,
|
||||
browserLanguageCodes: ["fr-CA"],
|
||||
autoSelectLanguage: true,
|
||||
})
|
||||
).toBe("default");
|
||||
});
|
||||
|
||||
test("supports strict unmatched explicit language behavior", () => {
|
||||
expect(
|
||||
resolveSurveyLanguage({
|
||||
languages,
|
||||
explicitLanguageCode: "fr",
|
||||
browserLanguageCodes: ["es-MX"],
|
||||
autoSelectLanguage: true,
|
||||
unmatchedExplicitLanguageBehavior: "undefined",
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
interface TSurveyLanguageLike {
|
||||
default?: boolean | null;
|
||||
enabled?: boolean | null;
|
||||
language: {
|
||||
code: string;
|
||||
alias?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolveSurveyLanguageInput<T extends TSurveyLanguageLike> {
|
||||
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 = <T extends TSurveyLanguageLike>(
|
||||
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 = <T extends TSurveyLanguageLike>(
|
||||
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 = <T extends TSurveyLanguageLike>(
|
||||
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 = <T extends TSurveyLanguageLike>({
|
||||
languages,
|
||||
explicitLanguageCode,
|
||||
browserLanguageCodes = [],
|
||||
autoSelectLanguage,
|
||||
unmatchedExplicitLanguageBehavior = "fallback",
|
||||
}: ResolveSurveyLanguageInput<T>): 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";
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
Generated
+10
@@ -762,6 +762,10 @@ importers:
|
||||
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@7.3.3(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
packages/js-core:
|
||||
dependencies:
|
||||
'@formbricks/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
devDependencies:
|
||||
'@formbricks/config-typescript':
|
||||
specifier: workspace:*
|
||||
@@ -1038,6 +1042,12 @@ importers:
|
||||
'@formbricks/config-typescript':
|
||||
specifier: workspace:*
|
||||
version: link:../config-typescript
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.1.6
|
||||
version: 4.1.6(vitest@4.1.6)
|
||||
vitest:
|
||||
specifier: 4.1.6
|
||||
version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@8.0.12(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
|
||||
packages/vite-plugins:
|
||||
devDependencies:
|
||||
|
||||
Reference in New Issue
Block a user