Compare commits

..

2 Commits

Author SHA1 Message Date
Joel Ekström Svensson edc58b5cb9 Use proper Swedish abbreviation colon 2025-11-30 19:23:51 +01:00
Joel Ekström Svensson 4b6b171540 Add Swedish sv-SE translation
https://github.com/formbricks/formbricks/pull/6737 was used as a reference to see how a translation is implemented
2025-11-30 19:23:34 +01:00
37 changed files with 3182 additions and 481 deletions
+2 -1
View File
@@ -17,7 +17,8 @@
"zh-Hans-CN",
"zh-Hant-TW",
"nl-NL",
"es-ES"
"es-ES",
"sv-SE"
]
},
"version": 1.8
+1
View File
@@ -176,6 +176,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ja-JP",
"zh-Hans-CN",
"es-ES",
"sv-SE",
];
// Billing constants
+31 -3
View File
@@ -140,6 +140,7 @@ export const appLanguages = [
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
"sv-SE": "Engelska (USA)",
},
},
{
@@ -156,6 +157,7 @@ export const appLanguages = [
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
"es-ES": "Alemán",
"sv-SE": "Tyska",
},
},
{
@@ -172,6 +174,7 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
"sv-SE": "Portugisiska (Brasilien)",
},
},
{
@@ -188,6 +191,7 @@ export const appLanguages = [
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
"es-ES": "Francés",
"sv-SE": "Franska",
},
},
{
@@ -199,11 +203,12 @@ export const appLanguages = [
"fr-FR": "Chinois (Traditionnel)",
"zh-Hant-TW": "繁體中文",
"pt-PT": "Chinês (Tradicional)",
"ro-RO": "Chineză (Tradicională)",
"ro-RO": "Chineza (Tradițională)",
"ja-JP": "中国語(繁体字)",
"zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
"sv-SE": "Kinesiska (traditionell)",
},
},
{
@@ -220,6 +225,7 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
"sv-SE": "Portugisiska (Portugal)",
},
},
{
@@ -236,6 +242,7 @@ export const appLanguages = [
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
"es-ES": "Rumano",
"sv-SE": "Rumänska",
},
},
{
@@ -252,6 +259,7 @@ export const appLanguages = [
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
"es-ES": "Japonés",
"sv-SE": "Japanska",
},
},
{
@@ -263,11 +271,12 @@ export const appLanguages = [
"fr-FR": "Chinois (Simplifié)",
"zh-Hant-TW": "簡體中文",
"pt-PT": "Chinês (Simplificado)",
"ro-RO": "Chineză (Simplificată)",
"ro-RO": "Chineza (Simplificată)",
"ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
"sv-SE": "Kinesiska (förenklad)",
},
},
{
@@ -279,11 +288,12 @@ export const appLanguages = [
"fr-FR": "Néerlandais",
"zh-Hant-TW": "荷蘭語",
"pt-PT": "Holandês",
"ro-RO": "Olandeză",
"ro-RO": "Olandeza",
"ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
"sv-SE": "Nederländska",
},
},
{
@@ -300,6 +310,24 @@ 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",
},
},
];
+6
View File
@@ -69,6 +69,12 @@ 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", () => {
+3 -1
View File
@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -93,6 +93,8 @@ 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":
+22
View File
@@ -1,6 +1,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";
// Mock the Next.js headers function
@@ -84,4 +85,25 @@ 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,16 +133,13 @@ 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 &&
block.buttonLabel["default"]?.trim() !== "" &&
!isLabelValidForAllLanguages(block.buttonLabel, surveyLanguages);
block.buttonLabel !== undefined && !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(""), []),
backButtonLabel: createI18nString(t(""), []),
buttonLabel: createI18nString(t("templates.next"), []),
backButtonLabel: createI18nString(t("templates.back"), []),
};
return {
+2 -2
View File
@@ -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: "Finish" }).click();
await page.locator("#questionCard-12").getByRole("button", { name: "Next" }).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: "Finish" }).click();
await page.locator("#questionCard-13").getByRole("button", { name: "Next" }).click();
// loading spinner -> wait for it to disappear
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
+1
View File
@@ -220,6 +220,7 @@ vi.mock("@/lib/constants", () => ({
"ja-JP",
"zh-Hans-CN",
"es-ES",
"sv-SE",
],
DEFAULT_LOCALE: "en-US",
BREVO_API_KEY: "mock-brevo-api-key",
-1
View File
@@ -234,7 +234,6 @@
"self-hosting/configuration/smtp",
"self-hosting/configuration/file-uploads",
"self-hosting/configuration/domain-configuration",
"self-hosting/configuration/custom-subpath",
{
"group": "Auth & SSO",
"icon": "lock",
@@ -1,90 +0,0 @@
---
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.
@@ -25,16 +25,8 @@ https://app.formbricks.com/s/clin3dxja02k8l80hpwmx4bjy?question_id_1=answer_1&qu
## How it works
1. To prefill survey questions, add query parameters to the survey URL using the format `questionId=answer`.
2. For choice-based questions, you can use either **option IDs** (recommended) or **option labels** (backward compatible).
3. The answer must match the question's expected type to pass [validation](/xm-and-surveys/surveys/link-surveys/data-prefilling#validation).
4. The answer needs to be [URL encoded](https://www.urlencoder.org/) (if it contains spaces or special characters)
### Option IDs vs Option Labels
- **Option IDs (Recommended):** Use the unique ID of each option. These don't change with translations and are more reliable. Example: `?country=choice-us`
- **Option Labels (Backward Compatible):** Use the exact text of the option. Example: `?country=United%20States`
If you provide a value that matches an option ID, it will be used. Otherwise, the system will try to match it as an option label.
2. The answer must match the questions expected type to pass [validation](/xm-and-surveys/surveys/link-surveys/data-prefilling#validation).
3. The answer needs to be [URL encoded](https://www.urlencoder.org/) (if it contains spaces or special characters)
### Skip prefilled questions
@@ -85,15 +77,9 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?single_select_question_id
### Multi Select Question (Checkbox)
Selects multiple options in the multi select question using comma-separated values. You can use either option IDs or labels:
Selects three options 'Sun, Palms and Beach' in the multi select question. The strings have to be identical to the options in your question:
**Using Option IDs (Recommended):**
```sh Multi-select Question (by ID)
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?multi_select_question_id=sport%2Cmusic%2Ctravel
```
**Using Option Labels (Backward Compatible):**
```sh Multi-select Question (by Label)
```sh Multi-select Question
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?multi_select_question_id=Sun%2CPalms%2CBeach
```
@@ -127,22 +113,12 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?consent_question_id=accep
### Picture Selection Question
Selects image options in the Picture Selection question using comma-separated option IDs:
Adds index of the selected image(s) as the answer to the Picture Selection question. The index starts from 1
```txt Picture Selection Question
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?pictureSelection_question_id=choice-1%2Cchoice-2%2Cchoice-3
```txt Picture Selection Question.
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?pictureSelection_question_id=1%2C2%2C3
```
### Ranking Question
Orders items in a Ranking question using comma-separated option IDs. The order matters and determines the rank:
```txt Ranking Question (order matters)
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?ranking_question_id=item-3%2Citem-1%2Citem-2
```
<Note>The order of comma-separated values determines the ranking order.</Note>
<Note>All other question types, you currently cannot prefill via the URL.</Note>
## Validation
@@ -154,17 +130,10 @@ The URL validation works as follows:
- For Rating or NPS questions, the response is parsed as a number and verified if it's accepted by the schema.
- For CTA type questions, the valid values are "clicked" (main CTA) and "dismissed" (skip CTA).
- For Consent type questions, the valid values are "accepted" (consent given) and "dismissed" (consent not given).
- For Picture Selection, Multi-select, and Ranking questions, the response can be comma-separated option IDs or labels.
- For choice-based questions (Single-select, Multi-select, Ranking, Picture Selection), the system will first try to match as an option ID, then fallback to matching as an option label.
- For Picture Selection type questions, the response is parsed as an array of numbers and verified if it's accepted by the schema.
- All other question types are strings.
<Note>
If an answer is invalid, the prefilling will be ignored and the question is
presented as if not prefilled.
</Note>
## Finding Option IDs
Option IDs are unique identifiers for each choice option in your survey. You can find them in the **Advanced Settings** at the bottom of each question card in the Survey Editor. For choice-based questions (Single-select, Multi-select, Ranking, Picture Selection), the option ID is displayed next to each option.
You can update option IDs to any string you like **before you publish a survey.** After you publish a survey, you cannot change the IDs anymore.
+1 -1
View File
@@ -7,7 +7,7 @@
},
"locale": {
"source": "en",
"targets": ["de", "it", "fr", "es", "ar", "pt", "ru", "uz", "ro", "ja", "zh-Hans", "hi", "nl"]
"targets": ["de", "it", "fr", "es", "ar", "pt", "ru", "uz", "ro", "ja", "zh-Hans", "hi", "nl", "sv"]
},
"version": 1.8
}
+73
View File
@@ -0,0 +1,73 @@
{
"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."
}
}
}
@@ -43,6 +43,8 @@ export function AddressElement({
return Array.isArray(value) ? value : ["", "", "", "", "", ""];
}, [value]);
const isCurrent = element.id === currentElementId;
const fields = useMemo(
() => [
{
@@ -164,7 +166,7 @@ export function AddressElement({
handleChange(field.id, e.currentTarget.value);
}}
ref={index === 0 ? addressRef : null}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
aria-label={field.label}
dir={!safeValue[index] ? dir : "auto"}
/>
@@ -32,6 +32,7 @@ export function ConsentElement({
}: Readonly<ConsentElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
@@ -65,7 +66,7 @@ export function ConsentElement({
/>
<label
ref={consentRef}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
id={`${element.id}-label`}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
@@ -37,6 +37,7 @@ export function ContactInfoElement({
const isMediaAvailable = element.imageUrl || element.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const safeValue = useMemo(() => {
return Array.isArray(value) ? value : ["", "", "", "", ""];
}, [value]);
@@ -148,7 +149,7 @@ export function ContactInfoElement({
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
aria-label={field.label}
dir={!safeValue[index] ? dir : "auto"}
/>
@@ -67,7 +67,7 @@ export function CTAElement({
<button
dir="auto"
type="button"
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
onClick={handleExternalButtonClick}
className="fb-text-heading focus:fb-ring-focus fb-flex fb-items-center fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
{getLocalizedValue(element.ctaButtonLabel, languageCode)}
@@ -86,6 +86,7 @@ export function DateElement({
const [errorMessage, setErrorMessage] = useState("");
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value ? new Date(value) : undefined);
const [hideInvalid, setHideInvalid] = useState(!selectedDate);
@@ -160,7 +161,7 @@ export function DateElement({
onClick={() => {
setDatePickerOpen(true);
}}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
type="button"
onKeyDown={(e) => {
if (e.key === " ") setDatePickerOpen(true);
@@ -31,6 +31,7 @@ export function MatrixElement({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const rowShuffleIdx = useMemo(() => {
if (element.shuffleOption !== "none") {
return getShuffledRowIndices(element.rows.length, element.shuffleOption);
@@ -126,7 +127,7 @@ export function MatrixElement({
{element.columns.map((column, columnIndex) => (
<td
key={`column-${columnIndex.toString()}`}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
className={`fb-outline-brand fb-px-4 fb-py-2 fb-text-slate-800 ${columnIndex === element.columns.length - 1 ? "fb-rounded-r-custom" : ""}`}
onClick={() => {
handleSelect(
@@ -57,6 +57,7 @@ export function MultipleChoiceMultiElement({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const shuffledChoicesIds = useMemo(() => {
if (element.shuffleOption) {
return getShuffledChoicesIds(element.choices, element.shuffleOption);
@@ -211,7 +212,7 @@ export function MultipleChoiceMultiElement({
return (
<label
key={choice.id}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(choice.id)}
autoFocus={idx === 0 && autoFocusEnabled}>
@@ -259,12 +260,15 @@ export function MultipleChoiceMultiElement({
: "Please specify";
return (
<label tabIndex={0} className={labelClassName} onKeyDown={handleKeyDown(otherOption.id)}>
<label
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(otherOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
tabIndex={-1}
tabIndex={isCurrent ? 0 : -1}
id={otherOption.id}
name={element.id}
value={otherLabel}
@@ -285,7 +289,7 @@ export function MultipleChoiceMultiElement({
id={`${otherOption.id}-specify`}
maxLength={250}
name={element.id}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
value={otherValue}
pattern=".*\S+.*"
onChange={(e) => setOtherValue(e.currentTarget.value)}
@@ -309,7 +313,10 @@ export function MultipleChoiceMultiElement({
);
return (
<label tabIndex={0} className={labelClassName} onKeyDown={handleKeyDown(noneOption.id)}>
<label
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
@@ -36,6 +36,7 @@ export function MultipleChoiceSingleElement({
const otherSpecify = useRef<HTMLInputElement | null>(null);
const choicesContainerRef = useRef<HTMLDivElement | null>(null);
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
const shuffledChoicesIds = useMemo(() => {
if (element.shuffleOption) {
return getShuffledChoicesIds(element.choices, element.shuffleOption);
@@ -67,10 +68,22 @@ export function MultipleChoiceSingleElement({
const noneOption = useMemo(() => element.choices.find((choice) => choice.id === "none"), [element.choices]);
useEffect(() => {
if (!value) {
const prefillAnswer = new URLSearchParams(window.location.search).get(element.id);
if (
prefillAnswer &&
otherOption &&
prefillAnswer === getLocalizedValue(otherOption.label, languageCode)
) {
setOtherSelected(true);
return;
}
}
const isOtherSelected =
value !== undefined && !elementChoices.some((choice) => choice?.label[languageCode] === value);
setOtherSelected(isOtherSelected);
}, [languageCode, elementChoices, value]);
}, [languageCode, otherOption, element.id, elementChoices, value]);
useEffect(() => {
// Scroll to the bottom of choices container and focus on 'otherSpecify' input when 'otherSelected' is true
@@ -145,7 +158,7 @@ export function MultipleChoiceSingleElement({
return (
<label
key={choice.id}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(choice.id)}
autoFocus={idx === 0 && autoFocusEnabled}>
@@ -184,7 +197,7 @@ export function MultipleChoiceSingleElement({
: "Please specify";
return (
<label tabIndex={0} className={labelClassName} onKeyDown={handleOtherKeyDown}>
<label tabIndex={isCurrent ? 0 : -1} className={labelClassName} onKeyDown={handleOtherKeyDown}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
@@ -232,7 +245,10 @@ export function MultipleChoiceSingleElement({
);
return (
<label tabIndex={0} className={labelClassName} onKeyDown={handleKeyDown(noneOption.id)}>
<label
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
@@ -33,6 +33,7 @@ export function NPSElement({
const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1);
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) => {
@@ -73,7 +74,7 @@ export function NPSElement({
return (
<label
key={number}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
onMouseOver={() => {
setHoveredNumber(number);
}}
@@ -169,7 +169,7 @@ export function OpenTextElement({
<input
ref={inputRef as RefObject<HTMLInputElement>}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
name={element.id}
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode)}
@@ -195,7 +195,7 @@ export function OpenTextElement({
rows={3}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={element.id}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
aria-label="textarea"
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode, true)}
@@ -148,7 +148,7 @@ export function PictureSelectionElement({
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
onKeyDown={handleKeyDown}
onClick={() => handleChange(choice.id)}
className={getButtonClassName(choice.id)}>
@@ -159,7 +159,7 @@ export function RankingElement({
)}>
<button
autoFocus={idx === 0 && autoFocusEnabled}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
@@ -46,6 +46,7 @@ export function RatingElement({
const [hoveredNumber, setHoveredNumber] = useState(0);
const [startTime, setStartTime] = useState(performance.now());
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) => {
@@ -162,7 +163,7 @@ export function RatingElement({
const renderNumberScale = (number: number, totalLength: number) => {
return (
<label
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
onKeyDown={handleKeyDown(number)}
className={getNumberLabelClassName(number, totalLength)}>
{element.isColorCodingEnabled && (
@@ -179,7 +180,7 @@ export function RatingElement({
const renderStarScale = (number: number) => {
return (
<label
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
onKeyDown={handleKeyDown(number)}
className={getStarLabelClassName(number)}
onFocus={handleFocus(number)}
@@ -200,7 +201,7 @@ export function RatingElement({
const renderSmileyScale = (number: number, idx: number) => {
return (
<label
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
className={getSmileyLabelClassName(number)}
onKeyDown={handleKeyDown(number)}
onFocus={handleFocus(number)}
@@ -81,32 +81,32 @@ export function BlockConditional({
ttcCollectorRef.current[elementId] = elementTtc;
};
// Handle prefilled data at block level
// Handle skipPrefilled at block level
useEffect(() => {
if (prefilledResponseData) {
// Populate ALL available prefilled values for this block
const prefilledData: TResponseData = {};
const prefilledTtc: TResponseTtc = {};
if (skipPrefilled && prefilledResponseData) {
// Check if ALL elements in this block have prefilled values
const allElementsPrefilled = block.elements.every(
(element) => prefilledResponseData[element.id] !== undefined
);
block.elements.forEach((element) => {
if (prefilledResponseData[element.id] !== undefined) {
if (allElementsPrefilled) {
// Auto-populate all prefilled values
const prefilledData: TResponseData = {};
const prefilledTtc: TResponseTtc = {};
block.elements.forEach((element) => {
prefilledData[element.id] = prefilledResponseData[element.id];
prefilledTtc[element.id] = 0; // 0 TTC for prefilled questions
}
});
prefilledTtc[element.id] = 0; // 0 TTC for prefilled/skipped questions
});
// ALWAYS populate what we have
if (Object.keys(prefilledData).length > 0) {
// Update state with prefilled data
onChange(prefilledData);
setTtc({ ...ttc, ...prefilledTtc });
// Only auto-skip if skipPrefilled=true AND all elements are filled
const allElementsPrefilled = Object.keys(prefilledData).length === block.elements.length;
if (skipPrefilled && allElementsPrefilled) {
setTimeout(() => {
onSubmit(prefilledData, prefilledTtc);
}, 0);
}
// Auto-submit the entire block (skip to next)
setTimeout(() => {
onSubmit(prefilledData, prefilledTtc);
}, 0);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once when block mounts
@@ -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-border-border fb-border",
"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",
dir === "rtl" ? "fb-left-8" : "fb-right-8"
)}
ref={languageDropdownRef}>
@@ -25,7 +25,6 @@ import { AutoCloseWrapper } from "@/components/wrappers/auto-close-wrapper";
import { StackedCardsContainer } from "@/components/wrappers/stacked-cards-container";
import { ApiClient } from "@/lib/api-client";
import { evaluateLogic, performActions } from "@/lib/logic";
import { parsePrefillFromURL } from "@/lib/prefill";
import { parseRecallInformation } from "@/lib/recall";
import { ResponseQueue } from "@/lib/response-queue";
import { SurveyState } from "@/lib/survey-state";
@@ -56,6 +55,7 @@ export function Survey({
onResponseCreated,
onOpenExternalURL,
isRedirectDisabled = false,
prefillResponseData,
skipPrefilled,
languageCode,
getSetIsError,
@@ -203,14 +203,6 @@ export function Survey({
return styling.cardArrangement?.appSurveys ?? "straight";
}, [localSurvey.type, styling.cardArrangement?.linkSurveys, styling.cardArrangement?.appSurveys]);
// Parse prefill data from URL
const effectivePrefillData = useMemo(() => {
if (typeof window !== "undefined") {
return parsePrefillFromURL(localSurvey, selectedLanguage);
}
return undefined;
}, [localSurvey, selectedLanguage]);
// Current block tracking (replaces currentQuestionIndex)
const currentBlockIndex = localSurvey.blocks.findIndex((b) => b.id === blockId);
const currentBlock = localSurvey.blocks[currentBlockIndex];
@@ -819,7 +811,7 @@ export function Survey({
onFileUpload={onFileUpload}
isFirstBlock={block.id === localSurvey.blocks[0]?.id}
skipPrefilled={skipPrefilled}
prefilledResponseData={offset === 0 ? effectivePrefillData : undefined}
prefilledResponseData={offset === 0 ? prefillResponseData : undefined}
isLastBlock={block.id === localSurvey.blocks[localSurvey.blocks.length - 1].id}
languageCode={selectedLanguage}
autoFocusEnabled={autoFocusEnabled}
+19 -1
View File
@@ -13,6 +13,7 @@ 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";
@@ -21,7 +22,23 @@ i18n
.use(initReactI18next)
.init({
fallbackLng: "en",
supportedLngs: ["en", "de", "it", "fr", "es", "ar", "pt", "ro", "ja", "ru", "uz", "zh-Hans", "hi", "nl"],
supportedLngs: [
"en",
"de",
"it",
"fr",
"es",
"ar",
"pt",
"ro",
"ja",
"ru",
"uz",
"zh-Hans",
"hi",
"nl",
"sv",
],
resources: {
en: { translation: enTranslations },
@@ -36,6 +53,7 @@ i18n
nl: { translation: nlTranslations },
ru: { translation: ruTranslations },
uz: { translation: uzTranslations },
sv: { translation: svTranslations },
"zh-Hans": { translation: zhHansTranslations },
hi: { translation: hiTranslations },
},
-153
View File
@@ -1,153 +0,0 @@
import { beforeEach, describe, expect, test } from "vitest";
import type { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { parsePrefillFromURL } from "./prefill";
describe("parsePrefillFromURL", () => {
let mockSurvey: TJsEnvironmentStateSurvey;
beforeEach(() => {
// Reset URL search params
delete (window as any).location;
(window as any).location = { search: "" };
mockSurvey = {
id: "survey-1",
name: "Test Survey",
type: "link",
welcomeCard: { enabled: false },
endings: [],
variables: [],
questions: [],
hiddenFields: [],
blocks: [
{
id: "block-1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "What is your name?" },
},
{
id: "q2",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Choose one" },
choices: [
{ id: "choice-us", label: { default: "United States" } },
{ id: "choice-uk", label: { default: "United Kingdom" } },
{ id: "choice-ca", label: { default: "Canada" } },
],
},
{
id: "q3",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Choose multiple" },
choices: [
{ id: "sport", label: { default: "Sports" } },
{ id: "music", label: { default: "Music" } },
{ id: "travel", label: { default: "Travel" } },
],
},
],
},
],
} as any;
});
test("should parse simple text parameter", () => {
(window as any).location.search = "?q1=John";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q1: "John" });
});
test("should handle URL encoded values", () => {
(window as any).location.search = "?q1=John%20Doe";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q1: "John Doe" });
});
test("should resolve single choice by ID", () => {
(window as any).location.search = "?q2=choice-us";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q2: "United States" });
});
test("should fallback to label matching for single choice", () => {
(window as any).location.search = "?q2=United%20States";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q2: "United States" });
});
test("should resolve multiple choices by ID", () => {
(window as any).location.search = "?q3=sport,music,travel";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q3: ["Sports", "Music", "Travel"] });
});
test("should handle multiple parameters", () => {
(window as any).location.search = "?q1=John&q2=choice-uk&q3=sport,music";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({
q1: "John",
q2: "United Kingdom",
q3: ["Sports", "Music"],
});
});
test("should return undefined when no matching parameters", () => {
(window as any).location.search = "?unrelated=value";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toBeUndefined();
});
test("should ignore invalid choice values", () => {
(window as any).location.search = "?q2=invalid-choice&q1=John";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q1: "John" });
});
test("should handle empty string parameters", () => {
(window as any).location.search = "?q1=&q2=choice-us";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q2: "United States" });
});
test("should handle mixed valid and invalid values in multi-choice", () => {
(window as any).location.search = "?q3=sport,invalid,music";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q3: ["Sports", "Music"] });
});
test("should return undefined if window is not defined", () => {
const originalWindow = global.window;
// @ts-expect-error - testing undefined window
delete global.window;
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toBeUndefined();
global.window = originalWindow;
});
});
-127
View File
@@ -1,127 +0,0 @@
import type { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import type { TResponseData } from "@formbricks/types/responses";
import {
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
TSurveyPictureChoice,
} from "@formbricks/types/surveys/elements";
import { getLocalizedValue } from "@/lib/i18n";
/**
* Parse URL query parameters and return prefill data for the survey
* Supports option IDs and labels for choice-based questions
* Multi-value fields use comma-separated syntax
*/
export function parsePrefillFromURL(
survey: TJsEnvironmentStateSurvey,
languageCode: string
): TResponseData | undefined {
if (typeof window === "undefined") {
return undefined;
}
const searchParams = new URLSearchParams(window.location.search);
const prefillData: TResponseData = {};
// Get all elements from all blocks
const allElements: TSurveyElement[] = [];
survey.blocks.forEach((block) => {
allElements.push(...block.elements);
});
// For each element, check if URL has a matching parameter
allElements.forEach((element) => {
const urlValue = searchParams.get(element.id);
if (urlValue !== null && urlValue !== "") {
// Resolve the value based on element type
const resolvedValue = resolveChoiceValue(element, urlValue, languageCode);
if (resolvedValue !== undefined) {
prefillData[element.id] = resolvedValue;
}
}
});
// Return undefined if no prefill data found
return Object.keys(prefillData).length > 0 ? prefillData : undefined;
}
/**
* Resolve a URL parameter value to the correct format for an element
* For choice-based questions, tries to match option ID first, then label
* Handles comma-separated values for multi-value fields
*/
function resolveChoiceValue(
element: TSurveyElement,
value: string,
languageCode: string
): string | string[] | undefined {
// Handle choice-based questions
if (
element.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
element.type === TSurveyElementTypeEnum.Ranking ||
element.type === TSurveyElementTypeEnum.PictureSelection
) {
// Check if this is a multi-value field
const isMultiValue =
element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
element.type === TSurveyElementTypeEnum.Ranking;
if (isMultiValue) {
// Split by comma and resolve each value
const values = value.split(",").map((v) => v.trim());
const resolvedValues: string[] = [];
for (const v of values) {
const resolved = matchChoice(element.choices, v, languageCode);
if (resolved !== undefined) {
resolvedValues.push(resolved);
}
}
return resolvedValues.length > 0 ? resolvedValues : undefined;
} else {
// Single choice - return as string
return matchChoice(element.choices, value, languageCode);
}
}
// For non-choice elements, return value as-is
// (text, number, date, etc.)
return value || undefined;
}
/**
* Match a value against choices (either by ID or label)
* First tries exact ID match, then tries label match for backward compatibility
*/
function matchChoice(
choices: (TSurveyElementChoice | TSurveyPictureChoice)[],
value: string,
languageCode: string
): string | undefined {
// 1. Try exact ID match
const byId = choices.find((choice) => choice.id === value);
if (byId) {
// For regular choices, return the localized label
if ("label" in byId) {
return getLocalizedValue(byId.label, languageCode);
}
// For picture choices, return the ID (they don't have labels)
return byId.id;
}
// 2. Try label match (backward compatibility) - only for regular choices
const byLabel = choices.find(
(choice) => "label" in choice && getLocalizedValue(choice.label, languageCode) === value
);
if (byLabel) {
return value;
}
// No match found
return undefined;
}
-3
View File
@@ -1355,10 +1355,7 @@ 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() !== ""
) {
+1
View File
@@ -12,6 +12,7 @@ export const ZUserLocale = z.enum([
"ja-JP",
"zh-Hans-CN",
"es-ES",
"sv-SE",
]);
export type TUserLocale = z.infer<typeof ZUserLocale>;