Compare commits

..

22 Commits
est ... main

Author SHA1 Message Date
a532eecdca убираю спасибы 2025-08-18 16:12:35 +03:00
326e2c98b3 квизы умеют слушать запрет ..не пускать назад.. 2025-08-18 04:17:10 +03:00
b58042554f ci: production container deployment 2025-08-13 01:48:18 +03:00
147b776550 без логов 2025-08-13 00:56:46 +03:00
0c9b6e5b7a логи 2025-08-12 04:35:49 +03:00
785e56f9b0 fix шильдики отображают правильный цвет 2025-08-12 04:16:29 +03:00
f330c5c05a поле email при несуществовании не влияет на ФК 2025-08-11 17:29:00 +03:00
b1c3ab7314 fix inntr quiz id param 2025-08-09 23:57:33 +03:00
3baaef83fe deploy to docker 2025-08-09 16:36:54 +03:00
9e301e694f fix fields 2025-07-29 18:26:32 +03:00
f13df84fec ci: workaround branch error on registry deploy 2025-07-29 18:26:07 +03:00
4e2e591cd4 Merge branch 'staging' of gitea.pena:SQuiz/frontAnswerer into staging 2025-07-29 00:39:36 +03:00
670d2bcb3f fix lang in lib 2025-07-29 00:23:25 +03:00
c8ebf9cff0 Merge branch 'main' of gitea.pena:SQuiz/frontAnswerer 2025-07-29 00:18:46 +03:00
656ba9473c ФК кнопка отправки заблокирована пока не введут почту 2025-07-29 00:17:56 +03:00
17bd5cd53f ci: debug on pacjage deploy 2025-07-26 18:26:38 +03:00
b495974dba fix lang и номер телефона не даст отправить данные ФК если короче положенного 2025-07-26 01:48:51 +03:00
87f78ff8cf -- 2025-07-26 00:51:47 +03:00
c5e931702b ci: deploy on push to container registry 2025-07-25 15:12:05 +03:00
106d8b6ad7 fix config widget
Some checks failed
Deploy / DeployService (push) Successful in 24s
Deploy / CreateImage (push) Has been cancelled
2025-07-25 14:37:50 +03:00
32e5853ee8 передеплой
Some checks are pending
Deploy / CreateImage (push) Waiting to run
Deploy / DeployService (push) Successful in 24s
2025-07-25 13:55:22 +03:00
bcf2ca0842 fix lang in lib
All checks were successful
Deploy / CreateImage (push) Successful in 4m30s
Deploy / DeployService (push) Successful in 27s
2025-07-24 02:29:22 +03:00
68 changed files with 54084 additions and 345 deletions

@ -2,23 +2,29 @@ name: Deploy
run-name: ${{ gitea.actor }} build image and push to container registry run-name: ${{ gitea.actor }} build image and push to container registry
on: on:
push: registry_package:
branches: types: [published]
- "main" #package_name: "gitea.pena/squiz/frontanswerer/main:latest"
jobs: jobs:
CreateImage: # CreateImage:
runs-on: [skeris] # runs-on: [skeris]
uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p # uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
with: # with:
runner: skeris # runner: skeris
secrets: # secrets:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} # REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} # REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DeployService: DeployService:
if: contains(github.event.package.name, 'main')
runs-on: [frontprod] runs-on: [frontprod]
needs: CreateImage container:
uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/deploy.yml@v1.1.4-p7 image: gitea.pena/penadevops/container-images/node-compose:main
with: env:
runner: hubprod GITHUB_RUN_NUMBER: "${{ inputs.actionid }}"
actionid: ${{ gitea.run_id }} volumes:
- /run/user/1000/podman/podman.sock:/run/user/1000/podman/podman.sock
steps:
- name: Check out repository code
uses: http://gitea.pena/PenaDevops/actions.git/checkout@v1
- run: compose -f deployments/main/docker-compose.yaml up -d

@ -2,23 +2,28 @@ name: Deploy
run-name: ${{ gitea.actor }} build image and push to container registry run-name: ${{ gitea.actor }} build image and push to container registry
on: on:
push: registry_package:
branches: types: [published]
- "staging"
jobs: jobs:
CreateImage: # CreateImage:
runs-on: [skeris] # runs-on: [skeris]
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p # uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
with: # with:
runner: hubstaging # runner: hubstaging
secrets: # secrets:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} # REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} # REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DeployService: DeployService:
if: contains(github.event.package.name, 'staging')
runs-on: [frontstaging] runs-on: [frontstaging]
needs: CreateImage container:
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/deploy.yml@v1.1.4-p7 image: gitea.pena:3000/penadevops/container-images/node-compose:main
with: env:
runner: frontstaging GITHUB_RUN_NUMBER: "${{ inputs.actionid }}"
actionid: ${{ gitea.run_id }} volumes:
- /run/user/1000/podman/podman.sock:/run/user/1000/podman/podman.sock
steps:
- name: Check out repository code
uses: http://gitea.pena:3000/PenaDevops/actions.git/checkout@v1
- run: compose -f deployments/staging/docker-compose.yaml up -d

@ -2,6 +2,7 @@ services:
respondent: respondent:
container_name: respondent container_name: respondent
restart: unless-stopped restart: unless-stopped
image: gitea.pena/squiz/frontanswerer/main:$GITHUB_RUN_NUMBER image: gitea.pena/squiz/frontanswerer/main:latest
hostname: respondent hostname: respondent
tty: true tty: true
pull_policy: always

@ -2,9 +2,7 @@ services:
respondent: respondent:
container_name: respondent container_name: respondent
restart: unless-stopped restart: unless-stopped
labels: image: gitea.pena/squiz/frontanswerer/staging:latest
com.pena.domains: s.hbpn.link
com.pena.front_headers: "Access-Control-Allow-Origin $$http_origin always"
image: gitea.pena:3000/squiz/frontanswerer/staging:$GITHUB_RUN_NUMBER
hostname: respondent hostname: respondent
tty: true tty: true
pull_policy: always

@ -39,7 +39,6 @@ export function useQuizData(quizId: string, preview: boolean = false) {
}); });
setQuizData(firstData); setQuizData(firstData);
// Определяем нужно ли загружать все данные
if (!["ai"].includes(firstData.settings.status)) { if (!["ai"].includes(firstData.settings.status)) {
setNeedFullLoad(true); // Триггерит новый запрос через изменение ключа setNeedFullLoad(true); // Триггерит новый запрос через изменение ключа
return firstData; return firstData;
@ -57,7 +56,13 @@ export function useQuizData(quizId: string, preview: boolean = false) {
}); });
addQuestions(data.questions.slice(1)); addQuestions(data.questions.slice(1));
return data;
// Возвращаем полную структуру данных с настройками из store
const currentState = useQuizStore.getState();
return {
...currentState,
questions: [...currentState.questions, ...data.questions.slice(1)],
};
} }
if (currentPage >= questions.length) { if (currentPage >= questions.length) {
@ -71,7 +76,13 @@ export function useQuizData(quizId: string, preview: boolean = false) {
}); });
addQuestions(data.questions); addQuestions(data.questions);
changeNextLoading(false); changeNextLoading(false);
return data;
// Возвращаем полную структуру данных с настройками из store
const currentState = useQuizStore.getState();
return {
...currentState,
questions: [...currentState.questions, ...data.questions],
};
} catch (p) { } catch (p) {
setPage(questions.length); setPage(questions.length);
changeNextLoading(false); changeNextLoading(false);

File diff suppressed because one or more lines are too long

@ -22,6 +22,7 @@ import { HelmetProvider } from "react-helmet-async";
import "moment/dist/locale/ru"; import "moment/dist/locale/ru";
import { useQuizStore, setQuizData, addquizid } from "@/stores/useQuizStore"; import { useQuizStore, setQuizData, addquizid } from "@/stores/useQuizStore";
import { initDataManager, statusOfQuiz } from "@/utils/hooks/useQuestionFlowControl"; import { initDataManager, statusOfQuiz } from "@/utils/hooks/useQuestionFlowControl";
moment.locale("ru"); moment.locale("ru");
const localeText = ruRU.components.MuiLocalizationProvider.defaultProps.localeText; const localeText = ruRU.components.MuiLocalizationProvider.defaultProps.localeText;
@ -90,15 +91,26 @@ function QuizAnswererInner({
}; };
}, []); }, []);
if (isLoading && !questions.length) return <LoadingSkeleton />; if (isLoading && !questions.length) {
if (error) return <ApologyPage error={error} />; return <LoadingSkeleton />;
}
if (error) {
return <ApologyPage error={error} />;
}
if (Object.keys(settings).length == 0) return <ApologyPage error={new Error("quiz data is null")} />; if (Object.keys(settings).length == 0) {
if (questions.length === 0) return <ApologyPage error={new Error("No questions found")} />; return <ApologyPage error={new Error("quiz data is null")} />;
}
if (questions.length === 0) {
return <ApologyPage error={new Error("No questions found")} />;
}
if (questions.length === 1 && settings.cfg.noStartPage && statusOfQuiz != "ai") if (questions.length === 1 && settings.cfg.noStartPage && statusOfQuiz != "ai") {
return <ApologyPage error={new Error("quiz is empty")} />; return <ApologyPage error={new Error("quiz is empty")} />;
if (!quizId) return <ApologyPage error={new Error("no quiz id")} />; }
if (!quizId) {
return <ApologyPage error={new Error("no quiz id")} />;
}
const quizContainer = ( const quizContainer = (
<Box <Box

@ -0,0 +1,472 @@
import { useEffect, useRef, useState } from "react";
import { Box, Button, Link, Typography, useTheme } from "@mui/material";
import { enqueueSnackbar } from "notistack";
import CustomCheckbox from "@ui_kit/CustomCheckbox";
import { Inputs } from "@/components/ViewPublicationPage/ContactForm/Inputs/Inputs";
import { ContactTextBlock } from "./ContactTextBlock";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import { sendFC, SendFCParams } from "@api/quizRelase";
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
import { EMAIL_REGEXP } from "@utils/emailRegexp";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { DESIGN_LIST } from "@utils/designList";
import { NameplateLogo } from "@icons/NameplateLogo";
import type { FormContactFieldData, FormContactFieldName } from "@model/settingsData";
import type { QuizQuestionResult } from "@model/questionTypes/result";
import type { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import { isProduction } from "@/utils/defineDomain";
import { useQuizStore } from "@/stores/useQuizStore";
import { useTranslation } from "react-i18next";
import { NameplateLogoDark } from "@/assets/icons/NameplateLogoDark";
type Props = {
currentQuestion: AnyTypedQuizQuestion;
onShowResult: () => void;
};
//Костыль для особого квиза. Для него не нужно показывать email адрес
const isDisableEmail = window.location.pathname.includes("/377c7570-1bee-4320-ac1e-d731b6223ce8");
export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
const theme = useTheme();
const { settings, questions, quizId, show_badge, preview } = useQuizStore();
const [ready, setReady] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [text, setText] = useState("");
const [adress, setAdress] = useState("");
const [screenHeight, setScreenHeight] = useState<number>(window.innerHeight);
const [emailError, setEmailError] = useState("");
const [phoneError, setPhoneError] = useState("");
const fireOnce = useRef(true);
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);
useEffect(() => {
function handleResize() {
setScreenHeight(window.innerHeight);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const resultQuestion =
currentQuestion.type === "result"
? currentQuestion
: questions.find((question): question is QuizQuestionResult => {
if (settings?.cfg.haveRoot) {
return question.type === "result" && question.content.rule.parentId === currentQuestion.content.id;
} else {
return question.type === "result" && question.content.rule.parentId === "line";
}
});
if (!resultQuestion) throw new Error("Result question not found");
const inputHC = async () => {
const FC = settings.cfg.formContact.fields || settings.cfg.formContact;
const body: SendFCParams["body"] = {};
if (name.length > 0) body.name = name;
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 || t("Last name")]: text };
if (Object.keys(body).length > 0) {
try {
await sendFC({
questionId: currentQuestion.id,
body: body,
qid: quizId,
preview,
});
const sessions = JSON.parse(localStorage.getItem("sessions") || "{}");
localStorage.setItem("sessions", JSON.stringify({ ...sessions, [quizId]: new Date().getTime() }));
} catch (e) {
enqueueSnackbar(t("The answer was not counted"));
}
}
};
const FCcopy: Record<FormContactFieldName, FormContactFieldData> =
settings.cfg.formContact.fields || settings.cfg.formContact;
const filteredFC: Partial<Record<FormContactFieldName, FormContactFieldData>> = {};
for (const i in FCcopy) {
const field = FCcopy[i as keyof typeof FCcopy];
if (field.used) {
filteredFC[i as FormContactFieldName] = field;
}
}
async function handleShowResultsClick() {
const FC = settings.cfg.formContact.fields;
// Проверяем email только если поле отображается
if (isEmailFieldVisible && !EMAIL_REGEXP.test(email)) {
return enqueueSnackbar("Incorrect email entered");
}
if (fireOnce.current) {
// Проверяем, что хотя бы одно видимое поле заполнено
const hasVisibleFieldsFilled =
(isNameFieldVisible() && name.length > 0) ||
(isEmailFieldVisible && email.length > 0) ||
(isPhoneFieldVisible() && phone.length > 0) ||
(isTextFieldVisible() && text.length > 0) ||
(isAddressFieldVisible() && adress.length > 0);
if (!hasVisibleFieldsFilled) {
return enqueueSnackbar(t("Please fill in the fields"));
}
//почта валидна, хоть одно поле заполнено
setFire(true);
try {
await inputHC();
fireOnce.current = false;
const sessions = JSON.parse(localStorage.getItem("sessions") || "{}");
sessions[quizId] = Date.now();
localStorage.setItem("sessions", JSON.stringify(sessions));
vkMetrics.contactsFormFilled();
yandexMetrics.contactsFormFilled();
//Оповещаем какие поля были заполнены
if (name.length !== 0) {
vkMetrics.contactsFormField("name");
yandexMetrics.contactsFormField("name");
}
if (email.length !== 0) {
vkMetrics.contactsFormField("email");
yandexMetrics.contactsFormField("email");
}
if (phone.length !== 0) {
vkMetrics.contactsFormField("phone");
yandexMetrics.contactsFormField("phone");
}
if (text.length !== 0) {
vkMetrics.contactsFormField("text");
yandexMetrics.contactsFormField("text");
}
if (adress.length !== 0) {
vkMetrics.contactsFormField("address");
yandexMetrics.contactsFormField("address");
}
} catch (e) {
enqueueSnackbar(t("Please try again later"));
}
if (settings.cfg.resultInfo.showResultForm === "after") {
onShowResult();
}
enqueueSnackbar(t("Data sent successfully"));
}
setFire(false);
}
useEffect(() => {
vkMetrics.contactsFormOpened();
yandexMetrics.contactsFormOpened();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Функция валидации телефона
const validatePhone = (phoneValue: string) => {
// Убираем все нецифровые символы и считаем только цифры
const digitsOnly = phoneValue.replace(/\D/g, "");
// Для российских номеров (начинающихся с +7) нужно 11 цифр
// Для остальных стран - минимум 10 цифр
const isRussianNumber = phoneValue.startsWith("+7");
const minDigits = isRussianNumber ? 11 : 10;
// Если есть какие-то символы в инпуте, но цифр меньше минимума - это ошибка
if (phoneValue.trim().length > 0 && digitsOnly.length < minDigits) {
return t("Please complete the phone number");
}
return "";
};
// Проверяем валидность телефона при каждом изменении
const digitsOnly = phone.replace(/\D/g, "");
const isRussianNumber = phone.startsWith("+7");
const minDigits = isRussianNumber ? 11 : 10;
const isPhoneValid = phone.trim().length === 0 || digitsOnly.length >= minDigits;
// Проверяем валидность email - должен быть заполнен и соответствовать формату
const validateEmail = (emailValue: string) => {
if (emailValue.trim().length === 0) return false;
// Проверяем наличие @ и .
const atIndex = emailValue.indexOf("@");
const dotIndex = emailValue.lastIndexOf(".");
if (atIndex === -1 || dotIndex === -1) return false;
// Точка должна быть после @
if (dotIndex <= atIndex) return false;
// Между @ и . должно быть минимум 3 символа
const domainPart = emailValue.substring(atIndex + 1, dotIndex);
if (domainPart.length < 3) return false;
// После точки должно быть минимум 2 символа
const tldPart = emailValue.substring(dotIndex + 1);
if (tldPart.length < 2) return false;
return true;
};
const isEmailValid = validateEmail(email);
// Определяем, отображается ли поле email
const isEmailFieldVisible = settings.cfg.formContact.fields?.email?.used && !isDisableEmail;
// Функции для определения видимости полей
const isNameFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["name"].used : true;
};
const isPhoneFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["phone"].used : true;
};
const isTextFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["text"].used : false;
};
const isAddressFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["address"].used : false;
};
// Обработчик изменения телефона
const handlePhoneChange = (newPhone: string) => {
setPhone(newPhone);
// Очищаем ошибку если поле стало пустым
if (newPhone.trim().length === 0) {
setPhoneError("");
}
};
// Обработчик изменения email
const handleEmailChange = (newEmail: string) => {
setEmail(newEmail);
// Очищаем ошибку если поле стало пустым
if (newEmail.trim().length === 0) {
setEmailError("");
}
};
// Обработчик потери фокуса для email
const handleEmailBlur = () => {
if (email.trim().length > 0 && !validateEmail(email)) {
setEmailError(t("Please enter a valid email"));
} else {
setEmailError("");
}
};
// Обработчик потери фокуса для телефона
const handlePhoneBlur = () => {
if (phone.trim().length > 0 && !isPhoneValid) {
setPhoneError(t("Please enter a valid phone number"));
} else {
setPhoneError("");
}
};
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.palette.background.default,
height: screenHeight > 500 ? "100%" : "auto",
overflow: "auto",
"&::-webkit-scrollbar": {
width: "0",
display: "none",
msOverflowStyle: "none",
},
scrollbarWidth: "none",
msOverflowStyle: "none",
backgroundPosition: "center",
backgroundSize: "cover",
backgroundImage:
settings.cfg.design && !isMobile
? quizThemes[settings.cfg.theme].isLight
? `url(${DESIGN_LIST[settings.cfg.theme]})`
: `linear-gradient(90deg, rgba(39, 38, 38, 0.95) 7.66%, rgba(42, 42, 46, 0.85) 42.12%, rgba(51, 54, 71, 0.4) 100%), url(${
DESIGN_LIST[settings.cfg.theme]
})`
: null,
}}
>
<Box
sx={{
width: !isMobile ? "100%" : isMobile ? undefined : "530px",
borderRadius: "4px",
height: isMobile ? "100%" : "auto",
minHeight: "100%",
display: "flex",
flexDirection: isMobile ? "column" : "row",
background: settings.cfg.design && !isMobile ? undefined : theme.palette.background.default,
}}
>
<ContactTextBlock settings={settings} />
<Box
sx={{
flexGrow: isMobile ? 1 : 0,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexDirection: "column",
backgroundColor: theme.palette.background.default,
height: "auto",
}}
>
<Box
sx={{
display: "flex",
alignItems: isMobile ? undefined : "center",
justifyContent: "center",
flexDirection: "column",
p: isMobile ? "0 20px" : isTablet ? "105px 40px 0 60px" : "105px 60px 0 60px",
margin: isMobile ? "0" : "auto 0",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
mt: isMobile ? "10px" : "20px",
mb: "20px",
}}
>
<Inputs
name={name}
setName={setName}
email={email}
setEmail={handleEmailChange}
phone={phone}
setPhone={handlePhoneChange}
text={text}
setText={setText}
adress={adress}
setAdress={setAdress}
emailError={emailError}
phoneError={phoneError}
onEmailBlur={handleEmailBlur}
onPhoneBlur={handlePhoneBlur}
crutch={{
disableEmail: isDisableEmail,
}}
/>
</Box>
<Box
sx={{
display: "flex",
width: isMobile ? "300px" : "390px",
}}
>
<CustomCheckbox
label=""
handleChange={({ target }) => {
setReady(target.checked);
}}
checked={ready}
colorIcon={theme.palette.primary.main}
sx={{ marginRight: "0" }}
/>
<Typography
sx={{
color: theme.palette.text.primary,
lineHeight: "18.96px",
}}
fontSize={"16px"}
>
С&ensp;
<Link
href={"https://shub.pena.digital/ppdd"}
target="_blank"
>
{`${t("Regulation on the processing of personal data")} `}
</Link>
&ensp;{t("and")}&ensp;
<Link
href={"https://shub.pena.digital/docs/privacy"}
target="_blank"
>
{" "}
{`${t("Privacy Policy")} `}
</Link>
&ensp;{t("familiarized")}
</Typography>
</Box>
<Button
disabled={!(ready && !fire && isPhoneValid && (isEmailFieldVisible ? isEmailValid : true))}
variant="contained"
onClick={handleShowResultsClick}
sx={{
border: `1px solid ${theme.palette.primary.main}`,
margin: isMobile ? "auto" : undefined,
mt: "20px",
p: "10px 20px",
"&:disabled": {
border: "1px solid #9A9AAF",
color: "#9A9AAF",
},
}}
>
{settings.cfg.formContact?.button || t("Get results")}
</Button>
</Box>
{show_badge && (
<Box
component={Link}
target={"_blank"}
href={`https://${isProduction ? "" : "s"}quiz.pena.digital/answer/v1.0.0/logo?q=${quizId}`}
sx={{
display: "flex",
alignItems: "center",
mt: "55px",
mb: isMobile ? "30px" : isTablet ? "40px" : "50px",
gap: "10px",
textDecoration: "none",
margitTop: "auto",
}}
>
{quizThemes[settings.cfg.theme].isLight ? <NameplateLogoDark /> : <NameplateLogo />}
</Box>
)}
</Box>
</Box>
</Box>
);
};

@ -0,0 +1,67 @@
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;
};
export const ContactTextBlock: FC<ContactTextBlockProps> = ({ settings }) => {
const theme = useTheme();
const isMobile = useRootContainerSize() < 850;
const isTablet = useRootContainerSize() < 1000;
const { t } = useTranslation();
return (
<Box
sx={{
flexGrow: isMobile ? 0 : 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
borderRight: isMobile ? undefined : "1px solid #9A9AAF80",
margin: isMobile ? 0 : "40px 0",
padding: isMobile ? "0" : "0 40px",
}}
>
<Box
sx={{
maxWidth: isMobile ? "100%" : isTablet ? "410px" : "630px",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "center",
padding: isMobile ? "40px 20px 0 20px" : "0",
mt: isMobile ? 0 : isTablet ? "-180px" : "-47px",
}}
>
<Typography
sx={{
textAlign: isTablet ? undefined : "center",
fontSize: "24px",
lineHeight: "normal",
fontWeight: 501,
color: theme.palette.text.primary,
wordBreak: "break-word",
}}
>
{settings.cfg.formContact.title || t("Fill out the form to receive your test results")}
</Typography>
{settings.cfg.formContact.desc && (
<Typography
sx={{
color: theme.palette.text.primary,
m: "20px 0",
fontSize: "18px",
wordBreak: "break-word",
}}
>
{settings.cfg.formContact.desc}
</Typography>
)}
</Box>
</Box>
);
};

@ -0,0 +1,66 @@
import { MenuItem, Select, SelectChangeEvent, useTheme } from "@mui/material";
import { Dispatch, FC, SetStateAction, useState } from "react";
import { phoneMasksByCountry } from "@utils/phoneMasksByCountry.tsx";
import { Value } from "react-phone-number-input";
type CountrySelectorProps = {
setMask: Dispatch<SetStateAction<string>>;
};
export const CountrySelector: FC<CountrySelectorProps> = ({ setMask }) => {
const theme = useTheme();
const [country, setCountry] = useState("RU");
const handleChange = (e: SelectChangeEvent<Value>) => {
setCountry(e.target.value);
setMask(phoneMasksByCountry[e.target.value][1]);
};
return (
<Select
//@ts-ignore
value={country}
onChange={handleChange}
renderValue={(value) => value}
// autoComplete={true}
MenuProps={{
PaperProps: {
style: {
backgroundColor: theme.palette.background.default,
borderRadius: "12px",
scrollbarWidth: "none",
},
},
}}
sx={{
minWidth: 50,
backgroundColor: theme.palette.background.default,
"& .MuiSelect-select": {
paddingLeft: "5px",
paddingRight: "5px",
color: "gray",
fontSize: "12px",
border: "none",
},
"& .MuiOutlinedInput-notchedOutline": {
border: "none",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
border: "none",
},
"&:hover:before": {
border: "none",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
border: "none",
},
"&.Mui-focused:hover .MuiOutlinedInput-notchedOutline": {
border: "none",
},
}}
>
{Object.keys(phoneMasksByCountry).map((countryCode) => {
return <MenuItem value={countryCode}>{phoneMasksByCountry[countryCode][0]}</MenuItem>;
})}
</Select>
);
};

