mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-27 07:34:47 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e2f51414b | |||
| cbf255ab0d | |||
| 942366956c | |||
| a6ee796cef | |||
| a535529bd3 |
+1
-2
@@ -17,8 +17,7 @@
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES",
|
||||
"sv-SE"
|
||||
"es-ES"
|
||||
]
|
||||
},
|
||||
"version": 1.8
|
||||
|
||||
@@ -1171,7 +1171,6 @@ checksums:
|
||||
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
|
||||
environments/surveys/edit/back_button_label: 25af945e77336724b5276de291cc92d9
|
||||
environments/surveys/edit/background_styling: 4e1e6fd2ec767bbff8767f6c0f68a731
|
||||
environments/surveys/edit/block_deleted: c682259eb138ad84f8b4441abfd9b572
|
||||
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
|
||||
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
|
||||
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
|
||||
|
||||
@@ -176,7 +176,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
|
||||
@@ -140,7 +140,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "英语(美国)",
|
||||
"nl-NL": "Engels (VS)",
|
||||
"es-ES": "Inglés (EE.UU.)",
|
||||
"sv-SE": "Engelska (USA)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -157,7 +156,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "德语",
|
||||
"nl-NL": "Duits",
|
||||
"es-ES": "Alemán",
|
||||
"sv-SE": "Tyska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -174,7 +172,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
||||
"nl-NL": "Portugees (Brazilië)",
|
||||
"es-ES": "Portugués (Brasil)",
|
||||
"sv-SE": "Portugisiska (Brasilien)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -191,7 +188,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "法语",
|
||||
"nl-NL": "Frans",
|
||||
"es-ES": "Francés",
|
||||
"sv-SE": "Franska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -203,12 +199,11 @@ export const appLanguages = [
|
||||
"fr-FR": "Chinois (Traditionnel)",
|
||||
"zh-Hant-TW": "繁體中文",
|
||||
"pt-PT": "Chinês (Tradicional)",
|
||||
"ro-RO": "Chineza (Tradițională)",
|
||||
"ro-RO": "Chineză (Tradicională)",
|
||||
"ja-JP": "中国語(繁体字)",
|
||||
"zh-Hans-CN": "繁体中文",
|
||||
"nl-NL": "Chinees (Traditioneel)",
|
||||
"es-ES": "Chino (Tradicional)",
|
||||
"sv-SE": "Kinesiska (traditionell)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -225,7 +220,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
||||
"nl-NL": "Portugees (Portugal)",
|
||||
"es-ES": "Portugués (Portugal)",
|
||||
"sv-SE": "Portugisiska (Portugal)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,7 +236,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "罗马尼亚语",
|
||||
"nl-NL": "Roemeens",
|
||||
"es-ES": "Rumano",
|
||||
"sv-SE": "Rumänska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -259,7 +252,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "日语",
|
||||
"nl-NL": "Japans",
|
||||
"es-ES": "Japonés",
|
||||
"sv-SE": "Japanska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -271,12 +263,11 @@ export const appLanguages = [
|
||||
"fr-FR": "Chinois (Simplifié)",
|
||||
"zh-Hant-TW": "簡體中文",
|
||||
"pt-PT": "Chinês (Simplificado)",
|
||||
"ro-RO": "Chineza (Simplificată)",
|
||||
"ro-RO": "Chineză (Simplificată)",
|
||||
"ja-JP": "中国語(簡体字)",
|
||||
"zh-Hans-CN": "简体中文",
|
||||
"nl-NL": "Chinees (Vereenvoudigd)",
|
||||
"es-ES": "Chino (Simplificado)",
|
||||
"sv-SE": "Kinesiska (förenklad)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -288,12 +279,11 @@ export const appLanguages = [
|
||||
"fr-FR": "Néerlandais",
|
||||
"zh-Hant-TW": "荷蘭語",
|
||||
"pt-PT": "Holandês",
|
||||
"ro-RO": "Olandeza",
|
||||
"ro-RO": "Olandeză",
|
||||
"ja-JP": "オランダ語",
|
||||
"zh-Hans-CN": "荷兰语",
|
||||
"nl-NL": "Nederlands",
|
||||
"es-ES": "Neerlandés",
|
||||
"sv-SE": "Nederländska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -310,24 +300,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "西班牙语",
|
||||
"nl-NL": "Spaans",
|
||||
"es-ES": "Español",
|
||||
"sv-SE": "Spanska",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
"de-DE": "Schwedisch",
|
||||
"pt-BR": "Sueco",
|
||||
"fr-FR": "Suédois",
|
||||
"zh-Hant-TW": "瑞典語",
|
||||
"pt-PT": "Sueco",
|
||||
"ro-RO": "Suedeză",
|
||||
"ja-JP": "スウェーデン語",
|
||||
"zh-Hans-CN": "瑞典语",
|
||||
"nl-NL": "Zweeds",
|
||||
"es-ES": "Sueco",
|
||||
"sv-SE": "Svenska",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -69,12 +69,6 @@ describe("Time Utilities", () => {
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "de-DE")).toBe("vor etwa 1 Stunde");
|
||||
});
|
||||
|
||||
test("should format time since in Swedish", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeSinceDate", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
@@ -93,8 +93,6 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return fr;
|
||||
case "nl-NL":
|
||||
return nl;
|
||||
case "sv-SE":
|
||||
return sv;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
case "pt-PT":
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
|
||||
// Mock the Next.js headers function
|
||||
@@ -85,25 +84,4 @@ describe("locale", () => {
|
||||
expect(result).toBe(germanLocale);
|
||||
expect(nextHeaders.headers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Swedish locale (sv-SE) is available and selectable", async () => {
|
||||
// Verify sv-SE is in AVAILABLE_LOCALES
|
||||
expect(AVAILABLE_LOCALES).toContain("sv-SE");
|
||||
|
||||
// Verify Swedish has a language entry with proper labels
|
||||
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
|
||||
expect(swedishLanguage).toBeDefined();
|
||||
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
|
||||
expect(swedishLanguage?.label["sv-SE"]).toBe("Svenska");
|
||||
|
||||
// Verify the locale can be matched from Accept-Language header
|
||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||
get: vi.fn().mockReturnValue("sv-SE,en-US"),
|
||||
} as any);
|
||||
|
||||
const result = await findMatchingLocale();
|
||||
|
||||
expect(result).toBe("sv-SE");
|
||||
expect(nextHeaders.headers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -133,13 +133,16 @@ export const BlockCard = ({
|
||||
// A button label is invalid if it exists but doesn't have valid text for all enabled languages
|
||||
const surveyLanguages = localSurvey.languages ?? [];
|
||||
const hasInvalidButtonLabel =
|
||||
block.buttonLabel !== undefined && !isLabelValidForAllLanguages(block.buttonLabel, surveyLanguages);
|
||||
block.buttonLabel !== undefined &&
|
||||
block.buttonLabel["default"]?.trim() !== "" &&
|
||||
!isLabelValidForAllLanguages(block.buttonLabel, surveyLanguages);
|
||||
|
||||
// Check if back button label is invalid
|
||||
// Back button label should exist for all blocks except the first one
|
||||
const hasInvalidBackButtonLabel =
|
||||
blockIdx > 0 &&
|
||||
block.backButtonLabel !== undefined &&
|
||||
block.backButtonLabel["default"]?.trim() !== "" &&
|
||||
!isLabelValidForAllLanguages(block.backButtonLabel, surveyLanguages);
|
||||
|
||||
// Block should be highlighted if it has invalid elements OR invalid button labels
|
||||
|
||||
@@ -513,8 +513,8 @@ export const ElementsView = ({
|
||||
id: newBlockId,
|
||||
name: getBlockName(index ?? prevSurvey.blocks.length),
|
||||
elements: [{ ...updatedElement, isDraft: true }],
|
||||
buttonLabel: createI18nString(t("templates.next"), []),
|
||||
backButtonLabel: createI18nString(t("templates.back"), []),
|
||||
buttonLabel: createI18nString(t(""), []),
|
||||
backButtonLabel: createI18nString(t(""), []),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
|
||||
"generate-api-specs": "./scripts/openapi/generate.sh",
|
||||
"merge-client-endpoints": "tsx ./scripts/openapi/merge-client-endpoints.ts",
|
||||
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints"
|
||||
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints",
|
||||
"i18n:generate": "npx lingo.dev@latest i18n"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.879.0",
|
||||
|
||||
@@ -232,7 +232,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
for (let i = 0; i < surveys.createAndSubmit.ranking.choices.length; i++) {
|
||||
await page.getByText(surveys.createAndSubmit.ranking.choices[i]).click();
|
||||
}
|
||||
await page.locator("#questionCard-12").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("#questionCard-12").getByRole("button", { name: "Finish" }).click();
|
||||
// loading spinner -> wait for it to disappear
|
||||
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
|
||||
});
|
||||
@@ -979,7 +979,7 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
.fill("This is my city");
|
||||
await expect(page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.zip)).toBeVisible();
|
||||
await page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.zip).fill("12345");
|
||||
await page.locator("#questionCard-13").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("#questionCard-13").getByRole("button", { name: "Finish" }).click();
|
||||
|
||||
// loading spinner -> wait for it to disappear
|
||||
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
|
||||
|
||||
@@ -220,7 +220,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
],
|
||||
DEFAULT_LOCALE: "en-US",
|
||||
BREVO_API_KEY: "mock-brevo-api-key",
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
"self-hosting/configuration/smtp",
|
||||
"self-hosting/configuration/file-uploads",
|
||||
"self-hosting/configuration/domain-configuration",
|
||||
"self-hosting/configuration/custom-subpath",
|
||||
{
|
||||
"group": "Auth & SSO",
|
||||
"icon": "lock",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: "Custom Subpath"
|
||||
description: "Serve Formbricks from a custom URL prefix when you cannot expose it on the root domain."
|
||||
icon: "link"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Custom subpath deployments are currently under internal review. If you need early access, please reach out via
|
||||
[GitHub Discussions](https://github.com/formbricks/formbricks/discussions).
|
||||
</Note>
|
||||
|
||||
### When to use a custom subpath
|
||||
|
||||
Use a custom subpath (also called a Next.js base path) when your reverse proxy reserves the root domain for another
|
||||
service, but you still want Formbricks to live under the same hostname—for example `https://example.com/feedback`.
|
||||
Support for a build-time `BASE_PATH` variable is available in the Formbricks web app so that all internal routes,
|
||||
assets, and sign-in redirects honor the prefix.
|
||||
|
||||
### Requirements and limitations
|
||||
|
||||
- `BASE_PATH` must be present during `pnpm build`; changing it afterward requires a rebuild.
|
||||
- Official Formbricks Docker images do **not** accept this flag for technical reasons, so you must build your own image.
|
||||
- All public URLs (`WEBAPP_URL`, `NEXTAUTH_URL`, webhook targets, OAuth callbacks, etc.) need the same prefix.
|
||||
- Your proxy must rewrite `/custom-path/*` to the Formbricks container while keeping the prefix visible to clients.
|
||||
|
||||
### Configure environment variables
|
||||
|
||||
Add the following variables to the environment you use for builds (local, CI, or Docker build args):
|
||||
|
||||
```bash
|
||||
BASE_PATH="/custom-path"
|
||||
WEBAPP_URL="https://yourdomain.com/custom-path"
|
||||
NEXTAUTH_URL="https://yourdomain.com/custom-path/api/auth"
|
||||
```
|
||||
|
||||
If you use email links, webhooks, or third-party OAuth providers, ensure every URL you register includes the prefix.
|
||||
|
||||
### Build a Docker image with a custom subpath
|
||||
|
||||
<Steps>
|
||||
<Step title="Clone Formbricks and prepare secrets">
|
||||
Make sure you have the repository checked out and create temporary files (or use <code>--secret</code>) for the
|
||||
required build-time secrets such as <code>DATABASE_URL</code>, <code>ENCRYPTION_KEY</code>, <code>REDIS_URL</code>,
|
||||
and optional telemetry tokens.
|
||||
</Step>
|
||||
<Step title="Pass BASE_PATH as a build argument">
|
||||
Use the Formbricks web Dockerfile and supply the custom subpath via <code>--build-arg</code>. Example:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--progress=plain \
|
||||
--no-cache \
|
||||
--build-arg BASE_PATH=/custom-path \
|
||||
--secret id=database_url,src=<(printf "postgresql://user:password@localhost:5432/formbricks?schema=public") \
|
||||
--secret id=encryption_key,src=<(printf "your-32-character-encryption-key-here") \
|
||||
--secret id=redis_url,src=<(printf "redis://localhost:6379") \
|
||||
--secret id=sentry_auth_token,src=<(printf "") \
|
||||
-f apps/web/Dockerfile \
|
||||
-t formbricks-web \
|
||||
.
|
||||
```
|
||||
|
||||
During the build logs you should see <code>BASE PATH /custom-path</code>, confirming that Next.js picked up the
|
||||
prefix.
|
||||
</Step>
|
||||
<Step title="Run the container behind your proxy">
|
||||
Start the resulting image with the same runtime environment variables you normally use (database credentials,
|
||||
mailing provider, etc.). Point your reverse proxy so that <code>/custom-path</code> requests forward to
|
||||
<code>http://formbricks-web:3000/custom-path</code> without stripping the prefix.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Verify the deployment
|
||||
|
||||
1. Open `https://yourdomain.com/custom-path` and complete the onboarding flow.
|
||||
2. Create a survey and preview it—embedded scripts now load assets relative to the subpath.
|
||||
3. Sign out and confirm the login page still includes `/custom-path`.
|
||||
|
||||
### Troubleshooting checklist
|
||||
|
||||
- Confirm your build pipeline actually passes `BASE_PATH` (and, if needed, `WEBAPP_URL`/`NEXTAUTH_URL`) into the build
|
||||
stage—check CI logs for the `BASE PATH /your-prefix` line and make sure custom Dockerfiles or wrappers forward
|
||||
`--build-arg BASE_PATH=...` correctly.
|
||||
- If you cannot log in, double-check that `NEXTAUTH_URL` includes the prefix and uses the full route to the API as stated above. NextAuth rejects callbacks that do not
|
||||
match exactly.
|
||||
- Re-run the Docker build when changing `BASE_PATH`; simply editing the container environment is not sufficient.
|
||||
- Inspect your proxy configuration to ensure it does not rewrite paths internally (e.g., `strip_prefix` needs to stay
|
||||
disabled).
|
||||
- When in doubt, rebuild locally with `--progress=plain` and verify that the `BASE PATH` line reflects your prefix.
|
||||
|
||||
+3
-2
@@ -34,8 +34,9 @@
|
||||
"prepare": "husky install",
|
||||
"storybook": "turbo run storybook",
|
||||
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate",
|
||||
"i18n:generate": " pnpm --filter @formbricks/surveys i18n:generate",
|
||||
"generate-translations": "cd apps/web && npx lingo.dev@latest i18n",
|
||||
"i18n:surveys:generate": "pnpm --filter @formbricks/surveys i18n:generate",
|
||||
"i18n:web:generate": "pnpm --filter @formbricks/web i18n:generate",
|
||||
"generate-translations": "pnpm i18n:web:generate && pnpm i18n:surveys:generate",
|
||||
"scan-translations": "pnpm --filter @formbricks/i18n-utils scan-translations",
|
||||
"i18n": "pnpm generate-translations && pnpm scan-translations",
|
||||
"i18n:validate": "pnpm scan-translations"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["de", "it", "fr", "es", "ar", "pt", "ru", "uz", "ro", "ja", "zh-Hans", "hi", "nl", "sv"]
|
||||
"targets": ["de", "it", "fr", "es", "ar", "pt", "ru", "uz", "ro", "ja", "zh-Hans", "hi", "nl"]
|
||||
},
|
||||
"version": 1.8
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ checksums:
|
||||
errors/please_fill_out_this_field: 88d4fd502ae8d423277aef723afcd1a7
|
||||
errors/please_rank_all_items_before_submitting: 24fb14a2550bd7ec3e253dda0997cea8
|
||||
errors/please_select_a_date: 1abdc8ffb887dbbdcc0d05486cd84de7
|
||||
errors/please_select_a_rating: e871aa58c243589768f8b266ed6bb0aa
|
||||
errors/please_select_a_value: 0c86021b2b819e94c99ae70bfccfd3f0
|
||||
errors/please_upload_a_file: 4356dfca88553acb377664c923c2d6b7
|
||||
errors/recaptcha_error/message: b3f2c5950cbc0887f391f9e2bccb676e
|
||||
errors/recaptcha_error/title: 8e923ec38a92041569879a39c6467131
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "يرجى ملء هذا الحقل",
|
||||
"please_rank_all_items_before_submitting": "يرجى ترتيب جميع العناصر قبل الإرسال",
|
||||
"please_select_a_date": "يرجى اختيار تاريخ",
|
||||
"please_select_a_rating": "يرجى اختيار تقييم",
|
||||
"please_select_a_value": "يرجى اختيار قيمة",
|
||||
"please_upload_a_file": "يرجى تحميل ملف",
|
||||
"recaptcha_error": {
|
||||
"message": "تعذر إرسال ردك لأنه تم تصنيفه كنشاط آلي. إذا كنت تتنفس، يرجى المحاولة مرة أخرى.",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "Bitte füllen Sie dieses Feld aus",
|
||||
"please_rank_all_items_before_submitting": "Bitte ordnen Sie alle Elemente vor dem Absenden",
|
||||
"please_select_a_date": "Bitte wählen Sie ein Datum aus",
|
||||
"please_select_a_rating": "Bitte wählen Sie eine Bewertung aus",
|
||||
"please_select_a_value": "Bitte wählen Sie einen Wert aus",
|
||||
"please_upload_a_file": "Bitte laden Sie eine Datei hoch",
|
||||
"recaptcha_error": {
|
||||
"message": "Ihre Antwort konnte nicht übermittelt werden, da sie als automatisierte Aktivität eingestuft wurde. Wenn Sie atmen, versuchen Sie es bitte erneut.",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "Please fill out this field",
|
||||
"please_rank_all_items_before_submitting": "Please rank all items before submitting",
|
||||
"please_select_a_date": "Please select a date",
|
||||
"please_select_a_rating": "Please select a rating",
|
||||
"please_select_a_value": "Please select a value",
|
||||
"please_upload_a_file": "Please upload a file",
|
||||
"recaptcha_error": {
|
||||
"message": "Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "Por favor, complete este campo",
|
||||
"please_rank_all_items_before_submitting": "Por favor, clasifique todos los elementos antes de enviar",
|
||||
"please_select_a_date": "Por favor, seleccione una fecha",
|
||||
"please_select_a_rating": "Por favor selecciona una calificación",
|
||||
"please_select_a_value": "Por favor selecciona un valor",
|
||||
"please_upload_a_file": "Por favor, suba un archivo",
|
||||
"recaptcha_error": {
|
||||
"message": "Su respuesta no pudo ser enviada porque fue marcada como actividad automatizada. Si respira, por favor inténtelo de nuevo.",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "Veuillez remplir ce champ",
|
||||
"please_rank_all_items_before_submitting": "Veuillez classer tous les éléments avant de soumettre",
|
||||
"please_select_a_date": "Veuillez sélectionner une date",
|
||||
"please_select_a_rating": "Veuillez sélectionner une note",
|
||||
"please_select_a_value": "Veuillez sélectionner une valeur",
|
||||
"please_upload_a_file": "Veuillez télécharger un fichier",
|
||||
"recaptcha_error": {
|
||||
"message": "Votre réponse n'a pas pu être soumise car elle a été signalée comme une activité automatisée. Si vous respirez, veuillez réessayer.",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "कृपया इस फील्ड को भरें",
|
||||
"please_rank_all_items_before_submitting": "जमा करने से पहले कृपया सभी आइटम्स को रैंक करें",
|
||||
"please_select_a_date": "कृपया एक तारीख चुनें",
|
||||
"please_select_a_rating": "कृपया एक रेटिंग चुनें",
|
||||
"please_select_a_value": "कृपया एक मान चुनें",
|
||||
"please_upload_a_file": "कृपया एक फाइल अपलोड करें",
|
||||
"recaptcha_error": {
|
||||
"message": "आपका प्रतिसाद जमा नहीं किया जा सका क्योंकि इसे स्वचालित गतिविधि के रूप में चिह्नित किया गया था। यदि आप सांस लेते हैं, तो कृपया पुनः प्रयास करें।",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "Compila questo campo",
|
||||
"please_rank_all_items_before_submitting": "Classifica tutti gli elementi prima di inviare",
|
||||
"please_select_a_date": "Seleziona una data",
|
||||
"please_select_a_rating": "Seleziona una valutazione",
|
||||
"please_select_a_value": "Seleziona un valore",
|
||||
"please_upload_a_file": "Carica un file",
|
||||
"recaptcha_error": {
|
||||
"message": "La tua risposta non può essere inviata perché è stata segnalata come attività automatizzata. Se respiri, riprova.",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "このフィールドに入力してください",
|
||||
"please_rank_all_items_before_submitting": "送信する前にすべての項目をランク付けしてください",
|
||||
"please_select_a_date": "日付を選択してください",
|
||||
"please_select_a_rating": "評価を選択してください",
|
||||
"please_select_a_value": "値を選択してください",
|
||||
"please_upload_a_file": "ファイルをアップロードしてください",
|
||||
"recaptcha_error": {
|
||||
"message": "自動化された活動としてフラグが立てられたため、回答を送信できませんでした。人間の方は、もう一度お試しください。",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "Vul dit veld in",
|
||||
"please_rank_all_items_before_submitting": "Rangschik alle items voordat u ze verzendt",
|
||||
"please_select_a_date": "Selecteer een datum",
|
||||
"please_select_a_rating": "Selecteer een beoordeling",
|
||||
"please_select_a_value": "Selecteer een waarde",
|
||||
"please_upload_a_file": "Upload een bestand",
|
||||
"recaptcha_error": {
|
||||
"message": "Uw reactie kan niet worden verzonden omdat deze is gemarkeerd als geautomatiseerde activiteit. Als u ademhaalt, probeer het dan opnieuw.",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "Por favor, preencha este campo",
|
||||
"please_rank_all_items_before_submitting": "Por favor, classifique todos os itens antes de enviar",
|
||||
"please_select_a_date": "Por favor, selecione uma data",
|
||||
"please_select_a_rating": "Por favor, selecione uma classificação",
|
||||
"please_select_a_value": "Por favor, selecione um valor",
|
||||
"please_upload_a_file": "Por favor, carregue um arquivo",
|
||||
"recaptcha_error": {
|
||||
"message": "Sua resposta não pôde ser enviada porque foi sinalizada como atividade automatizada. Se você respira, por favor tente novamente.",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "Vă rugăm să completați acest câmp",
|
||||
"please_rank_all_items_before_submitting": "Vă rugăm să clasificați toate elementele înainte de a trimite",
|
||||
"please_select_a_date": "Vă rugăm să selectați o dată",
|
||||
"please_select_a_rating": "Vă rugăm să selectați o evaluare",
|
||||
"please_select_a_value": "Vă rugăm să selectați o valoare",
|
||||
"please_upload_a_file": "Vă rugăm să încărcați un fișier",
|
||||
"recaptcha_error": {
|
||||
"message": "Răspunsul dumneavoastră nu a putut fi trimis deoarece a fost marcat ca activitate automată. Dacă respirați, încercați din nou.",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "Пожалуйста, заполните это поле",
|
||||
"please_rank_all_items_before_submitting": "Пожалуйста, оцените все элементы перед отправкой",
|
||||
"please_select_a_date": "Пожалуйста, выберите дату",
|
||||
"please_select_a_rating": "Пожалуйста, выберите оценку",
|
||||
"please_select_a_value": "Пожалуйста, выберите значение",
|
||||
"please_upload_a_file": "Пожалуйста, загрузите файл",
|
||||
"recaptcha_error": {
|
||||
"message": "Ваш ответ не может быть отправлен, так как он был помечен как автоматическая активность. Если вы дышите, попробуйте ещё раз.",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"and": "och",
|
||||
"apply": "Tillämpa",
|
||||
"auto_close_wrapper": "Automatisk stängning",
|
||||
"back": "Tillbaka",
|
||||
"click_or_drag_to_upload_files": "Klicka eller dra för att ladda upp filer.",
|
||||
"close_survey": "Stäng enkät",
|
||||
"company_logo": "Företagslogotyp",
|
||||
"delete_file": "Ta bort fil",
|
||||
"file_upload": "Ladda upp fil",
|
||||
"finish": "Slutför",
|
||||
"language_switch": "Språkväxlare",
|
||||
"less_than_x_minutes": "{count, plural, one {mindre än 1 minut} other {mindre än {count} minuter}}",
|
||||
"move_down": "Flytta {item} nedåt",
|
||||
"move_up": "Flytta {item} uppåt",
|
||||
"next": "Nästa",
|
||||
"open_in_new_tab": "Öppna i ny flik",
|
||||
"optional": "Valfritt",
|
||||
"options": "Alternativ",
|
||||
"people_responded": "{count, plural, one {1 person har svarat} other {{count} personer har svarat}}",
|
||||
"please_retry_now_or_try_again_later": "Försök igen nu eller försök igen senare.",
|
||||
"powered_by": "Drivs av",
|
||||
"privacy_policy": "Integritetspolicy",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Skyddas av reCAPTCHA och Googles",
|
||||
"question": "Fråga",
|
||||
"question_video": "Frågevideo",
|
||||
"ranking_items": "Rangordna objekt",
|
||||
"respondents_will_not_see_this_card": "Respondenter kommer inte att se detta kort",
|
||||
"retry": "Försök igen",
|
||||
"select_a_date": "Välj ett datum",
|
||||
"select_for_ranking": "Välj {item} för rangordning",
|
||||
"sending_responses": "Skickar svar...",
|
||||
"takes": "Tar",
|
||||
"terms_of_service": "Användarvillkor",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Servrarna kan inte nås för tillfället.",
|
||||
"they_will_be_redirected_immediately": "De kommer att omdirigeras omedelbart",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Ladda upp filer genom att klicka eller dra dem hit",
|
||||
"uploading": "Laddar upp",
|
||||
"x_minutes": "{count, plural, one {1 minut} other {{count} minuter}}",
|
||||
"x_plus_minutes": "{count}+ minuter",
|
||||
"you_have_selected_x_date": "Du har valt {date}",
|
||||
"you_have_successfully_uploaded_the_file": "Du har framgångsrikt laddat upp filen {fileName}",
|
||||
"your_feedback_is_stuck": "Din feedback fastnade :("
|
||||
},
|
||||
"errors": {
|
||||
"file_input": {
|
||||
"duplicate_files": "Följande filer är redan uppladdade: {duplicateNames}. Dubbletter av filer är inte tillåtna.",
|
||||
"file_size_exceeded": "Följande filer överstiger maxstorleken på {maxSizeInMB} MB och togs bort: {fileNames}",
|
||||
"file_size_exceeded_alert": "Filen måste vara mindre än {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Inga giltiga filtyper valda. Vänligen välj en giltig filtyp.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Endast en fil kan laddas upp åt gången.",
|
||||
"upload_failed": "Uppladdning misslyckades! Försök igen.",
|
||||
"you_can_only_upload_a_maximum_of_files": "Du kan ladda upp maximalt {FILE_LIMIT} filer."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Vänligen inaktivera skräppostskyddet i enkätinställningarna för att fortsätta använda denna enhet.",
|
||||
"title": "Denna enhet stöder inte skräppostskydd."
|
||||
},
|
||||
"please_book_an_appointment": "Vänligen boka ett möte",
|
||||
"please_enter_a_valid_email_address": "Vänligen ange en giltig e-postadress",
|
||||
"please_enter_a_valid_phone_number": "Vänligen ange ett giltigt telefonnummer",
|
||||
"please_enter_a_valid_url": "Vänligen ange en giltig URL",
|
||||
"please_fill_out_this_field": "Vänligen fyll i detta fält",
|
||||
"please_rank_all_items_before_submitting": "Vänligen rangordna alla objekt innan du skickar",
|
||||
"please_select_a_date": "Vänligen välj ett datum",
|
||||
"please_upload_a_file": "Vänligen ladda upp en fil",
|
||||
"recaptcha_error": {
|
||||
"message": "Ditt svar kunde inte skickas eftersom det flaggades som automatiserad aktivitet. Om du andas, försök igen.",
|
||||
"title": "Vi kunde inte verifiera att du är människa."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "Iltimos, ushbu maydonni to'ldiring",
|
||||
"please_rank_all_items_before_submitting": "Iltimos, yuborishdan oldin barcha elementlarni baholang",
|
||||
"please_select_a_date": "Iltimos, sanani tanlang",
|
||||
"please_select_a_rating": "Iltimos, reytingni tanlang",
|
||||
"please_select_a_value": "Iltimos, qiymatni tanlang",
|
||||
"please_upload_a_file": "Iltimos, faylni yuklang",
|
||||
"recaptcha_error": {
|
||||
"message": "Sizning javobingiz avtomatlashtirilgan faoliyat sifatida belgilanganligi sababli yuborilmadi. Agar siz nafas olayotgan bo'lsangiz, qayta urinib ko'ring.",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"please_fill_out_this_field": "请填写此字段",
|
||||
"please_rank_all_items_before_submitting": "请在提交之前对所有项目进行排名",
|
||||
"please_select_a_date": "请选择一个日期",
|
||||
"please_select_a_rating": "请选择一个评分",
|
||||
"please_select_a_value": "请选择一个值",
|
||||
"please_upload_a_file": "请上传一个文件",
|
||||
"recaptcha_error": {
|
||||
"message": "您的响应未能提交,因为它被标记为自动活动。如果您是人类,请重试。",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyNPSElement } from "@formbricks/types/surveys/elements";
|
||||
import { ElementMedia } from "@/components/general/element-media";
|
||||
@@ -18,6 +19,8 @@ interface NPSElementProps {
|
||||
autoFocusEnabled: boolean;
|
||||
currentElementId: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
shouldAutoAdvance?: boolean;
|
||||
onAutoSubmit?: (responseData: TResponseData, ttc: TResponseTtc) => void;
|
||||
}
|
||||
|
||||
export function NPSElement({
|
||||
@@ -29,17 +32,27 @@ export function NPSElement({
|
||||
setTtc,
|
||||
currentElementId,
|
||||
dir = "auto",
|
||||
shouldAutoAdvance,
|
||||
onAutoSubmit,
|
||||
}: Readonly<NPSElementProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [hoveredNumber, setHoveredNumber] = useState(-1);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isMediaAvailable = element.imageUrl || element.videoUrl;
|
||||
const isCurrent = element.id === currentElementId;
|
||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
|
||||
|
||||
const handleClick = (number: number) => {
|
||||
onChange({ [element.id]: number });
|
||||
setErrorMessage("");
|
||||
const responseData = { [element.id]: number };
|
||||
onChange(responseData);
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
|
||||
if (shouldAutoAdvance && onAutoSubmit) {
|
||||
onAutoSubmit(responseData, updatedTtcObj);
|
||||
}
|
||||
};
|
||||
|
||||
const getNPSOptionColor = (idx: number) => {
|
||||
@@ -53,6 +66,13 @@ export function NPSElement({
|
||||
key={element.id}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (element.required && value === undefined) {
|
||||
setErrorMessage(t("errors.please_select_a_value"));
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
}}>
|
||||
@@ -66,6 +86,11 @@ export function NPSElement({
|
||||
subheader={element.subheader ? getLocalizedValue(element.subheader, languageCode) : ""}
|
||||
elementId={element.id}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<div className="fb-mt-2 fb-text-sm fb-text-red-500" role="alert" aria-live="assertive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="fb-my-4">
|
||||
<fieldset>
|
||||
<legend className="fb-sr-only">Options</legend>
|
||||
@@ -121,7 +146,7 @@ export function NPSElement({
|
||||
onClick={() => {
|
||||
handleClick(number);
|
||||
}}
|
||||
required={element.required}
|
||||
// Note: We handle required validation manually via onSubmit to show custom error messages
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{number}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import type { JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyRatingElement } from "@formbricks/types/surveys/elements";
|
||||
import { ElementMedia } from "@/components/general/element-media";
|
||||
@@ -31,6 +32,8 @@ interface RatingElementProps {
|
||||
autoFocusEnabled: boolean;
|
||||
currentElementId: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
shouldAutoAdvance?: boolean;
|
||||
onAutoSubmit?: (responseData: TResponseData, ttc: TResponseTtc) => void;
|
||||
}
|
||||
|
||||
export function RatingElement({
|
||||
@@ -42,17 +45,28 @@ export function RatingElement({
|
||||
setTtc,
|
||||
currentElementId,
|
||||
dir = "auto",
|
||||
shouldAutoAdvance,
|
||||
onAutoSubmit,
|
||||
}: RatingElementProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hoveredNumber, setHoveredNumber] = useState(0);
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isMediaAvailable = element.imageUrl || element.videoUrl;
|
||||
const isCurrent = element.id === currentElementId;
|
||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
|
||||
|
||||
const handleSelect = (number: number) => {
|
||||
onChange({ [element.id]: number });
|
||||
setErrorMessage("");
|
||||
const responseData = { [element.id]: number };
|
||||
onChange(responseData);
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
|
||||
// Auto-advance if enabled (single required Rating element in block)
|
||||
if (shouldAutoAdvance && onAutoSubmit) {
|
||||
onAutoSubmit(responseData, updatedTtcObj);
|
||||
}
|
||||
};
|
||||
|
||||
function HiddenRadioInput({ number, id }: { number: number; id?: string }) {
|
||||
@@ -66,7 +80,7 @@ export function RatingElement({
|
||||
onClick={() => {
|
||||
handleSelect(number);
|
||||
}}
|
||||
required={element.required}
|
||||
// Note: We handle required validation manually via handleFormSubmit to show custom error messages
|
||||
checked={value === number}
|
||||
/>
|
||||
);
|
||||
@@ -93,6 +107,13 @@ export function RatingElement({
|
||||
|
||||
const handleFormSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (element.required && value === undefined) {
|
||||
setErrorMessage(t("errors.please_select_a_rating"));
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
};
|
||||
@@ -246,6 +267,11 @@ export function RatingElement({
|
||||
subheader={element.subheader ? getLocalizedValue(element.subheader, languageCode) : ""}
|
||||
elementId={element.id}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<div className="fb-mt-2 fb-text-sm fb-text-red-500" role="alert" aria-live="assertive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="fb-mb-4 fb-mt-6 fb-flex fb-items-center fb-justify-center">
|
||||
<fieldset className="fb-w-full">
|
||||
<legend className="fb-sr-only">Choices</legend>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
||||
@@ -67,6 +67,16 @@ export function BlockConditional({
|
||||
// Ref to collect TTC values synchronously (state updates are async)
|
||||
const ttcCollectorRef = useRef<TResponseTtc>({});
|
||||
|
||||
// Determine if we should auto-advance (single required NPS or Rating element in block)
|
||||
const shouldAutoAdvance = useMemo(() => {
|
||||
if (block.elements.length !== 1) return false;
|
||||
const element = block.elements[0];
|
||||
return (
|
||||
(element.type === TSurveyElementTypeEnum.NPS || element.type === TSurveyElementTypeEnum.Rating) &&
|
||||
element.required
|
||||
);
|
||||
}, [block.elements]);
|
||||
|
||||
// Handle change for an individual element
|
||||
const handleElementChange = (elementId: string, responseData: TResponseData) => {
|
||||
// If user moved to a different element, we should track it
|
||||
@@ -278,37 +288,42 @@ export function BlockConditional({
|
||||
}
|
||||
}}
|
||||
onTtcCollect={handleTtcCollect}
|
||||
shouldAutoAdvance={shouldAutoAdvance}
|
||||
onAutoSubmit={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"fb-flex fb-w-full fb-flex-row-reverse fb-justify-between",
|
||||
fullSizeCards ? "fb-sticky fb-bottom-0 fb-bg-white" : ""
|
||||
)}>
|
||||
<div>
|
||||
<SubmitButton
|
||||
buttonLabel={
|
||||
block.buttonLabel ? getLocalizedValue(block.buttonLabel, languageCode) : undefined
|
||||
}
|
||||
isLastQuestion={isLastBlock}
|
||||
onClick={handleBlockSubmit}
|
||||
tabIndex={0}
|
||||
/>
|
||||
{/* Hide navigation buttons when auto-advancing (check the condition for auto-advance above) */}
|
||||
{!shouldAutoAdvance && (
|
||||
<div
|
||||
className={cn(
|
||||
"fb-flex fb-w-full fb-flex-row-reverse fb-justify-between",
|
||||
fullSizeCards ? "fb-sticky fb-bottom-0 fb-bg-white" : ""
|
||||
)}>
|
||||
<div>
|
||||
<SubmitButton
|
||||
buttonLabel={
|
||||
block.buttonLabel ? getLocalizedValue(block.buttonLabel, languageCode) : undefined
|
||||
}
|
||||
isLastQuestion={isLastBlock}
|
||||
onClick={handleBlockSubmit}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
{!isFirstBlock && !isBackButtonHidden && (
|
||||
<BackButton
|
||||
backButtonLabel={
|
||||
block.backButtonLabel ? getLocalizedValue(block.backButtonLabel, languageCode) : undefined
|
||||
}
|
||||
onClick={onBack}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isFirstBlock && !isBackButtonHidden && (
|
||||
<BackButton
|
||||
backButtonLabel={
|
||||
block.backButtonLabel ? getLocalizedValue(block.backButtonLabel, languageCode) : undefined
|
||||
}
|
||||
onClick={onBack}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,8 @@ interface ElementConditionalProps {
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
|
||||
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
|
||||
shouldAutoAdvance?: boolean;
|
||||
onAutoSubmit?: (responseData: TResponseData, ttc: TResponseTtc) => void; // Ideally just calls onSubmit from the block conditional
|
||||
}
|
||||
|
||||
export function ElementConditional({
|
||||
@@ -62,6 +64,8 @@ export function ElementConditional({
|
||||
dir,
|
||||
formRef,
|
||||
onTtcCollect,
|
||||
shouldAutoAdvance,
|
||||
onAutoSubmit,
|
||||
}: ElementConditionalProps) {
|
||||
// Ref to the container div, used to find and expose the form element inside
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -184,6 +188,8 @@ export function ElementConditional({
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
shouldAutoAdvance={shouldAutoAdvance}
|
||||
onAutoSubmit={onAutoSubmit}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
@@ -214,6 +220,8 @@ export function ElementConditional({
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
shouldAutoAdvance={shouldAutoAdvance}
|
||||
onAutoSubmit={onAutoSubmit}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
|
||||
@@ -99,7 +99,7 @@ export function LanguageSwitch({
|
||||
{showLanguageDropdown ? (
|
||||
<div
|
||||
className={cn(
|
||||
"fb-bg-input-bg fb-text-heading fb-absolute fb-top-10 fb-max-h-64 fb-space-y-2 fb-overflow-auto fb-rounded-md fb-p-2 fb-text-xs",
|
||||
"fb-bg-input-bg fb-text-heading fb-absolute fb-top-10 fb-max-h-64 fb-space-y-2 fb-overflow-auto fb-rounded-md fb-p-2 fb-text-xs fb-border-border fb-border",
|
||||
dir === "rtl" ? "fb-left-8" : "fb-right-8"
|
||||
)}
|
||||
ref={languageDropdownRef}>
|
||||
|
||||
@@ -13,7 +13,6 @@ import nlTranslations from "../../locales/nl.json";
|
||||
import ptTranslations from "../../locales/pt.json";
|
||||
import roTranslations from "../../locales/ro.json";
|
||||
import ruTranslations from "../../locales/ru.json";
|
||||
import svTranslations from "../../locales/sv.json";
|
||||
import uzTranslations from "../../locales/uz.json";
|
||||
import zhHansTranslations from "../../locales/zh-Hans.json";
|
||||
|
||||
@@ -22,23 +21,7 @@ i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
supportedLngs: [
|
||||
"en",
|
||||
"de",
|
||||
"it",
|
||||
"fr",
|
||||
"es",
|
||||
"ar",
|
||||
"pt",
|
||||
"ro",
|
||||
"ja",
|
||||
"ru",
|
||||
"uz",
|
||||
"zh-Hans",
|
||||
"hi",
|
||||
"nl",
|
||||
"sv",
|
||||
],
|
||||
supportedLngs: ["en", "de", "it", "fr", "es", "ar", "pt", "ro", "ja", "ru", "uz", "zh-Hans", "hi", "nl"],
|
||||
|
||||
resources: {
|
||||
en: { translation: enTranslations },
|
||||
@@ -53,7 +36,6 @@ i18n
|
||||
nl: { translation: nlTranslations },
|
||||
ru: { translation: ruTranslations },
|
||||
uz: { translation: uzTranslations },
|
||||
sv: { translation: svTranslations },
|
||||
"zh-Hans": { translation: zhHansTranslations },
|
||||
hi: { translation: hiTranslations },
|
||||
},
|
||||
|
||||
@@ -1355,7 +1355,10 @@ export const ZSurvey = z
|
||||
}
|
||||
}
|
||||
|
||||
//only validate back button label for blocks other than the first one and if back button is not hidden
|
||||
if (
|
||||
!isBackButtonHidden &&
|
||||
blockIndex > 0 &&
|
||||
block.backButtonLabel?.[defaultLanguageCode] &&
|
||||
block.backButtonLabel[defaultLanguageCode].trim() !== ""
|
||||
) {
|
||||
|
||||
@@ -12,7 +12,6 @@ export const ZUserLocale = z.enum([
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
]);
|
||||
|
||||
export type TUserLocale = z.infer<typeof ZUserLocale>;
|
||||
|
||||
Reference in New Issue
Block a user