i18n
Some checks failed
Deploy / CreateImage (push) Has been cancelled
Deploy / DeployService (push) Has been cancelled

This commit is contained in:
Nastya 2025-04-20 18:16:22 +03:00
parent 066e622420
commit 7243ae77f1
31 changed files with 416 additions and 85 deletions

@ -77,12 +77,12 @@ function QuizAnswererInner({
if (error) return <ApologyPage error={error} />;
// if (!data) return <LoadingSkeleton />;
quizSettings ??= data;
if (!quizSettings) return <ApologyPage error={new Error("Quiz data is null")} />;
if (!quizSettings) return <ApologyPage error={new Error("quiz data is null")} />;
if (quizSettings.questions.length === 1 && quizSettings?.settings.cfg.noStartPage)
return <ApologyPage error={new Error("Quiz is empty")} />;
// if (quizSettings.questions.length === 1) return <ApologyPage error={new Error("No questions found")} />;
if (!quizId) return <ApologyPage error={new Error("No quiz id")} />;
return <ApologyPage error={new Error("quiz is empty")} />;
// if (quizSettings.questions.length === 1) return <ApologyPage error={new Error("no questions found")} />;
if (!quizId) return <ApologyPage error={new Error("no quiz id")} />;
const quizContainer = (
<Box

@ -1,18 +1,12 @@
import { Box, Typography } from "@mui/material";
import { FallbackProps } from "react-error-boundary";
import { useTranslation } from "react-i18next";
type Props = Partial<FallbackProps>;
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 (
<Box
@ -30,7 +24,7 @@ export const ApologyPage = ({ error }: Props) => {
color: "text.primary",
}}
>
{message}
{t(message.toLowerCase())}
</Typography>
</Box>
);

@ -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")} `}
</Link>
&ensp;и&ensp;
&ensp;{t("and")}&ensp;
<Link
href={"https://shub.pena.digital/docs/privacy"}
target="_blank"
>
{" "}
Политикой конфиденциальности{" "}
{`${t("Privacy Policy")} `}
</Link>
&ensp;ознакомлен
&ensp;{t("familiarized")}
</Typography>
</Box>
@ -315,7 +317,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
},
}}
>
{settings.cfg.formContact?.button || "Получить результаты"}
{settings.cfg.formContact?.button || t("Get results")}
</Button>
</Box>
{show_badge && (

@ -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<ContactTextBlockProps> = ({ settings }) => {
const theme = useTheme();
const isMobile = useRootContainerSize() < 850;
const isTablet = useRootContainerSize() < 1000;
const { t } = useTranslation();
return (
<Box
sx={{
@ -45,7 +47,7 @@ export const ContactTextBlock: FC<ContactTextBlockProps> = ({ settings }) => {
wordBreak: "break-word",
}}
>
{settings.cfg.formContact.title || "Заполните форму, чтобы получить результаты теста"}
{settings.cfg.formContact.title || t("Fill out the form to receive your test results")}
</Typography>
{settings.cfg.formContact.desc && (
<Typography

@ -7,6 +7,7 @@ import { Dispatch, SetStateAction } from "react";
import { CustomInput } from "@/components/ViewPublicationPage/ContactForm/CustomInput/CustomInput.tsx";
import PhoneIcon from "@icons/ContactFormIcon/PhoneIcon.tsx";
import PhoneInput from "react-phone-number-input";
import { useTranslation } from "react-i18next";
type InputsProps = {
name: string;
@ -38,6 +39,7 @@ export const Inputs = ({
crutch,
}: InputsProps) => {
const { settings } = useQuizSettings();
const { t } = useTranslation();
const FC = settings.cfg.formContact.fields;
if (!FC) return null;
@ -45,8 +47,8 @@ export const Inputs = ({
<CustomInput
onChange={({ target }) => 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 = ({
<CustomInput
onChange={({ target }) => 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 = ({
<CustomInput
onChange={({ target }) => 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}
/>
);

@ -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 (
<Box
@ -41,7 +43,7 @@ export const Footer = ({ stepNumber, nextButton, prevButton }: FooterProps) => {
{stepNumber !== null && (
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ color: theme.palette.text.primary }}>
Вопрос {stepNumber} из {questionsAmount}
{t("Step")} {stepNumber} {t("of")} {questionsAmount}
</Typography>
<Stepper
activeStep={stepNumber}

@ -4,11 +4,13 @@ import { Box, Typography, useTheme } from "@mui/material";
import { useQuizSettings } from "@/contexts/QuizDataContext";
import { useQuizViewStore } from "@/stores/quizView";
import { AnyTypedQuizQuestion, QuizQuestionVariant } from "@/index";
import { useTranslation } from "react-i18next";
export const PointSystemResultList = () => {
const theme = useTheme();
const { questions } = useQuizSettings();
const answers = useQuizViewStore((state) => state.answers);
const { t } = useTranslation();
const questionsWothoutResult = questions.filter<QuizQuestionVariant>(
(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")}
</Typography>
</Box>
<Typography
@ -81,7 +83,7 @@ export const PointSystemResultList = () => {
color: theme.palette.grey[500],
}}
>
Ваш ответ:
{t("Your answer")}:
</Typography>
<Box
sx={{

@ -1,6 +1,7 @@
import { useQuizSettings } from "@/contexts/QuizDataContext";
import { AnyTypedQuizQuestion } from "@/model/questionTypes/shared";
import { Box, FormControl, MenuItem, Select as MuiSelect, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
interface Props {
selectedQuestion: AnyTypedQuizQuestion;
@ -10,6 +11,7 @@ interface Props {
export default function QuestionSelect({ selectedQuestion, setQuestion }: Props) {
const theme = useTheme();
const { questions, preview } = useQuizSettings();
const { t } = useTranslation();
if (!preview) return null;
@ -35,7 +37,7 @@ export default function QuestionSelect({ selectedQuestion, setQuestion }: Props)
id="category-select"
variant="outlined"
value={selectedQuestion.id}
placeholder="Заголовок вопроса"
placeholder={t("Question title")}
onChange={({ target }) => {
setQuestion(target.value);
}}

@ -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")}:
</Typography>
</Box>
<Box
@ -248,7 +250,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
fontWeight: 600,
}}
>
Ваши баллы
{t("Your points")}
</Typography>
<Typography
sx={{
@ -257,7 +259,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
fontWeight: 600,
}}
>
{pointsSum} из {questions.filter((e) => e.type != "result").length}
{pointsSum} {t("of")} {questions.filter((e) => e.type != "result").length}
</Typography>
<TextAccordion
headerText={
@ -269,7 +271,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
},
}}
>
Посмотреть ответы
{t("View answers")}
</Typography>
}
sx={{
@ -339,7 +341,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
height: "50px",
}}
>
{resultQuestion.content.hint.text || "Узнать подробнее"}
{resultQuestion.content.hint.text || t("Find out more")}
</Button>
)}
{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")}
</Button>
)}
</Box>

@ -164,20 +164,6 @@ export const StartPageViewPublication = () => {
: "#FFFFFF",
}}
/>
{/*<Typography*/}
{/* sx={{*/}
{/* fontSize: "13px",*/}
{/* color:*/}
{/* settings.cfg.startpageType === "expanded"*/}
{/* ? "#F5F7FF"*/}
{/* : quizThemes[settings.cfg.theme].isLight*/}
{/* ? "#4D4D4D"*/}
{/* : "#F5F7FF",*/}
{/* whiteSpace: "nowrap",*/}
{/* }}*/}
{/*>*/}
{/* Сделано на PenaQuiz*/}
{/*</Typography>*/}
</Box>
);

@ -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) => {
}}
>
<Box>
<span style={{ marginLeft: "25px", color: theme.palette.text.primary }}>От</span>
<span style={{ marginLeft: "25px", color: theme.palette.text.primary }}>{t("From")}</span>
<DateCalendar
sx={{
"& .MuiInputBase-root": {
@ -74,7 +76,7 @@ export default ({ currentQuestion }: DateProps) => {
/>
</Box>
<Box>
<span style={{ marginLeft: "25px", color: theme.palette.text.primary }}>До</span>
<span style={{ marginLeft: "25px", color: theme.palette.text.primary }}>{t("До")}</span>
<DateCalendar
minDate={today}
sx={{

@ -17,6 +17,7 @@ import RadioIcon from "@ui_kit/RadioIcon";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next";
polyfillCountryFlagEmojis();
@ -108,6 +109,7 @@ export const EmojiVariant = ({
const answers = useQuizViewStore((state) => state.answers);
const { updateAnswer, deleteAnswer } = useQuizViewStore((state) => state);
const theme = useTheme();
const { t } = useTranslation();
const onVariantClick = async (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
@ -181,7 +183,7 @@ export const EmojiVariant = ({
pl: "15px",
}}
>
Введите свой ответ
{t("Enter your answer")}
</Typography>
)}
<FormControlLabel

@ -18,6 +18,7 @@ import UploadIcon from "@icons/UploadIcon";
import type { QuizQuestionFile } from "@model/questionTypes/file";
import type { ModalWarningType } from "./index";
import { useTranslation } from "react-i18next";
type UploadFileProps = {
currentQuestion: QuizQuestionFile;
@ -30,6 +31,7 @@ export const UploadFile = ({ currentQuestion, setModalWarningType, isSending, se
const { quizId, preview } = useQuizSettings();
const [isDropzoneHighlighted, setIsDropzoneHighlighted] = useState<boolean>(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);

@ -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 (
<Box sx={{ display: "flex", alignItems: "center", gap: "15px" }}>
<Typography color={theme.palette.text.primary}>Вы загрузили:</Typography>
<Typography color={theme.palette.text.primary}>{t("You have uploaded")}:</Typography>
<Box
sx={{
padding: "5px 5px 5px 16px",

@ -10,6 +10,7 @@ import { useQuizViewStore } from "@stores/quizView";
import { ACCEPT_SEND_FILE_TYPES_MAP } from "@/components/ViewPublicationPage/tools/fileUpload";
import type { QuizQuestionFile } from "@model/questionTypes/file";
import { useTranslation } from "react-i18next";
export type ModalWarningType = "errorType" | "errorSize" | "picture" | "video" | "audio" | "document" | null;
@ -101,18 +102,19 @@ export const File = ({ currentQuestion }: FileProps) => {
};
const CurrentModal = ({ status }: { status: ModalWarningType }) => {
const { t } = useTranslation();
switch (status) {
case null:
return null;
case "errorType":
return <Typography>Выбран некорректный тип файла</Typography>;
return <Typography>{t("Incorrect file type selected")}</Typography>;
case "errorSize":
return <Typography>Файл слишком большой. Максимальный размер 50 МБ</Typography>;
return <Typography>{t("File is too big. Maximum size is 50 MB")}</Typography>;
default:
return (
<>
<Typography>Допустимые расширения файлов:</Typography>
<Typography>{t("Acceptable file extensions")}:</Typography>
<Typography>{ACCEPT_SEND_FILE_TYPES_MAP[status].join(" ")}</Typography>
</>
);

@ -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")}
</Typography>
)}
<FormControlLabel

@ -17,6 +17,7 @@ import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import type { FC, MouseEvent } from "react";
import { useTranslation } from "react-i18next";
const TextField = MuiTextField as unknown as FC<TextFieldProps>;
@ -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<HTMLLabelElement>) => {
event.preventDefault();
@ -195,7 +198,7 @@ export const VariantItem = ({
top: "-23px",
}}
>
Введите свой ответ
{t("Enter your answer")}
</Typography>
<OwnInput
questionId={questionId}

@ -16,6 +16,7 @@ import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { useDebouncedCallback } from "use-debounce";
type VarimgVariantProps = {
@ -103,7 +104,7 @@ export const VarimgVariant = ({
answer,
}: VarimgVariantProps) => {
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")}
</Typography>
<FormControlLabel

@ -10,6 +10,7 @@ import BlankImage from "@icons/BlankImage";
import type { QuizQuestionVarImg } from "@model/questionTypes/varimg";
import moment from "moment";
import { useTranslation } from "react-i18next";
type VarimgProps = {
currentQuestion: QuizQuestionVarImg;
@ -20,6 +21,7 @@ export const Varimg = ({ currentQuestion }: VarimgProps) => {
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")
)}
</Box>
</Box>

@ -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 (
<Button
@ -23,7 +25,7 @@ export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }:
}}
onClick={moveToNextQuestion}
>
Далее
{t("Next")}
</Button>
);
}

@ -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 (
<Button
disabled={!isPreviousButtonEnabled}
@ -35,7 +37,7 @@ export default function PrevButton({ isPreviousButtonEnabled, moveToPrevQuestion
}}
onClick={moveToPrevQuestion}
>
{isMobileMini ? "←" : "← Назад"}
{isMobileMini ? "←" : `${t("Prev")}`}
</Button>
);
}

@ -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<UploadFileType, { title: string; description: string }>;
export const ACCEPT_SEND_FILE_TYPES_MAP = {

@ -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"
},

55
public/locales/en.json Normal file

@ -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"
}

55
public/locales/ru.json Normal file

@ -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": "Получить результаты"
}

55
public/locales/uz.json Normal file

@ -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"
}

63
src/i18n/i18n.ts Normal file

@ -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;

@ -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[] = [
{

@ -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"],

@ -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/

@ -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"