@ -0,0 +1,115 @@
import { Box, InputAdornment, TextField as MuiTextField, TextFieldProps, Typography, useTheme } from "@mui/material";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext.ts";
import { useIMask, IMask } from "react-imask";
import { quizThemes } from "@utils/themes/Publication/themePublication.ts";
import { ChangeEvent, FC, HTMLInputTypeAttribute, useEffect, useState } from "react";
import { CountrySelector } from "@/components/ViewPublicationPage/ContactForm/CustomInput/CountrySelector/CountrySelector.tsx";
import { phoneMasksByCountry } from "@utils/phoneMasksByCountry.tsx";
import { useQuizStore } from "@/stores/useQuizStore";
type InputProps = {
title: string;
desc: string;
Icon: FC<{ color: string; backgroundColor: string }>;
onChange: TextFieldProps["onChange"];
onChangePhone?: (phone: string) => void;
id: string;
isPhone?: boolean;
type?: HTMLInputTypeAttribute;
value?: string;
onBlur?: () => void;
error?: string;
};
const TextField = MuiTextField as unknown as FC<TextFieldProps>;
let first = true;
function phoneChange(e: ChangeEvent<HTMLInputElement>, mask: string) {
const masked = IMask.createMask({
mask: "+7 (000) 000-00-00",
// ...and other options
});
masked.value = e.target.value;
const a = IMask.pipe(e.target.value, {
mask,
});
return a || "";
}
export const CustomInput = ({
title,
desc,
Icon,
onChange,
onChangePhone,
isPhone,
type,
value,
onBlur,
error,
}: InputProps) => {
const theme = useTheme();
const isMobile = useRootContainerSize() < 600;
const { settings } = useQuizStore();
const [mask, setMask] = useState(phoneMasksByCountry["RU"][1]);
// const { ref } = useIMask({ mask });
return (
<Box m="10px 0">
<Typography
mb="7px"
color={theme.palette.text.primary}
fontSize={"16px"}
>
{title}
</Typography>
<TextField
// inputRef={isPhone ? ref : null}
//@ts-ignore
onChange={(e: ChangeEvent<HTMLInputElement>) =>
isPhone ? onChangePhone?.(phoneChange(e, mask)) : onChange?.(e)
}
onBlur={onBlur}
type={isPhone ? "tel" : type}
value={value}
error={!!error}
helperText={error}
sx={{
width: isMobile ? "100%" : "390px",
backgroundColor: theme.palette.background.default,
fontSize: "16px",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "#9A9AAF80",
borderRadius: "12px",
},
"& .MuiInputBase-root": {
paddingLeft: 0,
},
"& .MuiOutlinedInput-input": {
paddingLeft: "10px",
},
"& .MuiOutlinedInput-root": {
"&:hover fieldset": {
borderColor: theme.palette.primary.main,
},
},
}}
placeholder={desc}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Icon
color="gray"
backgroundColor={quizThemes[settings.cfg.theme].isLight ? "#F2F3F7" : "#F2F3F71A"}
/>
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">{isPhone && <CountrySelector setMask={setMask} />}</InputAdornment>
),
}}
/>
</Box>
);
};

@ -0,0 +1,138 @@
import NameIcon from "@icons/ContactFormIcon/NameIcon.tsx";
import EmailIcon from "@icons/ContactFormIcon/EmailIcon.tsx";
import TextIcon from "@icons/ContactFormIcon/TextIcon.tsx";
import AddressIcon from "@icons/ContactFormIcon/AddressIcon.tsx";
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 { useQuizStore } from "@/stores/useQuizStore";
import { useTranslation } from "react-i18next";
type InputsProps = {
name: string;
setName: Dispatch<SetStateAction<string>>;
email: string;
setEmail: (email: string) => void;
phone: string;
setPhone: (phone: string) => void;
text: string;
setText: Dispatch<SetStateAction<string>>;
adress: string;
setAdress: Dispatch<SetStateAction<string>>;
emailError?: string;
phoneError?: string;
onEmailBlur?: () => void;
onPhoneBlur?: () => void;
crutch: {
disableEmail: boolean;
};
};
const iscrutch = "/cc006b40-ccbd-4600-a1d3-f902f85aa0a0";
const pathOnly = window.location.pathname;
export const Inputs = ({
name,
setName,
email,
setEmail,
phone,
setPhone,
text,
setText,
adress,
setAdress,
emailError,
phoneError,
onEmailBlur,
onPhoneBlur,
crutch,
}: InputsProps) => {
const { settings } = useQuizStore();
const { t } = useTranslation();
const FC = settings.cfg.formContact.fields;
if (!FC) return null;
const Name = (
<CustomInput
onChange={({ target }) => setName(target.value)}
id={name}
title={
pathOnly === iscrutch
? "Введите имя и фамилию"
: FC["name"].innerText || `${t("Enter")} ${t("Name").toLowerCase()}`
}
desc={FC["name"].text || t("Name")}
Icon={NameIcon}
/>
);
const Email = (
<CustomInput
onChange={({ target }) => {
setEmail(target.value.replaceAll(/\s/g, ""));
}}
onBlur={onEmailBlur}
id={email}
title={FC["email"].innerText || `${t("Enter")} Email`}
desc={FC["email"].text || "Email"}
Icon={EmailIcon}
type="email"
error={emailError}
/>
);
const Phone = (
<CustomInput
onChange={({ target }) => setText(target.value)}
onChangePhone={(phone: string) => {
setPhone(phone);
}}
onBlur={onPhoneBlur}
value={phone}
id={phone}
title={FC["phone"].innerText || `${t("Enter")} ${t("Phone number").toLowerCase()}`}
desc={FC["phone"].text || t("Phone number")}
Icon={PhoneIcon}
isPhone={true}
error={phoneError}
/>
);
const Text = (
<CustomInput
onChange={({ target }) => setText(target.value)}
id={text}
title={FC["text"].text || `${t("Enter")} ${t("Last name").toLowerCase()}`}
desc={FC["text"].innerText || t("Last name")}
Icon={TextIcon}
/>
);
const Adress = (
<CustomInput
onChange={({ target }) => setAdress(target.value)}
id={adress}
title={FC["address"].innerText || `${t("Enter")} ${t("Address").toLowerCase()}`}
desc={FC["address"].text || t("Address")}
Icon={AddressIcon}
/>
);
if (Object.values(FC).some((data) => data.used)) {
return (
<>
{FC["name"].used ? Name : <></>}
{FC["email"].used && !crutch.disableEmail ? Email : <></>}
{FC["phone"].used ? Phone : <></>}
{FC["text"].used ? Text : <></>}
{FC["address"].used ? Adress : <></>}
</>
);
} else {
return (
<>
{Name}
{Email}
{Phone}
</>
);
}
};

@ -15,7 +15,7 @@ export const Footer = ({ stepNumber, nextButton, prevButton }: FooterProps) => {
const theme = useTheme(); const theme = useTheme();
const { questions, settings } = useQuizStore(); const { questions, settings } = useQuizStore();
const questionsAmount = questions.filter(({ type }) => type !== "result").length; const questionsAmount = questions.filter(({ type }) => type !== "result").length;
const { t } = useTranslation(); const { t, i18n } = useTranslation();
return ( return (
<Box <Box

@ -0,0 +1,166 @@
import { IncorrectAnswer } from "@/assets/icons/IncorrectAnswer";
import { CorrectAnswer } from "@/assets/icons/CorrectAnswer";
import { Box, Typography, useTheme } from "@mui/material";
import { useQuizViewStore } from "@/stores/quizView";
import { AnyTypedQuizQuestion, QuizQuestionVariant } from "@/index";
import { useTranslation } from "react-i18next";
import { useQuizStore } from "@/stores/useQuizStore";
const dinocrutch = window.location.pathname === "/413b9e24-996a-400e-9076-c158f64b9bd7";
// Функция для определения вопроса "спасибо"
const isThankYouQuestion = (question: QuizQuestionVariant): boolean => {
// Проверяем что у вопроса только один вариант ответа
if (question.content.variants.length !== 1) {
return false;
}
// Проверяем что текст варианта полностью состоит из слова "спасибо"
const variant = question.content.variants[0];
const answerText = variant.answer.toLowerCase().trim();
// Проверяем точное совпадение со словом "спасибо"
return answerText === "спасибо";
};
export const PointSystemResultList = () => {
const theme = useTheme();
const { questions } = useQuizStore();
const answers = useQuizViewStore((state) => state.answers);
const { t } = useTranslation();
const questionsWothoutResult = questions.filter<QuizQuestionVariant>(
(q: AnyTypedQuizQuestion): q is QuizQuestionVariant => q.type === "variant"
);
// Фильтруем вопросы "спасибо" только для указанного квиза
const filteredQuestions = dinocrutch
? questionsWothoutResult.filter((q) => !isThankYouQuestion(q))
: questionsWothoutResult;
return filteredQuestions.map((currentQuestion, index) => {
let answerIndex = 0;
let currentVariants = currentQuestion.content.variants;
const currentAnswer = answers.find((a) => a.questionId === currentQuestion.id);
const answeredVariant = currentVariants.find((v, i) => {
if (v.id === currentAnswer?.answer) {
answerIndex = i;
return true;
}
});
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Box
sx={{
display: "inline-flex",
justifyContent: "space-between",
width: "100%",
}}
>
<Box
sx={{
display: "inline-flex",
gap: "16px",
}}
>
<Typography
sx={{
color: theme.palette.grey[500],
}}
>
{index + 1}.
</Typography>
<Typography
sx={{
color: theme.palette.text.primary,
}}
>
{currentQuestion.title || t("Question without a title")}
</Typography>
</Box>
<Typography
sx={{
color: answeredVariant?.points ? theme.palette.primary.main : theme.palette.grey[500],
}}
>
{answeredVariant?.points || "0"}
</Typography>
</Box>
<Box
sx={{
display: "inline-flex",
mt: "15px",
gap: "10px",
}}
>
<Typography
sx={{
color: theme.palette.grey[500],
}}
>
{t("Your answer")}:
</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Line
checkTrue={Boolean(answeredVariant?.points)}
text={answeredVariant?.answer}
/>
{/* {Boolean(answeredVariant?.points) ? <CorrectAnswer /> : <IncorrectAnswer />}
<Typography>{answeredVariant?.answer || "не выбрано"}</Typography> */}
{currentVariants.map((v) => {
if (v.id === currentAnswer?.answer) {
return <></>;
} else
return (
<Line
checkTrue={Boolean(v?.points)}
text={v.answer}
/>
);
})}
</Box>
</Box>
</Box>
);
});
};
interface LineProps {
checkTrue: boolean;
text?: string;
}
const Line = ({ checkTrue, text }: LineProps) => {
const theme = useTheme();
return (
<Box
sx={{
display: "inline-flex",
gap: "10px",
mb: "10px",
}}
>
{checkTrue ? <CorrectAnswer /> : <IncorrectAnswer />}
<Typography
sx={{
color: theme.palette.grey[500],
}}
>
{text || "не выбрано"}
</Typography>
</Box>
);
};

@ -1,12 +1,23 @@
import { Box, Link, useTheme } from "@mui/material"; import { Box, Link, useTheme } from "@mui/material";
import { Footer } from "./Footer"; import { Footer } from "./Footer";
import { Date } from "./questions/Date";
import { Emoji } from "./questions/Emoji";
import { File } from "./questions/File";
import { Images } from "./questions/Images";
import { Number } from "./questions/Number";
import { Page } from "./questions/Page";
import { Rating } from "./questions/Rating";
import { Select } from "./questions/Select";
import { Text } from "./questions/Text"; import { Text } from "./questions/Text";
import { Variant } from "./questions/Variant";
import { Varimg } from "./questions/Varimg";
import type { RealTypedQuizQuestion } from "../../model/questionTypes/shared"; import type { RealTypedQuizQuestion } from "../../model/questionTypes/shared";
import { NameplateLogoFQ } from "@icons/NameplateLogoFQ"; import { NameplateLogoFQ } from "@icons/NameplateLogoFQ";
import { NameplateLogoFQDark } from "@icons/NameplateLogoFQDark"; import { NameplateLogoFQDark } from "@icons/NameplateLogoFQDark";
import { notReachable } from "@utils/notReachable";
import { quizThemes } from "@utils/themes/Publication/themePublication"; import { quizThemes } from "@utils/themes/Publication/themePublication";
import { DESIGN_LIST } from "@/utils/designList"; import { DESIGN_LIST } from "@/utils/designList";
@ -77,8 +88,9 @@ export const Question = ({
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<Text <QuestionByType
currentQuestion={currentQuestion} key={currentQuestion.id}
question={currentQuestion}
stepNumber={currentQuestionStepNumber} stepNumber={currentQuestionStepNumber}
/> />
{show_badge && ( {show_badge && (
@ -121,3 +133,37 @@ export const Question = ({
</Box> </Box>
); );
}; };
function QuestionByType({ question, stepNumber }: { question: RealTypedQuizQuestion; stepNumber: number | null }) {
switch (question.type) {
case "variant":
return <Variant currentQuestion={question} />;
case "images":
return <Images currentQuestion={question} />;
case "varimg":
return <Varimg currentQuestion={question} />;
case "emoji":
return <Emoji currentQuestion={question} />;
case "text":
return (
<Text
currentQuestion={question}
stepNumber={stepNumber}
/>
);
case "select":
return <Select currentQuestion={question} />;
case "date":
return <Date currentQuestion={question} />;
case "number":
return <Number currentQuestion={question} />;
case "file":
return <File currentQuestion={question} />;
case "page":
return <Page currentQuestion={question} />;
case "rating":
return <Rating currentQuestion={question} />;
default:
notReachable(question);
}
}

@ -12,17 +12,36 @@ import { quizThemes } from "@utils/themes/Publication/themePublication";
import { NameplateLogo } from "@icons/NameplateLogo"; import { NameplateLogo } from "@icons/NameplateLogo";
import type { QuizQuestionResult } from "@/model/questionTypes/result"; import type { QuizQuestionResult } from "@/model/questionTypes/result";
import type { QuizQuestionVariant } from "@/model/questionTypes/variant";
import QuizVideo from "@/ui_kit/VideoIframe/VideoIframe"; import QuizVideo from "@/ui_kit/VideoIframe/VideoIframe";
import { TextAccordion } from "./tools/TextAccordion";
import { PointSystemResultList } from "./PointSystemResultList";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { sendFC, sendResult } from "@/api/quizRelase"; import { sendFC, sendResult } from "@/api/quizRelase";
import { isProduction } from "@/utils/defineDomain"; import { isProduction } from "@/utils/defineDomain";
import { useQuizStore } from "@/stores/useQuizStore"; import { useQuizStore } from "@/stores/useQuizStore";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NameplateLogoDark } from "@/assets/icons/NameplateLogoDark";
type ResultFormProps = { type ResultFormProps = {
resultQuestion: QuizQuestionResult; resultQuestion: QuizQuestionResult;
}; };
// Функция для определения вопроса "спасибо"
const isThankYouQuestion = (question: QuizQuestionVariant): boolean => {
// Проверяем что у вопроса только один вариант ответа
if (question.content.variants.length !== 1) {
return false;
}
// Проверяем что текст варианта полностью состоит из слова "спасибо"
const variant = question.content.variants[0];
const answerText = variant.answer.toLowerCase().trim();
// Проверяем точное совпадение со словом "спасибо"
return answerText === "спасибо";
};
export const ResultForm = ({ resultQuestion }: ResultFormProps) => { export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useRootContainerSize() < 650; const isMobile = useRootContainerSize() < 650;
@ -36,6 +55,22 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber); const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber);
const { t } = useTranslation(); const { t } = useTranslation();
// Проверяем, является ли это квизом с костылем
const dinocrutch = window.location.pathname === "/413b9e24-996a-400e-9076-c158f64b9bd7";
// Вычисляем общее количество вопросов с учетом фильтрации
const totalQuestions = useMemo(() => {
if (dinocrutch) {
// Для квиза с костылем: исключаем вопросы "спасибо" и вопросы типа "result"
const variantQuestions = questions.filter((e) => e.type === "variant") as QuizQuestionVariant[];
const filteredQuestions = variantQuestions.filter((q) => !isThankYouQuestion(q));
return filteredQuestions.length;
}
// Для обычных квизов: исключаем только вопросы типа "result"
return questions.filter((e) => e.type !== "result").length;
}, [questions, dinocrutch]);
useEffect(() => { useEffect(() => {
vkMetrics.resultIdShown(resultQuestion.id); vkMetrics.resultIdShown(resultQuestion.id);
yandexMetrics.resultIdShown(resultQuestion.id); yandexMetrics.resultIdShown(resultQuestion.id);
@ -238,6 +273,55 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
{resultQuestion.content.text} {resultQuestion.content.text}
</Typography> </Typography>
)} )}
{settings.cfg?.score && (
<>
<Typography
sx={{
color: theme.palette.primary.main,
fontSize: "30px",
m: "30px 0",
fontWeight: 600,
}}
>
{t("Your points")}
</Typography>
<Typography
sx={{
color: theme.palette.primary.main,
fontSize: "30px",
fontWeight: 600,
}}
>
{pointsSum} {t("of")} {totalQuestions}
</Typography>
<TextAccordion
headerText={
<Typography
sx={{
color: theme.palette.primary.main,
"&:hover": {
color: theme.palette.primary.dark,
},
}}
>
{t("View answers")}
</Typography>
}
sx={{
mt: "60px",
width: "100%",
}}
>
<Box
sx={{
mt: "25px",
}}
>
<PointSystemResultList />
</Box>
</TextAccordion>
</>
)}
</Box> </Box>
</Box> </Box>
{show_badge && ( {show_badge && (
@ -257,12 +341,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
bottom: "90px", bottom: "90px",
}} }}
> >
<NameplateLogo {quizThemes[settings.cfg.theme].isLight ? <NameplateLogoDark /> : <NameplateLogo />}
style={{
fontSize: "23px",
color: quizThemes[settings.cfg.theme].isLight ? "#000000" : "#F5F7FF",
}}
/>
</Box> </Box>
)} )}
<Box <Box

@ -0,0 +1,41 @@
import { StartPageDesktop } from "./StartPageDesktop";
import { StartPageMobile } from "./StartPageMobile";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import type { QuizStartpageAlignType, QuizStartpageType } from "@model/settingsData";
type QuizPreviewLayoutByTypeProps = {
quizHeaderBlock: JSX.Element;
quizMainBlock: JSX.Element;
backgroundBlock: JSX.Element | null;
startpageType: QuizStartpageType;
alignType: QuizStartpageAlignType;
};
export const QuizPreviewLayoutByType = ({
quizHeaderBlock,
quizMainBlock,
backgroundBlock,
startpageType,
alignType,
}: QuizPreviewLayoutByTypeProps) => {
const isMobile = useRootContainerSize() < 700;
return isMobile ? (
<StartPageMobile
quizHeaderBlock={quizHeaderBlock}
quizMainBlock={quizMainBlock}
backgroundBlock={backgroundBlock}
startpageType={startpageType}
/>
) : (
<StartPageDesktop
alignType={alignType}
startpageType={startpageType}
quizHeaderBlock={quizHeaderBlock}
quizMainBlock={quizMainBlock}
backgroundBlock={backgroundBlock}
/>
);
};

@ -0,0 +1,263 @@
import { Box } from "@mui/material";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import { useQuizStore } from "@/stores/useQuizStore";
import { notReachable } from "@utils/notReachable";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import type { QuizStartpageAlignType, QuizStartpageType } from "@model/settingsData";
import { DESIGN_LIST } from "@/utils/designList";
type StartPageDesktopProps = {
quizHeaderBlock: JSX.Element;
quizMainBlock: JSX.Element;
backgroundBlock: JSX.Element | null;
startpageType: QuizStartpageType;
alignType: QuizStartpageAlignType;
};
type LayoutProps = Omit<StartPageDesktopProps, "startpageType">;
const StandartLayout = ({ alignType, quizHeaderBlock, quizMainBlock, backgroundBlock }: LayoutProps) => {
const size = useRootContainerSize();
const isTablet = size >= 700 && size < 1100;
const { settings } = useQuizStore();
return (
<Box
id="pain"
sx={{
display: "flex",
flexDirection: alignType === "left" ? "row" : "row-reverse",
height: "100%",
backgroundPosition: "center",
backgroundSize: "cover",
backgroundImage: settings.cfg.design ? `url(${DESIGN_LIST[settings.cfg.theme]})` : null,
scrollbarWidth: "none",
"&::-webkit-scrollbar": {
width: 0,
},
overflowY: "auto",
}}
>
<Box
sx={{
display: "flex",
flexDirection: alignType === "left" ? "row" : "row-reverse",
padding: isTablet ? "15px" : "0",
width: "100%",
background:
settings.cfg.design && !quizThemes[settings.cfg.theme].isLight
? alignType === "left"
? "linear-gradient(90deg, #272626, transparent)"
: alignType === "right"
? "linear-gradient(-90deg, #272626, transparent)"
: "linear-gradient(0deg, #272626, transparent)"
: null,
}}
>
<Box
sx={{
width: settings.cfg.startpage.background.desktop ? "40%" : undefined,
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "flex-start",
p: isTablet ? "25px" : alignType === "left" ? "25px 25px 25px 35px" : "25px 35px 25px 25px",
overflowY: "auto",
scrollbarWidth: "none",
"&::-webkit-scrollbar": {
width: 0,
},
}}
>
{quizHeaderBlock}
{quizMainBlock}
</Box>
{settings.cfg.startpage.background.desktop && (
<Box sx={{ width: "60%", overflow: "hidden" }}>
<Box
sx={{
width: "100%",
height: "100%",
padding: alignType === "left" ? "25px 25px 25px 15px" : "25px 15px 25px 25px",
display: "flex",
justifyContent: "center",
"& > img": { width: "100%", borderRadius: "12px" },
}}
onClick={(event) => event.preventDefault()}
>
{backgroundBlock}
</Box>
</Box>
)}
</Box>
</Box>
);
};
const ExpandedLayout = ({ alignType, quizHeaderBlock, quizMainBlock, backgroundBlock }: LayoutProps) => {
const size = useRootContainerSize();
const isTablet = size >= 700 && size < 1100;
return (
<>
<Box
sx={{
height: "100%",
width: alignType === "center" ? "100%" : isTablet ? "46%" : "42%",
display: "flex",
padding:
alignType === "center"
? isTablet
? "30px 40px"
: "30px 35px"
: alignType === "left"
? isTablet
? "25px 0 31px 40px"
: "25px 0 31px 35px"
: isTablet
? "25px 40px 31px 0"
: "25px 35px 31px 0",
margin: alignType === "center" ? "0 auto" : alignType === "left" ? "0" : "0 0 0 auto",
scrollbarWidth: "none",
"&::-webkit-scrollbar": {
width: 0,
},
overflowY: "auto",
}}
>
<Box
sx={{
minHeight: "calc(100% - 32px)",
position: "relative",
width: "100%",
padding: alignType === "center" ? "0" : alignType === "left" ? "0 40px 0 0" : "0 0 0 40px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: alignType === "center" ? "center" : "start",
borderRight: alignType === "left" ? "1px solid #9A9AAF80" : null,
borderLeft: alignType === "right" ? "1px solid #9A9AAF80" : null,
scrollbarWidth: "none",
"&::-webkit-scrollbar": {
width: 0,
},
}}
>
{alignType !== "center" && quizHeaderBlock}
{quizMainBlock}
</Box>
</Box>
<Box
sx={{
position: "absolute",
zIndex: -1,
left: 0,
top: 0,
height: "100%",
width: "100%",
overflow: "hidden",
}}
>
{backgroundBlock}
</Box>
</>
);
};
const CenteredLayout = ({ quizHeaderBlock, quizMainBlock, backgroundBlock }: LayoutProps) => {
const isTablet = useRootContainerSize() < 1100;
const { settings } = useQuizStore();
return (
<Box
sx={{
overflow: "auto",
padding: isTablet ? "25px 40px 40px" : "25px 25px 25px",
display: "flex",
flexDirection: "column",
alignItems: "center",
height: "100%",
backgroundPosition: "center",
backgroundSize: "cover",
backgroundImage: !settings.cfg.design
? null
: settings.cfg.design && !quizThemes[settings.cfg.theme].isLight
? `linear-gradient(0deg, #272626, transparent), url(${DESIGN_LIST[settings.cfg.theme]})`
: `url(${DESIGN_LIST[settings.cfg.theme]})`,
scrollbarWidth: "none",
"&::-webkit-scrollbar": {
width: 0,
},
overflowY: "auto",
}}
>
{quizHeaderBlock}
{backgroundBlock && settings.cfg.startpage.background.desktop && (
<Box
sx={{
width: "100%",
maxWidth: "844px",
height: isTablet ? "530px" : "306px",
display: "flex",
justifyContent: "center",
"& > img": { width: "100%", borderRadius: "12px" },
}}
onClick={(event) => event.preventDefault()}
>
{backgroundBlock}
</Box>
)}
{quizMainBlock}
</Box>
);
};
export const StartPageDesktop = ({
quizHeaderBlock,
quizMainBlock,
backgroundBlock,
startpageType,
alignType,
}: StartPageDesktopProps) => {
switch (startpageType) {
case null:
case "standard": {
return (
<StandartLayout
alignType={alignType}
quizHeaderBlock={quizHeaderBlock}
quizMainBlock={quizMainBlock}
backgroundBlock={backgroundBlock}
/>
);
}
case "expanded": {
return (
<ExpandedLayout
alignType={alignType}
quizHeaderBlock={quizHeaderBlock}
quizMainBlock={quizMainBlock}
backgroundBlock={backgroundBlock}
/>
);
}
case "centered": {
return (
<CenteredLayout
alignType={alignType}
quizHeaderBlock={quizHeaderBlock}
quizMainBlock={quizMainBlock}
backgroundBlock={backgroundBlock}
/>
);
}
default:
notReachable(startpageType);
}
};

