diff --git a/lib/components/QuizAnswerer.tsx b/lib/components/QuizAnswerer.tsx index 451a1b3..c733d39 100644 --- a/lib/components/QuizAnswerer.tsx +++ b/lib/components/QuizAnswerer.tsx @@ -77,12 +77,12 @@ function QuizAnswererInner({ if (error) return ; // if (!data) return ; quizSettings ??= data; - if (!quizSettings) return ; + if (!quizSettings) return ; if (quizSettings.questions.length === 1 && quizSettings?.settings.cfg.noStartPage) - return ; - // if (quizSettings.questions.length === 1) return ; - if (!quizId) return ; + return ; + // if (quizSettings.questions.length === 1) return ; + if (!quizId) return ; const quizContainer = ( ; export const ApologyPage = ({ error }: Props) => { - let message = "Что-то пошло не так"; - - if (error.response?.data === "quiz is inactive") message = "Квиз не активирован"; - if (error.message === "No questions found") message = "Нет созданных вопросов"; - if (error.message === "Quiz is empty") message = "Квиз пуст"; - if (error.message === "Quiz already completed") message = "Вы уже прошли этот опрос"; - if (error.message === "No quiz id") message = "Отсутствует id квиза"; - if (error.message === "Quiz data is null") message = "Не были переданы параметры квиза"; - if (error.response?.data === "Invalid request data") message = "Такого квиза не существует"; + let message = error.message || error.response?.data; + const { t } = useTranslation(); return ( { color: "text.primary", }} > - {message} + {t(message.toLowerCase())} ); diff --git a/lib/components/ViewPublicationPage/ContactForm/ContactForm.tsx b/lib/components/ViewPublicationPage/ContactForm/ContactForm.tsx index c57d4c9..73149a9 100644 --- a/lib/components/ViewPublicationPage/ContactForm/ContactForm.tsx +++ b/lib/components/ViewPublicationPage/ContactForm/ContactForm.tsx @@ -25,6 +25,7 @@ import type { FormContactFieldData, FormContactFieldName } from "@model/settings import type { QuizQuestionResult } from "@model/questionTypes/result"; import type { AnyTypedQuizQuestion } from "@model/questionTypes/shared"; import { isProduction } from "@/utils/defineDomain"; +import { useTranslation } from "react-i18next"; type Props = { currentQuestion: AnyTypedQuizQuestion; @@ -49,6 +50,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => { const [fire, setFire] = useState(false); const isMobile = useRootContainerSize() < 850; const isTablet = useRootContainerSize() < 1000; + const { t } = useTranslation(); const vkMetrics = useVkMetricsGoals(settings.cfg.vkMetricsNumber); const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber); @@ -85,7 +87,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => { if (email.length > 0) body.email = email; if (phone.length > 0) body.phone = phone; if (adress.length > 0) body.address = adress; - if (text.length > 0) body.customs = { [FC.text.text || "Фамилия"]: text }; + if (text.length > 0) body.customs = { [FC.text.text || t("Last name")]: text }; if (Object.keys(body).length > 0) { try { @@ -99,7 +101,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => { const sessions = JSON.parse(localStorage.getItem("sessions") || "{}"); localStorage.setItem("sessions", JSON.stringify({ ...sessions, [quizId]: new Date().getTime() })); } catch (e) { - enqueueSnackbar("ответ не был засчитан"); + enqueueSnackbar(t("The answer was not counted")); } } }; @@ -119,12 +121,12 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => { const FC = settings.cfg.formContact.fields; if (!isDisableEmail && FC["email"].used !== EMAIL_REGEXP.test(email)) { - return enqueueSnackbar("введена некорректная почта"); + return enqueueSnackbar("Incorrect email entered"); } if (fireOnce.current) { if (name.length === 0 && email.length === 0 && phone.length === 0 && text.length === 0 && adress.length === 0) - return enqueueSnackbar("Пожалуйста, заполните поля"); + return enqueueSnackbar(t("Please fill in the fields")); //почта валидна, хоть одно поле заполнено setFire(true); @@ -159,7 +161,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => { yandexMetrics.contactsFormField("address"); } } catch (e) { - enqueueSnackbar("повторите попытку позже"); + enqueueSnackbar(t("Please try again later")); } if (settings.cfg.resultInfo.showResultForm === "after") { onShowResult(); @@ -286,17 +288,17 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => { href={"https://shub.pena.digital/ppdd"} target="_blank" > - Положением об обработке персональных данных{" "} + {`${t("Regulation on the processing of personal data")} `} -  и  +  {t("and")}  {" "} - Политикой конфиденциальности{" "} + {`${t("Privacy Policy")} `} -  ознакомлен +  {t("familiarized")} @@ -315,7 +317,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => { }, }} > - {settings.cfg.formContact?.button || "Получить результаты"} + {settings.cfg.formContact?.button || t("Get results")} {show_badge && ( diff --git a/lib/components/ViewPublicationPage/ContactForm/ContactTextBlock/index.tsx b/lib/components/ViewPublicationPage/ContactForm/ContactTextBlock/index.tsx index f250b1a..1240c71 100644 --- a/lib/components/ViewPublicationPage/ContactForm/ContactTextBlock/index.tsx +++ b/lib/components/ViewPublicationPage/ContactForm/ContactTextBlock/index.tsx @@ -2,6 +2,7 @@ import { Box, Typography, useTheme } from "@mui/material"; import { useRootContainerSize } from "@contexts/RootContainerWidthContext.ts"; import { QuizSettingsConfig } from "@model/settingsData.ts"; import { FC } from "react"; +import { useTranslation } from "react-i18next"; type ContactTextBlockProps = { settings: QuizSettingsConfig; @@ -11,6 +12,7 @@ export const ContactTextBlock: FC = ({ settings }) => { const theme = useTheme(); const isMobile = useRootContainerSize() < 850; const isTablet = useRootContainerSize() < 1000; + const { t } = useTranslation(); return ( = ({ settings }) => { wordBreak: "break-word", }} > - {settings.cfg.formContact.title || "Заполните форму, чтобы получить результаты теста"} + {settings.cfg.formContact.title || t("Fill out the form to receive your test results")} {settings.cfg.formContact.desc && ( { const { settings } = useQuizSettings(); + const { t } = useTranslation(); const FC = settings.cfg.formContact.fields; if (!FC) return null; @@ -45,8 +47,8 @@ export const Inputs = ({ setName(target.value)} id={name} - title={FC["name"].innerText || "Введите имя"} - desc={FC["name"].text || "Имя"} + title={FC["name"].innerText || `${t("Enter")} ${t("Name").toLowerCase()}`} + desc={FC["name"].text || t("Name")} Icon={NameIcon} /> ); @@ -56,7 +58,7 @@ export const Inputs = ({ setEmail(target.value.replaceAll(/\s/g, "")); }} id={email} - title={FC["email"].innerText || "Введите Email"} + title={FC["email"].innerText || `${t("Enter")} Email`} desc={FC["email"].text || "Email"} Icon={EmailIcon} type="email" @@ -70,8 +72,8 @@ export const Inputs = ({ }} value={phone} id={phone} - title={FC["phone"].innerText || "Введите номер телефона"} - desc={FC["phone"].text || "Номер телефона"} + title={FC["phone"].innerText || `${t("Enter")} ${t("Phone number").toLowerCase()}`} + desc={FC["phone"].text || t("Phone number")} Icon={PhoneIcon} isPhone={true} /> @@ -80,8 +82,8 @@ export const Inputs = ({ setText(target.value)} id={text} - title={FC["text"].text || "Введите фамилию"} - desc={FC["text"].innerText || "Фамилия"} + title={FC["text"].text || `${t("Enter")} ${t("Last name").toLowerCase()}`} + desc={FC["text"].innerText || t("Last name")} Icon={TextIcon} /> ); @@ -89,8 +91,8 @@ export const Inputs = ({ setAdress(target.value)} id={adress} - title={FC["address"].innerText || "Введите адрес"} - desc={FC["address"].text || "Адрес"} + title={FC["address"].innerText || `${t("Enter")} ${t("Address").toLowerCase()}`} + desc={FC["address"].text || t("Address")} Icon={AddressIcon} /> ); diff --git a/lib/components/ViewPublicationPage/Footer.tsx b/lib/components/ViewPublicationPage/Footer.tsx index f9c41e3..0673610 100644 --- a/lib/components/ViewPublicationPage/Footer.tsx +++ b/lib/components/ViewPublicationPage/Footer.tsx @@ -4,6 +4,7 @@ import { Box, Typography, useTheme } from "@mui/material"; import { useQuizSettings } from "@contexts/QuizDataContext"; import Stepper from "@ui_kit/Stepper"; +import { useTranslation } from "react-i18next"; type FooterProps = { stepNumber: number | null; @@ -15,6 +16,7 @@ export const Footer = ({ stepNumber, nextButton, prevButton }: FooterProps) => { const theme = useTheme(); const { questions, settings } = useQuizSettings(); const questionsAmount = questions.filter(({ type }) => type !== "result").length; + const { t } = useTranslation(); return ( { {stepNumber !== null && ( - Вопрос {stepNumber} из {questionsAmount} + {t("Step")} {stepNumber} {t("of")} {questionsAmount} { const theme = useTheme(); const { questions } = useQuizSettings(); const answers = useQuizViewStore((state) => state.answers); + const { t } = useTranslation(); const questionsWothoutResult = questions.filter( (q: AnyTypedQuizQuestion): q is QuizQuestionVariant => q.type === "variant" @@ -58,7 +60,7 @@ export const PointSystemResultList = () => { color: theme.palette.text.primary, }} > - {currentQuestion.title || "Вопрос без названия"} + {currentQuestion.title || t("Question without a title")} { color: theme.palette.grey[500], }} > - Ваш ответ: + {t("Your answer")}: { setQuestion(target.value); }} diff --git a/lib/components/ViewPublicationPage/ResultForm.tsx b/lib/components/ViewPublicationPage/ResultForm.tsx index 2a55429..5289eac 100644 --- a/lib/components/ViewPublicationPage/ResultForm.tsx +++ b/lib/components/ViewPublicationPage/ResultForm.tsx @@ -19,6 +19,7 @@ import { PointSystemResultList } from "./PointSystemResultList"; import { enqueueSnackbar } from "notistack"; import { sendFC, sendResult } from "@/api/quizRelase"; import { isProduction } from "@/utils/defineDomain"; +import { useTranslation } from "react-i18next"; type ResultFormProps = { resultQuestion: QuizQuestionResult; @@ -35,6 +36,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => { const spec = settings.cfg.spec; const vkMetrics = useVkMetricsGoals(settings.cfg.vkMetricsNumber); const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber); + const { t } = useTranslation(); useEffect(() => { vkMetrics.resultIdShown(resultQuestion.id); @@ -55,7 +57,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => { const sessions = JSON.parse(localStorage.getItem("sessions") || "{}"); localStorage.setItem("sessions", JSON.stringify({ ...sessions, [quizId]: new Date().getTime() })); } catch (e) { - enqueueSnackbar("Заявка не может быть отправлена"); + enqueueSnackbar(t("The request could not be sent")); } } if (Boolean(settings.cfg.score)) { @@ -70,7 +72,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => { const sessions = JSON.parse(localStorage.getItem("sessions") || "{}"); localStorage.setItem("sessions", JSON.stringify({ ...sessions, [quizId]: new Date().getTime() })); } catch (e) { - enqueueSnackbar("Количество баллов не может быть отправлено"); + enqueueSnackbar(t("Количество баллов не может быть отправлено")); } } })(); @@ -154,7 +156,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => { wordBreak: "break-word", }} > - Ваш результат: + {t("Your result")}: { fontWeight: 600, }} > - Ваши баллы + {t("Your points")} { fontWeight: 600, }} > - {pointsSum} из {questions.filter((e) => e.type != "result").length} + {pointsSum} {t("of")} {questions.filter((e) => e.type != "result").length} { }, }} > - Посмотреть ответы + {t("View answers")} } sx={{ @@ -339,7 +341,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => { height: "50px", }} > - {resultQuestion.content.hint.text || "Узнать подробнее"} + {resultQuestion.content.hint.text || t("Find out more")} )} {settings.cfg.resultInfo.showResultForm === "after" && resultQuestion.content.redirect && ( @@ -361,7 +363,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => { width: "auto", }} > - {resultQuestion.content.hint.text || "Перейти на сайт"} + {resultQuestion.content.hint.text || t("Go to website")} )} diff --git a/lib/components/ViewPublicationPage/StartPageViewPublication/index.tsx b/lib/components/ViewPublicationPage/StartPageViewPublication/index.tsx index c3ad205..dd8db91 100644 --- a/lib/components/ViewPublicationPage/StartPageViewPublication/index.tsx +++ b/lib/components/ViewPublicationPage/StartPageViewPublication/index.tsx @@ -164,20 +164,6 @@ export const StartPageViewPublication = () => { : "#FFFFFF", }} /> - {/**/} - {/* Сделано на PenaQuiz*/} - {/**/} ); diff --git a/lib/components/ViewPublicationPage/questions/Date/DateRange.tsx b/lib/components/ViewPublicationPage/questions/Date/DateRange.tsx index ff2462b..6e3e30a 100644 --- a/lib/components/ViewPublicationPage/questions/Date/DateRange.tsx +++ b/lib/components/ViewPublicationPage/questions/Date/DateRange.tsx @@ -7,6 +7,7 @@ import type { Moment } from "moment"; import moment from "moment"; import { Box, Paper, TextField, useTheme } from "@mui/material"; import { useRootContainerSize } from "@/contexts/RootContainerWidthContext"; +import { useTranslation } from "react-i18next"; type DateProps = { currentQuestion: QuizQuestionDate; @@ -18,6 +19,7 @@ export default ({ currentQuestion }: DateProps) => { const isMobile = useRootContainerSize() < 690; const { settings } = useQuizSettings(); const { updateAnswer } = useQuizViewStore((state) => state); + const { t } = useTranslation(); const answers = useQuizViewStore((state) => state.answers); const answer = (answers.find(({ questionId }) => questionId === currentQuestion.id)?.answer as string) || ["0", "0"]; @@ -51,7 +53,7 @@ export default ({ currentQuestion }: DateProps) => { }} > - От + {t("From")} { /> - До + {t("До")} state.answers); const { updateAnswer, deleteAnswer } = useQuizViewStore((state) => state); const theme = useTheme(); + const { t } = useTranslation(); const onVariantClick = async (event: MouseEvent) => { event.preventDefault(); @@ -181,7 +183,7 @@ export const EmojiVariant = ({ pl: "15px", }} > - Введите свой ответ + {t("Enter your answer")} )} (false); const theme = useTheme(); + const { t } = useTranslation(); const answers = useQuizViewStore((state) => state.answers); const { updateAnswer } = useQuizViewStore((state) => state); const isMobile = useRootContainerSize() < 500; @@ -68,7 +70,7 @@ export const UploadFile = ({ currentQuestion, setModalWarningType, isSending, se updateAnswer(currentQuestion.id, `${file.name}|${URL.createObjectURL(file)}`, 0); } catch (error) { console.error(error); - enqueueSnackbar("ответ не был засчитан"); + enqueueSnackbar(t("The answer was not counted")); } setIsSending(false); diff --git a/lib/components/ViewPublicationPage/questions/File/UploadedFile.tsx b/lib/components/ViewPublicationPage/questions/File/UploadedFile.tsx index ca6b63a..3c08d31 100644 --- a/lib/components/ViewPublicationPage/questions/File/UploadedFile.tsx +++ b/lib/components/ViewPublicationPage/questions/File/UploadedFile.tsx @@ -7,6 +7,7 @@ import { useQuizViewStore } from "@stores/quizView"; import CloseBold from "@icons/CloseBold"; import type { QuizQuestionFile } from "@model/questionTypes/file"; +import { useTranslation } from "react-i18next"; type UploadedFileProps = { currentQuestion: QuizQuestionFile; @@ -18,6 +19,7 @@ export const UploadedFile = ({ currentQuestion, setIsSending }: UploadedFileProp const answers = useQuizViewStore((state) => state.answers); const { updateAnswer } = useQuizViewStore((state) => state); const theme = useTheme(); + const { t } = useTranslation(); const answer = answers.find(({ questionId }) => questionId === currentQuestion.id)?.answer as string; @@ -39,7 +41,7 @@ export const UploadedFile = ({ currentQuestion, setIsSending }: UploadedFileProp return ( - Вы загрузили: + {t("You have uploaded")}: { }; const CurrentModal = ({ status }: { status: ModalWarningType }) => { + const { t } = useTranslation(); switch (status) { case null: return null; case "errorType": - return Выбран некорректный тип файла; + return {t("Incorrect file type selected")}; case "errorSize": - return Файл слишком большой. Максимальный размер 50 МБ; + return {t("File is too big. Maximum size is 50 MB")}; default: return ( <> - Допустимые расширения файлов: + {t("Acceptable file extensions")}: {ACCEPT_SEND_FILE_TYPES_MAP[status].join(" ")} ); diff --git a/lib/components/ViewPublicationPage/questions/Images/ImageVariant.tsx b/lib/components/ViewPublicationPage/questions/Images/ImageVariant.tsx index 57b5f03..b25540e 100644 --- a/lib/components/ViewPublicationPage/questions/Images/ImageVariant.tsx +++ b/lib/components/ViewPublicationPage/questions/Images/ImageVariant.tsx @@ -8,6 +8,7 @@ import RadioIcon from "@ui_kit/RadioIcon"; import { quizThemes } from "@utils/themes/Publication/themePublication"; import { useMemo, type MouseEvent, useRef, useEffect } from "react"; import { useRootContainerSize } from "@contexts/RootContainerWidthContext"; +import { useTranslation } from "react-i18next"; type ImagesProps = { questionId: string; @@ -95,6 +96,7 @@ export const ImageVariant = ({ const { settings } = useQuizSettings(); const { deleteAnswer, updateAnswer } = useQuizViewStore((state) => state); const theme = useTheme(); + const { t } = useTranslation(); const answers = useQuizViewStore((state) => state.answers); const isMobile = useRootContainerSize() < 450; const isTablet = useRootContainerSize() < 850; @@ -204,7 +206,7 @@ export const ImageVariant = ({ pl: "15px", }} > - Введите свой ответ + {t("Enter your answer")} )} ; @@ -28,6 +29,7 @@ interface OwnInputProps { } const OwnInput = ({ questionId, variant, largeCheck, ownPlaceholder }: OwnInputProps) => { const theme = useTheme(); + const ownVariants = useQuizViewStore((state) => state.ownVariants); const { updateOwnVariant } = useQuizViewStore((state) => state); @@ -102,6 +104,7 @@ export const VariantItem = ({ const { settings } = useQuizSettings(); const theme = useTheme(); const { updateAnswer, deleteAnswer } = useQuizViewStore((state) => state); + const { t } = useTranslation(); const sendVariant = async (event: MouseEvent) => { event.preventDefault(); @@ -195,7 +198,7 @@ export const VariantItem = ({ top: "-23px", }} > - Введите свой ответ + {t("Enter your answer")} { const theme = useTheme(); - + const { t } = useTranslation(); const { settings } = useQuizSettings(); const { updateAnswer, deleteAnswer } = useQuizViewStore((state) => state); @@ -127,7 +128,7 @@ export const VarimgVariant = ({ pl: "15px", }} > - Введите свой ответ + {t("Enter your answer")} { const answers = useQuizViewStore((state) => state.answers); const ownVariants = useQuizViewStore((state) => state.ownVariants); const updateOwnVariant = useQuizViewStore((state) => state.updateOwnVariant); + const { t } = useTranslation(); const theme = useTheme(); const isMobile = useRootContainerSize() < 650; @@ -156,9 +158,9 @@ export const Varimg = ({ currentQuestion }: VarimgProps) => { ) : currentQuestion.content.replText !== " " && currentQuestion.content.replText.length > 0 ? ( currentQuestion.content.replText ) : variant?.extendedText || isMobile ? ( - "Выберите вариант ответа ниже" + t("Select an answer option below") ) : ( - "Выберите вариант ответа слева" + t("Select an answer option on the left") )} diff --git a/lib/components/ViewPublicationPage/tools/NextButton.tsx b/lib/components/ViewPublicationPage/tools/NextButton.tsx index 006c68d..f109ec0 100644 --- a/lib/components/ViewPublicationPage/tools/NextButton.tsx +++ b/lib/components/ViewPublicationPage/tools/NextButton.tsx @@ -1,6 +1,7 @@ import { useQuizSettings } from "@contexts/QuizDataContext"; import { Button } from "@mui/material"; import { quizThemes } from "@utils/themes/Publication/themePublication"; +import { useTranslation } from "react-i18next"; interface Props { isNextButtonEnabled: boolean; @@ -9,6 +10,7 @@ interface Props { export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }: Props) { const { settings } = useQuizSettings(); + const { t } = useTranslation(); return ( ); } diff --git a/lib/components/ViewPublicationPage/tools/PrevButton.tsx b/lib/components/ViewPublicationPage/tools/PrevButton.tsx index 80621e8..1f77569 100644 --- a/lib/components/ViewPublicationPage/tools/PrevButton.tsx +++ b/lib/components/ViewPublicationPage/tools/PrevButton.tsx @@ -2,6 +2,7 @@ import { Button, useTheme } from "@mui/material"; import { useRootContainerSize } from "../../../contexts/RootContainerWidthContext"; import { quizThemes } from "@utils/themes/Publication/themePublication"; import { useQuizSettings } from "@contexts/QuizDataContext"; +import { useTranslation } from "react-i18next"; interface Props { isPreviousButtonEnabled: boolean; @@ -12,6 +13,7 @@ export default function PrevButton({ isPreviousButtonEnabled, moveToPrevQuestion const theme = useTheme(); const { settings } = useQuizSettings(); const isMobileMini = useRootContainerSize() < 382; + const { t } = useTranslation(); return ( ); } diff --git a/lib/components/ViewPublicationPage/tools/fileUpload.ts b/lib/components/ViewPublicationPage/tools/fileUpload.ts index af24ccb..a96ac01 100644 --- a/lib/components/ViewPublicationPage/tools/fileUpload.ts +++ b/lib/components/ViewPublicationPage/tools/fileUpload.ts @@ -4,15 +4,15 @@ export const MAX_FILE_SIZE = 419430400; export const UPLOAD_FILE_DESCRIPTIONS_MAP = { picture: { - title: "Добавить изображение", - description: "Принимает изображения", + title: "Add image", + description: "Accepts images", }, video: { - title: "Добавить видео", - description: "Принимает .mp4 и .mov формат — максимум 50мб", + title: "Add video", + description: "Accepts .mp4 and .mov format - maximum 50mb", }, - audio: { title: "Добавить аудиофайл", description: "Принимает аудиофайлы" }, - document: { title: "Добавить документ", description: "Принимает документы" }, + audio: { title: "Add audio file", description: "Accepts audio files" }, + document: { title: "Add document", description: "Accepts documents" }, } as const satisfies Record; export const ACCEPT_SEND_FILE_TYPES_MAP = { diff --git a/package.json b/package.json index 3a18579..8d60a60 100755 --- a/package.json +++ b/package.json @@ -94,9 +94,13 @@ "country-flag-emoji-polyfill": "^0.1.8", "current-device": "^0.10.2", "hex-rgb": "^5.0.0", + "i18next": "^25.0.0", + "i18next-browser-languagedetector": "^8.0.5", + "i18next-http-backend": "^3.0.2", "mobile-detect": "^1.4.5", "mui-tel-input": "^5.1.2", "react-helmet-async": "^2.0.5", + "react-i18next": "^15.4.1", "react-imask": "^7.6.0", "react-phone-number-input": "^3.4.1" }, diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 0000000..9c2c720 --- /dev/null +++ b/public/locales/en.json @@ -0,0 +1,55 @@ +{ + "quiz is inactive": "Quiz is inactive", + "no questions found": "No questions found", + "quiz is empty": "Quiz is empty", + "quiz already completed": "You've already completed this quiz", + "no quiz id": "Missing quiz ID", + "quiz data is null": "No quiz parameters were provided", + "invalid request data": "This quiz doesn't exist", + "default message": "Something went wrong", + "The request could not be sent": "Request could not be sent", + "The number of points could not be sent": "Points could not be submitted", + "Your result": "Your result", + "Your points": "Your points", + "of": "of", + "View answers": "View answers", + "Find out more": "Learn more", + "Go to website": "Go to website", + "Question title": "Question title", + "Question without a title": "Untitled question", + "Your answer": "Your answer", + "Add image": "Add image", + "Accepts images": "Accepts images", + "Add video": "Add video", + "Accepts .mp4 and .mov format - maximum 50mb": "Accepts .mp4 and .mov format - maximum 50MB", + "Add audio file": "Add audio file", + "Accepts audio files": "Accepts audio files", + "Add document": "Add document", + "Accepts documents": "Accepts documents", + "Next": "Next", + "Prev": "Previous", + "From": "From", + "До": "To", + "Enter your answer": "Enter your answer", + "Incorrect file type selected": "Invalid file type selected", + "File is too big. Maximum size is 50 MB": "File is too large. Maximum size is 50 MB", + "Acceptable file extensions": "Allowed file extensions", + "You have uploaded": "You've uploaded", + "The answer was not counted": "Answer wasn't counted", + "Select an answer option below": "Select an answer option below", + "Select an answer option on the left": "Select an answer option on the left", + "Fill out the form to receive your test results": "Fill out the form to receive your test results", + "Enter": "Enter", + "Name": "Name", + "Phone number": "Phone number", + "Last name": "Last name", + "Address": "Address", + "Incorrect email entered": "Invalid email entered", + "Please fill in the fields": "Please fill in the fields", + "Please try again later": "Please try again later", + "Regulation on the processing of personal data": "Personal Data Processing Regulation", + "Privacy Policy": "Privacy Policy", + "familiarized": "acknowledged", + "and": "and", + "Get results": "Get results" +} diff --git a/public/locales/ru.json b/public/locales/ru.json new file mode 100644 index 0000000..0ca5e4c --- /dev/null +++ b/public/locales/ru.json @@ -0,0 +1,55 @@ +{ + "quiz is inactive": "Квиз не активирован", + "no questions found": "Нет созданных вопросов", + "quiz is empty": "Квиз пуст", + "quiz already completed": "Вы уже прошли этот опрос", + "no quiz id": "Отсутствует id квиза", + "quiz data is null": "Не были переданы параметры квиза", + "invalid request data": "Такого квиза не существует", + "default message": "Что-то пошло не так", + "The request could not be sent": "Заявка не может быть отправлена", + "The number of points could not be sent": "Количество баллов не может быть отправлено", + "Your result": "Ваш результат", + "Your points": "Ваши баллы", + "of": "из", + "View answers": "Посмотреть ответы", + "Find out more": "Узнать подробнее", + "Go to website": "Перейти на сайт", + "Question title": "Заголовок вопроса", + "Question without a title": "Вопрос без названия", + "Your answer": "Ваш ответ", + "Add image": "Добавить изображение", + "Accepts images": "Принимает изображения", + "Add video": "Добавить видео", + "Accepts .mp4 and .mov format - maximum 50mb": "Принимает .mp4 и .mov формат — максимум 50мб", + "Add audio file": "Добавить аудиофайл", + "Accepts audio files": "Принимает аудиофайлы", + "Add document": "Добавить документ", + "Accepts documents": "Принимает документы", + "Next": "Далее", + "Prev": "Назад", + "From": "От", + "До": "До", + "Enter your answer": "Введите свой ответ", + "Incorrect file type selected": "Выбран некорректный тип файла", + "File is too big. Maximum size is 50 MB": "Файл слишком большой. Максимальный размер 50 МБ", + "Acceptable file extensions": "Допустимые расширения файлов", + "You have uploaded": "Вы загрузили", + "The answer was not counted": "Ответ не был засчитан", + "Select an answer option below": "Выберите вариант ответа ниже", + "Select an answer option on the left": "Выберите вариант ответа слева", + "Fill out the form to receive your test results": "Заполните форму, чтобы получить результаты теста", + "Enter": "Введите", + "Name": "Имя", + "Phone number": "Номер телефона", + "Last name": "Фамилия", + "Address": "Адрес", + "Incorrect email entered": "Введена некорректная почта", + "Please fill in the fields": "Пожалуйста, заполните поля", + "Please try again later": "повторите попытку позже", + "Regulation on the processing of personal data": "Положением об обработке персональных данных", + "Privacy Policy": "Политикой конфиденциальности", + "familiarized": "ознакомлен", + "and": "и", + "Get results": "Получить результаты" +} diff --git a/public/locales/uz.json b/public/locales/uz.json new file mode 100644 index 0000000..2b41905 --- /dev/null +++ b/public/locales/uz.json @@ -0,0 +1,55 @@ +{ + "quiz is inactive": "Test faol emas", + "no questions found": "Savollar topilmadi", + "quiz is empty": "Test boʻsh", + "quiz already completed": "Siz bu testni allaqachon topshirgansiz", + "no quiz id": "Test IDsi yoʻq", + "quiz data is null": "Test parametrlari yuborilmagan", + "invalid request data": "Bunday test mavjud emas", + "default message": "Xatolik yuz berdi", + "The request could not be sent": "Soʻrov yuborib boʻlmadi", + "The number of points could not be sent": "Ballar yuborib boʻlmadi", + "Your result": "Sizning natijangiz", + "Your points": "Sizning ballaringiz", + "of": "/", + "View answers": "Javoblarni koʻrish", + "Find out more": "Batafsil maʼlumot", + "Go to website": "Veb-saytga oʻtish", + "Question title": "Savol sarlavhasi", + "Question without a title": "Sarlavhasiz savol", + "Your answer": "Sizning javobingiz", + "Add image": "Rasm qoʻshish", + "Accepts images": "Rasmlarni qabul qiladi", + "Add video": "Video qoʻshish", + "Accepts .mp4 and .mov format - maximum 50mb": ".mp4 va .mov formatlarini qabul qiladi - maksimal 50MB", + "Add audio file": "Audio fayl qoʻshish", + "Accepts audio files": "Audio fayllarni qabul qiladi", + "Add document": "Hujjat qoʻshish", + "Accepts documents": "Hujjatlarni qabul qiladi", + "Next": "Keyingi", + "Prev": "Oldingi", + "From": "Dan", + "До": "Gacha", + "Enter your answer": "Javobingizni kiriting", + "Incorrect file type selected": "Notoʻgʻri fayl turi tanlandi", + "File is too big. Maximum size is 50 MB": "Fayl juda katta. Maksimal hajmi 50 MB", + "Acceptable file extensions": "Qabul qilinadigan fayl kengaytmalari", + "You have uploaded": "Siz yuklagansiz", + "The answer was not counted": "Javob hisobga olinmadi", + "Select an answer option below": "Quyidagi javob variantlaridan birini tanlang", + "Select an answer option on the left": "Chapdagi javob variantlaridan birini tanlang", + "Fill out the form to receive your test results": "Test natijalaringizni olish uchun shaklni toʻldiring", + "Enter": "Kiriting", + "Name": "Ism", + "Phone number": "Telefon raqami", + "Last name": "Familiya", + "Address": "Manzil", + "Incorrect email entered": "Notoʻgʻri elektron pochta kiritildi", + "Please fill in the fields": "Iltimos, maydonlarni toʻldiring", + "Please try again later": "Iltimos, keyinroq urinib koʻring", + "Regulation on the processing of personal data": "Shaxsiy maʼlumotlarni qayta ishlash qoidalari", + "Privacy Policy": "Maxfiylik siyosati", + "familiarized": "tanishdim", + "and": "va", + "Get results": "Natijalarni olish" +} diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts new file mode 100644 index 0000000..d757bcf --- /dev/null +++ b/src/i18n/i18n.ts @@ -0,0 +1,63 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import Backend from "i18next-http-backend"; + +// 1. Функция для принудительного определения языка из URL +const getLanguageFromURL = (): string => { + const path = window.location.pathname; + + // Регулярка для /en/ /ru/ /uz/ в начале пути + const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i); + + if (langMatch) { + console.log("Язык из URL:", langMatch[1]); + return langMatch[1].toLowerCase(); + } + + console.log('Язык не указан в URL, используем "ru"'); + return "ru"; // Жёсткий фолбэк +}; + +// 2. Конфиг с полной отладкой +i18n + .use(Backend) + .use(initReactI18next) + .init({ + lng: getLanguageFromURL(), // Принудительно из URL + fallbackLng: "ru", + supportedLngs: ["en", "ru", "uz"], + debug: true, + interpolation: { + escapeValue: false, + }, + backend: { + loadPath: "/locales/{{lng}}.json", + allowMultiLoading: false, + }, + react: { + useSuspense: false, // Отключаем для совместимости с React 18 + }, + detection: { + order: ["path"], // Только из URL + lookupFromPathIndex: 0, + caches: [], // Не использовать localStorage + }, + }) + .then(() => { + console.log("i18n инициализирован! Текущий язык:", i18n.language); + console.log("Загруженные переводы:", i18n.store.data); + }) + .catch((err) => { + console.error("Ошибка i18n:", err); + }); + +// 3. Логирование всех событий +i18n.on("languageChanged", (lng) => { + console.log("Язык изменён на:", lng); +}); + +i18n.on("failedLoading", (lng, ns, msg) => { + console.error(`Ошибка загрузки ${lng}.json:`, msg); +}); + +export default i18n; diff --git a/src/main.tsx b/src/main.tsx index 197859c..ea1343f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,8 @@ import { createRoot } from "react-dom/client"; -import { RouteObject, RouterProvider, createBrowserRouter } from "react-router-dom"; +import { RouteObject, RouterProvider, createBrowserRouter, useParams } from "react-router-dom"; import App from "./App"; -import { StrictMode, lazy } from "react"; +import { StrictMode, lazy, useEffect } from "react"; +import "./i18n/i18n"; const routes: RouteObject[] = [ { diff --git a/tsconfig.json b/tsconfig.json index 4f90474..0748996 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,8 @@ "@api/*": ["./lib/api/*"], "@model/*": ["./lib/model/*"], "@utils/*": ["./lib/utils/*"], - "@contexts/*": ["./lib/contexts/*"] + "@contexts/*": ["./lib/contexts/*"], + "@locales/*": ["./lib/locales/*"] } }, "include": ["lib", "src"], diff --git a/vite.config.ts b/vite.config.ts index ee76c62..7ea4b17 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ export const alias = { "@model": resolve(__dirname, "./lib/model"), "@utils": resolve(__dirname, "./lib/utils"), "@contexts": resolve(__dirname, "./lib/contexts"), + "@locales": resolve(__dirname, "./lib/locales"), }; // https://vitejs.dev/config/ diff --git a/yarn.lock b/yarn.lock index 10fe9b2..78c6749 100644 --- a/yarn.lock +++ b/yarn.lock @@ -190,6 +190,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.25.0", "@babel/runtime@^7.26.10": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762" + integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.22.15", "@babel/template@^7.24.0": version "7.24.0" resolved "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz" @@ -1666,6 +1673,13 @@ country-flag-icons@^1.5.11: resolved "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.5.11.tgz" integrity sha512-B+mvFywunkRJs270k7kCBjhogvIA0uNn6GAXv6m2cPn3rrwqZzZVr2gBWcz+Cz7OGVWlcbERlYRIX0S6OGr8Bw== +cross-fetch@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983" + integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g== + dependencies: + node-fetch "^2.6.12" + cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" @@ -2435,6 +2449,13 @@ hoist-non-react-statics@^3.3.1: dependencies: react-is "^16.7.0" +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + http-signature@~1.3.6: version "1.3.6" resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz" @@ -2459,6 +2480,27 @@ husky@^9.0.11: resolved "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz" integrity sha512-AB6lFlbwwyIqMdHYhwPe+kjOC3Oc5P3nThEoW/AaO2BX3vJDjWPFxYLxokUZOo6RNX20He3AaT8sESs9NJcmEw== +i18next-browser-languagedetector@^8.0.5: + version "8.0.5" + resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.5.tgz#6cfdc72820457ce95e69a2788a4f837d1d8f4e9d" + integrity sha512-OstebRKqKiQw8xEvQF5aRyUujsCatanj7Q9eo5iiH2gJpoXGZ7483ol3sVBwfqbobTQPNH1J+NAyJ1aCQoEC+w== + dependencies: + "@babel/runtime" "^7.23.2" + +i18next-http-backend@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz#7c8daa31aa69679e155ec1f96a37846225bdf907" + integrity sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g== + dependencies: + cross-fetch "4.0.0" + +i18next@^25.0.0: + version "25.0.0" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.0.0.tgz#ae6b44dea48597e556a126c6f4f10d9c18cc62e2" + integrity sha512-POPvwjOPR1GQvRnbikTMPEhQD+ekd186MHE6NtVxl3Lby+gPp0iq60eCqGrY6wfRnp1lejjFNu0EKs1afA322w== + dependencies: + "@babel/runtime" "^7.26.10" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" @@ -3020,6 +3062,13 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-releases@^2.0.14: version "2.0.14" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz" @@ -3310,6 +3359,14 @@ react-helmet-async@^2.0.5: react-fast-compare "^3.2.2" shallowequal "^1.1.0" +react-i18next@^15.4.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.4.1.tgz#33f3e89c2f6c68e2bfcbf9aa59986ad42fe78758" + integrity sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw== + dependencies: + "@babel/runtime" "^7.25.0" + html-parse-stringify "^3.0.1" + react-imask@^7.6.0: version "7.6.0" resolved "https://registry.npmjs.org/react-imask/-/react-imask-7.6.0.tgz" @@ -3777,6 +3834,11 @@ tough-cookie@^4.1.3: universalify "^0.2.0" url-parse "^1.5.3" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + ts-api-utils@^1.0.1: version "1.3.0" resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz" @@ -3932,6 +3994,11 @@ vite@^5.0.8: optionalDependencies: fsevents "~2.3.3" +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + vue-template-compiler@^2.7.14: version "2.7.16" resolved "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz" @@ -3949,6 +4016,19 @@ vue-tsc@^1.8.27: "@vue/language-core" "1.8.27" semver "^7.5.4" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which@^2.0.1: version "2.0.2" resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"