mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 00:49:42 -06:00
feat: adds multi language functionality to surveys package (#6527)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -74,3 +74,4 @@ infra/terraform/.terraform/
|
||||
/*.iml
|
||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||
.cursorrules
|
||||
i18n.cache
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"prepare": "husky install",
|
||||
"storybook": "turbo run storybook",
|
||||
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate",
|
||||
"tolgee-pull": "BRANCH_NAME=$(node -p \"require('./branch.json').branchName\") && tolgee pull --tags \"draft:$BRANCH_NAME\" \"production\" && prettier --write ./apps/web/locales/*.json"
|
||||
"tolgee-pull": "BRANCH_NAME=$(node -p \"require('./branch.json').branchName\") && tolgee pull --tags \"draft:$BRANCH_NAME\" \"production\" && prettier --write ./apps/web/locales/*.json",
|
||||
"i18n:generate": " pnpm --filter @formbricks/surveys i18n:generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.1.0",
|
||||
|
||||
13
packages/surveys/i18n.json
Normal file
13
packages/surveys/i18n.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://lingo.dev/schema/i18n.json",
|
||||
"buckets": {
|
||||
"json": {
|
||||
"include": ["locales/[locale].json"]
|
||||
}
|
||||
},
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["de"]
|
||||
},
|
||||
"version": 1.8
|
||||
}
|
||||
63
packages/surveys/i18n.lock
Normal file
63
packages/surveys/i18n.lock
Normal file
@@ -0,0 +1,63 @@
|
||||
version: 1
|
||||
checksums:
|
||||
55dc377b5e931bfc64e4add83e35d9ea:
|
||||
common/and: ce8b9bb44f031705708a70e068bb73c8
|
||||
common/apply: eb61ee65aa06a70b2bc908cf0e9f2104
|
||||
common/auto_close_wrapper: fd0948e4d47150513e2b354c7685c1a7
|
||||
common/back: f541015a827e37cb3b1234e56bc2aa3c
|
||||
common/click_or_drag_to_upload_files: 64f59bc339568d52b8464b82546b70ea
|
||||
common/close_survey: 36e6aaa19051cb253aa155ad69a9edbc
|
||||
common/company_logo: 82d5c0d5994508210ee02d684819f4b8
|
||||
common/delete_file: ee2f3e3fb4d2b227aea90e44fdaca861
|
||||
common/file_upload: fe254dc8892e76cf5a008d712c6ce9c8
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/language_switch: fd72a9ada13f672f4fd5da863b22cc46
|
||||
common/less_than_x_minutes: 8a8528651d0b60dc93be451abf6a139b
|
||||
common/move_down: 479ab9ea756d1d814f7dcfe7cd7c21ba
|
||||
common/move_up: c18fce90954378eb8573f5c3050bf140
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb
|
||||
common/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
common/options: 59156082418d80acb211f973b1218f11
|
||||
common/people_responded: b685fb877090d8658db724ad07a0dbd8
|
||||
common/please_retry_now_or_try_again_later: 949a3841e2eb01fa249790a42bf23aa5
|
||||
common/powered_by: 6b6f88e2fa5a1ecec6cebf813abaeebb
|
||||
common/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
|
||||
common/protected_by_reCAPTCHA_and_the_Google: 32de026bff5d52e9edf5410d7d7b835f
|
||||
common/question: 0576462ce60d4263d7c482463fcc9547
|
||||
common/question_video: cc554b661fd62ac59db500307b3ba44e
|
||||
common/ranking_items: 463f2eb500f1b42fbce6cec17612fb9a
|
||||
common/respondents_will_not_see_this_card: 18c3dd44d6ff6ca2310ad196b84f30d3
|
||||
common/retry: 6e44d18639560596569a1278f9c83676
|
||||
common/select_a_date: 521e4a705800da06d091fde3e801ce02
|
||||
common/select_for_ranking: e5f4e20752d1c2d852cd02dc3a0e9dd0
|
||||
common/sending_responses: 184772f70cca69424eaf34f73520789f
|
||||
common/takes: 01f96e2e84741ea8392d97ff4bd2aa52
|
||||
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
|
||||
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
|
||||
common/upload_files_by_clicking_or_dragging_them_here: 771d058cb341fe8f5c5866d0a617d9d2
|
||||
common/uploading: 31c3e8fa5eca1a3108d3fa6178962d5d
|
||||
common/x_minutes: bf6ec8800c29b1447226447a991b9510
|
||||
common/x_plus_minutes: 2ef597aa029e3c71d442455fbb751991
|
||||
common/you_have_selected_x_date: 38eae371bac832a099c235cac97f3ef3
|
||||
common/you_have_successfully_uploaded_the_file: 42fa83be555dc13e619241502cda6d9c
|
||||
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
|
||||
errors/file_input/duplicate_files: 198dd29e67beb6abc5b2534ede7d7f68
|
||||
errors/file_input/file_size_exceeded: 072045b042a39fa1df76200f8fa36dd4
|
||||
errors/file_input/file_size_exceeded_alert: d8e482a2ff05e78bbacaed9e9db9b5eb
|
||||
errors/file_input/native_upload_failed: 46570e5745c6c0d2489840a3093abdb0
|
||||
errors/file_input/no_valid_file_types_selected: 795acdedcffbcf06e57ea93fc16771ce
|
||||
errors/file_input/only_one_file_can_be_uploaded_at_a_time: 1eda42bd46887f9702049e23fa7cb127
|
||||
errors/file_input/upload_failed: 735fdfc1a37ab035121328237ddd6fd0
|
||||
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
|
||||
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
|
||||
errors/invalid_device_error/title: 20d261b478aaba161b0853a588926e23
|
||||
errors/please_book_an_appointment: 9e8acea3721f660b6a988f79c4105ab8
|
||||
errors/please_enter_a_valid_phone_number: 1530eb9ab7d6d190bddb37667c711631
|
||||
errors/please_fill_out_this_field: 88d4fd502ae8d423277aef723afcd1a7
|
||||
errors/please_rank_all_items_before_submitting: 24fb14a2550bd7ec3e253dda0997cea8
|
||||
errors/please_select_a_date: 1abdc8ffb887dbbdcc0d05486cd84de7
|
||||
errors/please_upload_a_file: 4356dfca88553acb377664c923c2d6b7
|
||||
errors/recaptcha_error/message: b3f2c5950cbc0887f391f9e2bccb676e
|
||||
errors/recaptcha_error/title: 8e923ec38a92041569879a39c6467131
|
||||
72
packages/surveys/locales/de.json
Normal file
72
packages/surveys/locales/de.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"common": {
|
||||
"and": "und",
|
||||
"apply": "anwenden",
|
||||
"auto_close_wrapper": "Automatisches Schließen",
|
||||
"back": "Zurück",
|
||||
"click_or_drag_to_upload_files": "Klicken oder ziehen Sie, um Dateien hochzuladen.",
|
||||
"close_survey": "Umfrage schließen",
|
||||
"company_logo": "Firmenlogo",
|
||||
"delete_file": "Datei löschen",
|
||||
"file_upload": "Datei-Upload",
|
||||
"finish": "Fertig",
|
||||
"language_switch": "Sprachwechsel",
|
||||
"less_than_x_minutes": "{count, plural, one {weniger als 1 Minute} other {weniger als {count} Minuten}}",
|
||||
"move_down": "{item} nach unten verschieben",
|
||||
"move_up": "{item} nach oben verschieben",
|
||||
"next": "Weiter",
|
||||
"open_in_new_tab": "In neuem Tab öffnen",
|
||||
"optional": "Optional",
|
||||
"options": "Optionen",
|
||||
"people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}",
|
||||
"please_retry_now_or_try_again_later": "Bitte versuchen Sie es jetzt erneut oder später noch einmal.",
|
||||
"powered_by": "Bereitgestellt von",
|
||||
"privacy_policy": "Datenschutzrichtlinie",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Geschützt durch reCAPTCHA und die Google",
|
||||
"question": "Frage",
|
||||
"question_video": "Fragevideo",
|
||||
"ranking_items": "Ranking-Elemente",
|
||||
"respondents_will_not_see_this_card": "Befragte werden diese Karte nicht sehen",
|
||||
"retry": "Wiederholen",
|
||||
"select_a_date": "Datum auswählen",
|
||||
"select_for_ranking": "{item} für Ranking auswählen",
|
||||
"sending_responses": "Antworten werden gesendet...",
|
||||
"takes": "Dauert",
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Die Server sind momentan nicht erreichbar.",
|
||||
"they_will_be_redirected_immediately": "Sie werden sofort weitergeleitet",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Laden Sie Dateien hoch, indem Sie sie hier anklicken oder hierher ziehen",
|
||||
"uploading": "Wird hochgeladen",
|
||||
"x_minutes": "{count, plural, one {1 Minute} other {{count} Minuten}}",
|
||||
"x_plus_minutes": "{count}+ Minuten",
|
||||
"you_have_selected_x_date": "Sie haben {date} ausgewählt",
|
||||
"you_have_successfully_uploaded_the_file": "Sie haben die Datei {fileName} erfolgreich hochgeladen",
|
||||
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
|
||||
},
|
||||
"errors": {
|
||||
"file_input": {
|
||||
"duplicate_files": "Die folgenden Dateien sind bereits hochgeladen: {duplicateNames}. Doppelte Dateien sind nicht erlaubt.",
|
||||
"file_size_exceeded": "Die folgenden Dateien überschreiten die maximale Größe von {maxSizeInMB} MB und wurden entfernt: {fileNames}",
|
||||
"file_size_exceeded_alert": "Die Datei sollte kleiner als {maxSizeInMB} MB sein",
|
||||
"native_upload_failed": "Fehler beim Hochladen der nativen Datei.",
|
||||
"no_valid_file_types_selected": "Keine gültigen Dateitypen ausgewählt. Bitte wählen Sie einen gültigen Dateityp.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Es kann nur eine Datei gleichzeitig hochgeladen werden.",
|
||||
"upload_failed": "Upload fehlgeschlagen! Bitte versuchen Sie es erneut.",
|
||||
"you_can_only_upload_a_maximum_of_files": "Sie können maximal {FILE_LIMIT} Dateien hochladen."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Bitte deaktivieren Sie den Spam-Schutz in den Umfrageeinstellungen, um dieses Gerät weiterhin zu verwenden.",
|
||||
"title": "Dieses Gerät unterstützt keinen Spam-Schutz."
|
||||
},
|
||||
"please_book_an_appointment": "Bitte vereinbaren Sie einen Termin",
|
||||
"please_enter_a_valid_phone_number": "Bitte geben Sie eine gültige Telefonnummer ein",
|
||||
"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_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.",
|
||||
"title": "Wir konnten nicht verifizieren, dass Sie ein Mensch sind."
|
||||
}
|
||||
}
|
||||
}
|
||||
72
packages/surveys/locales/en.json
Normal file
72
packages/surveys/locales/en.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"common": {
|
||||
"and": "and",
|
||||
"apply": "apply",
|
||||
"auto_close_wrapper": "Auto close wrapper",
|
||||
"back": "Back",
|
||||
"click_or_drag_to_upload_files": "Click or drag to upload files.",
|
||||
"close_survey": "Close survey",
|
||||
"company_logo": "Company Logo",
|
||||
"delete_file": "Delete file",
|
||||
"file_upload": "File upload",
|
||||
"finish": "Finish",
|
||||
"language_switch": "Language switch",
|
||||
"less_than_x_minutes": "{count, plural, one {less than 1 minute} other {less than {count} minutes}}",
|
||||
"move_down": "Move {item} down",
|
||||
"move_up": "Move {item} up",
|
||||
"next": "Next",
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"optional": "Optional",
|
||||
"options": "Options",
|
||||
"people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}",
|
||||
"please_retry_now_or_try_again_later": "Please retry now or try again later.",
|
||||
"powered_by": "Powered by",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Protected by reCAPTCHA and the Google",
|
||||
"question": "Question",
|
||||
"question_video": "Question Video",
|
||||
"ranking_items": "Ranking Items",
|
||||
"respondents_will_not_see_this_card": "Respondents will not see this card",
|
||||
"retry": "Retry",
|
||||
"select_a_date": "Select a date",
|
||||
"select_for_ranking": "Select {item} for ranking",
|
||||
"sending_responses": "Sending responses...",
|
||||
"takes": "Takes",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
|
||||
"they_will_be_redirected_immediately": "They will be redirected immediately",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Upload files by clicking or dragging them here",
|
||||
"uploading": "Uploading",
|
||||
"x_minutes": "{count, plural, one {1 minute} other {{count} minutes}}",
|
||||
"x_plus_minutes": "{count}+ minutes",
|
||||
"you_have_selected_x_date": "You have selected {date}",
|
||||
"you_have_successfully_uploaded_the_file": "You've successfully uploaded the file {fileName}",
|
||||
"your_feedback_is_stuck": "Your feedback is stuck :("
|
||||
},
|
||||
"errors": {
|
||||
"file_input": {
|
||||
"duplicate_files": "The following files are already uploaded: {duplicateNames}. Duplicate files are not allowed.",
|
||||
"file_size_exceeded": "The following file(s) exceed the maximum size of {maxSizeInMB} MB and were removed: {fileNames}",
|
||||
"file_size_exceeded_alert": "File should be less than {maxSizeInMB} MB",
|
||||
"native_upload_failed": "Error uploading native file.",
|
||||
"no_valid_file_types_selected": "No valid file types selected. Please select a valid file type.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Only one file can be uploaded at a time.",
|
||||
"upload_failed": "Upload failed! Please try again.",
|
||||
"you_can_only_upload_a_maximum_of_files": "You can only upload a maximum of {FILE_LIMIT} files."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Please disable spam protection in the survey settings to continue using this device.",
|
||||
"title": "This device doesn’t support spam protection."
|
||||
},
|
||||
"please_book_an_appointment": "Please book an appointment",
|
||||
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
|
||||
"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_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.",
|
||||
"title": "We couldn't verify that you're human."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,15 +34,19 @@
|
||||
"preview": "vite preview",
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"i18n:generate": "npx lingo.dev@latest i18n"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/embed-snippet": "1.3.3",
|
||||
"@formkit/auto-animate": "0.8.2",
|
||||
"i18next": "25.5.2",
|
||||
"i18next-icu": "2.4.0",
|
||||
"isomorphic-dompurify": "2.24.0",
|
||||
"preact": "10.26.6",
|
||||
"react-calendar": "5.1.0",
|
||||
"react-date-picker": "11.0.0"
|
||||
"react-date-picker": "11.0.0",
|
||||
"react-i18next": "15.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("BackButton", () => {
|
||||
const { getByRole } = render(<BackButton onClick={onClick} />);
|
||||
const button = getByRole("button");
|
||||
expect(button).toBeDefined();
|
||||
expect(button.textContent?.trim()).toEqual("Back");
|
||||
expect(button.textContent?.trim()).toEqual("common.back");
|
||||
expect(button.tabIndex).toEqual(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface BackButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -7,6 +8,7 @@ interface BackButtonProps {
|
||||
}
|
||||
|
||||
export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
dir="auto"
|
||||
@@ -16,7 +18,7 @@ export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButto
|
||||
"fb-mb-1 hover:fb-bg-input-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-flex fb-items-center fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
|
||||
)}
|
||||
onClick={onClick}>
|
||||
{backButtonLabel || "Back"}
|
||||
{backButtonLabel || t("common.back")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ describe("SubmitButton", () => {
|
||||
test('renders default label "Next" when no buttonLabel is provided and isLastQuestion is false', () => {
|
||||
const { getByRole } = render(<SubmitButton buttonLabel={undefined} isLastQuestion={false} />);
|
||||
const button = getByRole("button");
|
||||
expect(button.textContent?.trim()).toBe("Next");
|
||||
expect(button.textContent?.trim()).toBe("common.next");
|
||||
});
|
||||
|
||||
test('renders "Finish" when isLastQuestion is true and no buttonLabel is provided', () => {
|
||||
const { getByRole } = render(<SubmitButton buttonLabel={undefined} isLastQuestion />);
|
||||
const button = getByRole("button");
|
||||
expect(button.textContent?.trim()).toBe("Finish");
|
||||
expect(button.textContent?.trim()).toBe("common.finish");
|
||||
});
|
||||
|
||||
test("renders custom buttonLabel when provided", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ButtonHTMLAttributes, useRef } from "preact/compat";
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SubmitButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
buttonLabel?: string;
|
||||
@@ -34,6 +35,7 @@ export function SubmitButton({
|
||||
};
|
||||
}
|
||||
}, [isProcessing]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
@@ -73,7 +75,7 @@ export function SubmitButton({
|
||||
className="fb-bg-brand fb-border-submit-button-border fb-text-on-brand focus:fb-ring-focus fb-rounded-custom fb-flex fb-items-center fb-border fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-mb-1"
|
||||
onClick={onClick}
|
||||
disabled={disabled}>
|
||||
{buttonLabel || (isLastQuestion ? "Finish" : "Next")}
|
||||
{buttonLabel || (isLastQuestion ? t("common.finish") : t("common.next"))}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("EndingCard", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Sending responses...")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.sending_responses")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onOpenExternalURL when button is clicked", async () => {
|
||||
@@ -121,8 +121,8 @@ describe("EndingCard", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Respondents will not see this card")).toBeInTheDocument();
|
||||
expect(screen.getByText("They will be redirected immediately")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.respondents_will_not_see_this_card")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.they_will_be_redirected_immediately")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles Enter key press for link surveys", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ScrollableContainer } from "@/components/wrappers/scrollable-container"
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { replaceRecallInfo } from "@/lib/recall";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
|
||||
import { type TSurveyEndScreenCard, type TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
|
||||
@@ -38,6 +39,7 @@ export function EndingCard({
|
||||
onOpenExternalURL,
|
||||
isPreviewMode,
|
||||
}: EndingCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const media =
|
||||
endingCard.type === "endScreen" && (endingCard.imageUrl ?? endingCard.videoUrl) ? (
|
||||
<QuestionMedia imgUrl={endingCard.imageUrl} videoUrl={endingCard.videoUrl} />
|
||||
@@ -158,10 +160,13 @@ export function EndingCard({
|
||||
<div>
|
||||
<Headline
|
||||
alignTextCenter
|
||||
headline={"Respondents will not see this card"}
|
||||
headline={t("common.respondents_will_not_see_this_card")}
|
||||
questionId="EndingCard"
|
||||
/>
|
||||
<Subheader
|
||||
subheader={t("common.they_will_be_redirected_immediately")}
|
||||
questionId="EndingCard"
|
||||
/>
|
||||
<Subheader subheader={"They will be redirected immediately"} questionId="EndingCard" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="fb-my-3">
|
||||
@@ -176,7 +181,7 @@ export function EndingCard({
|
||||
<div className="fb-my-3">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<h1 className="fb-text-brand">Sending responses...</h1>
|
||||
<h1 className="fb-text-brand">{t("common.sending_responses")}</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,12 +16,10 @@ describe("ErrorComponent", () => {
|
||||
expect(alertDiv).toBeInTheDocument();
|
||||
|
||||
const titleSpan = alertDiv.querySelector("span");
|
||||
expect(titleSpan?.textContent?.trim()).toBe("We couldn't verify that you're human.");
|
||||
expect(titleSpan?.textContent?.trim()).toBe("errors.recaptcha_error.title");
|
||||
|
||||
const messageP = alertDiv.querySelector("p");
|
||||
expect(messageP?.textContent?.trim()).toBe(
|
||||
"Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again."
|
||||
);
|
||||
expect(messageP?.textContent?.trim()).toBe("errors.recaptcha_error.message");
|
||||
});
|
||||
|
||||
test("renders InvalidDeviceError correctly", () => {
|
||||
@@ -31,12 +29,10 @@ describe("ErrorComponent", () => {
|
||||
expect(alertDiv).toBeInTheDocument();
|
||||
|
||||
const titleSpan = alertDiv.querySelector("span");
|
||||
expect(titleSpan?.textContent?.trim()).toBe("This device doesn’t support spam protection.");
|
||||
expect(titleSpan?.textContent?.trim()).toBe("errors.invalid_device_error.title");
|
||||
|
||||
const messageP = alertDiv.querySelector("p");
|
||||
expect(messageP?.textContent?.trim()).toBe(
|
||||
"Please disable spam protection in the survey settings to continue using this device."
|
||||
);
|
||||
expect(messageP?.textContent?.trim()).toBe("errors.invalid_device_error.message");
|
||||
});
|
||||
|
||||
test("has correct accessibility attributes", () => {
|
||||
@@ -59,7 +55,7 @@ describe("ErrorComponent", () => {
|
||||
"fb-items-center"
|
||||
);
|
||||
|
||||
const titleSpan = screen.getByText(/We couldn't verify that you're human/);
|
||||
const titleSpan = screen.getByText("errors.recaptcha_error.title");
|
||||
expect(titleSpan).toHaveClass(
|
||||
"fb-mb-1.5",
|
||||
"fb-text-base",
|
||||
@@ -68,7 +64,7 @@ describe("ErrorComponent", () => {
|
||||
"fb-text-slate-900"
|
||||
);
|
||||
|
||||
const messageP = screen.getByText(/Your response could not be submitted/);
|
||||
const messageP = screen.getByText("errors.recaptcha_error.message");
|
||||
expect(messageP).toHaveClass(
|
||||
"fb-max-w-lg",
|
||||
"fb-text-sm",
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ErrorComponentProps {
|
||||
readonly errorType: TResponseErrorCodesEnum.RecaptchaError | TResponseErrorCodesEnum.InvalidDeviceError;
|
||||
}
|
||||
|
||||
export function ErrorComponent({ errorType }: ErrorComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const errorData = {
|
||||
[TResponseErrorCodesEnum.RecaptchaError]: {
|
||||
title: "We couldn't verify that you're human.",
|
||||
message:
|
||||
"Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
|
||||
title: t("errors.recaptcha_error.title"),
|
||||
message: t("errors.recaptcha_error.message"),
|
||||
},
|
||||
[TResponseErrorCodesEnum.InvalidDeviceError]: {
|
||||
title: "This device doesn’t support spam protection.",
|
||||
message: "Please disable spam protection in the survey settings to continue using this device.",
|
||||
title: t("errors.invalid_device_error.title"),
|
||||
message: t("errors.invalid_device_error.message"),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("FileInput", () => {
|
||||
allowMultipleFiles={true}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText("File upload");
|
||||
const input = screen.getByLabelText("common.file_upload");
|
||||
const file = createFile("test.txt", 500, "text/plain");
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
@@ -73,11 +73,11 @@ describe("FileInput", () => {
|
||||
allowMultipleFiles={true}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText("File upload");
|
||||
const input = screen.getByLabelText("common.file_upload");
|
||||
const file = createFile("image.jpg", 1000, "image/jpeg");
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith("No valid file types selected. Please select a valid file type.");
|
||||
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.no_valid_file_types_selected");
|
||||
expect(onFileUpload).not.toHaveBeenCalled();
|
||||
expect(onUploadCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -92,11 +92,11 @@ describe("FileInput", () => {
|
||||
allowMultipleFiles={false}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText("File upload");
|
||||
const input = screen.getByLabelText("common.file_upload");
|
||||
const files = [createFile("one.txt", 500, "text/plain"), createFile("two.txt", 500, "text/plain")];
|
||||
fireEvent.change(input, { target: { files } });
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith("Only one file can be uploaded at a time.");
|
||||
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.only_one_file_can_be_uploaded_at_a_time");
|
||||
expect(onFileUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("FileInput", () => {
|
||||
);
|
||||
expect(screen.getByText("fileA.txt")).toBeInTheDocument();
|
||||
expect(screen.getByText("fileB.txt")).toBeInTheDocument();
|
||||
const deleteBtn = screen.getByLabelText("Delete file fileA.txt");
|
||||
const deleteBtn = screen.getByLabelText(/common.delete_file fileA.txt/);
|
||||
const svg = deleteBtn.querySelector("svg");
|
||||
if (!svg) throw new Error("Delete SVG not found");
|
||||
fireEvent.click(svg);
|
||||
@@ -132,12 +132,10 @@ describe("FileInput", () => {
|
||||
allowMultipleFiles={true}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText("File upload");
|
||||
const input = screen.getByLabelText("common.file_upload");
|
||||
const dupFile = createFile("dup.txt", 500, "text/plain");
|
||||
fireEvent.change(input, { target: { files: [dupFile] } });
|
||||
expect(alertSpy).toHaveBeenCalledWith(
|
||||
"The following files are already uploaded: dup.txt. Duplicate files are not allowed."
|
||||
);
|
||||
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.duplicate_files");
|
||||
});
|
||||
|
||||
test("handles native file upload event", async () => {
|
||||
@@ -192,7 +190,7 @@ describe("FileInput", () => {
|
||||
);
|
||||
|
||||
// Upload a small file first to verify normal behavior
|
||||
const input = screen.getByLabelText("File upload");
|
||||
const input = screen.getByLabelText("common.file_upload");
|
||||
fireEvent.change(input, { target: { files: [smallFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -227,11 +225,11 @@ describe("FileInput", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("File upload");
|
||||
const input = screen.getByLabelText("common.file_upload");
|
||||
const invalidFile = createFile("invalid.txt", 500, "text/plain");
|
||||
fireEvent.change(input, { target: { files: [invalidFile] } });
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith("No valid file types selected. Please select a valid file type.");
|
||||
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.no_valid_file_types_selected");
|
||||
expect(onFileUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -247,13 +245,11 @@ describe("FileInput", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("File upload");
|
||||
const input = screen.getByLabelText("common.file_upload");
|
||||
const dupFile = createFile("dup.txt", 500, "text/plain");
|
||||
fireEvent.change(input, { target: { files: [dupFile] } });
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith(
|
||||
"The following files are already uploaded: dup.txt. Duplicate files are not allowed."
|
||||
);
|
||||
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.duplicate_files");
|
||||
expect(onFileUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -286,7 +282,7 @@ describe("FileInput", () => {
|
||||
|
||||
// Check that the alert for rejected files was shown
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining("exceed the maximum size of 0.5 MB"));
|
||||
expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining("errors.file_input.file_size_exceeded"));
|
||||
});
|
||||
|
||||
// Only the small file should be uploaded
|
||||
@@ -344,7 +340,7 @@ describe("FileInput", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteBtn = screen.getByLabelText("Delete file fileA.txt");
|
||||
const deleteBtn = screen.getByLabelText(/common.delete_file fileA.txt/);
|
||||
const svg = deleteBtn.querySelector("svg");
|
||||
if (!svg) throw new Error("Delete SVG not found");
|
||||
fireEvent.click(svg);
|
||||
@@ -364,7 +360,9 @@ describe("FileInput", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const label = screen.getByLabelText("Upload files by clicking or dragging them here").closest("label");
|
||||
const label = screen
|
||||
.getByLabelText("common.upload_files_by_clicking_or_dragging_them_here")
|
||||
.closest("label");
|
||||
if (!label) throw new Error("Label not found");
|
||||
|
||||
// Create a mock file and DataTransfer object
|
||||
@@ -408,14 +406,14 @@ describe("FileInput", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("File upload");
|
||||
const input = screen.getByLabelText("common.file_upload");
|
||||
const file = createFile("error.txt", 500, "text/plain");
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
// Wait for the alert to be called
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith("Upload failed! Please try again.");
|
||||
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.upload_failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -435,11 +433,11 @@ describe("FileInput", () => {
|
||||
const files = Array(26)
|
||||
.fill(null)
|
||||
.map((_, i) => createFile(`file${i}.txt`, 500, "text/plain"));
|
||||
const input = screen.getByLabelText("File upload");
|
||||
const input = screen.getByLabelText("common.file_upload");
|
||||
|
||||
fireEvent.change(input, { target: { files } });
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith("You can only upload a maximum of 25 files.");
|
||||
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.you_can_only_upload_a_maximum_of_files");
|
||||
expect(onFileUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getMimeType, isFulfilled, isRejected } from "@/lib/utils";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { type JSXInternal } from "preact/src/jsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common";
|
||||
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
||||
@@ -31,6 +32,7 @@ export function FileInput({
|
||||
allowMultipleFiles,
|
||||
htmlFor = "",
|
||||
}: Readonly<FileInputProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [parent] = useAutoAnimate();
|
||||
@@ -60,14 +62,12 @@ export function FileInput({
|
||||
|
||||
if (duplicateFiles.length > 0) {
|
||||
const duplicateNames = duplicateFiles.map((file) => file.name).join(", ");
|
||||
alert(
|
||||
`The following files are already uploaded: ${duplicateNames}. Duplicate files are not allowed.`
|
||||
);
|
||||
alert(t("errors.file_input.duplicate_files", { duplicateNames }));
|
||||
}
|
||||
|
||||
return { filteredFiles, duplicateFiles };
|
||||
},
|
||||
[fileUrls, selectedFiles]
|
||||
[fileUrls, selectedFiles, t]
|
||||
);
|
||||
|
||||
// Listen for the native file-upload event dispatched via window.formbricksSurveys.onFilePick
|
||||
@@ -108,9 +108,7 @@ export function FileInput({
|
||||
// Display alert for rejected files
|
||||
if (rejectedFiles.length > 0) {
|
||||
const fileNames = rejectedFiles.join(", ");
|
||||
alert(
|
||||
`The following file(s) exceed the maximum size of ${maxSizeInMB} MB and were removed: ${fileNames}`
|
||||
);
|
||||
alert(t("errors.file_input.file_size_exceeded", { fileNames, maxSizeInMB }));
|
||||
}
|
||||
|
||||
// If no files remain after filtering, exit early
|
||||
@@ -126,7 +124,7 @@ export function FileInput({
|
||||
onUploadCallback(fileUrls ? [...fileUrls, ...uploadedUrls] : uploadedUrls);
|
||||
} catch (err) {
|
||||
console.error(`Error uploading native file.`);
|
||||
alert(`Upload failed! Please try again.`);
|
||||
alert(t("errors.file_input.upload_failed"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
@@ -144,6 +142,7 @@ export function FileInput({
|
||||
onUploadCallback,
|
||||
surveyId,
|
||||
filterDuplicateFiles,
|
||||
t,
|
||||
]);
|
||||
|
||||
const validateFileSize = async (file: File): Promise<boolean> => {
|
||||
@@ -151,7 +150,7 @@ export function FileInput({
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
const bufferKB = fileBuffer.byteLength / 1024;
|
||||
if (bufferKB > maxSizeInMB * 1024) {
|
||||
alert(`File should be less than ${maxSizeInMB.toString()} MB`);
|
||||
alert(t("errors.file_input.file_size_exceeded_alert", { maxSizeInMB }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -162,12 +161,12 @@ export function FileInput({
|
||||
let fileArray = Array.from(files);
|
||||
|
||||
if (!allowMultipleFiles && fileArray.length > 1) {
|
||||
alert("Only one file can be uploaded at a time.");
|
||||
alert(t("errors.file_input.only_one_file_can_be_uploaded_at_a_time"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowMultipleFiles && selectedFiles.length + fileArray.length > FILE_LIMIT) {
|
||||
alert(`You can only upload a maximum of ${FILE_LIMIT.toString()} files.`);
|
||||
alert(t("errors.file_input.you_can_only_upload_a_maximum_of_files", { FILE_LIMIT }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -193,7 +192,7 @@ export function FileInput({
|
||||
});
|
||||
|
||||
if (!validFiles.length) {
|
||||
alert("No valid file types selected. Please select a valid file type.");
|
||||
alert(t("errors.file_input.no_valid_file_types_selected"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -243,7 +242,7 @@ export function FileInput({
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("error in uploading file: ", err);
|
||||
alert("Upload failed! Please try again.");
|
||||
alert(t("errors.file_input.upload_failed"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
@@ -298,13 +297,13 @@ export function FileInput({
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
aria-label={`You've successfully uploaded the file ${fileName}`}
|
||||
aria-label={t("common.you_have_successfully_uploaded_the_file", { fileName })}
|
||||
tabIndex={0}
|
||||
className="fb-bg-input-bg-selected fb-border-border fb-relative fb-m-2 fb-rounded-md fb-border">
|
||||
<div className="fb-absolute fb-right-0 fb-top-0 fb-m-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Delete file ${fileName}`}
|
||||
aria-label={`${t("common.delete_file")} ${fileName}`}
|
||||
className="fb-bg-survey-bg fb-flex fb-h-5 fb-w-5 fb-cursor-pointer fb-items-center fb-justify-center fb-rounded-md">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -349,7 +348,7 @@ export function FileInput({
|
||||
{isUploading ? (
|
||||
<div className="fb-inset-0 fb-flex fb-animate-pulse fb-items-center fb-justify-center fb-rounded-lg fb-py-4">
|
||||
<label htmlFor={uniqueHtmlFor} className="fb-text-subheading fb-text-sm fb-font-medium">
|
||||
Uploading...
|
||||
{t("common.uploading")}...
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -359,7 +358,7 @@ export function FileInput({
|
||||
<button
|
||||
type="button"
|
||||
className="focus:fb-outline-brand fb-flex fb-flex-col fb-items-center fb-justify-center fb-py-6 hover:fb-cursor-pointer w-full"
|
||||
aria-label="Upload files by clicking or dragging them here"
|
||||
aria-label={t("common.upload_files_by_clicking_or_dragging_them_here")}
|
||||
onClick={() => document.getElementById(uniqueHtmlFor)?.click()}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -378,7 +377,7 @@ export function FileInput({
|
||||
<span
|
||||
className="fb-text-placeholder fb-mt-2 fb-text-sm dark:fb-text-slate-400"
|
||||
id={`${uniqueHtmlFor}-label`}>
|
||||
Click or drag to upload files.
|
||||
{t("common.click_or_drag_to_upload_files")}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
@@ -393,7 +392,7 @@ export function FileInput({
|
||||
}
|
||||
}}
|
||||
multiple={allowMultipleFiles}
|
||||
aria-label="File upload"
|
||||
aria-label={t("common.file_upload")}
|
||||
aria-describedby={`${uniqueHtmlFor}-label`}
|
||||
data-accept-multiple={allowMultipleFiles}
|
||||
data-accept-extensions={mimeTypeForAllowedFileExtensions}
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("FormbricksBranding", () => {
|
||||
test("renders branding text with correct link", () => {
|
||||
render(<FormbricksBranding />);
|
||||
|
||||
const link = screen.getByRole("link", { name: /powered by/i });
|
||||
const link = screen.getByRole("link", { name: /common.powered_by/i });
|
||||
expect(link).toHaveAttribute("href", "https://formbricks.com?utm_source=survey_branding");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function FormbricksBranding() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a
|
||||
href="https://formbricks.com?utm_source=survey_branding"
|
||||
@@ -7,7 +10,7 @@ export function FormbricksBranding() {
|
||||
className="fb-flex fb-justify-center"
|
||||
rel="noopener">
|
||||
<p className="fb-text-signature fb-text-xs">
|
||||
Powered by{" "}
|
||||
{t("common.powered_by")}{" "}
|
||||
<b>
|
||||
<span className="fb-text-branding-text hover:fb-text-signature">Formbricks</span>
|
||||
</b>
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("Headline", () => {
|
||||
const optionalText = container.querySelector("span");
|
||||
|
||||
expect(optionalText).toBeInTheDocument();
|
||||
expect(optionalText).toHaveTextContent("Optional");
|
||||
expect(optionalText).toHaveTextContent("common.optional");
|
||||
expect(optionalText).toHaveClass("fb-text-xs", "fb-opacity-60", "fb-font-normal");
|
||||
expect(optionalText).toHaveAttribute("tabIndex", "-1");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface HeadlineProps {
|
||||
@@ -7,6 +8,7 @@ interface HeadlineProps {
|
||||
alignTextCenter?: boolean;
|
||||
}
|
||||
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<label htmlFor={questionId} className="fb-text-heading fb-mb-[3px] fb-flex fb-flex-col">
|
||||
{!required && (
|
||||
@@ -14,7 +16,7 @@ export function Headline({ headline, questionId, required = true, alignTextCente
|
||||
className="fb-text-xs fb-opacity-60 fb-font-normal fb-leading-6 fb-mb-[3px]"
|
||||
tabIndex={-1}
|
||||
data-testid="fb__surveys__headline-optional-text-test">
|
||||
Optional
|
||||
{t("common.optional")}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("LanguageSwitch", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByTitle("Language switch");
|
||||
const toggleButton = screen.getByTitle("common.language_switch");
|
||||
// Initially closed
|
||||
expect(toggleButton).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("LanguageSwitch", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByTitle("Language switch");
|
||||
const toggleButton = screen.getByTitle("common.language_switch");
|
||||
// Open and select default language
|
||||
fireEvent.click(toggleButton);
|
||||
fireEvent.click(screen.getByText("en"));
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { LanguageIcon } from "@/components/icons/language-icon";
|
||||
import { mixColor } from "@/lib/color";
|
||||
import { getI18nLanguage } from "@/lib/i18n-utils";
|
||||
import i18n from "@/lib/i18n.config";
|
||||
import { useClickOutside } from "@/lib/use-click-outside-hook";
|
||||
import { checkIfSurveyIsRTL, cn } from "@/lib/utils";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { type TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
@@ -28,6 +31,7 @@ export function LanguageSwitch({
|
||||
dir = "auto",
|
||||
setDir,
|
||||
}: LanguageSwitchProps) {
|
||||
const { t } = useTranslation();
|
||||
const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -41,10 +45,19 @@ export function LanguageSwitch({
|
||||
return surveyLanguage.default;
|
||||
})?.language.code;
|
||||
|
||||
const handleI18nLanguage = (languageCode: string) => {
|
||||
const calculatedLanguage = getI18nLanguage(languageCode, surveyLanguages);
|
||||
if (i18n.language !== calculatedLanguage) {
|
||||
i18n.changeLanguage(calculatedLanguage);
|
||||
}
|
||||
};
|
||||
|
||||
const changeLanguage = (languageCode: string) => {
|
||||
const calculatedLanguageCode = languageCode === defaultLanguageCode ? "default" : languageCode;
|
||||
setSelectedLanguageCode(calculatedLanguageCode);
|
||||
|
||||
handleI18nLanguage(calculatedLanguageCode);
|
||||
|
||||
if (setDir) {
|
||||
const calculateDir = checkIfSurveyIsRTL(survey, calculatedLanguageCode) ? "rtl" : "auto";
|
||||
setDir?.(calculateDir);
|
||||
@@ -64,7 +77,7 @@ export function LanguageSwitch({
|
||||
return (
|
||||
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center">
|
||||
<button
|
||||
title="Language switch"
|
||||
title={t("common.language_switch")}
|
||||
type="button"
|
||||
className={cn(
|
||||
"fb-text-heading fb-relative fb-h-8 fb-w-8 fb-rounded-md focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-justify-center fb-flex fb-items-center"
|
||||
@@ -78,7 +91,7 @@ export function LanguageSwitch({
|
||||
tabIndex={-1}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={showLanguageDropdown}
|
||||
aria-label="Language switch"
|
||||
aria-label={t("common.language_switch")}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}>
|
||||
<LanguageIcon />
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("QuestionMedia", () => {
|
||||
const videoUrl = "https://www.youtube.com/watch?v=test123";
|
||||
render(<QuestionMedia videoUrl={videoUrl} />);
|
||||
|
||||
const iframe = screen.getByTitle("Question Video");
|
||||
const iframe = screen.getByTitle("common.question_video");
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(iframe.getAttribute("src")).toBe(videoUrl + "?controls=0");
|
||||
});
|
||||
@@ -30,7 +30,7 @@ describe("QuestionMedia", () => {
|
||||
const videoUrl = "https://vimeo.com/test123";
|
||||
render(<QuestionMedia videoUrl={videoUrl} />);
|
||||
|
||||
const iframe = screen.getByTitle("Question Video");
|
||||
const iframe = screen.getByTitle("common.question_video");
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(iframe.getAttribute("src")).toBe(
|
||||
videoUrl +
|
||||
@@ -42,7 +42,7 @@ describe("QuestionMedia", () => {
|
||||
const videoUrl = "https://www.loom.com/share/test123";
|
||||
render(<QuestionMedia videoUrl={videoUrl} />);
|
||||
|
||||
const iframe = screen.getByTitle("Question Video");
|
||||
const iframe = screen.getByTitle("common.question_video");
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(iframe.getAttribute("src")).toBe(
|
||||
videoUrl + "?hide_share=true&hideEmbedTopBar=true&hide_title=true"
|
||||
@@ -105,7 +105,7 @@ describe("QuestionMedia", () => {
|
||||
expect(loadingElement).toBeTruthy();
|
||||
|
||||
// Get iframe and trigger load
|
||||
const iframe = screen.getByTitle("Question Video");
|
||||
const iframe = screen.getByTitle("common.question_video");
|
||||
await iframe.dispatchEvent(new Event("load"));
|
||||
|
||||
// Wait for state update
|
||||
@@ -130,7 +130,7 @@ describe("QuestionMedia", () => {
|
||||
const videoUrl = "https://example.com/video.mp4";
|
||||
render(<QuestionMedia videoUrl={videoUrl} />);
|
||||
|
||||
const iframe = screen.getByTitle("Question Video");
|
||||
const iframe = screen.getByTitle("common.question_video");
|
||||
expect(iframe.getAttribute("src")).toBe(videoUrl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ImageDownIcon } from "@/components/icons/image-down-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
|
||||
import { useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
//Function to add extra params to videoUrls in order to reduce video controls
|
||||
const getVideoUrlWithParams = (videoUrl: string): string => {
|
||||
@@ -25,6 +26,7 @@ interface QuestionMediaProps {
|
||||
}
|
||||
|
||||
export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionMediaProps) {
|
||||
const { t } = useTranslation();
|
||||
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -55,7 +57,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
<div className="fb-rounded-custom fb-bg-black">
|
||||
<iframe
|
||||
src={videoUrlWithParams}
|
||||
title="Question Video"
|
||||
title={t("common.question_video")}
|
||||
frameBorder="0"
|
||||
className={cn("fb-rounded-custom fb-aspect-video fb-w-full", isLoading ? "fb-opacity-0" : "")}
|
||||
onLoad={() => {
|
||||
@@ -74,7 +76,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
href={imgUrl ? imgUrl : convertToEmbedUrl(videoUrl ?? "")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={"Open in new tab"}
|
||||
aria-label={t("common.open_in_new_tab")}
|
||||
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
|
||||
{imgUrl ? <ImageDownIcon size={20} /> : <ExpandIcon size={20} />}
|
||||
</a>
|
||||
|
||||
@@ -11,21 +11,21 @@ describe("RecaptchaBranding", () => {
|
||||
test("renders with correct text content", () => {
|
||||
const { container } = render(<RecaptchaBranding />);
|
||||
const paragraph = container.querySelector("p");
|
||||
expect(paragraph).toHaveTextContent("Protected by reCAPTCHA and the Google");
|
||||
expect(paragraph).toHaveTextContent("Privacy Policy");
|
||||
expect(paragraph).toHaveTextContent("Terms of Service");
|
||||
expect(paragraph).toHaveTextContent("apply.");
|
||||
expect(paragraph).toHaveTextContent("common.protected_by_reCAPTCHA_and_the_Google");
|
||||
expect(paragraph).toHaveTextContent("common.privacy_policy");
|
||||
expect(paragraph).toHaveTextContent("common.terms_of_service");
|
||||
expect(paragraph).toHaveTextContent("common.apply");
|
||||
});
|
||||
|
||||
test("renders links with correct attributes", () => {
|
||||
render(<RecaptchaBranding />);
|
||||
|
||||
const privacyLink = screen.getByText("Privacy Policy").closest("a");
|
||||
const privacyLink = screen.getByText("common.privacy_policy").closest("a");
|
||||
expect(privacyLink).toHaveAttribute("href", "https://policies.google.com/privacy");
|
||||
expect(privacyLink).toHaveAttribute("target", "_blank");
|
||||
expect(privacyLink).toHaveAttribute("rel", "noopener");
|
||||
|
||||
const termsLink = screen.getByText("Terms of Service").closest("a");
|
||||
const termsLink = screen.getByText("common.terms_of_service").closest("a");
|
||||
expect(termsLink).toHaveAttribute("href", "https://policies.google.com/terms");
|
||||
expect(termsLink).toHaveAttribute("target", "_blank");
|
||||
expect(termsLink).toHaveAttribute("rel", "noopener");
|
||||
@@ -40,10 +40,10 @@ describe("RecaptchaBranding", () => {
|
||||
test("links are wrapped in bold tags", () => {
|
||||
render(<RecaptchaBranding />);
|
||||
|
||||
const privacyLink = screen.getByText("Privacy Policy");
|
||||
const privacyLink = screen.getByText("common.privacy_policy");
|
||||
expect(privacyLink.parentElement?.tagName).toBe("B");
|
||||
|
||||
const termsLink = screen.getByText("Terms of Service");
|
||||
const termsLink = screen.getByText("common.terms_of_service");
|
||||
expect(termsLink.parentElement?.tagName).toBe("B");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function RecaptchaBranding() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<p className="fb-text-signature fb-text-xs fb-text-center fb-leading-6 fb-text-balance">
|
||||
Protected by reCAPTCHA and the Google{" "}
|
||||
{t("common.protected_by_reCAPTCHA_and_the_Google")}{" "}
|
||||
<b>
|
||||
<a target="_blank" rel="noopener" href="https://policies.google.com/privacy">
|
||||
Privacy Policy
|
||||
{t("common.privacy_policy")}
|
||||
</a>
|
||||
</b>{" "}
|
||||
and{" "}
|
||||
{t("common.and")}{" "}
|
||||
<b>
|
||||
<a target="_blank" rel="noopener" href="https://policies.google.com/terms">
|
||||
Terms of Service
|
||||
{t("common.terms_of_service")}
|
||||
</a>
|
||||
</b>{" "}
|
||||
apply.
|
||||
{t("common.apply")}.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ describe("ResponseErrorComponent", () => {
|
||||
<ResponseErrorComponent questions={mockQuestions} responseData={mockResponseData} onRetry={() => {}} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("Your feedback is stuck :(")).toBeDefined();
|
||||
expect(screen.getByText(/The servers cannot be reached at the moment/)).toBeDefined();
|
||||
expect(screen.getByText("Retry")).toBeDefined();
|
||||
expect(screen.getByText("common.your_feedback_is_stuck")).toBeDefined();
|
||||
expect(screen.getByText(/common.the_servers_cannot_be_reached_at_the_moment/)).toBeDefined();
|
||||
expect(screen.getByText("common.retry")).toBeDefined();
|
||||
});
|
||||
|
||||
test("displays questions and responses correctly", () => {
|
||||
@@ -52,10 +52,10 @@ describe("ResponseErrorComponent", () => {
|
||||
<ResponseErrorComponent questions={mockQuestions} responseData={mockResponseData} onRetry={() => {}} />
|
||||
);
|
||||
|
||||
const questions = screen.getAllByText(/Question \d/);
|
||||
const questions = screen.getAllByText(/common.question \d/);
|
||||
expect(questions).toHaveLength(2);
|
||||
expect(questions[0].textContent).toBe("Question 1");
|
||||
expect(questions[1].textContent).toBe("Question 2");
|
||||
expect(questions[0].textContent).toBe("common.question 1");
|
||||
expect(questions[1].textContent).toBe("common.question 2");
|
||||
|
||||
const answers = screen.getAllByText(/Answer \d/);
|
||||
expect(answers).toHaveLength(2);
|
||||
@@ -73,7 +73,7 @@ describe("ResponseErrorComponent", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const retryButton = screen.getByRole("button", { name: "Retry" });
|
||||
const retryButton = screen.getByRole("button", { name: "common.retry" });
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
expect(mockOnRetry).toHaveBeenCalledTimes(1);
|
||||
@@ -92,12 +92,12 @@ describe("ResponseErrorComponent", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const question = screen.getByText(/Question 1/);
|
||||
expect(question.textContent).toBe("Question 1");
|
||||
const question = screen.getByText("common.question 1");
|
||||
expect(question.textContent).toBe("common.question 1");
|
||||
|
||||
const answer = screen.getByText(/Answer 1/);
|
||||
const answer = screen.getByText("Answer 1");
|
||||
expect(answer.textContent).toBe("Answer 1");
|
||||
|
||||
expect(screen.queryByText(/Answer 2/)).toBeNull();
|
||||
expect(screen.queryByText("Answer 2")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||
import { processResponseData } from "@/lib/response";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData } from "@formbricks/types/responses";
|
||||
import { type TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -10,15 +11,16 @@ interface ResponseErrorComponentProps {
|
||||
}
|
||||
|
||||
export function ResponseErrorComponent({ questions, responseData, onRetry }: ResponseErrorComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="fb-flex fb-flex-col fb-bg-white fb-p-4">
|
||||
<span className="fb-mb-1.5 fb-text-base fb-font-bold fb-leading-6 fb-text-slate-900">
|
||||
Your feedback is stuck :(
|
||||
{t("common.your_feedback_is_stuck")}
|
||||
</span>
|
||||
<p className="fb-max-w-md fb-text-sm fb-font-normal fb-leading-6 fb-text-slate-600">
|
||||
The servers cannot be reached at the moment.
|
||||
{t("common.the_servers_cannot_be_reached_at_the_moment")}
|
||||
<br />
|
||||
Please retry now or try again later.
|
||||
{t("common.please_retry_now_or_try_again_later")}
|
||||
</p>
|
||||
<div className="fb-mt-4 fb-rounded-lg fb-border fb-border-slate-200 fb-bg-slate-100 fb-px-4 fb-py-5">
|
||||
<div className="fb-flex fb-max-h-36 fb-flex-1 fb-flex-col fb-space-y-3 fb-overflow-y-scroll">
|
||||
@@ -27,7 +29,7 @@ export function ResponseErrorComponent({ questions, responseData, onRetry }: Res
|
||||
if (!response) return;
|
||||
return (
|
||||
<div className="fb-flex fb-flex-col" key={`response-${index.toString()}`}>
|
||||
<span className="fb-text-sm fb-leading-6 fb-text-slate-900">{`Question ${(index + 1).toString()}`}</span>
|
||||
<span className="fb-text-sm fb-leading-6 fb-text-slate-900">{`${t("common.question")} ${(index + 1).toString()}`}</span>
|
||||
<span className="fb-mt-1 fb-text-sm fb-font-semibold fb-leading-6 fb-text-slate-900">
|
||||
{processResponseData(response)}
|
||||
</span>
|
||||
@@ -38,7 +40,7 @@ export function ResponseErrorComponent({ questions, responseData, onRetry }: Res
|
||||
</div>
|
||||
<div className="fb-mt-4 fb-flex fb-flex-1 fb-flex-row fb-items-center fb-justify-end fb-space-x-2">
|
||||
<SubmitButton
|
||||
buttonLabel="Retry"
|
||||
buttonLabel={t("common.retry")}
|
||||
isLastQuestion={false}
|
||||
onClick={() => {
|
||||
onRetry?.();
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("SurveyCloseButton", () => {
|
||||
const button = wrapper.querySelector("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8");
|
||||
expect(button).toHaveAttribute("aria-label", "Close survey");
|
||||
expect(button).toHaveAttribute("aria-label", "common.close_survey");
|
||||
|
||||
const backgroundColor = button?.style?.backgroundColor;
|
||||
expect(backgroundColor).toBe("transparent");
|
||||
@@ -33,7 +33,7 @@ describe("SurveyCloseButton", () => {
|
||||
const button = wrapper.querySelector("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8");
|
||||
expect(button).toHaveAttribute("aria-label", "Close survey");
|
||||
expect(button).toHaveAttribute("aria-label", "common.close_survey");
|
||||
|
||||
// hover over the button
|
||||
fireEvent.mouseEnter(button as HTMLButtonElement);
|
||||
@@ -51,7 +51,7 @@ describe("SurveyCloseButton", () => {
|
||||
const button = wrapper.querySelector("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8");
|
||||
expect(button).toHaveAttribute("aria-label", "Close survey");
|
||||
expect(button).toHaveAttribute("aria-label", "common.close_survey");
|
||||
expect(button).toHaveStyle({
|
||||
borderRadius: "12px",
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CloseIcon } from "@/components/icons/close-icon";
|
||||
import { mixColor } from "@/lib/color";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SurveyCloseButtonProps {
|
||||
onClose?: () => void;
|
||||
@@ -10,6 +11,7 @@ interface SurveyCloseButtonProps {
|
||||
}
|
||||
|
||||
export function SurveyCloseButton({ onClose, hoverColor, borderRadius }: Readonly<SurveyCloseButtonProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8);
|
||||
|
||||
@@ -28,7 +30,7 @@ export function SurveyCloseButton({ onClose, hoverColor, borderRadius }: Readonl
|
||||
className={cn(
|
||||
"fb-text-heading fb-relative focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-p-2 fb-h-8 fb-w-8 flex items-center justify-center"
|
||||
)}
|
||||
aria-label="Close survey">
|
||||
aria-label={t("common.close_survey")}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -50,14 +50,14 @@ describe("WelcomeCard", () => {
|
||||
|
||||
const timeDisplay = container.querySelector(".fb-text-subheading");
|
||||
expect(timeDisplay).toBeInTheDocument();
|
||||
expect(timeDisplay).toHaveTextContent(/Takes/);
|
||||
expect(timeDisplay).toHaveTextContent(/common.takes/);
|
||||
});
|
||||
|
||||
test("shows response count when showResponseCount is true and count > 3", () => {
|
||||
const { queryByTestId } = render(<WelcomeCard {...defaultProps} responseCount={10} />);
|
||||
|
||||
const responseText = queryByTestId("fb__surveys__welcome-card__response-count");
|
||||
expect(responseText).toHaveTextContent(/10 people responded/);
|
||||
expect(responseText).toHaveTextContent(/common.people_responded/);
|
||||
});
|
||||
|
||||
test("handles submit button click", () => {
|
||||
@@ -82,7 +82,7 @@ describe("WelcomeCard", () => {
|
||||
const { queryByTestId } = render(<WelcomeCard {...defaultProps} responseCount={3} />);
|
||||
|
||||
const responseText = queryByTestId("fb__surveys__welcome-card__response-count");
|
||||
expect(responseText).not.toHaveTextContent(/3 people responded/);
|
||||
expect(responseText).not.toHaveTextContent(/common.people_responded/);
|
||||
});
|
||||
|
||||
test("shows company logo when fileUrl is provided", () => {
|
||||
@@ -93,7 +93,7 @@ describe("WelcomeCard", () => {
|
||||
|
||||
render(<WelcomeCard {...propsWithLogo} />);
|
||||
|
||||
const logo = screen.getByAltText("Company Logo");
|
||||
const logo = screen.getByAltText("common.company_logo");
|
||||
expect(logo).toBeInTheDocument();
|
||||
expect(logo).toHaveAttribute("src", "https://example.com/logo.png");
|
||||
});
|
||||
@@ -102,7 +102,7 @@ describe("WelcomeCard", () => {
|
||||
// Test short survey (2 questions)
|
||||
const { container } = render(<WelcomeCard {...defaultProps} />);
|
||||
const timeDisplay = container.querySelector(".fb-text-subheading");
|
||||
expect(timeDisplay).toHaveTextContent(/Takes less than 1 minute/);
|
||||
expect(timeDisplay).toHaveTextContent(/common.takes common.less_than_x_minutes/);
|
||||
|
||||
// Test medium survey (12 questions)
|
||||
const mediumSurvey = {
|
||||
@@ -111,7 +111,7 @@ describe("WelcomeCard", () => {
|
||||
};
|
||||
const { container: mediumContainer } = render(<WelcomeCard {...defaultProps} survey={mediumSurvey} />);
|
||||
const mediumTimeDisplay = mediumContainer.querySelector(".fb-text-subheading");
|
||||
expect(mediumTimeDisplay).toHaveTextContent(/Takes 3 minutes/);
|
||||
expect(mediumTimeDisplay).toHaveTextContent(/common.takes common.x_minutes/);
|
||||
|
||||
// Test long survey (25 questions)
|
||||
const longSurvey = {
|
||||
@@ -120,7 +120,7 @@ describe("WelcomeCard", () => {
|
||||
};
|
||||
const { container: longContainer } = render(<WelcomeCard {...defaultProps} survey={longSurvey} />);
|
||||
const longTimeDisplay = longContainer.querySelector(".fb-text-subheading");
|
||||
expect(longTimeDisplay).toHaveTextContent(/Takes 6\+ minutes/);
|
||||
expect(longTimeDisplay).toHaveTextContent(/common.takes common.x_plus_minutes/);
|
||||
});
|
||||
|
||||
test("shows both time and response count when both flags are true", () => {
|
||||
@@ -140,7 +140,7 @@ describe("WelcomeCard", () => {
|
||||
);
|
||||
|
||||
const textDisplay = queryByTestId("fb__surveys__welcome-card__info-text-test");
|
||||
expect(textDisplay).toHaveTextContent(/Takes.*10 people responded/);
|
||||
expect(textDisplay).toHaveTextContent(/common.takes.*common.people_responded/);
|
||||
});
|
||||
|
||||
test("handles missing optional props gracefully", () => {
|
||||
@@ -206,7 +206,7 @@ describe("WelcomeCard", () => {
|
||||
<WelcomeCard {...defaultProps} responseCount={3} />
|
||||
);
|
||||
expect(queryByTestId3("fb__surveys__welcome-card__response-count")).not.toHaveTextContent(
|
||||
/3 people responded/
|
||||
/common.people_responded/
|
||||
);
|
||||
|
||||
// unmount to not have conflicting test ids
|
||||
@@ -215,7 +215,7 @@ describe("WelcomeCard", () => {
|
||||
// Test with 4 responses (just above boundary)
|
||||
const { queryByTestId: queryByTestId4 } = render(<WelcomeCard {...defaultProps} responseCount={4} />);
|
||||
expect(queryByTestId4("fb__surveys__welcome-card__response-count")).toHaveTextContent(
|
||||
/4 people responded/
|
||||
/common.people_responded/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -229,7 +229,9 @@ describe("WelcomeCard", () => {
|
||||
const { container: emptyContainer } = render(
|
||||
<WelcomeCard {...defaultProps} survey={emptyQuestionsSurvey} />
|
||||
);
|
||||
expect(emptyContainer.querySelector(".fb-text-subheading")).toHaveTextContent(/Takes less than 1 minute/);
|
||||
expect(emptyContainer.querySelector(".fb-text-subheading")).toHaveTextContent(
|
||||
/common.takes common.less_than_x_minutes/
|
||||
);
|
||||
|
||||
// Test with exactly 24 questions (6 minutes boundary)
|
||||
const boundaryQuestionsSurvey = {
|
||||
@@ -239,7 +241,9 @@ describe("WelcomeCard", () => {
|
||||
const { container: boundaryContainer } = render(
|
||||
<WelcomeCard {...defaultProps} survey={boundaryQuestionsSurvey} />
|
||||
);
|
||||
expect(boundaryContainer.querySelector(".fb-text-subheading")).toHaveTextContent(/Takes 6 minutes/);
|
||||
expect(boundaryContainer.querySelector(".fb-text-subheading")).toHaveTextContent(
|
||||
/common.takes common.x_minutes/
|
||||
);
|
||||
});
|
||||
|
||||
test("correctly processes localized content", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { replaceRecallInfo } from "@/lib/recall";
|
||||
import { calculateElementIdx } from "@/lib/utils";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { type TResponseData, type TResponseTtc, type TResponseVariables } from "@formbricks/types/responses";
|
||||
import { type TI18nString } from "@formbricks/types/surveys/types";
|
||||
@@ -76,6 +77,8 @@ export function WelcomeCard({
|
||||
responseData,
|
||||
variablesData,
|
||||
}: WelcomeCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const calculateTimeToComplete = () => {
|
||||
let totalCards = survey.questions.length;
|
||||
if (survey.endings.length > 0) totalCards += 1;
|
||||
@@ -86,7 +89,7 @@ export function WelcomeCard({
|
||||
const timeInSeconds = (survey.questions.length / idx) * 15; //15 seconds per question.
|
||||
if (timeInSeconds > 360) {
|
||||
// If it's more than 6 minutes
|
||||
return "6+ minutes";
|
||||
return t("common.x_plus_minutes", { count: 6 });
|
||||
}
|
||||
// Calculate minutes, if there are any seconds left, add a minute
|
||||
const minutes = Math.floor(timeInSeconds / 60);
|
||||
@@ -96,13 +99,13 @@ export function WelcomeCard({
|
||||
// If there are any seconds left, we'll need to round up to the next minute
|
||||
if (minutes === 0) {
|
||||
// If less than 1 minute, return 'less than 1 minute'
|
||||
return "less than 1 minute";
|
||||
return t("common.less_than_x_minutes", { count: 1 });
|
||||
}
|
||||
// If more than 1 minute, return 'less than X minutes', where X is minutes + 1
|
||||
return `less than ${(minutes + 1).toString()} minutes`;
|
||||
return t("common.less_than_x_minutes", { count: minutes + 1 });
|
||||
}
|
||||
// If there are no remaining seconds, just return the number of minutes
|
||||
return `${minutes.toString()} minutes`;
|
||||
return t("common.x_minutes", { count: minutes });
|
||||
};
|
||||
|
||||
const timeToFinish = survey.welcomeCard.timeToFinish;
|
||||
@@ -136,7 +139,11 @@ export function WelcomeCard({
|
||||
<ScrollableContainer>
|
||||
<div>
|
||||
{fileUrl ? (
|
||||
<img src={fileUrl} className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-object-contain" alt="Company Logo" />
|
||||
<img
|
||||
src={fileUrl}
|
||||
className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-object-contain"
|
||||
alt={t("common.company_logo")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Headline
|
||||
@@ -165,7 +172,9 @@ export function WelcomeCard({
|
||||
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
|
||||
<TimerIcon />
|
||||
<p className="fb-pt-1 fb-text-xs">
|
||||
<span> Takes {calculateTimeToComplete()} </span>
|
||||
<span>
|
||||
{t("common.takes")} {calculateTimeToComplete()}{" "}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -173,7 +182,9 @@ export function WelcomeCard({
|
||||
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
|
||||
<UsersIcon />
|
||||
<p className="fb-pt-1 fb-text-xs">
|
||||
<span data-testid="fb__surveys__welcome-card__response-count">{`${responseCount.toString()} people responded`}</span>
|
||||
<span data-testid="fb__surveys__welcome-card__response-count">
|
||||
{t("common.people_responded", { count: responseCount })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -181,9 +192,13 @@ export function WelcomeCard({
|
||||
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
|
||||
<TimerIcon />
|
||||
<p className="fb-pt-1 fb-text-xs" data-testid="fb__surveys__welcome-card__info-text-test">
|
||||
<span> Takes {calculateTimeToComplete()} </span>
|
||||
<span>
|
||||
{t("common.takes")} {calculateTimeToComplete()}{" "}
|
||||
</span>
|
||||
<span data-testid="fb__surveys__welcome-card__response-count">
|
||||
{responseCount && responseCount > 3 ? `⋅ ${responseCount.toString()} people responded` : ""}
|
||||
{responseCount && responseCount > 3
|
||||
? `⋅ ${t("common.people_responded", { count: responseCount })}`
|
||||
: ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
13
packages/surveys/src/components/i18n/provider.tsx
Normal file
13
packages/surveys/src/components/i18n/provider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "../../lib/i18n.config";
|
||||
|
||||
export const I18nProvider = ({ language, children }: { language: string; children?: ComponentChildren }) => {
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language);
|
||||
}, [language]);
|
||||
|
||||
// work around for react-i18next not supporting preact
|
||||
return <I18nextProvider i18n={i18n}>{children as unknown as React.ReactNode}</I18nextProvider>;
|
||||
};
|
||||
@@ -104,7 +104,7 @@ describe("CalQuestion - New Error Handling", () => {
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Please book an appointment")).toBeInTheDocument();
|
||||
expect(screen.getByText("errors.please_book_an_appointment")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("CalQuestion - New Error Handling", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const calEmbed = screen.getByTestId("cal-embed");
|
||||
const errorMessage = screen.getByText("Please book an appointment");
|
||||
const errorMessage = screen.getByText("errors.please_book_an_appointment");
|
||||
|
||||
// Check that error message comes after CalEmbed in DOM order
|
||||
expect(calEmbed.compareDocumentPosition(errorMessage)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
@@ -129,6 +129,6 @@ describe("CalQuestion - New Error Handling", () => {
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(screen.queryByText("Please book an appointment")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("errors.please_book_an_appointment")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { useCallback, useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import { type TSurveyCalQuestion, type TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -44,6 +45,7 @@ export function CalQuestion({
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<CalQuestionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
@@ -64,7 +66,7 @@ export function CalQuestion({
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (question.required && !value) {
|
||||
setErrorMessage("Please book an appointment");
|
||||
setErrorMessage(t("errors.please_book_an_appointment"));
|
||||
// Scroll to bottom to show the error message
|
||||
setTimeout(() => {
|
||||
if (scrollableRef.current?.scrollToBottom) {
|
||||
|
||||
@@ -57,11 +57,11 @@ describe("DateQuestion", () => {
|
||||
test("renders date question correctly", () => {
|
||||
render(<DateQuestion {...defaultProps} />);
|
||||
|
||||
expect(screen.getAllByText("Select a date")[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.select_a_date")[0]).toBeInTheDocument();
|
||||
expect(screen.getByText("Please choose a date")).toBeInTheDocument();
|
||||
expect(screen.getByText("Next")).toBeInTheDocument();
|
||||
expect(screen.getByText("Back")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select a date", { selector: "span" })).toBeInTheDocument();
|
||||
expect(screen.getByText("common.select_a_date", { selector: "span" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays error message when form is submitted without a date if required", async () => {
|
||||
@@ -71,7 +71,7 @@ describe("DateQuestion", () => {
|
||||
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
expect(screen.getByText("Please select a date.")).toBeInTheDocument();
|
||||
expect(screen.getByText("errors.please_select_a_date")).toBeInTheDocument();
|
||||
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -101,13 +101,13 @@ describe("DateQuestion", () => {
|
||||
test("does not render back button when isFirstQuestion is true", () => {
|
||||
render(<DateQuestion {...defaultProps} isFirstQuestion={true} />);
|
||||
|
||||
expect(screen.queryByText("Back")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("common.back")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render back button when isBackButtonHidden is true", () => {
|
||||
render(<DateQuestion {...defaultProps} isBackButtonHidden={true} />);
|
||||
|
||||
expect(screen.queryByText("Back")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("common.back")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders media content when available", () => {
|
||||
@@ -119,7 +119,7 @@ describe("DateQuestion", () => {
|
||||
render(<DateQuestion {...defaultProps} question={questionWithMedia} />);
|
||||
|
||||
// Media component should be rendered (implementation detail check)
|
||||
expect(screen.getAllByText("Select a date")[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.select_a_date")[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens date picker when button is clicked", async () => {
|
||||
@@ -128,7 +128,7 @@ describe("DateQuestion", () => {
|
||||
render(<DateQuestion {...defaultProps} />);
|
||||
|
||||
// Click the select date button
|
||||
const dateButton = screen.getByRole("button", { name: /select a date/i });
|
||||
const dateButton = screen.getByRole("button", { name: /common.select_a_date/i });
|
||||
await user.click(dateButton);
|
||||
|
||||
// We can check for our mocked date picker
|
||||
@@ -142,7 +142,7 @@ describe("DateQuestion", () => {
|
||||
render(<DateQuestion {...props} />);
|
||||
|
||||
// Handle timezone differences by allowing either 14th or 15th
|
||||
const dateRegex = /(14th|15th) of January, 2023/;
|
||||
const dateRegex = /(14th|15th) of (January|February), 2023/;
|
||||
const dateElement = screen.getByText(dateRegex);
|
||||
expect(dateElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import DatePicker from "react-date-picker";
|
||||
import { DatePickerProps } from "react-date-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyDateQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import "../../styles/date-picker.css";
|
||||
@@ -104,6 +105,8 @@ export function DateQuestion({
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value ? new Date(value) : undefined);
|
||||
const [hideInvalid, setHideInvalid] = useState(!selectedDate);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (datePickerOpen) {
|
||||
if (!selectedDate) setSelectedDate(new Date());
|
||||
@@ -140,7 +143,7 @@ export function DateQuestion({
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (question.required && !value) {
|
||||
setErrorMessage("Please select a date.");
|
||||
setErrorMessage(t("errors.please_select_a_date"));
|
||||
return;
|
||||
}
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
@@ -175,7 +178,11 @@ export function DateQuestion({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " ") setDatePickerOpen(true);
|
||||
}}
|
||||
aria-label={selectedDate ? `You have selected ${formattedDate}` : "Select a date"}
|
||||
aria-label={
|
||||
selectedDate
|
||||
? t("common.you_have_selected_x_date", { date: formattedDate })
|
||||
: t("common.select_a_date")
|
||||
}
|
||||
aria-describedby={errorMessage ? "error-message" : undefined}
|
||||
className="focus:fb-outline-brand fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-text-heading fb-rounded-custom fb-relative fb-flex fb-h-[12dvh] fb-w-full fb-cursor-pointer fb-appearance-none fb-items-center fb-justify-center fb-border fb-text-left fb-text-base fb-font-normal">
|
||||
<div className="fb-flex fb-items-center fb-gap-2">
|
||||
@@ -185,7 +192,7 @@ export function DateQuestion({
|
||||
</div>
|
||||
) : (
|
||||
<div className="fb-flex fb-items-center fb-gap-2">
|
||||
<CalendarIcon /> <span>Select a date</span>
|
||||
<CalendarIcon /> <span>{t("common.select_a_date")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -196,7 +196,7 @@ describe("FileUploadQuestion", () => {
|
||||
const form = container.querySelector("form");
|
||||
fireEvent.submit(form as HTMLFormElement);
|
||||
|
||||
expect(window.alert).toHaveBeenCalledWith("Please upload a file");
|
||||
expect(window.alert).toHaveBeenCalledWith("errors.please_upload_a_file");
|
||||
expect(onSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ScrollableContainer } from "@/components/wrappers/scrollable-container"
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
||||
@@ -47,6 +48,7 @@ export function FileUploadQuestion({
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<FileUploadQuestionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
@@ -64,7 +66,7 @@ export function FileUploadQuestion({
|
||||
if (value && value.length > 0) {
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
} else {
|
||||
alert("Please upload a file");
|
||||
alert(t("errors.please_upload_a_file"));
|
||||
}
|
||||
} else if (value) {
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
|
||||
@@ -141,7 +141,7 @@ describe("OpenTextQuestion", () => {
|
||||
|
||||
const input = screen.getByPlaceholderText("Type here...");
|
||||
expect(input).toHaveAttribute("pattern", "^[0-9+][0-9+\\- ]*[0-9]$");
|
||||
expect(input).toHaveAttribute("title", "Enter a valid phone number");
|
||||
expect(input).toHaveAttribute("title", "errors.please_enter_a_valid_phone_number");
|
||||
});
|
||||
|
||||
test("applies correct attributes for required fields", () => {
|
||||
@@ -385,7 +385,7 @@ describe("OpenTextQuestion", () => {
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toHaveAttribute("title", "Please enter a valid phone number");
|
||||
expect(textarea).toHaveAttribute("title", "errors.please_enter_a_valid_phone_number");
|
||||
});
|
||||
|
||||
test("applies character limits for textarea", () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { type RefObject } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -50,6 +51,7 @@ export function OpenTextQuestion({
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -71,7 +73,7 @@ export function OpenTextQuestion({
|
||||
input?.setCustomValidity("");
|
||||
|
||||
if (question.required && (!value || value.trim() === "")) {
|
||||
input?.setCustomValidity("Please fill out this field.");
|
||||
input?.setCustomValidity(t("errors.please_fill_out_this_field"));
|
||||
input?.reportValidity();
|
||||
return;
|
||||
}
|
||||
@@ -116,7 +118,9 @@ export function OpenTextQuestion({
|
||||
}}
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
|
||||
pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
|
||||
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
|
||||
title={
|
||||
question.inputType === "phone" ? t("errors.please_enter_a_valid_phone_number") : undefined
|
||||
}
|
||||
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
|
||||
maxLength={
|
||||
question.inputType === "text"
|
||||
@@ -143,7 +147,9 @@ export function OpenTextQuestion({
|
||||
handleInputChange(e.currentTarget.value);
|
||||
}}
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
|
||||
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
|
||||
title={
|
||||
question.inputType === "phone" ? t("errors.please_enter_a_valid_phone_number") : undefined
|
||||
}
|
||||
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
|
||||
maxLength={question.inputType === "text" ? question.charLimit?.max : undefined}
|
||||
/>
|
||||
|
||||
@@ -201,7 +201,7 @@ describe("PictureSelectionQuestion", () => {
|
||||
test("prevents default action when clicking image expand link", async () => {
|
||||
render(<PictureSelectionQuestion {...mockProps} />);
|
||||
|
||||
const links = screen.getAllByTitle("Open in new tab");
|
||||
const links = screen.getAllByTitle("common.open_in_new_tab");
|
||||
const mockStopPropagation = vi.fn();
|
||||
|
||||
// Simulate clicking the link but prevent the event from propagating
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getOriginalFileNameFromUrl } from "@/lib/storage";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyPictureSelectionQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -45,6 +46,7 @@ export function PictureSelectionQuestion({
|
||||
isBackButtonHidden,
|
||||
dir = "auto",
|
||||
}: Readonly<PictureSelectionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>(() => {
|
||||
const initialLoadingState: Record<string, boolean> = {};
|
||||
@@ -121,7 +123,7 @@ export function PictureSelectionQuestion({
|
||||
/>
|
||||
<div className="fb-mt-4">
|
||||
<fieldset>
|
||||
<legend className="fb-sr-only">Options</legend>
|
||||
<legend className="fb-sr-only">{t("common.options")}</legend>
|
||||
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-1 sm:fb-grid-cols-2 fb-gap-4">
|
||||
{questionChoices.map((choice) => (
|
||||
<div className="fb-relative" key={choice.id}>
|
||||
@@ -197,7 +199,7 @@ export function PictureSelectionQuestion({
|
||||
tabIndex={-1}
|
||||
href={choice.imageUrl}
|
||||
target="_blank"
|
||||
title="Open in new tab"
|
||||
title={t("common.open_in_new_tab")}
|
||||
rel="noreferrer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -206,7 +208,7 @@ export function PictureSelectionQuestion({
|
||||
"fb-absolute fb-bottom-4 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20",
|
||||
dir === "rtl" ? "fb-left-2" : "fb-right-2"
|
||||
)}>
|
||||
<span className="fb-sr-only">Open in new tab</span>
|
||||
<span className="fb-sr-only">{t("common.open_in_new_tab")}</span>
|
||||
<ImageDownIcon />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("RankingQuestion - New Error Handling", () => {
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Please rank all items before submitting.")).toBeInTheDocument();
|
||||
expect(screen.getByText("errors.please_rank_all_items_before_submitting")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,6 @@ describe("RankingQuestion - New Error Handling", () => {
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(defaultProps.onSubmit).toHaveBeenCalled();
|
||||
expect(screen.queryByText("Please rank all items before submitting.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("errors.please_rank_all_items_before_submitting")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { cn, getShuffledChoicesIds } from "@/lib/utils";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type {
|
||||
TSurveyQuestionChoice,
|
||||
@@ -50,6 +51,7 @@ export function RankingQuestion({
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<RankingQuestionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
const shuffledChoicesIds = useMemo(() => {
|
||||
@@ -122,7 +124,7 @@ export function RankingQuestion({
|
||||
(!question.required && sortedItems.length > 0 && sortedItems.length < question.choices.length);
|
||||
|
||||
if (hasIncompleteRanking) {
|
||||
setError("Please rank all items before submitting.");
|
||||
setError(t("errors.please_rank_all_items_before_submitting"));
|
||||
// Scroll to bottom to show the error message
|
||||
setTimeout(() => {
|
||||
if (scrollableRef.current?.scrollToBottom) {
|
||||
@@ -168,7 +170,7 @@ export function RankingQuestion({
|
||||
/>
|
||||
<div className="fb-mt-4">
|
||||
<fieldset>
|
||||
<legend className="fb-sr-only">Ranking Items</legend>
|
||||
<legend className="fb-sr-only">{t("common.ranking_items")}</legend>
|
||||
<div className="fb-relative" ref={parent}>
|
||||
{[...sortedItems, ...unsortedItems].map((item, idx) => {
|
||||
if (!item) return null;
|
||||
@@ -197,7 +199,9 @@ export function RankingQuestion({
|
||||
handleItemClick(item);
|
||||
}}
|
||||
type="button"
|
||||
aria-label={`Select ${getLocalizedValue(item.label, languageCode)} for ranking`}
|
||||
aria-label={t("common.select_for_ranking", {
|
||||
item: getLocalizedValue(item.label, languageCode),
|
||||
})}
|
||||
className="fb-flex fb-gap-x-4 fb-px-4 fb-items-center fb-grow fb-h-full group text-left focus:outline-none">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -221,7 +225,9 @@ export function RankingQuestion({
|
||||
e.preventDefault();
|
||||
handleMove(item.id, "up");
|
||||
}}
|
||||
aria-label={`Move ${getLocalizedValue(item.label, languageCode)} up`}
|
||||
aria-label={t("common.move_up", {
|
||||
item: getLocalizedValue(item.label, languageCode),
|
||||
})}
|
||||
className={cn(
|
||||
"fb-px-2 fb-flex fb-flex-1 fb-items-center fb-justify-center",
|
||||
isFirst
|
||||
@@ -256,7 +262,9 @@ export function RankingQuestion({
|
||||
? "fb-opacity-30 fb-cursor-not-allowed"
|
||||
: "hover:fb-bg-black/5 fb-rounded-br-custom fb-transition-colors"
|
||||
)}
|
||||
aria-label={`Move ${getLocalizedValue(item.label, languageCode)} down`}
|
||||
aria-label={t("common.move_down", {
|
||||
item: getLocalizedValue(item.label, languageCode),
|
||||
})}
|
||||
disabled={isLast}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AutoCloseProgressBar } from "@/components/general/auto-close-progress-bar";
|
||||
import React from "preact/compat";
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
|
||||
interface AutoCloseProps {
|
||||
@@ -21,7 +22,7 @@ export function AutoCloseWrapper({
|
||||
setHasInteracted,
|
||||
}: AutoCloseProps) {
|
||||
const [countDownActive, setCountDownActive] = useState(true);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isAppSurvey = survey.type === "app";
|
||||
|
||||
@@ -69,7 +70,7 @@ export function AutoCloseWrapper({
|
||||
className="fb-h-full fb-w-full"
|
||||
data-testid="fb__surveys__auto-close-wrapper-test"
|
||||
onKeyDown={stopCountdown}
|
||||
aria-label="Auto close wrapper"
|
||||
aria-label={t("common.auto_close_wrapper")}
|
||||
onTouchStart={stopCountdown}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { RenderSurvey } from "@/components/general/render-survey";
|
||||
import { I18nProvider } from "@/components/i18n/provider";
|
||||
import { FILE_PICK_EVENT } from "@/lib/constants";
|
||||
import { getI18nLanguage } from "@/lib/i18n-utils";
|
||||
import { addCustomThemeToDom, addStylesToDom } from "@/lib/styles";
|
||||
import { h, render } from "preact";
|
||||
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
|
||||
@@ -17,11 +19,13 @@ export const renderSurvey = (props: SurveyContainerProps) => {
|
||||
// render SurveyNew
|
||||
// if survey type is link, we don't pass the placement, darkOverlay, clickOutside, onClose
|
||||
|
||||
const { mode, containerId } = props;
|
||||
const { mode, containerId, languageCode } = props;
|
||||
|
||||
addStylesToDom();
|
||||
addCustomThemeToDom({ styling: props.styling });
|
||||
|
||||
const language = getI18nLanguage(languageCode, props.survey.languages);
|
||||
|
||||
if (mode === "inline") {
|
||||
if (!containerId) {
|
||||
throw new Error("renderSurvey: containerId is required for inline mode");
|
||||
@@ -35,9 +39,13 @@ export const renderSurvey = (props: SurveyContainerProps) => {
|
||||
const { placement, darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
|
||||
|
||||
render(
|
||||
h(RenderSurvey, {
|
||||
...surveyInlineProps,
|
||||
}),
|
||||
h(
|
||||
I18nProvider,
|
||||
{ language },
|
||||
h(RenderSurvey, {
|
||||
...surveyInlineProps,
|
||||
})
|
||||
),
|
||||
element
|
||||
);
|
||||
} else {
|
||||
@@ -46,9 +54,13 @@ export const renderSurvey = (props: SurveyContainerProps) => {
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
render(
|
||||
h(RenderSurvey, {
|
||||
...props,
|
||||
}),
|
||||
h(
|
||||
I18nProvider,
|
||||
{ language },
|
||||
h(RenderSurvey, {
|
||||
...props,
|
||||
})
|
||||
),
|
||||
modalContainer
|
||||
);
|
||||
}
|
||||
|
||||
12
packages/surveys/src/lib/i18n-utils.ts
Normal file
12
packages/surveys/src/lib/i18n-utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getI18nLanguage = (languageCode: string, languages: TSurveyLanguage[]) => {
|
||||
let locale = languageCode;
|
||||
|
||||
const isDefaultLanguage = languageCode === "default";
|
||||
if (isDefaultLanguage) {
|
||||
locale = languages.find((lng) => lng.default)?.language?.code || "en";
|
||||
}
|
||||
|
||||
return locale;
|
||||
};
|
||||
22
packages/surveys/src/lib/i18n.config.ts
Normal file
22
packages/surveys/src/lib/i18n.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import i18n from "i18next";
|
||||
import ICU from "i18next-icu";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import deTranslations from "../../locales/de.json";
|
||||
import enTranslations from "../../locales/en.json";
|
||||
|
||||
i18n
|
||||
.use(ICU)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
supportedLngs: ["en", "de"],
|
||||
|
||||
resources: {
|
||||
en: { translation: enTranslations },
|
||||
de: { translation: deTranslations },
|
||||
},
|
||||
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -8,7 +8,8 @@
|
||||
"jsxImportSource": "preact",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"extends": "@formbricks/config-typescript/js-library.json",
|
||||
"include": ["src", "../types/surveys.d.ts"]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import preact from "@preact/preset-vite";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { loadEnv } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { copyCompiledAssetsPlugin } from "../vite-plugins/copy-compiled-assets";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { copyCompiledAssetsPlugin } from "../vite-plugins/copy-compiled-assets";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -16,17 +16,18 @@ const config = ({ mode }) => {
|
||||
return defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
environmentMatchGlobs: [["**/*.test.tsx", "jsdom"], ["**/lib/**/*.test.ts", "jsdom"]],
|
||||
environmentMatchGlobs: [
|
||||
["**/*.test.tsx", "jsdom"],
|
||||
["**/lib/**/*.test.ts", "jsdom"],
|
||||
],
|
||||
setupFiles: ["./vitestSetup.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
env: env,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
reportsDirectory: "./coverage",
|
||||
include: [
|
||||
"src/lib/**/*.{ts,tsx}",
|
||||
"src/components/**/*.{ts,tsx}"
|
||||
],
|
||||
include: ["src/lib/**/*.{ts,tsx}", "src/components/**/*.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
define: {
|
||||
@@ -56,4 +57,4 @@ const config = ({ mode }) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default config;
|
||||
|
||||
16
packages/surveys/vitestSetup.ts
Normal file
16
packages/surveys/vitestSetup.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
// mock react-i18next useTranslation on components
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
81
pnpm-lock.yaml
generated
81
pnpm-lock.yaml
generated
@@ -766,6 +766,12 @@ importers:
|
||||
'@formkit/auto-animate':
|
||||
specifier: 0.8.2
|
||||
version: 0.8.2
|
||||
i18next:
|
||||
specifier: 25.5.2
|
||||
version: 25.5.2(typescript@5.8.3)
|
||||
i18next-icu:
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0(intl-messageformat@10.7.16)
|
||||
isomorphic-dompurify:
|
||||
specifier: 2.24.0
|
||||
version: 2.24.0
|
||||
@@ -778,6 +784,9 @@ importers:
|
||||
react-date-picker:
|
||||
specifier: 11.0.0
|
||||
version: 11.0.0(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-i18next:
|
||||
specifier: 15.7.3
|
||||
version: 15.7.3(i18next@25.5.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
||||
devDependencies:
|
||||
'@formbricks/config-typescript':
|
||||
specifier: workspace:*
|
||||
@@ -6590,6 +6599,9 @@ packages:
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
html-to-text@9.0.5:
|
||||
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -6631,6 +6643,19 @@ packages:
|
||||
hyphenate-style-name@1.1.0:
|
||||
resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==}
|
||||
|
||||
i18next-icu@2.4.0:
|
||||
resolution: {integrity: sha512-LnoZOVS/lp2l4jOMVq2vN/LPf1oGi7sMovdcnKiVjcs7Ysek61rkXMOVEbPZ/3N990uToZpKe6kNE1t2wxcsfA==}
|
||||
peerDependencies:
|
||||
intl-messageformat: ^10.3.3
|
||||
|
||||
i18next@25.5.2:
|
||||
resolution: {integrity: sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==}
|
||||
peerDependencies:
|
||||
typescript: ^5
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -6689,6 +6714,9 @@ packages:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
intl-messageformat@10.7.16:
|
||||
resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==}
|
||||
|
||||
ip-address@9.0.5:
|
||||
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -8334,6 +8362,22 @@ packages:
|
||||
react: '>=16'
|
||||
react-dom: '>=16'
|
||||
|
||||
react-i18next@15.7.3:
|
||||
resolution: {integrity: sha512-AANws4tOE+QSq/IeMF/ncoHlMNZaVLxpa5uUGW1wjike68elVYr0018L9xYoqBr1OFO7G7boDPrbn0HpMCJxTw==}
|
||||
peerDependencies:
|
||||
i18next: '>= 25.4.1'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
typescript: ^5
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
@@ -9580,6 +9624,10 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
vscode-oniguruma@2.0.1:
|
||||
resolution: {integrity: sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==}
|
||||
|
||||
@@ -17046,6 +17094,10 @@ snapshots:
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
|
||||
html-to-text@9.0.5:
|
||||
dependencies:
|
||||
'@selderee/plugin-htmlparser2': 0.11.0
|
||||
@@ -17110,6 +17162,16 @@ snapshots:
|
||||
|
||||
hyphenate-style-name@1.1.0: {}
|
||||
|
||||
i18next-icu@2.4.0(intl-messageformat@10.7.16):
|
||||
dependencies:
|
||||
intl-messageformat: 10.7.16
|
||||
|
||||
i18next@25.5.2(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -17162,6 +17224,13 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
intl-messageformat@10.7.16:
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.4
|
||||
'@formatjs/fast-memoize': 2.2.7
|
||||
'@formatjs/icu-messageformat-parser': 2.11.2
|
||||
tslib: 2.8.1
|
||||
|
||||
ip-address@9.0.5:
|
||||
dependencies:
|
||||
jsbn: 1.1.0
|
||||
@@ -18801,6 +18870,16 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
react-i18next@15.7.3(i18next@25.5.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 25.5.2(typescript@5.8.3)
|
||||
react: 19.1.0
|
||||
optionalDependencies:
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
typescript: 5.8.3
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@17.0.2: {}
|
||||
@@ -20340,6 +20419,8 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
vscode-oniguruma@2.0.1: {}
|
||||
|
||||
vscode-textmate@9.2.0: {}
|
||||
|
||||
Reference in New Issue
Block a user