@ -0,0 +1,273 @@
import { Box } from "@mui/material";
import { useQuizStore } from "@/stores/useQuizStore";
import { notReachable } from "@utils/notReachable";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import type { QuizStartpageType } from "@model/settingsData";
import { DESIGN_LIST } from "@/utils/designList";
type StartPageMobileProps = {
quizHeaderBlock: JSX.Element;
quizMainBlock: JSX.Element;
backgroundBlock: JSX.Element | null;
startpageType: QuizStartpageType;
};
type MobileLayoutProps = Omit<StartPageMobileProps, "startpageType">;
const StandartMobileLayout = ({ quizHeaderBlock, quizMainBlock, backgroundBlock }: MobileLayoutProps) => {
const { settings } = useQuizStore();
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
justifyContent: "flex-end",
minHeight: "100%",
height: "100%",
"&::-webkit-scrollbar": { width: 0 },
backgroundPosition: "center",
backgroundSize: "cover",
backgroundImage: settings.cfg.design ? `url(${DESIGN_LIST[settings.cfg.theme]})` : null,
}}
>
<Box
sx={{
width: "100%",
display: "flex",
flexGrow: 1,
flexDirection: "column",
justifyContent: "space-between",
alignItems: "flex-start",
p: "20px",
height: "100%",
overflowY: "auto",
overflowX: "hidden",
background:
settings.cfg.design && !quizThemes[settings.cfg.theme].isLight
? "linear-gradient(90deg,#272626,transparent)"
: null,
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: "#b8babf",
},
}}
>
<Box sx={{ marginBottom: "13px" }}>{quizHeaderBlock}</Box>
{settings.cfg.startpage.background.desktop && (
<Box sx={{ width: "100%", overflow: "hidden" }}>
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
"& > img": {
width: "100%",
borderRadius: "12px",
},
}}
onClick={(event) => event.preventDefault()}
>
{backgroundBlock}
</Box>
</Box>
)}
<Box
sx={{
height: "80%",
display: "flex",
flexGrow: 1,
flexDirection: "column",
justifyContent: "space-between",
width: "100%",
marginTop: "30px",
}}
>
{quizMainBlock}
</Box>
</Box>
</Box>
);
};
const ExpandedMobileLayout = ({ quizHeaderBlock, quizMainBlock, backgroundBlock }: MobileLayoutProps) => (
<Box
sx={{
display: "flex",
flexDirection: "column-reverse",
flexGrow: 1,
justifyContent: "flex-end",
minHeight: "100%",
height: "100%",
"&::-webkit-scrollbar": { width: 0 },
}}
>
<Box
sx={{
zIndex: 3,
width: "100%",
display: "flex",
flexGrow: 1,
flexDirection: "column",
justifyContent: "space-between",
alignItems: "flex-start",
height: "100%",
overflowY: "auto",
overflowX: "hidden",
"&::-webkit-scrollbar": { width: "4px" },
"&::-webkit-scrollbar-thumb": { backgroundColor: "#b8babf" },
}}
>
<Box
sx={{
padding: "20px",
height: "80%",
display: "flex",
flexGrow: 1,
flexDirection: "column",
justifyContent: "space-between",
width: "100%",
}}
>
{quizHeaderBlock}
{quizMainBlock}
</Box>
</Box>
<Box
sx={{
zIndex: -1,
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
// minHeight: "100%",
overflow: "hidden",
"& > img": {
display: "block",
minHeight: "100%",
},
}}
onClick={(event) => event.preventDefault()}
>
{backgroundBlock}
</Box>
</Box>
);
const CenteredMobileLayout = ({ quizHeaderBlock, quizMainBlock, backgroundBlock }: MobileLayoutProps) => {
const { settings } = useQuizStore();
return (
<Box
sx={{
display: "flex",
flexDirection: "column-reverse",
flexGrow: 1,
justifyContent: "flex-end",
minHeight: "100%",
height: "100%",
backgroundPosition: "center",
backgroundSize: "cover",
backgroundImage: !settings.cfg.design
? null
: settings.cfg.design && !quizThemes[settings.cfg.theme].isLight
? `linear-gradient(0deg, #272626, transparent), url(${DESIGN_LIST[settings.cfg.theme]})`
: `url(${DESIGN_LIST[settings.cfg.theme]})`,
"&::-webkit-scrollbar": { width: 0 },
}}
>
<Box
sx={{
width: "100%",
display: "flex",
flexGrow: 1,
flexDirection: "column",
justifyContent: "space-between",
alignItems: "flex-start",
padding: "20px",
height: "100%",
overflowY: "auto",
overflowX: "hidden",
"&::-webkit-scrollbar": { width: "4px" },
"&::-webkit-scrollbar-thumb": { backgroundColor: "#b8babf" },
}}
>
{quizHeaderBlock}
{settings.cfg.startpage.background.desktop && (
<Box
sx={{
width: "100%",
overflow: "hidden",
"& > img": { width: "100%", borderRadius: "12px" },
}}
onClick={(event) => event.preventDefault()}
>
{backgroundBlock}
</Box>
)}
<Box
sx={{
height: "80%",
display: "flex",
flexGrow: 1,
flexDirection: "column",
justifyContent: "space-between",
width: "100%",
}}
>
{quizMainBlock}
</Box>
</Box>
</Box>
);
};
export const StartPageMobile = ({
quizHeaderBlock,
quizMainBlock,
backgroundBlock,
startpageType,
}: StartPageMobileProps) => {
switch (startpageType) {
case null:
case "standard": {
return (
<StandartMobileLayout
quizHeaderBlock={quizHeaderBlock}
quizMainBlock={quizMainBlock}
backgroundBlock={backgroundBlock}
/>
);
}
case "expanded": {
return (
<ExpandedMobileLayout
quizHeaderBlock={quizHeaderBlock}
quizMainBlock={quizMainBlock}
backgroundBlock={backgroundBlock}
/>
);
}
case "centered": {
return (
<CenteredMobileLayout
quizHeaderBlock={quizHeaderBlock}
quizMainBlock={quizMainBlock}
backgroundBlock={backgroundBlock}
/>
);
}
default:
notReachable(startpageType);
}
};

@ -0,0 +1,477 @@
import { Box, Button, ButtonBase, Link, Paper, Typography, useTheme } from "@mui/material";
import { QuizPreviewLayoutByType } from "./QuizPreviewLayoutByType";
import { useQuizStore } from "@/stores/useQuizStore";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import { useUADevice } from "@utils/hooks/useUADevice";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { NameplateLogo } from "@icons/NameplateLogo";
import { NameplateLogoDark } from "@icons/NameplateLogoDark";
import { useQuizViewStore } from "@/stores/quizView";
import { DESIGN_LIST } from "@/utils/designList";
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
import QuizVideo from "@/ui_kit/VideoIframe/VideoIframe";
import { isProduction } from "@/utils/defineDomain";
export const StartPageViewPublication = () => {
const theme = useTheme();
const { settings, show_badge, quizId, questions } = useQuizStore();
const { isMobileDevice } = useUADevice();
const setCurrentQuizStep = useQuizViewStore((state) => state.setCurrentQuizStep);
const size = useRootContainerSize();
const isMobile = size < 700;
const isTablet = size >= 700 && size < 1100;
const vkMetrics = useVkMetricsGoals(settings.cfg.vkMetricsNumber);
const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber);
const handleCopyNumber = () => {
navigator.clipboard.writeText(settings.cfg.info.phonenumber);
vkMetrics.phoneNumberOpened();
yandexMetrics.phoneNumberOpened();
};
const background =
settings.cfg.startpage.background.type === "image" ? (
<img
src={settings.cfg.startpage.background.desktop || DESIGN_LIST[settings.cfg.theme] || ""}
alt=""
style={{
display: "block",
width: isMobile || settings.cfg.startpageType === "expanded" ? "100%" : undefined,
height: "100%",
minWidth: "100%",
maxHeight: "100%",
objectFit: "cover",
overflow: "hidden",
}}
/>
) : settings.cfg.startpage.background.type === "video" ? (
settings.cfg.startpage.background.video ? (
<QuizVideo
videoUrl={settings.cfg.startpage.background.video}
containerSX={{
width: settings.cfg.startpageType === "centered" ? "550px" : "100%",
height: settings.cfg.startpageType === "centered" ? "275px" : "100%",
borderRadius: settings.cfg.startpageType === "centered" ? "10px" : "0",
overflow: "hidden",
"& iframe": {
width: "100%",
height: "100%",
transform:
settings.cfg.startpageType === "centered"
? ""
: settings.cfg.startpageType === "expanded"
? "scale(1.5)"
: "scale(2.4)",
},
}}
/>
) : null
) : null;
const quizHeaderBlock = (
<Box
sx={{
margin: settings.cfg.startpageType === "centered" ? "0 auto" : null,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap:
settings.cfg.startpageType === "expanded" && settings.cfg.startpage.position === "center"
? "nowrap"
: "wrap",
gap: isMobile ? "20px" : "30px",
mb:
settings.cfg.startpageType === "centered"
? isMobile
? "20px"
: "25px"
: settings.cfg.startpageType === "expanded" && settings.cfg.startpage.position === "center" && !isMobile
? 0
: "7px",
justifyContent:
settings.cfg.startpageType === "expanded" && settings.cfg.startpage.position === "center" && isMobile
? "center"
: undefined,
}}
onClick={(event) => event.preventDefault()}
>
{settings.cfg.startpage.logo && (
<img
src={settings.cfg.startpage.logo}
style={{
maxHeight: isMobile ? "30px" : "40px",
maxWidth: isMobile ? "100px" : "110px",
objectFit: "cover",
}}
alt=""
/>
)}
<Typography
sx={{
fontSize: "12px",
color: settings.cfg.startpageType === "expanded" ? "white" : theme.palette.text.primary,
wordBreak:
settings.cfg.startpageType === "expanded" && settings.cfg.startpage.position === "center"
? "normal"
: "break-word",
}}
>
{settings.cfg.info.orgname}
</Typography>
</Box>
</Box>
);
const PenaBadge = (
<Box
component={Link}
target={"_blank"}
href={`https://${isProduction ? "" : "s"}quiz.pena.digital/answer/v1.0.0/logo?q=${quizId}`}
sx={{
display: "flex",
alignItems: "center",
gap: "7px",
textDecoration: "none",
marginLeft:
settings.cfg.startpageType === "expanded" &&
settings.cfg.startpage.position === "center" &&
!isTablet &&
!isMobile
? "61px"
: undefined,
}}
>
{settings.cfg.startpageType === "expanded" ? (
<NameplateLogo />
) : quizThemes[settings.cfg.theme].isLight ? (
<NameplateLogoDark />
) : (
<NameplateLogo />
)}
</Box>
);
const realQuestionsCount = questions.filter(
(question) => question.type !== null && question.type !== "result"
).length;
const onQuizStart = () => {
setCurrentQuizStep("question");
vkMetrics.firstPageOpened();
yandexMetrics.firstPageOpened();
};
const onSiteClick = () => {
vkMetrics.emailOpened();
yandexMetrics.emailOpened();
setTimeout(() => {
location.href = (
settings.cfg.info.site.includes("https") ? settings.cfg.info.site : `https://${settings.cfg.info.site}`
).replace(/\s+/g, "");
}, 1000);
};
return (
<Paper
className="settings-preview-draghandle"
sx={{
borderRadius: 0,
height: "100%",
width: "100%",
background:
settings.cfg.startpageType === "expanded"
? settings.cfg.startpage.position === "left" || (isMobile && settings.cfg.startpage.position === "right")
? "linear-gradient(90deg, rgba(39, 38, 38, 0.95) 7.66%, rgba(42, 42, 46, 0.85) 42.12%, rgba(51, 54, 71, 0.4) 100%)"
: settings.cfg.startpage.position === "center"
? "linear-gradient(0deg, rgba(39, 38, 38, 0.95) 7.66%, rgba(42, 42, 46, 0.85) 42.12%, rgba(51, 54, 71, 0.4) 100%)"
: "linear-gradient(-90deg, rgba(39, 38, 38, 0.95) 7.66%, rgba(42, 42, 46, 0.85) 42.12%, rgba(51, 54, 71, 0.4) 100%)"
: theme.palette.background.default,
color: settings.cfg.startpageType === "expanded" ? "white" : "black",
}}
onClick={(event) => event.preventDefault()}
>
<QuizPreviewLayoutByType
quizHeaderBlock={quizHeaderBlock}
quizMainBlock={
<>
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: settings.cfg.startpageType === "standard" && isMobile ? "start" : "center",
flexGrow: settings.cfg.startpageType === "centered" ? 0 : 1,
wordBreak: "break-word",
alignItems:
settings.cfg.startpageType === "centered"
? "center"
: settings.cfg.startpageType === "expanded"
? settings.cfg.startpage.position === "center"
? "center"
: "start"
: "start",
marginTop: settings.cfg.startpageType === "centered" ? "30px" : isMobile ? "0px" : "5px",
maxWidth: isMobile
? "100%"
: settings.cfg.startpageType === "centered"
? "700px"
: isTablet &&
settings.cfg.startpageType !== "expanded" &&
settings.cfg.startpage.position !== "center"
? "380px"
: "531px",
}}
>
<Typography
sx={{
fontWeight: "700",
fontSize: isMobile ? "24px" : "27px",
fontStyle: "normal",
fontStretch: "normal",
lineHeight: isMobile ? "26.4px" : "normal",
overflowWrap: "break-word",
width: "100%",
textAlign:
settings.cfg.startpageType === "centered" || settings.cfg.startpage.position === "center"
? "center"
: "-moz-initial",
color: settings.cfg.startpageType === "expanded" ? "white" : theme.palette.text.primary,
}}
>
{settings.name}
</Typography>
<Typography
sx={{
fontSize: isMobile ? "16px" : "17px",
fontWeight: "400",
lineHeight: isMobile ? "19.2px" : "normal",
margin: "12px 0 30px",
overflowWrap: "break-word",
width: "100%",
textAlign:
settings.cfg.startpageType === "centered" || settings.cfg.startpage.position === "center"
? "center"
: "-moz-initial",
color: settings.cfg.startpageType === "expanded" ? "white" : theme.palette.text.primary,
}}
>
{settings.cfg.startpage.description}
</Typography>
<Box width={settings.cfg.startpageType === "standard" ? "100%" : "auto"}>
<Button
variant="contained"
disabled={realQuestionsCount === 0}
sx={{
fontSize: "18px",
padding: "10px 20px",
width: "auto",
background: theme.palette.primary.main,
borderRadius: "12px",
}}
onClick={onQuizStart}
>
{settings.cfg.startpage.button.trim() ? settings.cfg.startpage.button : "Пройти тест"}
</Button>
</Box>
</Box>
<Box
sx={{
display: "flex",
flexGrow: settings.cfg.startpageType === "centered" ? (isMobile ? 0 : 1) : 0,
gap: isMobile ? "30px" : "40px",
alignItems: "flex-end",
justifyContent:
(settings.cfg.startpageType === "expanded" &&
settings.cfg.startpage.position === "center" &&
isMobile) ||
(settings.cfg.startpageType === "centered" && isMobile)
? "center"
: "space-between",
width: "100%",
flexWrap:
settings.cfg.startpageType === "expanded" && settings.cfg.startpage.position === "center"
? isMobile
? "wrap-reverse"
: "nowrap"
: "wrap",
}}
>
{settings.cfg.startpageType === "expanded" &&
settings.cfg.startpage.position === "center" &&
!isMobile &&
quizHeaderBlock}
<Box
sx={{
maxWidth: "300px",
display:
(settings.cfg.startpageType === "centered" && isMobile) ||
(settings.cfg.startpageType === "expanded" &&
settings.cfg.startpage.position === "center" &&
isMobile)
? "flex"
: "block",
flexDirection: "column",
alignItems: "center",
order:
settings.cfg.startpageType === "expanded" && settings.cfg.startpage.position === "center"
? "2"
: "0",
}}
>
{settings.cfg.info.site && (
<ButtonBase
onClick={onSiteClick}
sx={{
display: "block",
width: "100%",
marginTop: "10px",
marginLeft:
settings.cfg.startpageType === "expanded" &&
settings.cfg.startpage.position === "center" &&
!isMobile
? "auto"
: undefined,
}}
>
<Typography
sx={{
lineHeight: "19px",
fontSize: "16px",
textAlign:
settings.cfg.startpageType === "expanded" &&
settings.cfg.startpage.position === "center" &&
!isMobile
? "end"
: (settings.cfg.startpageType === "expanded" &&
settings.cfg.startpage.position === "center" &&
isMobile) ||
(settings.cfg.startpageType === "centered" && isMobile)
? "center"
: "start",
color: theme.palette.primary.main,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{settings.cfg.info.site}
</Typography>
</ButtonBase>
)}
{settings.cfg.info.clickable ? (
isMobileDevice ? (
<Link href={`tel:${settings.cfg.info.phonenumber}`}>
<Typography
sx={{
lineHeight: "19px",
textAlign:
settings.cfg.startpageType === "expanded" && settings.cfg.startpage.position === "center"
? "end"
: "none",
fontSize: "16px",
color: settings.cfg.startpageType === "expanded" ? "#FFFFFF" : theme.palette.text.primary,
}}
>
{settings.cfg.info.phonenumber}
</Typography>
</Link>
) : (
<ButtonBase
onClick={handleCopyNumber}
sx={{
display: "block",
marginTop: "10px",
marginLeft:
settings.cfg.startpageType === "expanded" &&
settings.cfg.startpage.position === "center" &&
!isMobile
? "auto"
: undefined,
}}
>
<Typography
sx={{
textAlign:
settings.cfg.startpageType === "expanded" && settings.cfg.startpage.position === "center"
? "end"
: "none",
fontSize: "16px",
lineHeight: "19px",
color: settings.cfg.startpageType === "expanded" ? "#FFFFFF" : theme.palette.text.primary,
}}
>
{settings.cfg.info.phonenumber}
</Typography>
</ButtonBase>
)
) : (
<Typography
sx={{
lineHeight: "19px",
textAlign:
settings.cfg.startpageType === "expanded" && settings.cfg.startpage.position === "center"
? "end"
: "none",
fontSize: "16px",
marginTop: "10px",
color: settings.cfg.startpageType === "expanded" ? "#FFFFFF" : theme.palette.text.primary,
}}
>
{settings.cfg.info.phonenumber}
</Typography>
)}
<Typography
sx={{
lineHeight: "14px",
width: "100%",
overflowWrap: "break-word",
fontSize: "12px",
textAlign:
settings.cfg.startpageType === "expanded" &&
settings.cfg.startpage.position === "center" &&
!isMobile
? "end"
: (settings.cfg.startpageType === "expanded" &&
settings.cfg.startpage.position === "center" &&
isMobile) ||
(settings.cfg.startpageType === "centered" && isMobile)
? "center"
: "none",
maxHeight: "120px",
overflow: "auto",
marginTop: "10px",
"&::-webkit-scrollbar": { width: 0 },
color: settings.cfg.startpageType === "expanded" ? "white" : theme.palette.text.primary,
}}
>
{settings.cfg.info.law}
</Typography>
</Box>
{show_badge && PenaBadge}
</Box>
</>
}
backgroundBlock={background}
startpageType={settings.cfg.startpageType}
alignType={settings.cfg.startpage.position}
/>
</Paper>
);
};

@ -1,3 +1,4 @@
import { ContactForm } from "@/components/ViewPublicationPage/ContactForm/ContactForm.tsx";
import { extractImageLinksFromQuestion } from "@/utils/extractImageLinks"; import { extractImageLinksFromQuestion } from "@/utils/extractImageLinks";
import { useVKMetrics } from "@/utils/hooks/metrics/useVKMetrics"; import { useVKMetrics } from "@/utils/hooks/metrics/useVKMetrics";
import { useYandexMetrics } from "@/utils/hooks/metrics/useYandexMetrics"; import { useYandexMetrics } from "@/utils/hooks/metrics/useYandexMetrics";
@ -13,6 +14,7 @@ import { Helmet } from "react-helmet-async";
import { Question } from "./Question"; import { Question } from "./Question";
import QuestionSelect from "./QuestionSelect"; import QuestionSelect from "./QuestionSelect";
import { ResultForm } from "./ResultForm"; import { ResultForm } from "./ResultForm";
import { StartPageViewPublication } from "./StartPageViewPublication";
import NextButton from "./tools/NextButton"; import NextButton from "./tools/NextButton";
import PrevButton from "./tools/PrevButton"; import PrevButton from "./tools/PrevButton";
import unscreen from "@/ui_kit/unscreen"; import unscreen from "@/ui_kit/unscreen";
@ -68,7 +70,7 @@ export default function ViewPublicationPage() {
if (settings.cfg.antifraud && recentlyCompleted) throw new Error("Quiz already completed"); if (settings.cfg.antifraud && recentlyCompleted) throw new Error("Quiz already completed");
if (currentQuizStep === "startpage" && settings.cfg.noStartPage) currentQuizStep = "question"; if (currentQuizStep === "startpage" && settings.cfg.noStartPage) currentQuizStep = "question";
if (!currentQuestion) if (!currentQuestion) {
return ( return (
<ThemeProvider theme={quizThemes[settings.cfg.theme || "StandardTheme"].theme}> <ThemeProvider theme={quizThemes[settings.cfg.theme || "StandardTheme"].theme}>
<Typography <Typography
@ -79,13 +81,22 @@ export default function ViewPublicationPage() {
</Typography> </Typography>
</ThemeProvider> </ThemeProvider>
); );
}
const currentAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id); const currentAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id);
let quizStepElement: ReactElement; let quizStepElement: ReactElement;
switch (currentQuizStep) {
case "startpage": {
quizStepElement = <StartPageViewPublication />;
break;
}
case "question": {
if (currentQuestion.type === "result") { if (currentQuestion.type === "result") {
quizStepElement = <ResultForm resultQuestion={currentQuestion} />; quizStepElement = <ResultForm resultQuestion={currentQuestion} />;
} else { break;
}
quizStepElement = ( quizStepElement = (
<Question <Question
key={currentQuestion.id} key={currentQuestion.id}
@ -120,6 +131,19 @@ export default function ViewPublicationPage() {
} }
/> />
); );
break;
}
case "contactform": {
quizStepElement = (
<ContactForm
currentQuestion={currentQuestion}
onShowResult={showResultAfterContactForm}
/>
);
break;
}
default:
notReachable(currentQuizStep);
} }
const preloadLinks = new Set([ const preloadLinks = new Set([

@ -0,0 +1,77 @@
import { useQuizViewStore } from "@/stores/quizView";
import { useQuizStore } from "@/stores/useQuizStore";
import CalendarIcon from "@icons/CalendarIcon";
import type { QuizQuestionDate } from "@model/questionTypes/date";
import { Box, Typography, useTheme } from "@mui/material";
import { DatePicker } from "@mui/x-date-pickers";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import type { Moment } from "moment";
import moment from "moment";
type DateProps = {
currentQuestion: QuizQuestionDate;
};
export default ({ currentQuestion }: DateProps) => {
const { settings } = useQuizStore();
const answers = useQuizViewStore((state) => state.answers);
const { updateAnswer } = useQuizViewStore((state) => state);
const theme = useTheme();
const answer = answers.find(({ questionId }) => questionId === currentQuestion.id)?.answer as string;
const currentAnswer = moment(answer) || moment();
const onDateChange = async (date: Moment | null) => {
if (!date) return;
updateAnswer(currentQuestion.id, date, 0);
};
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
<DatePicker
format="DD/MM/YYYY"
slots={{
openPickerIcon: () => (
<CalendarIcon
sx={{
"& path": { stroke: theme.palette.primary.main },
"& rect": { stroke: theme.palette.primary.main },
}}
/>
),
}}
value={currentAnswer}
onChange={onDateChange}
slotProps={{
openPickerButton: { sx: { p: 0 }, "data-cy": "open-datepicker" },
layout: {
sx: { backgroundColor: theme.palette.background.default },
},
}}
sx={{
"& .MuiInputBase-root": {
backgroundColor: settings.cfg.design
? quizThemes[settings.cfg.theme].isLight
? "#F2F3F7"
: "rgba(154,154,175, 0.2)"
: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
borderRadius: "10px",
maxWidth: "250px",
pr: "30px",
"& input": { py: "11px", pl: "20px", lineHeight: "19px" },
"& fieldset": { borderColor: "#9A9AAF" },
},
}}
/>
</Box>
);
};

@ -0,0 +1,104 @@
import { useQuizViewStore } from "@/stores/quizView";
import type { QuizQuestionDate } from "@model/questionTypes/date";
import { DateCalendar } from "@mui/x-date-pickers";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import type { Moment } from "moment";
import moment from "moment";
import { Box, Paper, TextField, useTheme } from "@mui/material";
import { useRootContainerSize } from "@/contexts/RootContainerWidthContext";
import { useQuizStore } from "@/stores/useQuizStore";
import { useTranslation } from "react-i18next";
type DateProps = {
currentQuestion: QuizQuestionDate;
};
export default ({ currentQuestion }: DateProps) => {
const theme = useTheme();
const today = moment();
const isMobile = useRootContainerSize() < 690;
const { settings } = useQuizStore();
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"];
const currentFrom = Number(answer[0]) ? moment(Number(answer[0])) : moment().utc();
const currentTo = Number(answer[1]) ? moment(Number(answer[1])) : moment().utc();
const onDateChange = async (date: Moment | null, index: number) => {
if (!date) return;
let newAnswer = [...answer];
newAnswer[index] = (moment(date).unix() * 1000).toString();
updateAnswer(currentQuestion.id, newAnswer, 0);
};
return (
<Paper
sx={{
backgroundColor: settings.cfg.design
? quizThemes[settings.cfg.theme].isLight
? "#F2F3F7"
: "rgba(154,154,175, 0.2)"
: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
width: isMobile ? "min-content" : "auto",
display: "inline-flex",
flexWrap: "wrap",
marginTop: "20px",
p: "20px",
}}
>
<Box>
<span style={{ marginLeft: "25px", color: theme.palette.text.primary }}>{t("From")}</span>
<DateCalendar
sx={{
"& .MuiInputBase-root": {
backgroundColor: settings.cfg.design
? quizThemes[settings.cfg.theme].isLight
? "#F2F3F7"
: "rgba(154,154,175, 0.2)"
: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
borderRadius: "10px",
maxWidth: "250px",
pr: "30px",
"& input": { py: "11px", pl: "20px", lineHeight: "19px" },
"& fieldset": { borderColor: "#9A9AAF" },
},
}}
value={currentFrom}
onChange={(data) => onDateChange(data, 0)}
/>
</Box>
<Box>
<span style={{ marginLeft: "25px", color: theme.palette.text.primary }}>{t("До")}</span>
<DateCalendar
minDate={today}
sx={{
"& .MuiInputBase-root": {
backgroundColor: settings.cfg.design
? quizThemes[settings.cfg.theme].isLight
? "#F2F3F7"
: "rgba(154,154,175, 0.2)"
: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
borderRadius: "10px",
maxWidth: "250px",
pr: "30px",
"& input": { py: "11px", pl: "20px", lineHeight: "19px" },
"& fieldset": { borderColor: "#9A9AAF" },
},
}}
value={currentTo}
onChange={(data) => onDateChange(data, 1)}
/>
</Box>
</Paper>
);
};

@ -0,0 +1,29 @@
import type { QuizQuestionDate } from "@model/questionTypes/date";
import DateRange from "./DateRange";
import DatePicker from "./DatePicker";
import { Box, Typography, useTheme } from "@mui/material";
type DateProps = {
currentQuestion: QuizQuestionDate;
};
export const Date = ({ currentQuestion }: DateProps) => {
const theme = useTheme();
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
{currentQuestion.content.isRange ? (
<DateRange currentQuestion={currentQuestion} />
) : (
<DatePicker currentQuestion={currentQuestion} />
)}
</Box>
);
};

@ -0,0 +1,49 @@
import EmojiPickerOriginal from "@emoji-mart/react";
import { Box } from "@mui/material";
type Emoji = {
emoticons: string[];
id: string;
keywords: string[];
name: string;
native: string;
shortcodes: string;
unified: string;
};
type EmojiPickerProps = {
onEmojiSelect: (emoji: Emoji) => void;
};
export const EmojiPicker = ({ onEmojiSelect }: EmojiPickerProps) => (
<Box sx={{ minWidth: "352px" }}>
<EmojiPickerOriginal
onEmojiSelect={onEmojiSelect}
theme="light"
locale="ru"
exceptEmojis={ignoreEmojis}
/>
</Box>
);
const ignoreEmojis = [
"two_men_holding_hands",
"two_women_holding_hands",
"man-kiss-man",
"woman-kiss-woman",
"man-heart-man",
"woman-heart-woman",
"man-man-boy",
"man-man-girl",
"man-man-girl-boy",
"man-man-girl-girl",
"man-man-boy-boy",
"woman-woman-boy",
"woman-woman-girl",
"woman-woman-girl-boy",
"woman-woman-girl-girl",
"woman-woman-boy-boy",
"rainbow-flag",
"transgender_flag",
"transgender_symbol",
];

@ -0,0 +1,271 @@
import type { QuestionVariant } from "@/model/questionTypes/shared";
import { useQuizStore } from "@/stores/useQuizStore";
import { useQuizViewStore, type OwnVariant } from "@stores/quizView";
import {
Box,
Checkbox,
FormControl,
FormControlLabel,
Input,
Radio,
TextareaAutosize,
Typography,
useTheme,
} from "@mui/material";
import RadioCheck from "@ui_kit/RadioCheck";
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";
import { useEffect } from "react";
import { OwnEmojiPicker } from "./OwnEmojiPicker";
polyfillCountryFlagEmojis();
type EmojiVariantProps = {
questionId: string;
variant: QuestionVariant;
index: number;
isMulti: boolean;
own: boolean;
questionLargeCheck: boolean;
ownPlaceholder: string;
answer: string | string[] | undefined;
};
interface OwnInputProps {
questionId: string;
variant: QuestionVariant;
largeCheck: boolean;
ownPlaceholder: string;
}
const OwnInput = ({ questionId, variant, largeCheck, ownPlaceholder }: OwnInputProps) => {
const theme = useTheme();
const ownVariants = useQuizViewStore((state) => state.ownVariants);
const { updateOwnVariant } = useQuizViewStore((state) => state);
const ownAnswer = ownVariants[ownVariants.findIndex((v: OwnVariant) => v.id === variant.id)]?.variant.answer || "";
return largeCheck ? (
<Box sx={{ overflow: "auto" }}>
<TextareaAutosize
placeholder={ownPlaceholder || "|"}
style={{
resize: "none",
width: "100%",
fontSize: "16px",
color: ownAnswer.length === 0 ? "ownPlaceholder" : theme.palette.text.primary,
letterSpacing: "-0.4px",
wordSpacing: "-3px",
outline: "0px none",
backgroundColor: "inherit",
border: "none",
//@ts-ignore
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.primary.main,
},
scrollbarColor: theme.palette.primary.main,
overflow: "auto",
}}
value={ownAnswer}
onClick={(e: React.MouseEvent<HTMLTextAreaElement>) => e.stopPropagation()}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
updateOwnVariant(variant.id, e.target.value);
}}
/>
</Box>
) : (
<Input
placeholder={ownPlaceholder || "|"}
sx={{
backgroundColor: "inherit",
width: "100%",
fontSize: "18px",
color: ownAnswer.length === 0 ? "ownPlaceholder" : theme.palette.text.primary,
}}
value={ownAnswer}
disableUnderline
onClick={(e: React.MouseEvent<HTMLElement>) => e.stopPropagation()}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
updateOwnVariant(variant.id, e.target.value);
}}
/>
);
};
export const EmojiVariant = ({
answer,
variant,
index,
questionId,
isMulti,
own,
questionLargeCheck,
ownPlaceholder,
}: EmojiVariantProps) => {
const { settings } = useQuizStore();
const { updateAnswer, deleteAnswer, updateOwnVariant, ownVariants } = useQuizViewStore((state) => state);
const theme = useTheme();
const { t } = useTranslation();
const customEmoji = ownVariants.find((v: OwnVariant) => v.id === variant.id)?.variant.extendedText || "";
const onVariantClick = async (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
const variantId = variant.id;
if (isMulti) {
const currentAnswer = Array.isArray(answer) ? answer : [];
const newAnswer = currentAnswer.includes(variantId)
? currentAnswer.filter((item) => item !== variantId)
: [...currentAnswer, variantId];
updateAnswer(questionId, newAnswer, variant.points || 0);
} else {
if (answer === variant.id) {
deleteAnswer(questionId);
} else {
updateAnswer(questionId, variant.id, variant.points || 0);
}
}
};
const handleEmojiSelect = (emoji: string) => {
// We store custom emoji in ownVariants store, with a specific field to differentiate
const currentOwnAnswer = ownVariants.find((v: OwnVariant) => v.id === variant.id)?.variant.answer || "";
updateOwnVariant(variant.id, currentOwnAnswer, emoji);
};
const handleEmojiRemove = () => {
// Сохраняем текущий answer, очищаем только extendedText (эмодзи)
const currentOwnAnswer = ownVariants.find((v: OwnVariant) => v.id === variant.id)?.variant.answer || "";
updateOwnVariant(variant.id, currentOwnAnswer, "");
};
const isSelected = isMulti ? Array.isArray(answer) && answer.includes(variant.id) : answer === variant.id;
return (
<FormControl
key={index}
sx={{
borderRadius: "12px",
border: `1px solid`,
borderColor: isSelected ? theme.palette.primary.main : "#9A9AAF",
overflow: "hidden",
maxWidth: "317px",
width: "100%",
height: "255px",
background:
settings.cfg.design && !quizThemes[settings.cfg.theme].isLight
? "rgba(255,255,255, 0.3)"
: (settings.cfg.design && quizThemes[settings.cfg.theme].isLight) || quizThemes[settings.cfg.theme].isLight
? "#FFFFFF"
: "transparent",
"&:hover": { borderColor: theme.palette.primary.main },
}}
onClick={onVariantClick}
>
<Box
sx={{
display: "flex",
alignItems: "center",
height: "193px",
background: "#ffffff",
cursor: "pointer",
}}
>
{own ? (
<OwnEmojiPicker
emoji={customEmoji || variant.extendedText}
onEmojiSelect={handleEmojiSelect}
onEmojiRemove={customEmoji ? handleEmojiRemove : undefined}
/>
) : (
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
{variant.extendedText && <Typography fontSize="100px">{variant.extendedText}</Typography>}
</Box>
)}
</Box>
{own && (
<Typography
sx={{
color: theme.palette.text.primary,
fontSize: "14px",
pl: "15px",
}}
>
{t("Enter your answer")}
</Typography>
)}
<FormControlLabel
key={variant.id}
sx={{
textAlign: "center",
color: theme.palette.text.primary,
margin: 0,
padding: "15px",
display: "flex",
alignItems: variant.answer.length <= 60 ? "center" : "flex-start",
position: "relative",
height: "80px",
justifyContent: "center",
"& .MuiFormControlLabel-label": {
wordBreak: "break-word",
height: variant.answer.length <= 60 ? "100%" : "60px",
overflow: "auto",
"&::-webkit-scrollbar": { width: "4px" },
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.primary.main,
},
scrollbarColor: theme.palette.primary.main,
width: "100%",
},
"& .MuiFormControlLabel-label.Mui-disabled": {
color: theme.palette.text.primary,
},
}}
value={index}
control={
isMulti ? (
<Checkbox
checked={isSelected}
checkedIcon={<RadioCheck color={theme.palette.primary.main} />}
icon={<RadioIcon />}
sx={{ position: "absolute", top: "-162px", right: "12px" }}
/>
) : (
<Radio
checked={isSelected}
checkedIcon={<RadioCheck color={theme.palette.primary.main} />}
icon={<RadioIcon />}
sx={{ position: "absolute", top: "-162px", right: "12px" }}
/>
)
}
label={
own ? (
<OwnInput
questionId={questionId}
variant={variant}
largeCheck={questionLargeCheck}
ownPlaceholder={ownPlaceholder || "|"}
/>
) : (
<Box sx={{ display: "flex", gap: "10px" }}>
<Typography sx={{ wordBreak: "break-word", lineHeight: "normal" }}>{variant.answer}</Typography>
</Box>
)
}
/>
</FormControl>
);
};

@ -0,0 +1,103 @@
import { Box, ButtonBase, Typography, useTheme, Modal, IconButton } from "@mui/material";
import { useState } from "react";
import { EmojiPicker } from "./EmojiPicker";
import { useTranslation } from "react-i18next";
import CloseIcon from "@mui/icons-material/Close";
interface Props {
emoji: string;
onEmojiSelect?: (emoji: string) => void;
onEmojiRemove?: () => void;
}
export const OwnEmojiPicker = ({ emoji = "", onEmojiSelect, onEmojiRemove }: Props) => {
const theme = useTheme();
const { t } = useTranslation();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const handleEmojiSelect = (emojiData: any) => {
onEmojiSelect?.(emojiData.native);
setIsPickerOpen(false);
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsPickerOpen(true);
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
setIsPickerOpen(false);
};
const handleRemoveEmoji = (e: React.MouseEvent) => {
e.stopPropagation();
onEmojiRemove?.();
};
return (
<>
<Box sx={{ width: "100%", height: "100%", position: "relative" }}>
<ButtonBase
onClick={handleClick}
sx={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
"&:hover": {
bgcolor: theme.palette.grey[100],
},
}}
>
<Typography fontSize={emoji ? "100px" : "18px"}>{emoji || t("select emoji")}</Typography>
</ButtonBase>
{onEmojiRemove && (
<IconButton
onClick={handleRemoveEmoji}
sx={{
position: "absolute",
top: 8,
left: 8,
zIndex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
color: "white",
height: "25px",
width: "25px",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
}}
>
<CloseIcon />
</IconButton>
)}
</Box>
<Modal
open={isPickerOpen}
onClose={handleClose}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
keepMounted
>
<Box
onClick={(e) => e.stopPropagation()}
sx={{
bgcolor: "background.paper",
borderRadius: 2,
p: 2,
boxShadow: 24,
}}
>
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
</Box>
</Modal>
</>
);
};

@ -0,0 +1,66 @@
import type { QuizQuestionEmoji } from "@model/questionTypes/emoji";
import { Box, RadioGroup, Typography, useTheme } from "@mui/material";
import { useQuizViewStore } from "@stores/quizView";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import { EmojiVariant } from "./EmojiVariant";
import moment from "moment";
polyfillCountryFlagEmojis();
type EmojiProps = {
currentQuestion: QuizQuestionEmoji;
};
export const Emoji = ({ currentQuestion }: EmojiProps) => {
const answers = useQuizViewStore((state) => state.answers);
const theme = useTheme();
const { answer } = answers.find(({ questionId }) => questionId === currentQuestion.id) ?? {};
const selectedVariantId = Array.isArray(answer) ? answer[0] : answer;
if (moment.isMoment(answer)) throw new Error("Answer is Moment in Variant question");
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
<RadioGroup
name={currentQuestion.id}
value={selectedVariantId}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
marginTop: "20px",
}}
>
<Box sx={{ display: "flex", width: "100%", gap: "42px", flexWrap: "wrap" }}>
{currentQuestion.content.variants
.filter((v) => {
if (!v.isOwn) return true;
return v.isOwn && currentQuestion.content.own;
})
.map((variant, index) => (
<EmojiVariant
key={variant.id}
questionId={currentQuestion.id}
variant={variant}
index={index}
isMulti={Boolean(currentQuestion.content.multi)}
own={Boolean(variant.isOwn)}
questionLargeCheck={true}
answer={answer}
ownPlaceholder={currentQuestion.content?.ownPlaceholder || ""}
/>
))}
</Box>
</RadioGroup>
</Box>
);
};

@ -0,0 +1,150 @@
import { useState } from "react";
import { Box, ButtonBase, Skeleton, Typography, useTheme } from "@mui/material";
import { enqueueSnackbar } from "notistack";
import { sendAnswer, sendFile } from "@api/quizRelase";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import { useQuizViewStore } from "@stores/quizView";
import {
ACCEPT_SEND_FILE_TYPES_MAP,
MAX_FILE_SIZE,
UPLOAD_FILE_DESCRIPTIONS_MAP,
} from "@/components/ViewPublicationPage/tools/fileUpload";
import Info from "@icons/Info";
import UploadIcon from "@icons/UploadIcon";
import type { QuizQuestionFile } from "@model/questionTypes/file";
import type { ModalWarningType } from "./index";
import { useQuizStore } from "@/stores/useQuizStore";
import { useTranslation } from "react-i18next";
type UploadFileProps = {
currentQuestion: QuizQuestionFile;
setModalWarningType: (modalType: ModalWarningType) => void;
isSending: boolean;
setIsSending: (isSending: boolean) => void;
};
export const UploadFile = ({ currentQuestion, setModalWarningType, isSending, setIsSending }: UploadFileProps) => {
const { quizId, preview } = useQuizStore();
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;
const answer = answers.find(({ questionId }) => questionId === currentQuestion.id)?.answer as string;
const uploadFile = async (file: File | undefined) => {
if (isSending) return;
if (!file) return;
if (file.size > MAX_FILE_SIZE) return setModalWarningType("errorSize");
const isFileTypeAccepted = ACCEPT_SEND_FILE_TYPES_MAP[currentQuestion.content.type].some((fileType) =>
file.name.toLowerCase().endsWith(fileType)
);
if (!isFileTypeAccepted) return setModalWarningType("errorType");
setIsSending(true);
try {
const data = await sendFile({
questionId: currentQuestion.id,
body: {
file: file,
name: file.name,
preview,
},
qid: quizId,
});
await sendAnswer({
questionId: currentQuestion.id,
body: `${data!.data.fileIDMap[currentQuestion.id]}`,
qid: quizId,
preview,
});
updateAnswer(currentQuestion.id, `${file.name}|${URL.createObjectURL(file)}`, 0);
} catch (error) {
console.error(error);
enqueueSnackbar(t("The answer was not counted"));
}
setIsSending(false);
};
const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDropzoneHighlighted(false);
const file = event.dataTransfer.files[0];
uploadFile(file);
};
return (
<Box sx={{ display: "flex", alignItems: "center" }}>
{isSending ? (
<Skeleton
variant="rounded"
sx={{ width: "100%", height: "120px", maxWidth: "560px" }}
/>
) : (
<ButtonBase
component="label"
sx={{ justifyContent: "flex-start", width: "100%" }}
>
<input
onChange={({ target }) => uploadFile(target.files?.[0])}
hidden
accept={ACCEPT_SEND_FILE_TYPES_MAP[currentQuestion.content.type].join(",")}
multiple
type="file"
/>
<Box
onDragEnter={() => !answer?.split("|")[0] && setIsDropzoneHighlighted(true)}
onDragLeave={() => setIsDropzoneHighlighted(false)}
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
sx={{
width: "100%",
height: isMobile ? undefined : "120px",
display: "flex",
gap: "50px",
justifyContent: "flex-start",
alignItems: "center",
padding: "33px 44px 33px 55px",
backgroundColor: "#F2F3F7",
border: `1px solid ${isDropzoneHighlighted ? "red" : "#9A9AAF"}`,
borderRadius: "8px",
}}
>
<UploadIcon />
<Box>
<Typography sx={{ color: "#9A9AAF", fontWeight: 500 }}>
{t(UPLOAD_FILE_DESCRIPTIONS_MAP[currentQuestion.content.type].title)}
</Typography>
<Typography
sx={{
color: "#9A9AAF",
fontSize: "16px",
lineHeight: "19px",
}}
>
{t(UPLOAD_FILE_DESCRIPTIONS_MAP[currentQuestion.content.type].description)}
</Typography>
</Box>
</Box>
</ButtonBase>
)}
<Info
sx={{ width: "40px", height: "40px" }}
color={theme.palette.primary.main}
onClick={() => setModalWarningType(currentQuestion.content.type)}
/>
</Box>
);
};

@ -0,0 +1,75 @@
import { Box, IconButton, Typography, useTheme } from "@mui/material";
import { sendAnswer } from "@api/quizRelase";
import { useQuizViewStore } from "@stores/quizView";
import CloseBold from "@icons/CloseBold";
import type { QuizQuestionFile } from "@model/questionTypes/file";
import { useQuizStore } from "@/stores/useQuizStore";
import { useTranslation } from "react-i18next";
type UploadedFileProps = {
currentQuestion: QuizQuestionFile;
setIsSending: (isSending: boolean) => void;
};
export const UploadedFile = ({ currentQuestion, setIsSending }: UploadedFileProps) => {
const { quizId, preview } = useQuizStore();
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;
const deleteFile = async () => {
if (answer.length > 0) {
setIsSending(true);
await sendAnswer({
questionId: currentQuestion.id,
body: "",
qid: quizId,
preview,
});
}
updateAnswer(currentQuestion.id, "", 0);
setIsSending(false);
};
return (
<Box sx={{ display: "flex", alignItems: "center", gap: "15px" }}>
<Typography color={theme.palette.text.primary}>{t("You have uploaded")}:</Typography>
<Box
sx={{
padding: "5px 5px 5px 16px",
backgroundColor: theme.palette.primary.main,
borderRadius: "8px",
color: "#FFFFFF",
display: "flex",
alignItems: "center",
overflow: "hidden",
gap: "15px",
}}
>
<Typography
sx={{
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
}}
>
{answer?.split("|")[0]}
</Typography>
<IconButton
sx={{ p: 0 }}
onClick={deleteFile}
>
<CloseBold />
</IconButton>
</Box>
</Box>
);
};

@ -0,0 +1,122 @@
import { useState } from "react";
import { Box, Modal, Typography, useTheme } from "@mui/material";
import { UploadFile } from "./UploadFile";
import { UploadedFile } from "./UploadedFile";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
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;
type FileProps = {
currentQuestion: QuizQuestionFile;
};
export const File = ({ currentQuestion }: FileProps) => {
const theme = useTheme();
const answers = useQuizViewStore((state) => state.answers);
const [modalWarningType, setModalWarningType] = useState<ModalWarningType>(null);
const [isSending, setIsSending] = useState<boolean>(false);
const isMobile = useRootContainerSize() < 500;
const answer = answers.find(({ questionId }) => questionId === currentQuestion.id)?.answer as string;
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
maxWidth: answer?.split("|")[0] ? "640px" : "600px",
}}
>
{answer?.split("|")[0] ? (
<UploadedFile
currentQuestion={currentQuestion}
setIsSending={setIsSending}
/>
) : (
<UploadFile
currentQuestion={currentQuestion}
setModalWarningType={setModalWarningType}
isSending={isSending}
setIsSending={setIsSending}
/>
)}
{answer && currentQuestion.content.type === "picture" && (
<img
src={answer.split("|")[1]}
style={{ marginTop: "15px", maxWidth: "300px", maxHeight: "300px" }}
alt=""
/>
)}
{answer && currentQuestion.content.type === "video" && (
<video
src={answer.split("|")[1]}
style={{
marginTop: "15px",
maxWidth: "300px",
maxHeight: "300px",
objectFit: "cover",
}}
/>
)}
</Box>
<Modal
open={modalWarningType !== null}
onClose={() => setModalWarningType(null)}
>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: isMobile ? 300 : 400,
bgcolor: "background.paper",
borderRadius: 3,
boxShadow: 24,
p: 4,
}}
>
<CurrentModal status={modalWarningType} />
</Box>
</Modal>
</Box>
);
};
const CurrentModal = ({ status }: { status: ModalWarningType }) => {
const { t } = useTranslation();
switch (status) {
case null:
return null;
case "errorType":
return <Typography>{t("Incorrect file type selected")}</Typography>;
case "errorSize":
return <Typography>{t("File is too big. Maximum size is 50 MB")}</Typography>;
default:
return (
<>
<Typography>{t("Acceptable file extensions")}:</Typography>
<Typography>{ACCEPT_SEND_FILE_TYPES_MAP[status].join(" ")}</Typography>
</>
);
}
};

@ -0,0 +1,282 @@
import type { QuestionVariant, QuestionVariantWithEditedImages } from "@/model/questionTypes/shared";
import { Box, Checkbox, FormControlLabel, Input, Radio, TextareaAutosize, Typography, useTheme } from "@mui/material";
import { useQuizViewStore } from "@stores/quizView";
import RadioCheck from "@ui_kit/RadioCheck";
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 { useQuizStore } from "@/stores/useQuizStore";
import { useTranslation } from "react-i18next";
import { OwnImage } from "./OwnImage";
import { useSnackbar } from "notistack";
type ImagesProps = {
questionId: string;
variant: QuestionVariantWithEditedImages;
index: number;
answer: string | string[] | undefined;
isMulti: boolean;
own: boolean;
questionLargeCheck: boolean;
ownPlaceholder: string;
};
interface OwnInputProps {
questionId: string;
variant: QuestionVariant;
largeCheck: boolean;
ownPlaceholder: string;
}
const OwnInput = ({ variant, largeCheck, ownPlaceholder }: OwnInputProps) => {
const theme = useTheme();
const ownVariants = useQuizViewStore((state) => state.ownVariants);
const { updateOwnVariant } = useQuizViewStore((state) => state);
const ownAnswer = ownVariants[ownVariants.findIndex((v) => v.id === variant.id)]?.variant.answer || "";
return largeCheck ? (
<Box sx={{ overflow: "auto" }}>
<TextareaAutosize
placeholder={ownPlaceholder || "|"}
style={{
resize: "none",
width: "100%",
fontSize: "16px",
color: ownAnswer.length === 0 ? "ownPlaceholder" : theme.palette.text.primary,
letterSpacing: "-0.4px",
wordSpacing: "-3px",
outline: "0px none",
backgroundColor: "inherit",
border: "none",
//@ts-ignore
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.primary.main,
},
scrollbarColor: theme.palette.primary.main,
}}
value={ownAnswer}
onClick={(e: React.MouseEvent<HTMLTextAreaElement>) => e.stopPropagation()}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
updateOwnVariant(variant.id, e.target.value);
}}
/>
</Box>
) : (
<Input
placeholder={ownPlaceholder || "|"}
sx={{
backgroundColor: "inherit",
width: "100%",
fontSize: "18px",
color: ownAnswer.length === 0 ? "ownPlaceholder" : theme.palette.text.primary,
}}
value={ownAnswer}
disableUnderline
onClick={(e: React.MouseEvent<HTMLElement>) => e.stopPropagation()}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
updateOwnVariant(variant.id, e.target.value);
}}
/>
);
};
export const ImageVariant = ({
questionId,
answer,
isMulti,
variant,
index,
own,
questionLargeCheck,
ownPlaceholder,
}: ImagesProps) => {
const { settings } = useQuizStore();
const { deleteAnswer, updateAnswer } = useQuizViewStore((state) => state);
const theme = useTheme();
const { t } = useTranslation();
const isMobile = useRootContainerSize() < 450;
const isTablet = useRootContainerSize() < 850;
const { enqueueSnackbar } = useSnackbar();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const onVariantClick = async (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
const variantId = variant.id;
if (isMulti) {
const currentAnswer = typeof answer !== "string" ? answer || [] : [];
return updateAnswer(
questionId,
currentAnswer.includes(variantId)
? currentAnswer?.filter((item) => item !== variantId)
: [...currentAnswer, variantId],
variant.points || 0
);
}
updateAnswer(questionId, variantId, variant.points || 0);
if (answer === variantId) {
deleteAnswer(questionId);
}
};
const choiceImgUrl = useMemo(() => {
if (variant.editedUrlImagesList !== undefined && variant.editedUrlImagesList !== null) {
return variant.editedUrlImagesList[isMobile ? "mobile" : isTablet ? "tablet" : "desktop"];
} else {
return variant.extendedText;
}
}, []);
useEffect(() => {
if (canvasRef.current !== null) {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (ctx !== null) {
const img = new Image();
img.src = choiceImgUrl;
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
};
}
}
}, []);
return (
<Box
sx={{
position: "relative",
cursor: "pointer",
borderRadius: "12px",
border: `1px solid`,
borderColor: !!answer?.includes(variant.id) ? theme.palette.primary.main : "#9A9AAF",
"&:hover": { borderColor: theme.palette.primary.main },
background:
settings.cfg.design && !quizThemes[settings.cfg.theme].isLight
? "rgba(255,255,255, 0.3)"
: (settings.cfg.design && quizThemes[settings.cfg.theme].isLight) || quizThemes[settings.cfg.theme].isLight
? "#FFFFFF"
: "transparent",
}}
onClick={onVariantClick}
>
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Box sx={{ width: "100%", height: "300px" }}>
{own ? (
<OwnImage
imageUrl={choiceImgUrl}
questionId={questionId}
variantId={variant.id}
onValidationError={(errorType) => {
enqueueSnackbar(errorType === "size" ? t("file is too big") : t("file type is not supported"), {
variant: "warning",
});
}}
/>
) : (
variant.extendedText && (
<canvas
ref={canvasRef}
style={{
display: "block",
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: "12px 12px 0 0",
}}
/>
)
)}
</Box>
</Box>
{own && (
<Typography
sx={{
color: theme.palette.text.primary,
fontSize: "14px",
pl: "15px",
}}
>
{t("Enter your answer")}
</Typography>
)}
<FormControlLabel
key={variant.id}
sx={{
textAlign: "center",
color: theme.palette.text.primary,
marginTop: "10px",
marginLeft: 0,
padding: "10px",
display: "flex",
alignItems: variant.answer.length <= 60 ? "center" : "flex-start",
justifyContent: "center",
position: "relative",
height: "80px",
"& .MuiFormControlLabel-label": {
wordBreak: "break-word",
height: variant.answer.length <= 60 ? undefined : "60px",
lineHeight: "normal",
overflow: "auto",
maxHeight: "100%",
width: "100%",
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.primary.main,
},
scrollbarColor: theme.palette.primary.main,
},
}}
value={index}
control={
isMulti ? (
<Checkbox
checked={!!answer?.includes(variant.id)}
checkedIcon={<RadioCheck color={theme.palette.primary.main} />}
icon={<RadioIcon />}
sx={{
position: "absolute",
top: "-297px",
right: 0,
}}
/>
) : (
<Radio
checkedIcon={<RadioCheck color={theme.palette.primary.main} />}
icon={<RadioIcon />}
sx={{
position: "absolute",
top: "-297px",
right: 0,
}}
/>
)
}
label={
own ? (
<OwnInput
questionId={questionId}
variant={variant}
largeCheck={questionLargeCheck}
ownPlaceholder={ownPlaceholder || "|"}
/>
) : (
variant.answer
)
}
/>
</Box>
);
};

@ -0,0 +1,187 @@
import { Box, ButtonBase, IconButton, Typography, useTheme } from "@mui/material";
import { useState, useRef } from "react";
import CloseIcon from "@mui/icons-material/Close";
import { useTranslation } from "react-i18next";
import { useQuizStore } from "@/stores/useQuizStore";
import { useQuizViewStore } from "@/stores/quizView";
import { useSnackbar } from "notistack";
import { Skeleton } from "@mui/material";
import UploadIcon from "@/assets/icons/UploadIcon";
import { sendFile } from "@/api/quizRelase";
import { ACCEPT_SEND_FILE_TYPES_MAP, MAX_FILE_SIZE } from "../../tools/fileUpload";
// Пропсы компонента
export type OwnImageProps = {
imageUrl?: string;
questionId: string;
variantId: string;
onValidationError: (error: "size" | "type") => void;
};
export const OwnImage = ({ imageUrl, questionId, variantId, onValidationError }: OwnImageProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { quizId, preview } = useQuizStore();
const { ownVariants, updateOwnVariant } = useQuizViewStore((state) => state);
const { enqueueSnackbar } = useSnackbar();
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Получаем ownVariant для этого варианта
const ownVariantData = ownVariants.find((v) => v.id === variantId);
// Загрузка файла
const uploadImage = async (file: File) => {
if (isUploading) return;
if (!file) return;
if (file.size > MAX_FILE_SIZE) {
onValidationError("size");
return;
}
const isFileTypeAccepted = ACCEPT_SEND_FILE_TYPES_MAP.picture.some((fileType) =>
file.name.toLowerCase().endsWith(fileType)
);
if (!isFileTypeAccepted) {
onValidationError("type");
return;
}
setIsUploading(true);
try {
const data = await sendFile({
questionId,
body: { file, name: file.name, preview },
qid: quizId,
});
const fileId = data?.data.fileIDMap[questionId];
const localImageUrl = URL.createObjectURL(file);
updateOwnVariant(variantId, "", "", fileId, localImageUrl);
} catch (error) {
console.error("Error uploading image:", error);
enqueueSnackbar(t("The answer was not counted"));
} finally {
setIsUploading(false);
}
};
// Обработчик выбора файла
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
uploadImage(file);
}
};
// Открытие диалога выбора файла
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (fileInputRef.current) fileInputRef.current.value = "";
fileInputRef.current?.click();
};
// Удаление изображения
const handleRemoveImage = (e: React.MouseEvent) => {
e.stopPropagation();
updateOwnVariant(variantId, ownVariantData?.variant.answer || "", "", "", "");
/*
1 - answer - письменный ответ
2 - extendedText - строка используется в эмодзи-вопросах для хранения выбранного эмодзи
3 - originalImageUrl - полный URL изображения, загруженного на сервер
4 - localImageUrl - временный URL для отображения изображения в браузере
*/
};
// Определяем, что показывать
let imageToDisplay: string | null = null;
if (ownVariantData?.variant.localImageUrl) {
imageToDisplay = ownVariantData.variant.localImageUrl;
} else if (imageUrl) {
imageToDisplay = imageUrl;
}
if (isUploading) {
return (
<Skeleton
variant="rounded"
sx={{ width: "100%", height: "100%", borderRadius: "12px" }}
/>
);
}
return (
<ButtonBase
component="div"
onClick={handleClick}
disabled={isUploading}
sx={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "12px",
transition: "border-color 0.3s, background-color 0.3s",
overflow: "hidden",
position: "relative",
opacity: isUploading ? 0.7 : 1,
}}
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept={ACCEPT_SEND_FILE_TYPES_MAP.picture.join(",")}
hidden
/>
{imageToDisplay ? (
<>
<Box sx={{ width: "100%", height: "100%", position: "relative" }}>
<img
src={imageToDisplay}
alt="Preview"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</Box>
<IconButton
onClick={handleRemoveImage}
sx={{
position: "absolute",
top: 8,
left: 8,
zIndex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
color: "white",
height: "25px",
width: "25px",
display: ownVariantData?.variant.localImageUrl ? "inherit" : "none",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
}}
>
<CloseIcon />
</IconButton>
</>
) : (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
opacity: 0.5,
}}
>
<UploadIcon />
<Typography
variant="body2"
color="text.secondary"
sx={{ p: 2, textAlign: "center" }}
>
{t("Add your image")}
</Typography>
</Box>
)}
</ButtonBase>
);
};

@ -0,0 +1,71 @@
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import type { QuizQuestionImages } from "@model/questionTypes/images";
import { Box, RadioGroup, Typography, useTheme } from "@mui/material";
import { createQuizViewStore, useQuizViewStore } from "@stores/quizView";
import { ImageVariant } from "./ImageVariant";
import moment from "moment";
type ImagesProps = {
currentQuestion: QuizQuestionImages;
};
export const Images = ({ currentQuestion }: ImagesProps) => {
const answers = useQuizViewStore((state) => state.answers);
const theme = useTheme();
const answer = answers.find(({ questionId }) => questionId === currentQuestion.id)?.answer;
const isTablet = useRootContainerSize() < 1000;
const isMobile = useRootContainerSize() < 500;
if (moment.isMoment(answer)) throw new Error("Answer is Moment in Variant question");
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
<RadioGroup
name={currentQuestion.id.toString()}
value={currentQuestion.content.variants.findIndex(({ id }) => answer === id)}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
marginTop: "20px",
}}
>
<Box
sx={{
display: "grid",
gap: "15px",
gridTemplateColumns: isTablet ? (isMobile ? "repeat(1, 1fr)" : "repeat(2, 1fr)") : "repeat(3, 1fr)",
width: "100%",
}}
>
{currentQuestion.content.variants
.filter((v) => {
if (!v.isOwn) return true;
return v.isOwn && currentQuestion.content.own;
})
.map((variant, index) => (
<ImageVariant
key={variant.id}
questionId={currentQuestion.id}
variant={variant}
index={index}
answer={answer}
isMulti={Boolean(currentQuestion.content.multi)}
own={Boolean(variant.isOwn)}
questionLargeCheck={true}
ownPlaceholder={currentQuestion.content?.ownPlaceholder || ""}
/>
))}
</Box>
</RadioGroup>
</Box>
);
};

@ -0,0 +1,387 @@
import { useQuizStore } from "@/stores/useQuizStore";
import type { QuizQuestionNumber } from "@model/questionTypes/number";
import { Box, Typography, useTheme } from "@mui/material";
import { useQuizViewStore } from "@stores/quizView";
import { CustomSlider } from "@ui_kit/CustomSlider";
import CustomTextField from "@ui_kit/CustomTextField";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import type { ChangeEvent, SyntheticEvent } from "react";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
type NumberProps = {
currentQuestion: QuizQuestionNumber;
};
export const Number = ({ currentQuestion }: NumberProps) => {
const [inputValue, setInputValue] = useState<string>("0");
const [minRange, setMinRange] = useState<string>("0");
const [maxRange, setMaxRange] = useState<string>("100000000000");
const [reversedInputValue, setReversedInputValue] = useState<string>("0");
const [reversedMinRange, setReversedMinRange] = useState<string>("0");
const [reversedMaxRange, setReversedMaxRange] = useState<string>("100000000000");
const { settings } = useQuizStore();
const { updateAnswer } = useQuizViewStore((state) => state);
const answers = useQuizViewStore((state) => state.answers);
const theme = useTheme();
const [minBorder, maxBorder] = currentQuestion.content.range.split("—").map(window.Number);
const min = minBorder < maxBorder ? minBorder : maxBorder;
const max = minBorder < maxBorder ? maxBorder : minBorder;
const reversed = minBorder > maxBorder;
const answer = answers.find(({ questionId }) => questionId === currentQuestion.id)?.answer as string;
const sliderValue =
answer ||
(reversed ? max + min - currentQuestion.content.start + "—" + max : currentQuestion.content.start + "—" + max);
const sendAnswerToBackend = async (value: string, noUpdate = false) => {
if (!noUpdate) {
updateAnswer(currentQuestion.id, value, 0);
}
};
const updateValueDebounced = useDebouncedCallback(async (value: string) => {
if (reversed) {
const newValue =
window.Number(value) < window.Number(min)
? String(min)
: window.Number(value) > window.Number(max)
? String(max)
: value;
setReversedInputValue(newValue);
updateAnswer(currentQuestion.id, String(max + min - window.Number(newValue)), 0);
await sendAnswerToBackend(String(window.Number(newValue)), true);
return;
}
const newValue =
window.Number(value) < window.Number(minRange)
? minRange
: window.Number(value) > window.Number(maxRange)
? maxRange
: value;
setInputValue(newValue);
await sendAnswerToBackend(newValue);
}, 1000);
const updateMinRangeDebounced = useDebouncedCallback(async (value: string, crowded = false) => {
if (reversed) {
const newMinRange = crowded
? window.Number(value.split("—")[1])
: max + min - window.Number(value.split("—")[0]) < min
? min
: max + min - window.Number(value.split("—")[0]);
const newMinValue = window.Number(value.split("—")[0]) > max ? String(max) : value.split("—")[0];
setReversedMinRange(crowded ? String(max + min - window.Number(newMinValue)) : newMinValue);
updateAnswer(currentQuestion.id, `${newMinRange}${value.split("—")[1]}`, 0);
await sendAnswerToBackend(`${newMinValue}${value.split("—")[1]}`, true);
return;
}
const newMinValue = crowded
? maxRange
: window.Number(value.split("—")[0]) < min
? String(min)
: value.split("—")[0];
setMinRange(newMinValue);
await sendAnswerToBackend(`${newMinValue}${value.split("—")[1]}`);
}, 1000);
const updateMaxRangeDebounced = useDebouncedCallback(async (value: string, crowded = false) => {
if (reversed) {
const newMaxRange = crowded
? window.Number(value.split("—")[1])
: max + min - window.Number(value.split("—")[1]) > max
? max
: max + min - window.Number(value.split("—")[1]);
const newMaxValue = window.Number(value.split("—")[1]) < min ? String(min) : value.split("—")[1];
setReversedMaxRange(crowded ? String(max + min - window.Number(newMaxValue)) : newMaxValue);
updateAnswer(currentQuestion.id, `${value.split("—")[0]}${newMaxRange}`, 0);
await sendAnswerToBackend(`${value.split("—")[0]}${newMaxValue}`, true);
return;
}
const newMaxValue = crowded
? minRange
: window.Number(value.split("—")[1]) > max
? String(max)
: value.split("—")[1];
setMaxRange(newMaxValue);
await sendAnswerToBackend(`${value.split("—")[0]}${newMaxValue}`);
}, 1000);
useEffect(() => {
if (answer) {
if (answer.includes("—")) {
if (reversed) {
setReversedMinRange(String(max + min - window.Number(answer.split("—")[0])));
setReversedMaxRange(String(max + min - window.Number(answer.split("—")[1])));
} else {
setMinRange(answer.split("—")[0]);
setMaxRange(answer.split("—")[1]);
}
} else {
if (reversed) {
setReversedInputValue(String(max + min - window.Number(answer)));
} else {
setInputValue(answer);
}
}
}
if (!answer) {
setMinRange(String(currentQuestion.content.start));
setMaxRange(String(max));
if (currentQuestion.content.chooseRange) {
setReversedMinRange(String(currentQuestion.content.start));
setReversedMaxRange(String(min));
}
setReversedInputValue(String(currentQuestion.content.start));
setInputValue(String(currentQuestion.content.start));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onSliderChange = (_: Event, value: number | number[]) => {
const range = Array.isArray(value) ? `${value[0]}${value[1]}` : String(value);
updateAnswer(currentQuestion.id, range, 0);
};
const onChangeCommitted = async (_: Event | SyntheticEvent<Element, Event>, value: number | number[]) => {
if (currentQuestion.content.chooseRange && Array.isArray(value)) {
if (reversed) {
const newMinReversedValue = String(max + min - value[0]);
const newMaxReversedValue = String(max + min - value[1]);
setMinRange(String(value[0]));
setMaxRange(String(value[1]));
setReversedMinRange(newMinReversedValue);
setReversedMaxRange(newMaxReversedValue);
await sendAnswerToBackend(`${newMinReversedValue}${newMaxReversedValue}`, true);
return;
}
setMinRange(String(value[0]));
setMaxRange(String(value[1]));
await sendAnswerToBackend(`${value[0]}${value[1]}`);
return;
}
if (reversed) {
setReversedInputValue(String(max + min - window.Number(value)));
} else {
setInputValue(String(value));
}
await sendAnswerToBackend(String(value));
};
const changeValueLabelFormat = (value: number) => {
if (!reversed) {
return value;
}
const [minSliderBorder, maxSliderBorder] = sliderValue.split("—").map(window.Number);
if (value === minSliderBorder) {
return max + min - minSliderBorder;
}
return max + min - maxSliderBorder;
};
const onInputChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const value = target.value.replace(/\D/g, "");
if (reversed) {
setReversedInputValue(value);
} else {
setInputValue(value);
}
updateValueDebounced(value);
};
const onMinInputChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const newValue = target.value.replace(/\D/g, "");
if (reversed) {
setReversedMinRange(newValue);
if (window.Number(newValue) <= window.Number(reversedMaxRange)) {
const value = max + min - window.Number(reversedMaxRange);
updateMinRangeDebounced(`${value}${value}`, true);
return;
}
updateMinRangeDebounced(`${newValue}${max + min - window.Number(reversedMaxRange)}`);
return;
}
setMinRange(newValue);
if (window.Number(newValue) >= window.Number(maxRange)) {
updateMinRangeDebounced(`${maxRange}${maxRange}`, true);
return;
}
updateMinRangeDebounced(`${newValue}${maxRange}`);
};
const onMaxInputChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const newValue = target.value.replace(/\D/g, "");
if (reversed) {
setReversedMaxRange(newValue);
if (window.Number(newValue) >= window.Number(reversedMinRange)) {
const value = max + min - window.Number(reversedMinRange);
updateMaxRangeDebounced(`${value}${value}`, true);
return;
}
updateMaxRangeDebounced(`${max + min - window.Number(reversedMinRange)}${newValue}`);
return;
}
setMaxRange(newValue);
if (window.Number(newValue) <= window.Number(minRange)) {
updateMaxRangeDebounced(`${minRange}${minRange}`, true);
return;
}
updateMaxRangeDebounced(`${minRange}${newValue}`);
};
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
gap: "30px",
padding: "0 30px",
}}
>
<CustomSlider
value={
currentQuestion.content.chooseRange
? sliderValue.split("—").length || 0 > 1
? sliderValue.split("—").map((item) => window.Number(item))
: [min, min + 1]
: window.Number(sliderValue.split("—")[0])
}
min={min}
max={max}
step={currentQuestion.content.step || 1}
onChange={onSliderChange}
onChangeCommitted={onChangeCommitted}
valueLabelFormat={changeValueLabelFormat}
sx={{
color: theme.palette.primary.main,
"& .MuiSlider-valueLabel": {
background: theme.palette.primary.main,
borderRadius: "8px",
minWidth: "60px",
height: "36px",
},
}}
/>
{!currentQuestion.content.chooseRange && (
<CustomTextField
placeholder="0"
value={reversed ? reversedInputValue : inputValue}
onChange={onInputChange}
sx={{
maxWidth: "80px",
borderColor: theme.palette.text.primary,
"& .MuiOutlinedInput-root": { background: "transparent" },
"& .MuiInputBase-input": { textAlign: "center", zIndex: 1 },
"& .MuiOutlinedInput-notchedOutline": {
backgroundColor: quizThemes[settings.cfg.theme].isLight ? "white" : theme.palette.background.default,
borderColor: "#9A9AAF",
},
}}
/>
)}
{currentQuestion.content.chooseRange && (
<Box
sx={{
display: "flex",
gap: "15px",
alignItems: "center",
"& .MuiFormControl-root": { width: "auto" },
}}
>
<CustomTextField
placeholder="0"
value={reversed ? String(reversedMinRange) : minRange}
onChange={onMinInputChange}
sx={{
maxWidth: "80px",
borderColor: theme.palette.text.primary,
"& .MuiOutlinedInput-root": { background: "transparent" },
"& .MuiInputBase-input": { textAlign: "center", zIndex: 1 },
"& .MuiOutlinedInput-notchedOutline": {
backgroundColor: quizThemes[settings.cfg.theme].isLight ? "white" : theme.palette.background.default,
borderColor: "#9A9AAF",
},
}}
/>
<Typography color={theme.palette.text.primary}>до</Typography>
<CustomTextField
placeholder="0"
value={reversed ? String(reversedMaxRange) : maxRange}
onChange={onMaxInputChange}
sx={{
maxWidth: "80px",
"& .MuiOutlinedInput-root": { background: "transparent" },
"& .MuiInputBase-input": { textAlign: "center", zIndex: 1 },
"& .MuiOutlinedInput-notchedOutline": {
backgroundColor: quizThemes[settings.cfg.theme].isLight ? "white" : theme.palette.background.default,
borderColor: "#9A9AAF",
},
}}
/>
</Box>
)}
</Box>
</Box>
);
};

@ -0,0 +1,76 @@
import { Box, Typography, useTheme } from "@mui/material";
import type { QuizQuestionPage } from "@model/questionTypes/page";
import QuizVideo from "@/ui_kit/VideoIframe/VideoIframe";
type PageProps = {
currentQuestion: QuizQuestionPage;
};
export const Page = ({ currentQuestion }: PageProps) => {
const theme = useTheme();
return (
<Box>
<Typography
variant="h5"
sx={{
paddingBottom: "25px",
color: theme.palette.text.primary,
wordBreak: "break-word",
}}
>
{currentQuestion.title}
</Typography>
<Typography
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.content.text}
</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
{currentQuestion.content.useImage
? currentQuestion.content.back && (
<Box
sx={{
borderRadius: "12px",
border: "1px solid #9A9AAF",
overflow: "hidden",
}}
onClick={(event) => event.preventDefault()}
>
<img
key={currentQuestion.id}
src={currentQuestion.content.back}
alt=""
style={{
display: "block",
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
</Box>
)
: currentQuestion.content.video && (
<QuizVideo
containerSX={{
width: "100%",
height: "calc(100% - 270px)",
maxHeight: "80%",
objectFit: "contain",
aspectRatio: "16 / 9",
}}
videoUrl={currentQuestion.content.video}
/>
)}
</Box>
</Box>
);
};

@ -0,0 +1,144 @@
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import FlagIcon from "@icons/questionsPage/FlagIcon";
import StarIconMini from "@icons/questionsPage/StarIconMini";
import HashtagIcon from "@icons/questionsPage/hashtagIcon";
import HeartIcon from "@icons/questionsPage/heartIcon";
import LightbulbIcon from "@icons/questionsPage/lightbulbIcon";
import LikeIcon from "@icons/questionsPage/likeIcon";
import TropfyIcon from "@icons/questionsPage/tropfyIcon";
import type { QuizQuestionRating } from "@model/questionTypes/rating";
import { Box, Rating as RatingComponent, Typography, useTheme } from "@mui/material";
import { useQuizViewStore } from "@stores/quizView";
const RATING_FORM_BUTTONS = [
{
name: "star",
icon: (color: string, width: number) => (
<StarIconMini
width={width}
color={color}
/>
),
},
{
name: "trophie",
icon: (color: string, width: number) => (
<TropfyIcon
width={width}
color={color}
/>
),
},
{
name: "flag",
icon: (color: string, width: number) => (
<FlagIcon
width={width}
color={color}
/>
),
},
{
name: "heart",
icon: (color: string, width: number) => (
<HeartIcon
width={width}
color={color}
/>
),
},
{
name: "like",
icon: (color: string, width: number) => (
<LikeIcon
width={width}
color={color}
/>
),
},
{
name: "bubble",
icon: (color: string, width: number) => (
<LightbulbIcon
width={width}
color={color}
/>
),
},
{
name: "hashtag",
icon: (color: string, width: number) => (
<HashtagIcon
width={width}
color={color}
/>
),
},
];
type RatingProps = {
currentQuestion: QuizQuestionRating;
};
export const Rating = ({ currentQuestion }: RatingProps) => {
const { updateAnswer } = useQuizViewStore((state) => state);
const answers = useQuizViewStore((state) => state.answers);
const theme = useTheme();
const isMobile = useRootContainerSize() < 650;
const isTablet = useRootContainerSize() < 750;
const { answer } = answers.find(({ questionId }) => questionId === currentQuestion.id) ?? {};
const form = RATING_FORM_BUTTONS.find(({ name }) => name === currentQuestion.content.form);
const sendRating = async (value: number | null) => {
updateAnswer(currentQuestion.id, String(value), 0);
};
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: "20px",
marginTop: "20px",
flexDirection: "column",
}}
>
<Box sx={{ display: "inline-block", width: "100%" }}>
<RatingComponent
value={Number(answer || 0)}
onChange={(_, value) => sendRating(value)}
sx={{
height: "50px",
opacity: "1!important",
"& .MuiRating-root.Mui-disabled": { opacity: "1!important" },
"& .MuiRating-icon": { mr: isMobile ? undefined : "15px" },
}}
max={currentQuestion.content.steps}
icon={form?.icon(theme.palette.primary.main, isMobile ? 30 : isTablet ? 40 : 50)}
emptyIcon={form?.icon("#9A9AAF", isMobile ? 30 : isTablet ? 40 : 50)}
/>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
gap: 2,
width: "100%",
}}
>
<Typography sx={{ color: "#9A9AAF" }}>{currentQuestion.content.ratingNegativeDescription}</Typography>
<Typography sx={{ color: "#9A9AAF" }}>{currentQuestion.content.ratingPositiveDescription}</Typography>
</Box>
</Box>
</Box>
);
};

@ -0,0 +1,66 @@
import { Select as SelectComponent } from "@/components/ViewPublicationPage/tools/Select";
import { useQuizStore } from "@/stores/useQuizStore";
import type { QuizQuestionSelect } from "@model/questionTypes/select";
import { Box, Typography, useTheme } from "@mui/material";
import { useQuizViewStore } from "@stores/quizView";
import { quizThemes } from "@utils/themes/Publication/themePublication";
type SelectProps = {
currentQuestion: QuizQuestionSelect;
};
export const Select = ({ currentQuestion }: SelectProps) => {
const { settings } = useQuizStore();
const { updateAnswer, deleteAnswer } = useQuizViewStore((state) => state);
const answers = useQuizViewStore((state) => state.answers);
const theme = useTheme();
const { answer } = answers.find(({ questionId }) => questionId === currentQuestion.id) ?? {};
const sendSelectedAnswer = async (value: number) => {
if (value < 0) {
deleteAnswer(currentQuestion.id);
return;
}
updateAnswer(currentQuestion.id, String(value), 0);
};
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
<SelectComponent
placeholder={currentQuestion.content.default}
activeItemIndex={answer ? Number(answer) : -1}
items={currentQuestion.content.variants.map(({ answer }) => answer)}
colorMain={theme.palette.primary.main}
sx={{
"& .MuiSelect-select.MuiSelect-outlined": { zIndex: 1 },
"& .MuiOutlinedInput-notchedOutline": {
background: settings.cfg.design
? quizThemes[settings.cfg.theme].isLight
? "#F2F3F7"
: "rgba(255,255,255, 0.3)"
: "transparent",
},
}}
onChange={(_, value) => sendSelectedAnswer(value)}
/>
</Box>
</Box>
);
};

@ -37,6 +37,7 @@ export const TextNormal = ({ currentQuestion, answer }: TextNormalProps) => {
return currentQuestion.content.back; return currentQuestion.content.back;
} }
}, [currentQuestion]); }, [currentQuestion]);
let isCrutch23022025 = window.location.pathname === "/bf8cae3a-e150-479d-befa-7f264087b223";
return ( return (
<Box> <Box>
<Typography <Typography
@ -51,7 +52,7 @@ export const TextNormal = ({ currentQuestion, answer }: TextNormalProps) => {
display: "flex", display: "flex",
width: "100%", width: "100%",
marginTop: "20px", marginTop: "20px",
flexDirection: isMobile ? "column-reverse" : undefined, flexDirection: isCrutch23022025 ? "column" : isMobile ? "column-reverse" : undefined,
alignItems: "center", alignItems: "center",
}} }}
> >
@ -74,9 +75,9 @@ export const TextNormal = ({ currentQuestion, answer }: TextNormalProps) => {
{choiceImgUrlQuestion && choiceImgUrlQuestion !== " " && choiceImgUrlQuestion !== null && ( {choiceImgUrlQuestion && choiceImgUrlQuestion !== " " && choiceImgUrlQuestion !== null && (
<Box <Box
sx={{ sx={{
maxWidth: "400px", maxWidth: isCrutch23022025 ? undefined : "400px",
width: "100%", width: isCrutch23022025 ? "auto" : "100%",
height: "300px", height: isCrutch23022025 ? "auto" : "300px",
margin: "15px", margin: "15px",
}} }}
onClick={(event) => event.preventDefault()} onClick={(event) => event.preventDefault()}

@ -0,0 +1,139 @@
import { Box, TextField as MuiTextField, TextFieldProps, Typography, useTheme } from "@mui/material";
import { Answer, useQuizViewStore } from "@stores/quizView";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import type { ChangeEvent, FC } from "react";
import type { QuizQuestionText } from "@model/questionTypes/text";
import { useQuizStore } from "@/stores/useQuizStore";
const TextField = MuiTextField as unknown as FC<TextFieldProps>; // temporary fix ts(2590)
const ORIENTATION = [
{ horizontal: true },
{ horizontal: false },
{ horizontal: true },
{ horizontal: true },
{ horizontal: false },
{ horizontal: true },
{ horizontal: true },
{ horizontal: true },
{ horizontal: true },
{ horizontal: true },
{ horizontal: true },
{ horizontal: false },
{ horizontal: true },
{ horizontal: false },
{ horizontal: true },
{ horizontal: true },
{ horizontal: true },
{ horizontal: true },
{ horizontal: false },
{ horizontal: false },
{ horizontal: true },
{ horizontal: true },
{ horizontal: true },
{ horizontal: true },
];
interface TextSpecialProps {
currentQuestion: QuizQuestionText;
answer?: Answer;
stepNumber?: number | null;
}
export const TextSpecial = ({ currentQuestion, answer, stepNumber }: TextSpecialProps) => {
const { settings } = useQuizStore();
const { updateAnswer } = useQuizViewStore((state) => state);
const isHorizontal = ORIENTATION[Number(stepNumber) - 1].horizontal;
const theme = useTheme();
const isMobile = useRootContainerSize() < 650;
const onInputChange = async ({ target }: ChangeEvent<HTMLInputElement>) => {
updateAnswer(currentQuestion.id, target.value, 0);
};
return (
<Box
sx={{
display: "flex",
flexDirection: isMobile ? "column" : undefined,
alignItems: isMobile ? "center" : undefined,
}}
>
<Box
sx={{
display: "flex",
width: "100%",
marginTop: "20px",
flexDirection: "column",
alignItems: "center",
gap: "20px",
}}
>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
{isHorizontal && currentQuestion.content.back && currentQuestion.content.back !== " " && (
<Box
sx={{ margin: "30px", width: "50vw", maxHeight: "550px" }}
onClick={(event) => event.preventDefault()}
>
<img
key={currentQuestion.id}
src={currentQuestion.content.back}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
</Box>
)}
{
<TextField
autoFocus={true}
multiline
maxRows={4}
placeholder={currentQuestion.content.placeholder}
value={answer || ""}
onChange={onInputChange}
inputProps={{
maxLength: 400,
background: settings.cfg.design
? quizThemes[settings.cfg.theme].isLight
? "#F2F3F7"
: "rgba(154,154,175, 0.2)"
: "transparent",
}}
sx={{
width: "100%",
"& .MuiOutlinedInput-root": {
backgroundColor: settings.cfg.design ? "rgba(154,154,175, 0.2)" : "#FFFFFF",
},
"&:focus-visible": {
borderColor: theme.palette.primary.main,
},
}}
/>
}
</Box>
{!isHorizontal && currentQuestion.content.back && currentQuestion.content.back !== " " && (
<Box
sx={{ margin: "15px", width: "40vw" }}
onClick={(event) => event.preventDefault()}
>
<img
key={currentQuestion.id}
src={currentQuestion.content.back}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
alt=""
/>
</Box>
)}
</Box>
);
};

@ -0,0 +1,112 @@
import { Box, TextField as MuiTextField, TextFieldProps, Typography, useTheme } from "@mui/material";
import { Answer, useQuizViewStore } from "@stores/quizView";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import type { ChangeEvent, FC } from "react";
import type { QuizQuestionText } from "@model/questionTypes/text";
import { useQuizStore } from "@/stores/useQuizStore";
const TextField = MuiTextField as unknown as FC<TextFieldProps>; // temporary fix ts(2590)
interface TextSpecialProps {
currentQuestion: QuizQuestionText;
answer?: Answer;
stepNumber?: number | null;
}
export const TextSpecialHorisontal = ({ currentQuestion, answer, stepNumber }: TextSpecialProps) => {
const { settings } = useQuizStore();
const { updateAnswer } = useQuizViewStore((state) => state);
const isHorizontal = true;
const theme = useTheme();
const isMobile = useRootContainerSize() < 650;
const onInputChange = async ({ target }: ChangeEvent<HTMLInputElement>) => {
updateAnswer(currentQuestion.id, target.value, 0);
};
return (
<Box
sx={{
display: "flex",
flexDirection: isMobile ? "column" : undefined,
alignItems: isMobile ? "center" : undefined,
}}
>
<Box
sx={{
display: "flex",
width: "100%",
marginTop: "20px",
flexDirection: "column",
alignItems: "center",
gap: "20px",
}}
>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
{isHorizontal && currentQuestion.content.back && currentQuestion.content.back !== " " && (
<Box
sx={{ margin: "30px", width: "50vw", maxHeight: "550px" }}
onClick={(event) => event.preventDefault()}
>
<img
key={currentQuestion.id}
src={currentQuestion.content.back}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
alt=""
/>
</Box>
)}
{
<TextField
autoFocus={true}
multiline
maxRows={4}
placeholder={currentQuestion.content.placeholder}
value={answer || ""}
onChange={onInputChange}
inputProps={{
maxLength: 400,
background: settings.cfg.design
? quizThemes[settings.cfg.theme].isLight
? "#F2F3F7"
: "rgba(154,154,175, 0.2)"
: "transparent",
}}
sx={{
width: "100%",
"& .MuiOutlinedInput-root": {
backgroundColor: settings.cfg.design ? "rgba(154,154,175, 0.2)" : "#FFFFFF",
},
"&:focus-visible": {
borderColor: theme.palette.primary.main,
},
}}
/>
}
</Box>
{!isHorizontal && currentQuestion.content.back && currentQuestion.content.back !== " " && (
<Box
sx={{ margin: "15px", width: "40vw" }}
onClick={(event) => event.preventDefault()}
>
<img
key={currentQuestion.id}
src={currentQuestion.content.back}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
alt=""
/>
</Box>
)}
</Box>
);
};

@ -1,21 +1,55 @@
import { useQuizViewStore } from "@stores/quizView"; import { useQuizViewStore } from "@stores/quizView";
import { TextNormal } from "./TextNormal"; import { TextNormal } from "./TextNormal";
import { TextSpecial } from "./TextSpecial";
import { TextSpecialHorisontal } from "./TextSpecialHorisontal";
import type { QuizQuestionText } from "@model/questionTypes/text"; import type { QuizQuestionText } from "@model/questionTypes/text";
import { useQuizStore } from "@/stores/useQuizStore";
type TextProps = { type TextProps = {
currentQuestion: QuizQuestionText; currentQuestion: QuizQuestionText;
stepNumber: number | null; stepNumber: number | null;
}; };
const pathOnly = window.location.pathname;
export const Text = ({ currentQuestion, stepNumber }: TextProps) => { export const Text = ({ currentQuestion, stepNumber }: TextProps) => {
const { settings } = useQuizStore();
const answers = useQuizViewStore((state) => state.answers); const answers = useQuizViewStore((state) => state.answers);
const { answer } = answers.find(({ questionId }) => questionId === currentQuestion.id) ?? {}; const { answer } = answers.find(({ questionId }) => questionId === currentQuestion.id) ?? {};
if (pathOnly === "/92ed5e3e-8e6a-491e-87d0-d3197682d0e3" || pathOnly === "/cc006b40-ccbd-4600-a1d3-f902f85aa0a0")
return (
<TextSpecialHorisontal
currentQuestion={currentQuestion}
answer={answer}
stepNumber={stepNumber}
/>
);
switch (settings.cfg.spec) {
case true:
return (
<TextSpecial
currentQuestion={currentQuestion}
answer={answer}
stepNumber={stepNumber}
/>
);
case undefined:
return ( return (
<TextNormal <TextNormal
currentQuestion={currentQuestion} currentQuestion={currentQuestion}
answer={answer} answer={answer}
/> />
); );
default:
return (
<TextNormal
currentQuestion={currentQuestion}
answer={answer}
/>
);
}
}; };

@ -0,0 +1,216 @@
import { useQuizStore } from "@/stores/useQuizStore";
import type { QuestionVariant } from "@model/questionTypes/shared";
import {
Checkbox,
FormControlLabel,
Input,
TextField as MuiTextField,
Radio,
TextFieldProps,
TextareaAutosize,
Typography,
useTheme,
} from "@mui/material";
import { useQuizViewStore } from "@stores/quizView";
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>;
interface OwnInputProps {
questionId: string;
variant: QuestionVariant;
largeCheck: boolean;
ownPlaceholder: string;
}
const OwnInput = ({ questionId, variant, largeCheck, ownPlaceholder }: OwnInputProps) => {
const theme = useTheme();
const ownVariants = useQuizViewStore((state) => state.ownVariants);
const { updateOwnVariant } = useQuizViewStore((state) => state);
const ownAnswer = ownVariants[ownVariants.findIndex((v) => v.id === variant.id)]?.variant.answer || "";
return largeCheck ? (
<TextareaAutosize
placeholder={ownPlaceholder || "|"}
style={{
resize: "none",
width: "100%",
fontSize: "16px",
color: ownAnswer.length === 0 ? "ownPlaceholder" : theme.palette.text.primary,
letterSpacing: "-0.4px",
wordSpacing: "-3px",
outline: "0px none",
backgroundColor: "inherit",
border: "none",
//@ts-ignore
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.primary.main,
},
scrollbarColor: theme.palette.primary.main,
}}
value={ownAnswer}
onClick={(e: React.MouseEvent<HTMLTextAreaElement>) => e.stopPropagation()}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
updateOwnVariant(variant.id, e.target.value);
}}
/>
) : (
<Input
placeholder={ownPlaceholder || "|"}
sx={{
backgroundColor: "inherit",
width: "100%",
fontSize: "18px",
color: ownAnswer.length === 0 ? "ownPlaceholder" : theme.palette.text.primary,
}}
value={ownAnswer}
disableUnderline
onClick={(e: React.MouseEvent<HTMLElement>) => e.stopPropagation()}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
updateOwnVariant(variant.id, e.target.value);
}}
/>
);
};
export const VariantItem = ({
questionId,
isMulti,
variant,
answer,
index,
own = false,
questionLargeCheck,
ownPlaceholder,
}: {
isMulti: boolean;
questionId: string;
variant: QuestionVariant;
answer: string | string[] | undefined;
index: number;
own: boolean;
questionLargeCheck: boolean;
ownPlaceholder: string;
}) => {
const { settings } = useQuizStore();
const theme = useTheme();
const { updateAnswer, deleteAnswer } = useQuizViewStore((state) => state);
const { t } = useTranslation();
const sendVariant = async (event: MouseEvent<HTMLLabelElement>) => {
event.preventDefault();
const variantId = variant.id;
if (isMulti) {
const currentAnswer = typeof answer !== "string" ? answer || [] : [];
return updateAnswer(
questionId,
currentAnswer.includes(variantId)
? currentAnswer?.filter((item) => item !== variantId)
: [...currentAnswer, variantId],
variant.points || 0
);
}
updateAnswer(questionId, variantId, answer === variantId ? 0 : variant.points || 0);
if (answer === variantId) {
deleteAnswer(questionId);
}
};
return (
<FormControlLabel
key={variant.id}
sx={{
position: "relative",
margin: "0",
mt: own ? "10px" : "0",
borderRadius: "12px",
color: theme.palette.text.primary,
padding: "15px",
border: `1px solid`,
borderColor: answer === variant.id ? theme.palette.primary.main : "#9A9AAF",
backgroundColor: settings.cfg.design
? quizThemes[settings.cfg.theme].isLight
? "#FFFFFF"
: "rgba(255,255,255, 0.3)"
: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
display: "flex",
maxWidth: "685px",
maxHeight: "85px",
justifyContent: "space-between",
width: "100%",
"&:hover": { borderColor: theme.palette.primary.main },
"&.MuiFormControl-root": { width: "100%" },
"& .MuiFormControlLabel-label": {
width: "100%",
maxHeight: "100%",
wordBreak: "break-word",
height: variant.answer.length <= 60 ? undefined : "60px",
overflow: "auto",
lineHeight: "normal",
"&::-webkit-scrollbar": { width: "4px" },
"&::-webkit-scrollbar-thumb": { backgroundColor: theme.palette.primary.main },
scrollbarColor: theme.palette.primary.main,
},
"& .MuiFormControlLabel-label.Mui-disabled": {
color: theme.palette.text.primary,
},
}}
value={index}
labelPlacement="start"
control={
isMulti ? (
<Radio
checked={!!answer?.includes(variant.id)}
checkedIcon={<RadioCheck color={theme.palette.primary.main} />}
icon={<RadioIcon />}
/>
) : (
<Radio
checkedIcon={<RadioCheck color={theme.palette.primary.main} />}
icon={<RadioIcon />}
/>
)
}
label={
own ? (
<>
<Typography
sx={{
color: theme.palette.text.primary,
fontSize: "14px",
position: "absolute",
top: "-23px",
}}
>
{t("Enter your answer")}
</Typography>
<OwnInput
questionId={questionId}
variant={variant}
largeCheck={questionLargeCheck}
ownPlaceholder={ownPlaceholder || "|"}
/>
</>
) : (
variant.answer
)
}
onClick={sendVariant}
/>
);
};

@ -0,0 +1,159 @@
import { Box, FormGroup, RadioGroup, Typography, useTheme } from "@mui/material";
import { useEffect, useMemo } from "react";
import { VariantItem } from "./VariantItem";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import { useQuizViewStore } from "@stores/quizView";
import type { QuizQuestionVariant } from "@model/questionTypes/variant";
import moment from "moment";
type VariantProps = {
currentQuestion: QuizQuestionVariant;
};
// 23.02.2025
const crutchlist = {
115048: { x: 629, y: 491 },
115101: { x: 979, y: 980 },
115109: { x: 746, y: 745 },
115122: { x: 959, y: 960 },
115132: { x: 541, y: 541 },
115142: { x: 834, y: 544 },
115178: { x: 1127, y: 1127 },
115191: { x: 1106, y: 1106 },
115207: { x: 905, y: 906 },
115254: { x: 637, y: 637 },
115270: { x: 702, y: 703 },
115287: { x: 714, y: 715 },
115329: { x: 915, y: 916 },
115348: { x: 700, y: 701 },
115368: { x: 400, y: 300 },
115389: { x: 839, y: 840 },
115411: { x: 612, y: 610 },
115434: { x: 474, y: 473 },
115462: { x: 385, y: 385 },
115487: { x: 676, y: 677 },
115515: { x: 341, y: 341 },
115547: { x: 402, y: 403 },
115575: { x: 502, y: 503 },
115612: { x: 400, y: 300 },
115642: { x: 603, y: 603 },
};
export const Variant = ({ currentQuestion }: VariantProps) => {
const theme = useTheme();
const isMobile = useRootContainerSize() < 650;
const isTablet = useRootContainerSize() < 850;
const answers = useQuizViewStore((state) => state.answers);
const ownVariants = useQuizViewStore((state) => state.ownVariants);
const updateOwnVariant = useQuizViewStore((state) => state.updateOwnVariant);
const answer = answers.find(({ questionId }) => questionId === currentQuestion.id)?.answer;
const ownVariant = ownVariants.find((variant) => variant.id === currentQuestion.id);
const Group = currentQuestion.content.multi ? FormGroup : RadioGroup;
//let isCrutch23022025Question = isCrutch23022025 && crutchlist.hasOwnProperty(currentQuestion.id)
useEffect(() => {
if (!ownVariant) {
updateOwnVariant(currentQuestion.id, "");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const choiceImgUrlQuestion = useMemo(() => {
if (
currentQuestion.content.editedUrlImagesList !== undefined &&
currentQuestion.content.editedUrlImagesList !== null
) {
return currentQuestion.content.editedUrlImagesList[isMobile ? "mobile" : isTablet ? "tablet" : "desktop"];
} else {
return currentQuestion.content.back;
}
}, [currentQuestion]);
if (moment.isMoment(answer)) throw new Error("Answer is Moment in Variant question");
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
<Box
id="batya"
sx={{
display: "flex",
gap: "20px",
flexDirection: isMobile ? "column-reverse" : undefined,
alignItems: isMobile ? "center" : undefined,
}}
>
<Group
name={currentQuestion.id.toString()}
value={currentQuestion.content.variants.findIndex(({ id }) => answer === id)}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
flexBasis: "100%",
marginTop: "20px",
width: isMobile ? "100%" : undefined,
}}
>
<Box
sx={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
width: "100%",
gap: "20px",
}}
>
{currentQuestion.content.variants
.filter((v) => {
if (!v.isOwn) return true;
return v.isOwn && currentQuestion.content.own;
})
.map((variant, index) => (
<VariantItem
key={variant.id}
questionId={currentQuestion.id}
isMulti={currentQuestion.content.multi}
variant={variant}
answer={answer}
index={index}
own={Boolean(variant.isOwn)}
questionLargeCheck={currentQuestion.content.largeCheck}
ownPlaceholder={currentQuestion.content?.ownPlaceholder || ""}
/>
))}
</Box>
</Group>
{choiceImgUrlQuestion && choiceImgUrlQuestion !== " " && choiceImgUrlQuestion !== null && (
<Box
sx={{
maxWidth: "400px",
width: "100%",
height: "300px",
}}
onClick={(event) => event.preventDefault()}
>
<img
key={currentQuestion.id}
src={choiceImgUrlQuestion}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
</Box>
)}
</Box>
</Box>
);
};

@ -0,0 +1,83 @@
import React, { forwardRef, useState } from "react";
import { useQuizViewStore } from "@stores/quizView";
import { useQuizStore } from "@/stores/useQuizStore";
import { useSnackbar } from "notistack";
import { useTranslation } from "react-i18next";
import { sendFile } from "@/api/quizRelase";
import { ACCEPT_SEND_FILE_TYPES_MAP, MAX_FILE_SIZE } from "../../tools/fileUpload";
interface OwnVarimgImageProps {
questionId: string;
variantId: string;
}
export const OwnVarimgImage = forwardRef<HTMLInputElement, OwnVarimgImageProps>(({ questionId, variantId }, ref) => {
const { updateAnswer, updateOwnVariant } = useQuizViewStore((state) => state);
const { quizId, preview } = useQuizStore();
const { enqueueSnackbar } = useSnackbar();
const { t } = useTranslation();
const [isUploading, setIsUploading] = useState(false);
const uploadImage = async (file: File) => {
if (isUploading) return;
if (!file) return;
// Валидация размера файла
if (file.size > MAX_FILE_SIZE) {
enqueueSnackbar(t("file is too big"), { variant: "warning" });
return;
}
// Валидация типа файла
const isFileTypeAccepted = ACCEPT_SEND_FILE_TYPES_MAP.picture.some((fileType) =>
file.name.toLowerCase().endsWith(fileType)
);
if (!isFileTypeAccepted) {
enqueueSnackbar(t("file type is not supported"), { variant: "warning" });
return;
}
setIsUploading(true);
try {
const data = await sendFile({
questionId,
body: { file, name: file.name, preview },
qid: quizId,
});
const fileId = data?.data.fileIDMap[questionId];
const localImageUrl = URL.createObjectURL(file);
updateOwnVariant(variantId, "", "", fileId, localImageUrl);
// Убираем автоматический выбор own варианта - загрузка возможна только при выбранном own варианте
// updateAnswer(questionId, variantId, 0);
} catch (error) {
console.error("Error uploading image:", error);
enqueueSnackbar(t("The answer was not counted"));
} finally {
setIsUploading(false);
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
uploadImage(file);
event.target.value = "";
}
};
return (
<input
type="file"
ref={ref}
style={{ display: "none" }}
accept={ACCEPT_SEND_FILE_TYPES_MAP.picture.join(",")}
onChange={handleFileChange}
disabled={isUploading}
/>
);
});
OwnVarimgImage.displayName = "OwnVarimgImage";

@ -0,0 +1,237 @@
import type { QuestionVariant, QuestionVariantWithEditedImages } from "@/model/questionTypes/shared";
import { useQuizStore } from "@/stores/useQuizStore";
import { FormControlLabel, TextareaAutosize, Radio, useTheme, Box, Input, Typography } from "@mui/material";
import { useQuizViewStore } from "@stores/quizView";
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";
type VarimgVariantProps = {
questionId: string;
variant: QuestionVariantWithEditedImages;
index: number;
isSending: boolean;
setIsSending: (isSending: boolean) => void;
questionLargeCheck: boolean;
isMulti: boolean;
answer: string | string[] | undefined;
ownPlaceholder: string;
};
interface OwnInputProps {
questionId: string;
variant: QuestionVariant;
largeCheck: boolean;
ownPlaceholder: string;
}
const OwnInput = ({ questionId, variant, largeCheck, ownPlaceholder }: OwnInputProps) => {
const theme = useTheme();
const ownVariants = useQuizViewStore((state) => state.ownVariants);
const { updateOwnVariant } = useQuizViewStore((state) => state);
const ownAnswer = ownVariants[ownVariants.findIndex((v) => v.id === variant.id)]?.variant.answer || "";
return largeCheck ? (
<TextareaAutosize
placeholder={ownPlaceholder || "|"}
style={{
resize: "none",
width: "100%",
fontSize: "16px",
color: ownAnswer.length === 0 ? "ownPlaceholder" : theme.palette.text.primary,
letterSpacing: "-0.4px",
wordSpacing: "-3px",
outline: "0px none",
backgroundColor: "inherit",
border: "none",
//@ts-ignore
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.primary.main,
},
scrollbarColor: theme.palette.primary.main,
maxHeight: "44px",
overflow: "auto",
}}
value={ownAnswer}
onClick={(e: React.MouseEvent<HTMLTextAreaElement>) => e.stopPropagation()}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
updateOwnVariant(variant.id, e.target.value);
}}
/>
) : (
<Input
placeholder={ownPlaceholder || "|"}
sx={{
backgroundColor: "inherit",
width: "100%",
fontSize: "18px",
color: ownAnswer.length === 0 ? "ownPlaceholder" : theme.palette.text.primary,
}}
value={ownAnswer}
disableUnderline
onClick={(e: React.MouseEvent<HTMLElement>) => e.stopPropagation()}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
updateOwnVariant(variant.id, e.target.value);
}}
/>
);
};
export const VarimgVariant = ({
questionId,
variant,
index,
isSending,
setIsSending,
questionLargeCheck,
ownPlaceholder,
answer,
}: VarimgVariantProps) => {
const theme = useTheme();
const { settings } = useQuizStore();
const { t } = useTranslation();
const { updateAnswer, deleteAnswer } = useQuizViewStore((state) => state);
const sendVariant = async (event: MouseEvent<HTMLLabelElement>) => {
event.preventDefault();
updateAnswer(questionId, variant.id, variant.points || 0);
if (answer === variant.id) {
deleteAnswer(questionId);
}
};
if (variant?.isOwn) {
return (
<Box>
<Typography
sx={{
color: theme.palette.text.primary,
fontSize: "14px",
pl: "15px",
}}
>
{t("Enter your answer")}
</Typography>
<FormControlLabel
key={variant.id}
disabled={isSending}
sx={{
marginBottom: "15px",
borderRadius: "12px",
padding: "20px",
color: theme.palette.text.primary,
backgroundColor: settings.cfg.design
? quizThemes[settings.cfg.theme].isLight
? "#FFFFFF"
: "rgba(255,255,255, 0.3)"
: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
border: `1px solid`,
borderColor: answer === variant.id ? theme.palette.primary.main : "#9A9AAF",
display: "flex",
margin: 0,
justifyContent: "space-between",
"&:hover": { borderColor: theme.palette.primary.main },
"& .MuiFormControlLabel-label": {
wordBreak: "break-word",
height: variant.answer.length <= 60 ? undefined : "60px",
overflow: "auto",
lineHeight: "normal",
width: "100%",
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.primary.main,
},
scrollbarColor: theme.palette.primary.main,
},
"& .MuiFormControlLabel-label.Mui-disabled": {
color: theme.palette.text.primary,
},
}}
labelPlacement="start"
value={index}
onClick={sendVariant}
label={
<OwnInput
questionId={questionId}
variant={variant}
largeCheck={questionLargeCheck}
ownPlaceholder={ownPlaceholder || "|"}
/>
}
control={
<Radio
checkedIcon={<RadioCheck color={theme.palette.primary.main} />}
icon={<RadioIcon />}
/>
}
/>
</Box>
);
} else {
return (
<FormControlLabel
key={variant.id}
disabled={isSending}
sx={{
marginBottom: "15px",
borderRadius: "12px",
padding: "20px",
color: theme.palette.text.primary,
backgroundColor: settings.cfg.design
? quizThemes[settings.cfg.theme].isLight
? "#FFFFFF"
: "rgba(255,255,255, 0.3)"
: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
border: `1px solid`,
borderColor: answer === variant.id ? theme.palette.primary.main : "#9A9AAF",
display: "flex",
margin: 0,
justifyContent: "space-between",
"&:hover": { borderColor: theme.palette.primary.main },
"& .MuiFormControlLabel-label": {
wordBreak: "break-word",
height: variant.answer.length <= 60 ? undefined : "60px",
overflow: "auto",
lineHeight: "normal",
width: "100%",
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.primary.main,
},
scrollbarColor: theme.palette.primary.main,
},
"& .MuiFormControlLabel-label.Mui-disabled": {
color: theme.palette.text.primary,
},
}}
labelPlacement="start"
value={index}
onClick={sendVariant}
label={variant.answer}
control={
<Radio
checkedIcon={<RadioCheck color={theme.palette.primary.main} />}
icon={<RadioIcon />}
/>
}
/>
);
}
};

@ -0,0 +1,267 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, ButtonBase, RadioGroup, Typography, useTheme, IconButton } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { VarimgVariant } from "./VarimgVariant";
import { OwnVarimgImage } from "./OwnVarimgImage";
import { useQuizViewStore } from "@stores/quizView";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
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;
};
export const Varimg = ({ currentQuestion }: VarimgProps) => {
const [isSending, setIsSending] = useState<boolean>(false);
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;
const isTablet = useRootContainerSize() < 850;
const { answer } = answers.find(({ questionId }) => questionId === currentQuestion.id) ?? {};
const ownVariant = ownVariants.find((variant) => variant.id === currentQuestion.id);
const variant = currentQuestion.content.variants.find(({ id }) => answer === id);
const ownVariantInQuestion = useMemo(
() => currentQuestion.content.variants.find((v) => v.isOwn),
[currentQuestion.content.variants]
);
const ownVariantData = ownVariants.find((v) => v.id === answer);
const ownImageUrl = useMemo(() => {
return ownVariantData?.variant.localImageUrl;
}, [ownVariantData]);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!ownVariant) {
updateOwnVariant(currentQuestion.id, "");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const choiceImgUrlAnswer = useMemo(() => {
if (variant !== undefined) {
if (variant.editedUrlImagesList !== undefined && variant.editedUrlImagesList !== null) {
return variant.editedUrlImagesList[isMobile ? "mobile" : isTablet ? "tablet" : "desktop"];
} else {
return variant.extendedText;
}
}
}, [variant]);
const choiceImgUrlQuestion = useMemo(() => {
if (
currentQuestion.content.editedUrlImagesList !== undefined &&
currentQuestion.content.editedUrlImagesList !== null
) {
return currentQuestion.content.editedUrlImagesList[isMobile ? "mobile" : isTablet ? "tablet" : "desktop"];
} else {
return currentQuestion.content.back;
}
}, [variant]);
const handlePreviewAreaClick = () => {
// Загрузка возможна только если own вариант выбран
if (ownVariantInQuestion && answer === ownVariantInQuestion.id) {
inputRef.current?.click();
}
};
const handleRemoveImage = (e: React.MouseEvent) => {
e.stopPropagation();
if (ownVariantData) {
// Сохраняем текущий answer, очищаем только изображения
const currentAnswer = ownVariantData.variant.answer || "";
updateOwnVariant(ownVariantData.id, currentAnswer, "", "", "");
}
};
if (moment.isMoment(answer)) throw new Error("Answer is Moment in Variant question");
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{currentQuestion.title}
</Typography>
<Box
sx={{
display: "flex",
marginTop: "20px",
flexDirection: isMobile ? "column-reverse" : undefined,
gap: "30px",
alignItems: isMobile ? "center" : undefined,
}}
>
<RadioGroup
name={currentQuestion.id}
value={currentQuestion.content.variants.findIndex(({ id }) => answer === id)}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
flexBasis: "100%",
width: isMobile ? "100%" : undefined,
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
gap: "20px",
"&:focus": { color: theme.palette.text.primary },
"&:active": { color: theme.palette.text.primary },
}}
>
{currentQuestion.content.variants
.filter((v) => {
if (!v.isOwn) return true;
return v.isOwn && currentQuestion.content.own;
})
.map((variant, index) => (
<VarimgVariant
key={variant.id}
questionId={currentQuestion.id}
variant={variant}
isSending={isSending}
setIsSending={setIsSending}
index={index}
questionLargeCheck={currentQuestion.content.largeCheck}
ownPlaceholder={currentQuestion.content?.ownPlaceholder || ""}
isMulti={Boolean(currentQuestion.content?.multi)}
answer={answer}
/>
))}
{ownVariantInQuestion && (
<OwnVarimgImage
ref={inputRef}
questionId={currentQuestion.id}
variantId={ownVariantInQuestion.id}
/>
)}
</Box>
</RadioGroup>
<ButtonBase
onClick={handlePreviewAreaClick}
disabled={!ownVariantInQuestion || answer !== ownVariantInQuestion.id}
sx={{
maxWidth: "450px",
width: "100%",
height: "450px",
border: "1px solid #9A9AAF",
borderRadius: "12px",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#9A9AAF30",
color: theme.palette.text.primary,
textAlign: "center",
position: "relative",
"&:hover": {
backgroundColor:
ownVariantInQuestion && answer === ownVariantInQuestion.id ? "rgba(0,0,0,0.04)" : "transparent",
},
}}
>
{(() => {
if (answer) {
const imageUrl = variant?.isOwn && ownImageUrl ? ownImageUrl : choiceImgUrlAnswer;
if (imageUrl) {
return (
<>
<img
key={imageUrl}
src={imageUrl}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
{variant?.isOwn && ownImageUrl && (
<IconButton
onClick={handleRemoveImage}
sx={{
position: "absolute",
top: 8,
left: 8,
zIndex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
color: "white",
height: "25px",
width: "25px",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
}}
>
<CloseIcon />
</IconButton>
)}
</>
);
}
return (
<Box
sx={{
position: "relative",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<BlankImage />
{variant?.isOwn && (
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1,
}}
>
{t("Add your image")}
</Box>
)}
</Box>
);
}
if (choiceImgUrlQuestion && choiceImgUrlQuestion.trim().length > 0) {
return (
<img
src={choiceImgUrlQuestion}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
);
}
if (currentQuestion.content.replText && currentQuestion.content.replText.trim().length > 0) {
return currentQuestion.content.replText;
}
return isMobile ? t("Select an answer option below") : t("Select an answer option on the left");
})()}
</ButtonBase>
</Box>
</Box>
);
};

@ -10,7 +10,7 @@ interface Props {
export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }: Props) { export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }: Props) {
const { settings, nextLoading } = useQuizStore(); const { settings, nextLoading } = useQuizStore();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
return nextLoading ? ( return nextLoading ? (
<Skeleton <Skeleton

@ -13,7 +13,8 @@ export default function PrevButton({ isPreviousButtonEnabled, moveToPrevQuestion
const theme = useTheme(); const theme = useTheme();
const { settings } = useQuizStore(); const { settings } = useQuizStore();
const isMobileMini = useRootContainerSize() < 382; const isMobileMini = useRootContainerSize() < 382;
const { t } = useTranslation(); const { t, i18n } = useTranslation();
return ( return (
<Button <Button
disabled={!isPreviousButtonEnabled} disabled={!isPreviousButtonEnabled}

@ -0,0 +1,136 @@
import { useState, useEffect } from "react";
import { Select as MuiSelect, MenuItem, FormControl, Typography, useTheme } from "@mui/material";
import ArrowDown from "@icons/ArrowDownIcon";
import type { SelectChangeEvent, SxProps } from "@mui/material";
type SelectProps = {
items: string[];
activeItemIndex?: number;
empty?: boolean;
onChange?: (item: string, num: number) => void;
sx?: SxProps;
colorMain?: string;
colorPlaceholder?: string;
placeholder?: string;
};
export const Select = ({
items,
activeItemIndex = 0,
empty,
onChange,
sx,
placeholder = "",
colorMain = "#7E2AEA",
colorPlaceholder = "#9A9AAF",
}: SelectProps) => {
const [activeItem, setActiveItem] = useState<number>(empty ? -1 : activeItemIndex);
const theme = useTheme();
useEffect(() => {
setActiveItem(activeItemIndex);
}, [activeItemIndex]);
const handleChange = (event: SelectChangeEvent) => {
const newItemIndex = Number(event.target.value);
if (newItemIndex === activeItem) {
setActiveItem(-1);
onChange?.("", -1);
return;
}
setActiveItem(newItemIndex);
onChange?.(items[newItemIndex], newItemIndex);
};
return (
<FormControl
fullWidth
size="small"
sx={{ width: "100%", height: "48px", ...sx }}
>
<MuiSelect
displayEmpty
renderValue={(value) =>
value ? items[Number(value)] : <Typography sx={{ color: colorPlaceholder }}>{placeholder}</Typography>
}
id="display-select"
variant="outlined"
value={activeItem === -1 ? "" : String(activeItem)}
onChange={handleChange}
sx={{
width: "100%",
height: "48px",
borderRadius: "8px",
"& .MuiOutlinedInput-notchedOutline": {
border: `1px solid ${colorMain} !important`,
borderRadius: "10px",
},
"& .MuiSelect-icon": {
color: theme.palette.primary.main,
},
}}
MenuProps={{
PaperProps: {
sx: {
mt: "8px",
p: "4px",
borderRadius: "8px",
border: "1px solid #EEE4FC",
boxShadow: "0px 8px 24px rgba(210, 208, 225, 0.4)",
},
},
MenuListProps: {
sx: {
py: 0,
display: "flex",
flexDirection: "column",
gap: "8px",
maxWidth: "1380px",
"& .Mui-selected": {
backgroundColor: "#F2F3F7",
color: colorMain,
},
},
},
}}
inputProps={{
sx: {
color: theme.palette.text.primary,
display: "block",
px: "9px",
gap: "20px",
"& .MuiTypography-root": {
overflow: "hidden",
textOverflow: "ellipsis",
},
},
}}
IconComponent={(props) => <ArrowDown {...props} />}
>
{items.map((item, index) => (
<MenuItem
key={item + index}
value={index}
sx={{
display: "flex",
alignItems: "center",
gap: "20px",
padding: "10px",
borderRadius: "5px",
color: colorPlaceholder,
whiteSpace: "normal",
wordBreak: "break-word",
}}
>
{item}
</MenuItem>
))}
</MuiSelect>
</FormControl>
);
};

@ -50,7 +50,8 @@ export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizS
answerType: "single", answerType: "single",
onlyNumbers: false, onlyNumbers: false,
}; };
if (item.c) content.id = Math.floor(Math.random() * 9999999999) + 1; // Убираю замену ID - оставляю оригинальный с бэкенда
// if (item.c) content.id = Math.floor(Math.random() * 9999999999) + 1;
return { return {
description: item.desc, description: item.desc,
id: item.id, id: item.id,

@ -98,9 +98,21 @@ export interface QuizQuestionBase {
}; };
} }
export type AnyTypedQuizQuestion = QuizQuestionText | QuizQuestionResult; export type AnyTypedQuizQuestion =
| QuizQuestionVariant
| QuizQuestionImages
| QuizQuestionVarImg
| QuizQuestionEmoji
| QuizQuestionText
| QuizQuestionSelect
| QuizQuestionDate
| QuizQuestionNumber
| QuizQuestionFile
| QuizQuestionPage
| QuizQuestionRating
| QuizQuestionResult;
export type RealTypedQuizQuestion = Exclude<QuizQuestionText, QuizQuestionResult>; export type RealTypedQuizQuestion = Exclude<AnyTypedQuizQuestion, QuizQuestionResult>;
type FilterQuestionsWithVariants<T> = T extends { type FilterQuestionsWithVariants<T> = T extends {
content: { variants: QuestionVariant[] }; content: { variants: QuestionVariant[] };

@ -119,6 +119,7 @@ export interface QuizConfig {
showfc?: boolean; showfc?: boolean;
yandexMetricsNumber?: number; yandexMetricsNumber?: number;
vkMetricsNumber?: number; vkMetricsNumber?: number;
backBlocked?: boolean;
} }
export type FormContactFieldName = "name" | "email" | "phone" | "text" | "address"; export type FormContactFieldName = "name" | "email" | "phone" | "text" | "address";

@ -13,5 +13,6 @@ const isProduction = !(
//туризм больше не в исключениях //туризм больше не в исключениях
if (!isProduction) domain = "https://s.hbpn.link"; if (!isProduction) domain = "https://s.hbpn.link";
// domain = "https://hbpn.link";
export { domain, isProduction }; export { domain, isProduction };

@ -37,6 +37,6 @@ async function sendErrorsToServer() {
// body: errorsQueue, // body: errorsQueue,
// useToken: true, // useToken: true,
// }); // });
console.log(`Fake-sending ${errorsQueue.length} errors to server`, errorsQueue); console.info(`Fake-sending ${errorsQueue.length} errors to server`, errorsQueue);
errorsQueue = []; errorsQueue = [];
} }

@ -14,8 +14,6 @@ export function useAIQuiz() {
//Получаем инфо о квизе и список вопросов. //Получаем инфо о квизе и список вопросов.
const { settings, questions, quizId, cnt, quizStep } = useQuizStore(); const { settings, questions, quizId, cnt, quizStep } = useQuizStore();
useEffect(() => {}, [questions]);
//Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах //Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах
const answers = useQuizViewStore((state) => state.answers); const answers = useQuizViewStore((state) => state.answers);
@ -78,7 +76,7 @@ export function useAIQuiz() {
const setQuestion = useCallback((_: string) => {}, []); const setQuestion = useCallback((_: string) => {}, []);
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isPreviousButtonEnabled = quizStep > 0; const isPreviousButtonEnabled = settings.cfg?.backBlocked ? false : quizStep > 0;
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => { const isNextButtonEnabled = useMemo(() => {

@ -221,7 +221,7 @@ export function useBranchingQuiz() {
); );
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isPreviousButtonEnabled = Boolean(prevQuestion); const isPreviousButtonEnabled = settings.cfg?.backBlocked ? false : Boolean(prevQuestion);
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => { const isNextButtonEnabled = useMemo(() => {
@ -230,6 +230,7 @@ export function useBranchingQuiz() {
if ("required" in currentQuestion.content && currentQuestion.content.required) { if ("required" in currentQuestion.content && currentQuestion.content.required) {
return hasAnswer; return hasAnswer;
} }
if (linearQuestionIndex !== null && questions.length < cnt) return true; if (linearQuestionIndex !== null && questions.length < cnt) return true;
return Boolean(nextQuestion); return Boolean(nextQuestion);
}, [answers, currentQuestion, nextQuestion]); }, [answers, currentQuestion, nextQuestion]);

@ -221,7 +221,7 @@ export function useLinearQuiz() {
); );
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isPreviousButtonEnabled = Boolean(prevQuestion); const isPreviousButtonEnabled = settings.cfg?.backBlocked ? false : Boolean(prevQuestion);
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => { const isNextButtonEnabled = useMemo(() => {
@ -230,6 +230,7 @@ export function useLinearQuiz() {
if ("required" in currentQuestion.content && currentQuestion.content.required) { if ("required" in currentQuestion.content && currentQuestion.content.required) {
return hasAnswer; return hasAnswer;
} }
if (linearQuestionIndex !== null && questions.length < cnt) return true; if (linearQuestionIndex !== null && questions.length < cnt) return true;
return Boolean(nextQuestion); return Boolean(nextQuestion);
}, [answers, currentQuestion, nextQuestion]); }, [answers, currentQuestion, nextQuestion]);

@ -2,6 +2,7 @@ import { sendAnswer } from "@/api/quizRelase";
import { RealTypedQuizQuestion } from "@/model/questionTypes/shared"; import { RealTypedQuizQuestion } from "@/model/questionTypes/shared";
import { OwnVariant, QuestionAnswer, createQuizViewStore } from "@/stores/quizView"; import { OwnVariant, QuestionAnswer, createQuizViewStore } from "@/stores/quizView";
import moment from "moment"; import moment from "moment";
import { notReachable } from "./notReachable";
export async function sendQuestionAnswer( export async function sendQuestionAnswer(
quizId: string, quizId: string,
@ -16,8 +17,202 @@ export async function sendQuestionAnswer(
qid: quizId, qid: quizId,
}); });
} }
switch (question.type) {
case "date": {
let answer = "";
if (question.type === "text") { if (question.content.isRange) {
if (!Array.isArray(questionAnswer.answer)) throw new Error("Cannot send answer in range question");
let from = Number(questionAnswer.answer[0]);
let to = Number(questionAnswer.answer[1]);
if (
from !== 0 &&
to !== 0 &&
from !== Math.min(Number(questionAnswer.answer[0]), Number(questionAnswer.answer[1]))
) {
from = Math.min(Number(questionAnswer.answer[0]), Number(questionAnswer.answer[1]));
to = Math.max(Number(questionAnswer.answer[0]), Number(questionAnswer.answer[1]));
}
answer = `${!from ? "_" : moment(from).format("YYYY.MM.DD")} - ${!to ? "_" : moment(to).format("YYYY.MM.DD")}`;
} else {
if (!moment.isMoment(questionAnswer.answer)) throw new Error("Cannot send answer in date question");
answer = moment(questionAnswer.answer).format("YYYY.MM.DD");
}
return sendAnswer({
questionId: question.id,
body: answer,
qid: quizId,
});
}
case "emoji": {
if (question.content.multi) {
const answer = questionAnswer.answer as string[];
let answerString = ``;
const selectedVariants = question.content.variants.filter((v) => answer.includes(v.id));
selectedVariants.forEach((variant) => {
const ownVariantData = ownVariants.find((v) => v.id === variant.id)?.variant;
const customEmoji = ownVariantData?.extendedText || "";
const emojiToSend = customEmoji || variant.extendedText;
const textToSend = variant.isOwn ? ownVariantData?.answer || "" : variant.answer;
answerString += `\`${emojiToSend} ${textToSend}\`,`;
});
answerString = answerString.slice(0, -1);
return sendAnswer({
questionId: question.id,
body: answerString,
qid: quizId,
});
}
// Fallback for old string format for single choice
const answer = questionAnswer.answer as string;
const variant = question.content.variants.find((v) => v.id === answer);
if (!variant) {
// This can happen if the answer is not set, so we don't throw an error, just send empty
return sendAnswer({
questionId: question.id,
body: "",
qid: quizId,
});
}
const ownVariantData = ownVariants.find((v) => v.id === variant.id)?.variant;
const customEmoji = ownVariantData?.extendedText || "";
const emojiToSend = customEmoji || variant.extendedText;
const textToSend = variant.isOwn ? ownVariantData?.answer || "" : variant.answer;
const body = `${emojiToSend} ${textToSend}`.trim();
return sendAnswer({
questionId: question.id,
body: body,
qid: quizId,
});
}
case "file": {
return;
}
case "images": {
if (question.content.multi) {
const answer = questionAnswer.answer;
const ownAnswer = Array.isArray(answer)
? ownVariants[ownVariants.findIndex((variant) => answer.some((a: string) => a === variant.id))]?.variant
?.answer || ""
: ownVariants[ownVariants.findIndex((variant) => variant.id === questionAnswer.answer)]?.variant?.answer ||
"";
if (moment.isMoment(answer)) throw new Error("Answer is Moment in Variant question");
//Оставляем только выбранные варианты
const selectedVariants = question.content.variants.filter((v) => answer.includes(v.id));
let answerString = ``;
selectedVariants.forEach((e) => {
if (!e.isOwn || (e.isOwn && question.content.own)) {
let imageValue = e.extendedText;
if (e.isOwn) {
// Берем fileId из ownVariants для own вариантов
const ownVariantData = ownVariants.find((v) => v.id === e.id)?.variant;
if (ownVariantData?.originalImageUrl) {
// Конструируем полный URL для own вариантов
const baseUrl =
"https://s3.timeweb.cloud/3c580be9-cf31f296-d055-49cf-b39e-30c7959dc17b/squizimages/55c25eb9-4533-4d51-9da5-54e63e8aeace/";
// Убираем расширение файла из fileId
const fileIdWithoutExtension = ownVariantData.originalImageUrl.replace(
/\.(jpg|jpeg|png|gif|webp)$/i,
""
);
imageValue = baseUrl + fileIdWithoutExtension;
}
}
const body = {
Image: imageValue,
Description: e.isOwn ? ownAnswer : e.answer,
};
answerString += `\`${JSON.stringify(body)}\`,`;
}
});
answerString = answerString.slice(0, -1);
return sendAnswer({
questionId: question.id,
body: answerString,
qid: quizId,
});
}
const variant = question.content.variants.find((v) => v.id === questionAnswer.answer);
if (!variant) throw new Error(`Cannot find variant with id ${questionAnswer.answer} in question ${question.id}`);
let imageValue = variant.extendedText;
if (variant.isOwn) {
// Берем fileId из ownVariants для own вариантов
const ownVariantData = ownVariants.find((v) => v.id === variant.id)?.variant;
if (ownVariantData?.originalImageUrl) {
// Конструируем полный URL для own вариантов
const baseUrl =
"https://s3.timeweb.cloud/3c580be9-cf31f296-d055-49cf-b39e-30c7959dc17b/squizimages/55c25eb9-4533-4d51-9da5-54e63e8aeace/";
// Убираем расширение файла из fileId
const fileIdWithoutExtension = ownVariantData.originalImageUrl.replace(/\.(jpg|jpeg|png|gif|webp)$/i, "");
imageValue = baseUrl + fileIdWithoutExtension;
}
}
const body = {
Image: imageValue,
Description: variant.answer,
};
if (!body) throw new Error(`Body of answer in question ${question.id} is undefined`);
return sendAnswer({
questionId: question.id,
body: `\`${JSON.stringify(body)}\``,
qid: quizId,
});
}
case "number": {
if (typeof questionAnswer.answer !== "string") throw new Error("Cannot send answer in select question");
return sendAnswer({
questionId: question.id,
body: questionAnswer.answer,
qid: quizId,
});
}
case "page": {
return;
}
case "rating": {
if (typeof questionAnswer.answer !== "string") throw new Error("Cannot send answer in select question");
return sendAnswer({
questionId: question.id,
body: String(questionAnswer.answer) + " из " + question.content.steps,
qid: quizId,
});
}
case "select": {
if (typeof questionAnswer.answer !== "string") throw new Error("Cannot send answer in select question");
const variant = question.content.variants[Number(questionAnswer.answer)];
if (!variant) throw new Error(`Cannot find variant with id ${questionAnswer.answer} in question ${question.id}`);
return sendAnswer({
questionId: question.id,
body: variant.answer,
qid: quizId,
});
}
case "text": {
if (moment.isMoment(questionAnswer.answer)) throw new Error("Cannot send Moment in text question"); if (moment.isMoment(questionAnswer.answer)) throw new Error("Cannot send Moment in text question");
return sendAnswer({ return sendAnswer({
@ -25,5 +220,81 @@ export async function sendQuestionAnswer(
body: questionAnswer.answer, body: questionAnswer.answer,
qid: quizId, qid: quizId,
}); });
} else throw new Error("Inappropriate question type"); }
case "variant": {
if (question.content.multi) {
const answer = questionAnswer.answer;
if (moment.isMoment(answer)) throw new Error("Answer is Moment in Variant question");
const ownAnswer = Array.isArray(answer)
? ownVariants[ownVariants.findIndex((variant) => answer.some((a: string) => a === variant.id))]?.variant
?.answer || ""
: ownVariants[ownVariants.findIndex((variant) => variant.id === questionAnswer.answer)]?.variant?.answer ||
"";
//Оставляем только выбранные варианты
const selectedVariants = question.content.variants.filter((v) => answer.includes(v.id));
let answerString = ``;
selectedVariants.forEach((e) => {
if (!e.isOwn) answerString += `\`${e.answer}\`,`;
});
if (question.content.own && selectedVariants.some((v) => v.isOwn)) {
answerString += `\`${ownAnswer}\`,`;
}
answerString = answerString.slice(0, -1);
return sendAnswer({
questionId: question.id,
body: answerString,
qid: quizId,
});
}
const variant = question.content.variants.find((v) => v.id === questionAnswer.answer);
if (!variant) throw new Error(`Cannot find variant with id ${questionAnswer.answer} in question ${question.id}`);
return sendAnswer({
questionId: question.id,
body: variant.answer,
qid: quizId,
});
}
case "varimg": {
const variant = question.content.variants.find((v) => v.id === questionAnswer.answer);
const ownAnswer =
ownVariants[ownVariants.findIndex((variant) => variant.id === questionAnswer.answer)]?.variant?.answer || "";
if (!variant) throw new Error(`Cannot find variant with id ${questionAnswer.answer} in question ${question.id}`);
let imageValue = variant.extendedText;
if (variant.isOwn) {
// Берем fileId из ownVariants для own вариантов
const ownVariantData = ownVariants.find((v) => v.id === variant.id)?.variant;
if (ownVariantData?.originalImageUrl) {
// Конструируем полный URL для own вариантов
const baseUrl =
"https://s3.timeweb.cloud/3c580be9-cf31f296-d055-49cf-b39e-30c7959dc17b/squizimages/55c25eb9-4533-4d51-9da5-54e63e8aeace/";
// Убираем расширение файла из fileId
const fileIdWithoutExtension = ownVariantData.originalImageUrl.replace(/\.(jpg|jpeg|png|gif|webp)$/i, "");
imageValue = baseUrl + fileIdWithoutExtension;
}
}
const body = {
Image: imageValue,
Description: variant.isOwn ? ownAnswer : variant.answer,
};
if (!body) throw new Error(`Body of answer in question ${question.id} is undefined`);
return sendAnswer({
questionId: question.id,
body: `\`${JSON.stringify(body)}\``,
qid: quizId,
});
}
default:
notReachable(question);
}
} }

@ -26,6 +26,7 @@
"preview": "vite preview", "preview": "vite preview",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"prepublishOnly": "npm run build:package", "prepublishOnly": "npm run build:package",
"deploy": "docker login gitea.pena && docker build -t gitea.pena/squiz/frontanswerer/$(git branch --show-current):latest . && docker push gitea.pena/squiz/frontanswerer/$(git branch --show-current):latest",
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {

@ -56,5 +56,8 @@
"Step": "Шаг", "Step": "Шаг",
"questions are not ready yet": "Вопросы для аудитории ещё не созданы. Пожалуйста, подождите", "questions are not ready yet": "Вопросы для аудитории ещё не созданы. Пожалуйста, подождите",
"Add your image": "Добавьте своё изображение", "Add your image": "Добавьте своё изображение",
"select emoji": "выберите смайлик" "select emoji": "выберите смайлик",
"Please complete the phone number": "Пожалуйста, завершите номер телефона",
"Please enter a valid email": "Пожалуйста, введите корректную почту",
"Please enter a valid phone number": "Пожалуйста, введите корректный номер телефона"
} }

@ -10,11 +10,10 @@ const getLanguageFromURL = (): string => {
const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i); const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i);
if (langMatch) { if (langMatch) {
//console.log("Язык из URL:", langMatch[1]); const detectedLang = langMatch[1].toLowerCase();
return langMatch[1].toLowerCase(); return detectedLang;
} }
//console.log('Язык не указан в URL, используем "ru"');
return "ru"; // Жёсткий фолбэк return "ru"; // Жёсткий фолбэк
}; };
@ -33,6 +32,9 @@ i18n
backend: { backend: {
loadPath: "/locales/{{lng}}.json", loadPath: "/locales/{{lng}}.json",
allowMultiLoading: false, allowMultiLoading: false,
requestOptions: {
cache: "no-store",
},
}, },
react: { react: {
useSuspense: false, // Отключаем для совместимости с React 18 useSuspense: false, // Отключаем для совместимости с React 18
@ -43,11 +45,11 @@ i18n
caches: [], // Не использовать localStorage caches: [], // Не использовать localStorage
}, },
parseMissingKeyHandler: (key) => { parseMissingKeyHandler: (key) => {
console.warn("Missing translation:", key); console.warn("⚠️ Main i18n: Missing translation:", key);
return key; // Вернёт ключ вместо ошибки return key; // Вернёт ключ вместо ошибки
}, },
missingKeyHandler: (lngs, ns, key) => { missingKeyHandler: (lngs, ns, key) => {
console.error("🚨 Missing i18n key:", { console.error("🚨 Main i18n: Missing i18n key:", {
key, key,
languages: lngs, languages: lngs,
namespace: ns, namespace: ns,
@ -55,19 +57,35 @@ i18n
}); });
}, },
}) })
.then(() => {
//console.log("i18n инициализирован! Текущий язык:", i18n.language);
//console.log("Загруженные переводы:", i18n.store.data);
})
.catch((err) => { .catch((err) => {
console.error("Ошибка i18n:", err); console.error("❌ Main i18n: Initialization failed:", err);
}); });
// 3. Логирование всех событий // 3. Логирование всех событий
i18n.on("languageChanged", (lng) => {}); i18n.on("languageChanged", (lng) => {
console.log("🔄 Main i18n: Language changed to:", lng);
});
i18n.on("initialized", (options) => {
console.log("🎯 Main i18n: Initialized event fired with options:", options);
});
i18n.on("failedLoading", (lng, ns, msg) => { i18n.on("failedLoading", (lng, ns, msg) => {
console.error(`Ошибка загрузки ${lng}.json:`, msg); console.error(`💥 Main i18n: Failed loading ${lng}.json:`, msg);
// Если не удалось загрузить русский, пробуем английский
if (lng === "ru") {
console.log("🔄 Main i18n: Trying to load English as fallback");
i18n.changeLanguage("en");
}
});
i18n.on("loaded", (loaded) => {
console.log("📦 Main i18n: Translations loaded:", loaded);
});
i18n.on("missingKey", (lngs, namespace, key, res) => {
console.warn("⚠️ Main i18n: Missing key event:", { lngs, namespace, key, res });
}); });
export default i18n; export default i18n;

@ -5,13 +5,15 @@ import { initReactI18next } from "react-i18next";
const getLanguageFromURL = (): string => { const getLanguageFromURL = (): string => {
const path = window.location.pathname; const path = window.location.pathname;
const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i); const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i);
return langMatch ? langMatch[1].toLowerCase() : "ru"; // Фолбэк на 'ru' const detectedLang = langMatch ? langMatch[1].toLowerCase() : "ru"; // Фолбэк на 'ru'
return detectedLang;
}; };
// 2. Локали, встроенные прямо в конфиг // 2. Локали, встроенные прямо в конфиг
const r = { const r = {
ru: { ru: {
translation: {
"quiz is inactive": "Квиз не активирован", "quiz is inactive": "Квиз не активирован",
"no questions found": "Нет созданных вопросов", "no questions found": "Нет созданных вопросов",
"quiz is empty": "Квиз пуст", "quiz is empty": "Квиз пуст",
@ -67,12 +69,15 @@ const r = {
"Get results": "Получить результаты", "Get results": "Получить результаты",
"Data sent successfully": "Данные успешно отправлены", "Data sent successfully": "Данные успешно отправлены",
Step: "Шаг", Step: "Шаг",
"questions are not ready yet": "Вопросы для аудитории ещё не созданы. Пожалуйста, подождите", "questions are not ready yet": "Вопросы для аудитории пока не готовы. Подождите",
"Add your image": "Добавьте своё изображение", "Add your image": "Добавьте своё изображение",
"select emoji": "выберите смайлик", "select emoji": "выберите смайлик",
"Please complete the phone number": "Пожалуйста, заполните номер телефона до конца",
"": "", // Пустой ключ для fallback "": "", // Пустой ключ для fallback
}, },
},
en: { en: {
translation: {
"quiz is inactive": "Quiz is inactive", "quiz is inactive": "Quiz is inactive",
"no questions found": "No questions found", "no questions found": "No questions found",
"quiz is empty": "Quiz is empty", "quiz is empty": "Quiz is empty",
@ -131,9 +136,12 @@ const r = {
"questions are not ready yet": "There are no questions for the audience yet. Please wait", "questions are not ready yet": "There are no questions for the audience yet. Please wait",
"Add your image": "Add your image", "Add your image": "Add your image",
"select emoji": "select emoji", "select emoji": "select emoji",
"Please complete the phone number": "Please complete the phone number",
"": "", // Пустой ключ для fallback "": "", // Пустой ключ для fallback
}, },
},
uz: { uz: {
translation: {
"quiz is inactive": "Test faol emas", "quiz is inactive": "Test faol emas",
"no questions found": "Savollar topilmadi", "no questions found": "Savollar topilmadi",
"quiz is empty": "Test boʻsh", "quiz is empty": "Test boʻsh",
@ -192,12 +200,35 @@ const r = {
"questions are not ready yet": "Tomoshabinlar uchun hozircha savollar yo'q. Iltimos kuting", "questions are not ready yet": "Tomoshabinlar uchun hozircha savollar yo'q. Iltimos kuting",
"Add your image": "Rasmingizni qo'shing", "Add your image": "Rasmingizni qo'shing",
"select emoji": "emoji tanlang", "select emoji": "emoji tanlang",
"Please complete the phone number": "Iltimos, telefon raqamini to'liq kiriting",
"": "", // Пустой ключ для fallback "": "", // Пустой ключ для fallback
}, },
},
}; };
// 3. Конфигурация i18n без Backend // Проверяем, не инициализирован ли уже i18n
i18n.use(initReactI18next).init({ if (i18n.isInitialized) {
// Добавляем ресурсы к существующему экземпляру
(Object.keys(r) as Array<"ru" | "en" | "uz">).forEach((lng) => {
if (i18n.store.data[lng] && i18n.store.data[lng].translation) {
// Объединяем с существующими переводами
i18n.store.data[lng].translation = {
...(i18n.store.data[lng].translation as Record<string, string>),
...r[lng].translation,
};
} else {
// Добавляем новые переводы
i18n.store.data[lng] = {
...(i18n.store.data[lng] as Record<string, any>),
translation: r[lng].translation,
};
}
});
} else {
// 3. Конфигурация i18n без Backend
i18n
.use(initReactI18next)
.init({
resources: r, // Используем встроенные переводы resources: r, // Используем встроенные переводы
lng: getLanguageFromURL(), lng: getLanguageFromURL(),
fallbackLng: "ru", fallbackLng: "ru",
@ -215,17 +246,42 @@ i18n.use(initReactI18next).init({
caches: [], caches: [],
}, },
parseMissingKeyHandler: (key) => { parseMissingKeyHandler: (key) => {
console.warn("Missing translation:", key); console.warn("⚠️ Widget i18n: Missing translation key:", key);
return key; return key;
}, },
missingKeyHandler: (lngs, ns, key) => { missingKeyHandler: (lngs, ns, key) => {
console.error("🚨 Missing i18n key:", { console.error("🚨 Widget i18n: Missing i18n key:", {
key, key,
languages: lngs, languages: lngs,
namespace: ns, namespace: ns,
stack: new Error().stack, stack: new Error().stack,
}); });
}, },
})
.catch((error) => {
console.error("❌ Widget i18n: Initialization failed:", error);
});
}
// 4. Логирование событий
i18n.on("languageChanged", (lng) => {
console.log("🔄 Widget i18n: Language changed to:", lng);
});
i18n.on("initialized", (options) => {
console.log("🎯 Widget i18n: Initialized event fired with options:", options);
});
i18n.on("loaded", (loaded) => {
console.log("📦 Widget i18n: Loaded event fired:", loaded);
});
i18n.on("failedLoading", (lng, ns, msg) => {
console.error("💥 Widget i18n: Failed loading:", { lng, ns, msg });
});
i18n.on("missingKey", (lngs, namespace, key, res) => {
console.warn("⚠️ Widget i18n: Missing key event:", { lngs, namespace, key, res });
}); });
export default i18n; export default i18n;

@ -2,6 +2,7 @@ import { createRoot } from "react-dom/client";
import { RouteObject, RouterProvider, createBrowserRouter } from "react-router-dom"; import { RouteObject, RouterProvider, createBrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import { StrictMode, lazy } from "react"; import { StrictMode, lazy } from "react";
import "./i18n/i18n"; import "./i18n/i18n";
const routes: RouteObject[] = [ const routes: RouteObject[] = [

@ -2,6 +2,7 @@ import QuizAnswerer from "@/components/QuizAnswerer";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export * from "./widgets"; export * from "./widgets";
import "./i18n/i18nWidget"; import "./i18n/i18nWidget";
// old widget // old widget
@ -16,7 +17,10 @@ const widget = {
changeFaviconAndTitle: boolean; changeFaviconAndTitle: boolean;
}) { }) {
const element = document.getElementById(selector); const element = document.getElementById(selector);
if (!element) throw new Error("Element for widget doesn't exist"); if (!element) {
console.error("❌ Widget: Element for widget doesn't exist:", selector);
throw new Error("Element for widget doesn't exist");
}
const root = createRoot(element); const root = createRoot(element);

@ -56,7 +56,7 @@ export default function QuizPopup({
if (!quizData) return null; if (!quizData) return null;
const isQuizCompleted = quizData.settings.cfg.antifraud ? quizData.recentlyCompleted : false; const isQuizCompleted = quizData.settings?.cfg?.antifraud ? quizData.recentlyCompleted : false;
if (isQuizCompleted) return null; if (isQuizCompleted) return null;
if (hideOnMobile && isMobile) return null; if (hideOnMobile && isMobile) return null;

@ -56,7 +56,7 @@ export default function QuizSideButton({
if (hideOnMobile && isMobile) return null; if (hideOnMobile && isMobile) return null;
if (!quizData) return null; if (!quizData) return null;
const isQuizCompleted = quizData.settings.cfg.antifraud ? quizData.recentlyCompleted : false; const isQuizCompleted = quizData.settings?.cfg?.antifraud ? quizData.recentlyCompleted : false;
const showButtonFlash = !isQuizCompleted && isFlashEnabled; const showButtonFlash = !isQuizCompleted && isFlashEnabled;
return createPortal( return createPortal(

47250
widget_en.js Normal file

File diff suppressed because one or more lines are too long