Compare commits

..

2 Commits

Author SHA1 Message Date
8e0d066970 не стабильно, вернула запрос 100 вопросов
Some checks failed
Deploy / CreateImage (push) Failing after 3m1s
Deploy / DeployService (push) Failing after 23s
2025-05-31 20:32:13 +03:00
15434027ba загрузка файла в ответ 2025-05-31 19:52:33 +03:00
46 changed files with 1318 additions and 2105 deletions

@ -0,0 +1,34 @@
name: Deploy
run-name: ${{ gitea.actor }} build image and push to container registry
on:
push:
branches:
- "main"
- "staging"
jobs:
CreateImage:
runs-on: [frontstaging]
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
with:
runner: frontstaging
secrets:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DeployService:
runs-on: [frontstaging]
container:
image: gitea.pena:3000/penadevops/container-images/node-compose:main
env:
GITHUB_RUN_NUMBER: "${{ inputs.actionid }}"
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: printenv
- run: GITHUB_RUN_NUMBER=${{ gitea.run_id }} compose -f deployments/${{ gitea.ref_name }}/docker-compose.yaml up -d
# uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/deploy.yml@v1.1.6-p
# with:
# runner: frontstaging

@ -1,98 +1,11 @@
import useSWR from "swr";
import { getAndParceData } from "./quizRelase";
import { useEffect, useState } from "react";
import { initDataManager, statusOfQuiz } from "@/utils/hooks/useQuestionFlowControl";
import { addQuestions, changeNextLoading, setQuizData, useQuizStore } from "@/stores/useQuizStore";
/*
У хука есть три режмиа работы: "line" | "branch" | "ai"
Для branch и line единовременно запрашиваются ВСЕ данные (пока что это количество на 100 штук. Позже нужно впилить доп запросы чтобы получить все вопросы.)
Для ai идёт последовательный запрос данных. При первом попадании на result - блокируется возможность запрашивать новые данные
*/
import { getQuizData } from "./quizRelase";
export function useQuizData(quizId: string, preview: boolean = false) {
const { quizStep, questions } = useQuizStore();
const [page, setPage] = useState(0);
const [needFullLoad, setNeedFullLoad] = useState(false);
useEffect(() => {
if (quizStep > page) setPage(quizStep);
}, [quizStep]);
return useSWR(
preview ? null : ["quizData", quizId, page, needFullLoad],
async ([, id, currentPage, fullLoad]) => {
// Первый запрос - получаем статус
if (currentPage === 0 && !fullLoad) {
const firstData = await getAndParceData({
quizId: id,
limit: 1,
page: currentPage,
needConfig: true,
});
//firstData.settings.status = "ai";
console.log("useQuizData: firstData received:", firstData);
console.log("useQuizData: firstData.settings:", firstData.settings);
initDataManager({
status: firstData.settings.status,
haveRoot: firstData.settings.cfg.haveRoot,
});
console.log("useQuizData: calling setQuizData with firstData");
setQuizData(firstData);
// Определяем нужно ли загружать все данные
console.log("Определяем нужно ли загружать все данные");
console.log(firstData.settings.status);
if (!["ai"].includes(firstData.settings.status)) {
setNeedFullLoad(true); // Триггерит новый запрос через изменение ключа
return firstData;
}
return firstData;
}
// Полная загрузка для line/branch
if (fullLoad) {
const data = await getAndParceData({
quizId: id,
limit: 100,
page: 0,
needConfig: false,
});
addQuestions(data.questions.slice(1));
return data;
}
if (currentPage >= questions.length) {
try {
// Для AI режима - последовательная загрузка
const data = await getAndParceData({
quizId: id,
page: currentPage,
limit: 1,
needConfig: false,
});
console.log(
"AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE "
);
console.log(data);
addQuestions(data.questions);
changeNextLoading(false);
return data;
} catch (p) {
console.log(p);
setPage(questions.length);
changeNextLoading(false);
}
}
},
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
refreshInterval: 0,
}
);
return useSWR(preview ? null : ["quizData", quizId], (params) => getQuizData({ quizId: params[1] }), {
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
refreshInterval: 0,
});
}

@ -8,7 +8,6 @@ import { replaceSpacesToEmptyLines } from "../components/ViewPublicationPage/too
import { QuizSettings } from "@model/settingsData";
import * as Bowser from "bowser";
import { domain } from "../utils/defineDomain";
import { statusOfQuiz } from "@/utils/hooks/useQuestionFlowControl";
let SESSIONS = "";
const md = new MobileDetect(window.navigator.userAgent);
@ -58,9 +57,6 @@ type PublicationMakeRequestParams = {
method: "POST";
};
const urlParams = new URLSearchParams(window.location.search);
const paudParam = urlParams.get("_paud");
export const publicationMakeRequest = ({ url, body }: PublicationMakeRequestParams) => {
return axios(url, {
data: body,
@ -80,31 +76,11 @@ export const publicationMakeRequest = ({ url, body }: PublicationMakeRequestPara
let globalStatus: string | null = null;
let isFirstRequest = true;
/*
если запросить 0 вопросов - придёт items: null
если не запрашивать конфиг - поле конфига вообще не придёт
*/
interface GetDataProps {
quizId: string;
limit: number;
page: number;
needConfig: boolean;
}
export async function getData({ quizId, limit, page, needConfig }: GetDataProps): Promise<{
export async function getData({ quizId }: { quizId: string }): Promise<{
data: GetQuizDataResponse | null;
isRecentlyCompleted: boolean;
error?: AxiosError;
}> {
const body = {
quiz_id: quizId,
limit,
page,
need_config: needConfig,
} as any;
if (paudParam) body.auditory = Number(paudParam);
try {
const { data, headers } = await axios<GetQuizDataResponse>(
domain + `/answer/v1.0.0/settings${window.location.search}`,
@ -118,18 +94,18 @@ export async function getData({ quizId, limit, page, needConfig }: GetDataProps)
OS: OSDevice,
Browser: userAgent,
},
data: body,
data: {
quiz_id: quizId,
limit: 100,
page: 0,
need_config: true,
},
}
);
const sessions = JSON.parse(localStorage.getItem("sessions") || "{}");
//Тут ещё проверка на антифрод без парса конфига. Нам не интересно время если не нужно запрещать проходить чаще чем в сутки
if (
needConfig &&
data?.settings !== undefined &&
typeof sessions[quizId] === "number" &&
data.settings.cfg.includes('antifraud":true')
) {
if (typeof sessions[quizId] === "number" && data.settings.cfg.includes('antifraud":true')) {
// unix время. Если меньше суток прошло - выводить ошибку, иначе пустить дальше
if (Date.now() - sessions[quizId] < 86400000) {
return { data, isRecentlyCompleted: true };
@ -145,11 +121,114 @@ export async function getData({ quizId, limit, page, needConfig }: GetDataProps)
return { data: null, isRecentlyCompleted: false, error: error };
}
}
export async function getDataSingle({ quizId, page }: { quizId: string; page?: number }): Promise<{
data: GetQuizDataResponse | null;
isRecentlyCompleted: boolean;
error?: AxiosError;
}> {
try {
// Первый запрос: 1 вопрос + конфиг
if (isFirstRequest) {
const { data, headers } = await axios<GetQuizDataResponse>(
domain + `/answer/v1.0.0/settings${window.location.search}`,
{
method: "POST",
headers: {
"X-Sessionkey": SESSIONS,
"Content-Type": "application/json",
DeviceType: DeviceType,
Device: Device,
OS: OSDevice,
Browser: userAgent,
},
data: {
quiz_id: quizId,
limit: 1,
page: 0,
need_config: true,
},
}
);
export async function getAndParceData(props: GetDataProps) {
if (!props.quizId) throw new Error("No quiz id");
globalStatus = data.settings.status;
isFirstRequest = false;
SESSIONS = headers["x-sessionkey"] || SESSIONS;
const response = await getData(props);
// Проверка антифрода
const sessions = JSON.parse(localStorage.getItem("sessions") || "{}");
if (typeof sessions[quizId] === "number" && data.settings.cfg.includes('antifraud":true')) {
if (Date.now() - sessions[quizId] < 86400000) {
return { data, isRecentlyCompleted: true };
}
}
// Если статус не AI - сразу делаем запрос за всеми вопросами
if (globalStatus !== "ai") {
const secondResponse = await axios<GetQuizDataResponse>(
domain + `/answer/v1.0.0/settings${window.location.search}`,
{
method: "POST",
headers: {
"X-Sessionkey": SESSIONS,
"Content-Type": "application/json",
DeviceType: DeviceType,
Device: Device,
OS: OSDevice,
Browser: userAgent,
},
data: {
quiz_id: quizId,
limit: 100,
page: 0,
need_config: false,
},
}
);
return {
data: { ...data, items: secondResponse.data.items },
isRecentlyCompleted: false,
};
}
return { data, isRecentlyCompleted: false };
}
// Последующие запросы
const response = await axios<GetQuizDataResponse>(domain + `/answer/v1.0.0/settings${window.location.search}`, {
method: "POST",
headers: {
"X-Sessionkey": SESSIONS,
"Content-Type": "application/json",
DeviceType: DeviceType,
Device: Device,
OS: OSDevice,
Browser: userAgent,
},
data: {
quiz_id: quizId,
limit: 1,
page: page,
need_config: false,
},
});
return {
data: response.data,
isRecentlyCompleted: false,
};
} catch (error) {
return {
data: null,
isRecentlyCompleted: false,
error: error as AxiosError,
};
}
}
export async function getQuizData({ quizId, status = "" }: { quizId: string; status?: string }) {
if (!quizId) throw new Error("No quiz id");
const response = await getData({ quizId });
const quizDataResponse = response.data;
if (response.error) {
@ -167,12 +246,8 @@ export async function getAndParceData(props: GetDataProps) {
throw new Error("Quiz not found");
}
//Парсим строки в строках
console.log("до парса_______________________");
const quizSettings = replaceSpacesToEmptyLines(parseQuizData(quizDataResponse));
console.log("после парса_______________________");
console.log(quizSettings);
//Единоразово стрингифаим ВСЁ распаршенное и удаляем лишние пробелы
const res = JSON.parse(
JSON.stringify({ data: quizSettings })
.replaceAll(/\\" \\"/g, '""')
@ -182,6 +257,124 @@ export async function getAndParceData(props: GetDataProps) {
return res;
}
let page = 1;
export async function getQuizDataAI(quizId: string) {
console.log("[getQuizDataAI] Starting with quizId:", quizId); // Добавлено
let maxRetries = 50;
if (!quizId) {
console.error("[getQuizDataAI] Error: No quiz id provided");
throw new Error("No quiz id");
}
let lastError: Error | null = null;
let responseData: any = null;
// Первый цикл - обработка result вопросов
console.log("[getQuizDataAI] Starting result retries loop"); // Добавлено
let resultRetryCount = 0;
while (resultRetryCount < maxRetries) {
try {
console.log(`[getQuizDataAI] Attempt ${resultRetryCount + 1} for result questions, page: ${page}`);
const response = await getData({ quizId, page });
console.log("[getQuizDataAI] Response from getData:", response);
if (response.error) {
console.error("[getQuizDataAI] Error in response:", response.error);
throw response.error;
}
if (!response.data) {
console.error("[getQuizDataAI] Error: Quiz not found");
throw new Error("Quiz not found");
}
const hasAiResult = response.data.items.some((item) => item.typ === "result");
console.log("[getQuizDataAI] Has AI result:", hasAiResult);
if (hasAiResult) {
page++;
resultRetryCount++;
console.log(`[getQuizDataAI] Found result question, incrementing page to ${page}`);
continue;
}
responseData = response;
console.log("[getQuizDataAI] Found non-result questions, breaking loop");
break;
} catch (error) {
lastError = error as Error;
resultRetryCount++;
console.error(`[getQuizDataAI] Error in attempt ${resultRetryCount}:`, error);
if (resultRetryCount >= maxRetries) {
console.error("[getQuizDataAI] Max retries reached for result questions");
break;
}
const delay = 1500 * resultRetryCount;
console.log(`[getQuizDataAI] Waiting ${delay}ms before next retry`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
if (!responseData) {
console.error("[getQuizDataAI] Failed after result retries, throwing error");
throw lastError || new Error("Failed to get quiz data after result retries");
}
// Второй цикл - обработка пустого массива
console.log("[getQuizDataAI] Starting empty items retry loop"); // Добавлено
let isEmpty = !responseData.data?.items.length;
let emptyRetryCount = 0;
while (isEmpty && emptyRetryCount < maxRetries) {
try {
console.log(`[getQuizDataAI] Empty items retry ${emptyRetryCount + 1}`);
await new Promise((resolve) => setTimeout(resolve, 1000));
const response = await getData({ quizId, page });
if (response.error) {
console.error("[getQuizDataAI] Error in empty items check:", response.error);
throw response.error;
}
if (!response.data) {
console.error("[getQuizDataAI] Error: Quiz not found in empty check");
throw new Error("Quiz not found");
}
isEmpty = !response.data.items.length;
console.log("[getQuizDataAI] Is items empty:", isEmpty);
if (!isEmpty) {
responseData = response;
console.log("[getQuizDataAI] Found non-empty items, updating responseData");
}
emptyRetryCount++;
} catch (error) {
lastError = error as Error;
emptyRetryCount++;
console.error(`[getQuizDataAI] Error in empty check attempt ${emptyRetryCount}:`, error);
if (emptyRetryCount >= maxRetries) {
console.error("[getQuizDataAI] Max empty retries reached");
break;
}
}
}
if (isEmpty) {
console.error("[getQuizDataAI] Items still empty after retries");
throw new Error("Items array is empty after maximum retries");
}
// Финальная обработка
console.log("[getQuizDataAI] Processing final response data");
console.log("[getQuizDataAI] Final response before return:", responseData);
return responseData.data.items;
}
type SendAnswerProps = {
questionId: string;
body: string | string[];
@ -190,14 +383,15 @@ type SendAnswerProps = {
};
export function sendAnswer({ questionId, body, qid, preview = false }: SendAnswerProps) {
console.log("qid");
console.log(qid);
if (preview) return;
const formData = new FormData();
const answers = [
{
question_id: questionId,
//Для АИ квизов нельзя слать пустые строки
content: statusOfQuiz != "ai" ? body : body.length ? body : "-", //тут массив с ответом
content: body, //тут массив с ответом
},
];
formData.append("answers", JSON.stringify(answers));

77
lib/api/useQuizGetNext.ts Normal file

@ -0,0 +1,77 @@
import { useState } from "react";
import { getQuizDataAI } from "./quizRelase";
import { addQuestion, useQuizStore } from "@/stores/useQuizStore";
import { AnyTypedQuizQuestion } from "..";
function qparse(q: { desc: string; id: string; req: boolean; title: string; typ: string }) {
return {
description: q.desc,
id: q.id,
required: q.req,
title: q.title,
type: q.typ,
page: 0,
content: {
answerType: "single",
autofill: false,
back: "",
hint: { text: "", video: "" },
id: "",
innerName: "",
innerNameCheck: false,
onlyNumbers: false,
originalBack: "",
placeholder: "",
required: false,
rule: {
children: [],
default: "",
main: [],
parentId: "",
},
},
};
}
export const useQuizGetNext = () => {
const { quizId, settings } = useQuizStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const loadMoreQuestions = async () => {
console.log("STATUS loadMoreQuestions");
console.log(settings);
console.log(settings.status);
if (settings.status === "ai") {
console.log("STATUS after IF");
setIsLoading(true);
setError(null);
try {
console.log("STATUS after TRY TRY TRY");
const data = await getQuizDataAI(quizId);
console.log("data");
console.log(data);
const newQuestion = qparse(data[0]);
console.log("newQuestion");
console.log(newQuestion);
if (newQuestion) {
newQuestion.page = currentPage;
//@ts-ignore
addQuestion(newQuestion as AnyTypedQuizQuestion);
setCurrentPage((old) => old++);
console.log("newQuestion + page");
console.log(newQuestion);
return newQuestion;
}
} catch (err) {
setError(err as Error);
} finally {
setIsLoading(false);
}
}
};
return { loadMoreQuestions, isLoading, error, currentPage };
};

File diff suppressed because one or more lines are too long

@ -1,39 +1,21 @@
import { FC, SVGProps } from "react";
// Fallback функция для получения языка, когда React Router недоступен
const getLanguageFromUrlFallback = (): string => {
const path = window.location.pathname;
const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i);
if (langMatch) {
return langMatch[1].toLowerCase();
}
return "ru";
};
import { useLocation } from "react-router-dom";
export const NameplateLogoFQ: FC<SVGProps<SVGSVGElement>> = (props) => {
let lang = "ru"; // fallback
const location = useLocation();
const pathname = location.pathname;
try {
// Пытаемся использовать React Router
const { useLocation } = require("react-router-dom");
const location = useLocation();
const pathname = location.pathname;
const getLanguageFromUrl = () => {
const parts = pathname.split("/");
if (parts.length >= 2) {
const langSegment = parts[1].toLowerCase();
if (langSegment === "en") return "en";
if (langSegment === "uz") return "uz";
}
return "ru";
};
const getLanguageFromUrl = () => {
const parts = pathname.split("/");
if (parts.length >= 2) {
const langSegment = parts[1].toLowerCase();
if (langSegment === "en") return "en";
if (langSegment === "uz") return "uz";
}
return "ru";
};
lang = getLanguageFromUrl();
} catch (error) {
// Если React Router недоступен (в виджете), используем fallback
lang = getLanguageFromUrlFallback();
}
const lang = getLanguageFromUrl(); // Оптимизация - вызываем функцию один раз
if (lang === "ru") return <RU {...props} />;
if (lang === "en") return <EN {...props} />;

@ -1,39 +1,21 @@
import { FC, SVGProps } from "react";
// Fallback функция для получения языка, когда React Router недоступен
const getLanguageFromUrlFallback = (): string => {
const path = window.location.pathname;
const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i);
if (langMatch) {
return langMatch[1].toLowerCase();
}
return "ru";
};
import { useLocation } from "react-router-dom";
export const NameplateLogoFQDark: FC<SVGProps<SVGSVGElement>> = (props) => {
let lang = "ru"; // fallback
const location = useLocation();
const pathname = location.pathname;
try {
// Пытаемся использовать React Router
const { useLocation } = require("react-router-dom");
const location = useLocation();
const pathname = location.pathname;
const getLanguageFromUrl = () => {
const parts = pathname.split("/");
if (parts.length >= 2) {
const langSegment = parts[1].toLowerCase();
if (langSegment === "en") return "en";
if (langSegment === "uz") return "uz";
}
return "ru";
};
const getLanguageFromUrl = () => {
const parts = pathname.split("/");
if (parts.length >= 2) {
const langSegment = parts[1].toLowerCase();
if (langSegment === "en") return "en";
if (langSegment === "uz") return "uz";
}
return "ru";
};
lang = getLanguageFromUrl();
} catch (error) {
// Если React Router недоступен (в виджете), используем fallback
lang = getLanguageFromUrlFallback();
}
const lang = getLanguageFromUrl(); // Оптимизация - вызываем функцию один раз
if (lang === "ru") return <RU {...props} />;
if (lang === "en") return <EN {...props} />;

@ -21,7 +21,6 @@ import { HelmetProvider } from "react-helmet-async";
import "moment/dist/locale/ru";
import { useQuizStore, setQuizData, addquizid } from "@/stores/useQuizStore";
import { initDataManager, statusOfQuiz } from "@/utils/hooks/useQuestionFlowControl";
moment.locale("ru");
const localeText = ruRU.components.MuiLocalizationProvider.defaultProps.localeText;
@ -33,7 +32,16 @@ type Props = {
className?: string;
disableGlobalCss?: boolean;
};
function isQuizSettingsValid(data: any): data is QuizSettings {
return (
data &&
Array.isArray(data.questions) &&
data.settings &&
typeof data.cnt === "number" &&
typeof data.recentlyCompleted === "boolean" &&
typeof data.show_badge === "boolean"
);
}
function QuizAnswererInner({
quizSettings,
quizId,
@ -70,16 +78,17 @@ function QuizAnswererInner({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
//Хук на случай если данные переданы нам сразу, а не "нам нужно их запросить"
if (quizSettings !== undefined) {
console.log("QuizAnswerer: calling setQuizData with quizSettings");
setQuizData(quizSettings);
initDataManager({
status: quizSettings.settings.status,
haveRoot: quizSettings.settings.cfg.haveRoot,
});
console.log("got data");
console.log(quizSettings);
console.log(data);
const quiz = quizSettings || data;
console.log("quiz");
console.log(quiz);
if (quiz !== undefined) {
console.log("is not undefined");
setQuizData(quiz);
}
}, [quizSettings]);
}, [quizSettings, data]);
useLayoutEffect(() => {
if (rootContainerRef.current) setRootContainerWidth(rootContainerRef.current.clientWidth);
@ -100,16 +109,13 @@ function QuizAnswererInner({
console.log("settings");
console.log(settings);
if (isLoading && !questions.length) return <LoadingSkeleton />;
console.log("error");
console.log(error);
if (isLoading) return <LoadingSkeleton />;
if (error) return <ApologyPage error={error} />;
if (Object.keys(settings).length == 0) 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")
return <ApologyPage error={new Error("quiz is empty")} />;
if (questions.length === 1 && settings.cfg.noStartPage) return <ApologyPage error={new Error("quiz is empty")} />;
if (!quizId) return <ApologyPage error={new Error("no quiz id")} />;
const quizContainer = (

@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
type Props = Partial<FallbackProps>;
export const ApologyPage = ({ error }: Props) => {
let message = error.message || error.response?.data || " ";
let message = error.message || error.response?.data;
console.log("message");
console.log(message.toLowerCase());
const { t } = useTranslation();

@ -26,6 +26,7 @@ import type { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import { isProduction } from "@/utils/defineDomain";
import { useQuizStore } from "@/stores/useQuizStore";
import { useTranslation } from "react-i18next";
import { isNeftyanka } from "@/ui_kit/neftyankacrutch";
type Props = {
currentQuestion: AnyTypedQuizQuestion;
@ -318,7 +319,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
},
}}
>
{settings.cfg.formContact?.button || t("Get results")}
{isNeftyanka ? t("neftyanka button") : settings.cfg.formContact?.button || t("Get results")}
</Button>
</Box>
{show_badge && (

@ -3,6 +3,7 @@ import { useRootContainerSize } from "@contexts/RootContainerWidthContext.ts";
import { QuizSettingsConfig } from "@model/settingsData.ts";
import { FC } from "react";
import { useTranslation } from "react-i18next";
import { isNeftyanka } from "@/ui_kit/neftyankacrutch";
type ContactTextBlockProps = {
settings: QuizSettingsConfig;
@ -47,7 +48,9 @@ export const ContactTextBlock: FC<ContactTextBlockProps> = ({ settings }) => {
wordBreak: "break-word",
}}
>
{settings.cfg.formContact.title || t("Fill out the form to receive your test results")}
{isNeftyanka
? t("neftyanka FK")
: settings.cfg.formContact.title || t("Fill out the form to receive your test results")}
</Typography>
{settings.cfg.formContact.desc && (
<Typography

@ -5,7 +5,7 @@ import { useYandexMetrics } from "@/utils/hooks/metrics/useYandexMetrics";
import { sendQuestionAnswer } from "@/utils/sendQuestionAnswer";
import { ThemeProvider, Typography } from "@mui/material";
import { useQuizViewStore } from "@stores/quizView";
import { statusOfQuiz, useQuestionFlowControl } from "@utils/hooks/useQuestionFlowControl";
import { useQuestionFlowControl } from "@utils/hooks/useQuestionFlowControl";
import { notReachable } from "@utils/notReachable";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { enqueueSnackbar } from "notistack";
@ -18,10 +18,7 @@ import { StartPageViewPublication } from "./StartPageViewPublication";
import NextButton from "./tools/NextButton";
import PrevButton from "./tools/PrevButton";
import unscreen from "@/ui_kit/unscreen";
import { changeNextLoading, useQuizStore } from "@/stores/useQuizStore";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
polyfillCountryFlagEmojis();
import { useQuizStore } from "@/stores/useQuizStore";
export default function ViewPublicationPage() {
const { settings, recentlyCompleted, quizId, preview, changeFaviconAndTitle } = useQuizStore();
@ -111,7 +108,6 @@ export default function ViewPublicationPage() {
<NextButton
isNextButtonEnabled={settings.status === "ai" || isNextButtonEnabled}
moveToNextQuestion={async () => {
if (statusOfQuiz == "ai") changeNextLoading(true);
if (!preview) {
await sendQuestionAnswer(quizId, currentQuestion, currentAnswer, ownVariants)?.catch((e) => {
enqueueSnackbar("Ошибка при отправке ответа");

@ -1,49 +0,0 @@
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",
];

@ -1,6 +1,5 @@
import type { QuestionVariant } from "@/model/questionTypes/shared";
import { useQuizStore } from "@/stores/useQuizStore";
import { useQuizViewStore, type OwnVariant } from "@stores/quizView";
import {
Box,
Checkbox,
@ -12,14 +11,13 @@ import {
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 { 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();
@ -45,7 +43,7 @@ const OwnInput = ({ questionId, variant, largeCheck, ownPlaceholder }: OwnInputP
const ownVariants = useQuizViewStore((state) => state.ownVariants);
const { updateOwnVariant } = useQuizViewStore((state) => state);
const ownAnswer = ownVariants[ownVariants.findIndex((v: OwnVariant) => v.id === variant.id)]?.variant.answer || "";
const ownAnswer = ownVariants[ownVariants.findIndex((v) => v.id === variant.id)]?.variant.answer || "";
return largeCheck ? (
<Box sx={{ overflow: "auto" }}>
@ -108,52 +106,41 @@ export const EmojiVariant = ({
ownPlaceholder,
}: EmojiVariantProps) => {
const { settings } = useQuizStore();
const { updateAnswer, deleteAnswer, updateOwnVariant, ownVariants } = useQuizViewStore((state) => state);
const answers = useQuizViewStore((state) => state.answers);
const { updateAnswer, deleteAnswer } = 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;
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 currentAnswer = typeof answer !== "string" ? answer || [] : [];
return updateAnswer(
questionId,
currentAnswer.includes(variantId)
? currentAnswer?.filter((item) => item !== variantId)
: [...currentAnswer, variantId],
variant.points || 0
);
}
updateAnswer(questionId, variant.id, variant.points || 0);
if (answer === variant.id) {
deleteAnswer(questionId);
}
};
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",
borderColor: answer?.includes(variant.id) ? theme.palette.primary.main : "#9A9AAF",
overflow: "hidden",
maxWidth: "317px",
width: "100%",
@ -166,6 +153,7 @@ export const EmojiVariant = ({
: "transparent",
"&:hover": { borderColor: theme.palette.primary.main },
}}
// value={index}
onClick={onVariantClick}
>
<Box
@ -177,23 +165,15 @@ export const EmojiVariant = ({
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
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
{variant.extendedText && <Typography fontSize="100px">{variant.extendedText}</Typography>}
</Box>
</Box>
{own && (
<Typography
@ -237,14 +217,13 @@ export const EmojiVariant = ({
control={
isMulti ? (
<Checkbox
checked={isSelected}
checked={!!answer?.includes(variant.id)}
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" }}

@ -1,103 +0,0 @@
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>
</>
);
};

@ -13,11 +13,10 @@ type EmojiProps = {
export const Emoji = ({ currentQuestion }: EmojiProps) => {
const answers = useQuizViewStore((state) => state.answers);
const { updateAnswer } = useQuizViewStore((state) => state);
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 (
@ -31,7 +30,14 @@ export const Emoji = ({ currentQuestion }: EmojiProps) => {
</Typography>
<RadioGroup
name={currentQuestion.id}
value={selectedVariantId}
value={currentQuestion.content.variants.findIndex(({ id }) => answer === id)}
onChange={({ target }) =>
updateAnswer(
currentQuestion.id,
currentQuestion.content.variants[Number(target.value)].answer,
currentQuestion.content.variants[Number(target.value)].points || 0
)
}
sx={{
display: "flex",
flexWrap: "wrap",

@ -0,0 +1,122 @@
import { Box, ButtonBase, Typography, useTheme } from "@mui/material";
import { useMemo, useState } from "react";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import { useTranslation } from "react-i18next";
import { enqueueSnackbar } from "notistack";
import { ACCEPT_SEND_FILE_TYPES_MAP } from "@/components/ViewPublicationPage/tools/fileUpload";
import UploadIcon from "@icons/UploadIcon";
import { uploadFile } from "@/utils/fileUpload";
import { useQuizStore } from "@/stores/useQuizStore";
interface ImageCardProps {
questionId: string;
imageUrl: string;
isOwn?: boolean;
onImageUpload?: (fileUrl: string) => void;
}
const useFileUpload = (questionId: string, onImageUpload?: (fileUrl: string) => void) => {
const { t } = useTranslation();
const [isSending, setIsSending] = useState(false);
const [currentImageUrl, setCurrentImageUrl] = useState<string | null>(null);
const { quizId, preview } = useQuizStore();
const handleFileUpload = async (file: File | undefined) => {
if (isSending || !file) return;
const result = await uploadFile({
file,
questionId,
quizId,
fileType: "picture",
preview,
onSuccess: (fileUrl) => {
setCurrentImageUrl(URL.createObjectURL(file));
onImageUpload?.(fileUrl);
},
onError: (error) => {
console.error(error);
enqueueSnackbar(t(error.message));
},
onProgress: () => {
setIsSending(true);
},
});
setIsSending(false);
};
return {
isSending,
currentImageUrl,
handleFileUpload,
};
};
export const ImageCard = ({ questionId, imageUrl, isOwn, onImageUpload }: ImageCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const isMobile = useRootContainerSize() < 450;
const isTablet = useRootContainerSize() < 850;
const [isDropzoneHighlighted, setIsDropzoneHighlighted] = useState(false);
const { currentImageUrl, handleFileUpload } = useFileUpload(questionId, onImageUpload);
const onDrop = (event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDropzoneHighlighted(false);
const file = event.dataTransfer.files[0];
handleFileUpload(file);
};
return (
<Box sx={{ width: "100%", height: "300px", position: "relative" }}>
<img
src={currentImageUrl || imageUrl}
style={{
display: "block",
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: "12px 12px 0 0",
}}
alt=""
/>
{isOwn && (
<Box
component="label"
sx={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: isDropzoneHighlighted ? 1 : 0,
transition: "opacity 0.2s",
"&:hover": {
opacity: 1,
},
borderRadius: "12px 12px 0 0",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onDragEnter={() => setIsDropzoneHighlighted(true)}
onDragLeave={() => setIsDropzoneHighlighted(false)}
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
>
<input
onChange={({ target }) => handleFileUpload(target.files?.[0])}
hidden
accept={ACCEPT_SEND_FILE_TYPES_MAP.picture.join(",")}
type="file"
/>
<UploadIcon color="#FFFFFF" />
</Box>
)}
</Box>
);
};

@ -1,15 +1,30 @@
import { CheckboxIcon } from "@/assets/icons/Checkbox";
import type { QuestionVariant, QuestionVariantWithEditedImages } from "@/model/questionTypes/shared";
import { Box, Checkbox, FormControlLabel, Input, Radio, TextareaAutosize, Typography, useTheme } from "@mui/material";
import {
Box,
Checkbox,
FormControlLabel,
Input,
Radio,
TextareaAutosize,
Typography,
useTheme,
ButtonBase,
} 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 { useMemo, type MouseEvent, useRef, useEffect, useState } 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";
import { sendAnswer, sendFile } from "@api/quizRelase";
import { enqueueSnackbar } from "notistack";
import { ACCEPT_SEND_FILE_TYPES_MAP, MAX_FILE_SIZE } from "@/components/ViewPublicationPage/tools/fileUpload";
import UploadIcon from "@icons/UploadIcon";
import { uploadFile } from "@/utils/fileUpload";
import { ImageCard } from "./ImageCard";
type ImagesProps = {
questionId: string;
@ -23,7 +38,6 @@ type ImagesProps = {
};
interface OwnInputProps {
questionId: string;
variant: QuestionVariant;
largeCheck: boolean;
ownPlaceholder: string;
@ -98,15 +112,20 @@ export const ImageVariant = ({
const { deleteAnswer, updateAnswer } = useQuizViewStore((state) => state);
const theme = useTheme();
const { t } = useTranslation();
const answers = useQuizViewStore((state) => state.answers);
const isMobile = useRootContainerSize() < 450;
const isTablet = useRootContainerSize() < 850;
const { enqueueSnackbar } = useSnackbar();
const [isSending, setIsSending] = useState(false);
const { quizId, preview } = useQuizStore();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const onVariantClick = async (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
if (own) return;
const variantId = variant.id;
if (isMulti) {
const currentAnswer = typeof answer !== "string" ? answer || [] : [];
@ -127,13 +146,37 @@ export const ImageVariant = ({
}
};
const handleFileUpload = async (file: File | undefined) => {
if (isSending || !file) return;
const result = await uploadFile({
file,
questionId,
quizId,
fileType: "picture",
preview,
onSuccess: (fileUrl) => {
setImageUrl(URL.createObjectURL(file));
},
onError: (error) => {
console.error(error);
enqueueSnackbar(t(error.message));
},
onProgress: () => {
setIsSending(true);
},
});
setIsSending(false);
};
const choiceImgUrl = useMemo(() => {
if (variant.editedUrlImagesList !== undefined && variant.editedUrlImagesList !== null) {
return variant.editedUrlImagesList[isMobile ? "mobile" : isTablet ? "tablet" : "desktop"];
} else {
return variant.extendedText;
}
}, []);
}, [variant.editedUrlImagesList, isMobile, isTablet, variant.extendedText]);
useEffect(() => {
if (canvasRef.current !== null) {
@ -156,11 +199,11 @@ export const ImageVariant = ({
<Box
sx={{
position: "relative",
cursor: "pointer",
cursor: own ? "default" : "pointer",
borderRadius: "12px",
border: `1px solid`,
borderColor: !!answer?.includes(variant.id) ? theme.palette.primary.main : "#9A9AAF",
"&:hover": { borderColor: theme.palette.primary.main },
borderColor: !own && !!answer?.includes(variant.id) ? theme.palette.primary.main : "#9A9AAF",
"&:hover": { borderColor: !own ? theme.palette.primary.main : "#9A9AAF" },
background:
settings.cfg.design && !quizThemes[settings.cfg.theme].isLight
? "rgba(255,255,255, 0.3)"
@ -171,33 +214,13 @@ export const ImageVariant = ({
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>
{variant.extendedText && (
<ImageCard
questionId={questionId}
imageUrl={choiceImgUrl}
isOwn={own}
/>
)}
</Box>
{own && (
<Typography
@ -267,7 +290,6 @@ export const ImageVariant = ({
label={
own ? (
<OwnInput
questionId={questionId}
variant={variant}
largeCheck={questionLargeCheck}
ownPlaceholder={ownPlaceholder || "|"}

@ -1,187 +0,0 @@
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,120 @@
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;
}
function highlightQuestions(text: string) {
// Регулярка с учётом возможной точки в конце
const regex = /(вопрос\s\d+[a-zA-Zа-яА-Я]\.?)/g;
// Замена на <span> с жирным текстом
return text.replace(regex, '<span style="font-weight: bold">$1</span>');
}
export const TextNeftyanka = ({ 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",
}}
>
{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>
)}
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>
{highlightQuestions(currentQuestion.title)}
</Typography>
{
<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>
);
};

@ -5,6 +5,8 @@ import { TextSpecialHorisontal } from "./TextSpecialHorisontal";
import type { QuizQuestionText } from "@model/questionTypes/text";
import { useQuizStore } from "@/stores/useQuizStore";
import { isNeftyanka } from "@/ui_kit/neftyankacrutch";
import { TextNeftyanka } from "./TextNeftyanka";
type TextProps = {
currentQuestion: QuizQuestionText;
@ -18,7 +20,16 @@ export const Text = ({ currentQuestion, stepNumber }: TextProps) => {
const answers = useQuizViewStore((state) => state.answers);
const { answer } = answers.find(({ questionId }) => questionId === currentQuestion.id) ?? {};
if (pathOnly === "/92ed5e3e-8e6a-491e-87d0-d3197682d0e3" || pathOnly === "/cc006b40-ccbd-4600-a1d3-f902f85aa0a0")
if (isNeftyanka)
return (
<TextNeftyanka
currentQuestion={currentQuestion}
answer={answer}
stepNumber={stepNumber}
/>
);
if (pathOnly === "/92ed5e3e-8e6a-491e-87d0-d3197682d0e3")
return (
<TextSpecialHorisontal
currentQuestion={currentQuestion}

@ -1,83 +0,0 @@
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";

@ -1,12 +1,23 @@
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 {
FormControlLabel,
TextareaAutosize,
Radio,
useTheme,
Box,
Input,
FormControl,
InputLabel,
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";
import { useDebouncedCallback } from "use-debounce";
type VarimgVariantProps = {
questionId: string;
@ -164,12 +175,16 @@ export const VarimgVariant = ({
value={index}
onClick={sendVariant}
label={
<OwnInput
questionId={questionId}
variant={variant}
largeCheck={questionLargeCheck}
ownPlaceholder={ownPlaceholder || "|"}
/>
variant?.isOwn ? (
<OwnInput
questionId={questionId}
variant={variant}
largeCheck={questionLargeCheck}
ownPlaceholder={ownPlaceholder || "|"}
/>
) : (
variant.answer
)
}
control={
<Radio
@ -224,7 +239,18 @@ export const VarimgVariant = ({
labelPlacement="start"
value={index}
onClick={sendVariant}
label={variant.answer}
label={
variant?.isOwn ? (
<OwnInput
questionId={questionId}
variant={variant}
largeCheck={questionLargeCheck}
ownPlaceholder={ownPlaceholder || "|"}
/>
) : (
variant.answer
)
}
control={
<Radio
checkedIcon={<RadioCheck color={theme.palette.primary.main} />}

@ -1,9 +1,7 @@
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 { useEffect, useMemo, useState } from "react";
import { Box, RadioGroup, Typography, useTheme } from "@mui/material";
import { VarimgVariant } from "./VarimgVariant";
import { OwnVarimgImage } from "./OwnVarimgImage";
import { useQuizViewStore } from "@stores/quizView";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
@ -32,16 +30,6 @@ export const Varimg = ({ currentQuestion }: VarimgProps) => {
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) {
@ -70,23 +58,6 @@ export const Varimg = ({ currentQuestion }: VarimgProps) => {
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 (
@ -148,18 +119,9 @@ export const Varimg = ({ currentQuestion }: VarimgProps) => {
answer={answer}
/>
))}
{ownVariantInQuestion && (
<OwnVarimgImage
ref={inputRef}
questionId={currentQuestion.id}
variantId={ownVariantInQuestion.id}
/>
)}
</Box>
</RadioGroup>
<ButtonBase
onClick={handlePreviewAreaClick}
disabled={!ownVariantInQuestion || answer !== ownVariantInQuestion.id}
<Box
sx={{
maxWidth: "450px",
width: "100%",
@ -173,94 +135,34 @@ export const Varimg = ({ currentQuestion }: VarimgProps) => {
backgroundColor: "#9A9AAF30",
color: theme.palette.text.primary,
textAlign: "center",
position: "relative",
"&:hover": {
backgroundColor:
ownVariantInQuestion && answer === ownVariantInQuestion.id ? "rgba(0,0,0,0.04)" : "transparent",
},
}}
onClick={(event) => event.preventDefault()}
>
{(() => {
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>
{answer ? (
choiceImgUrlAnswer ? (
<img
key={choiceImgUrlAnswer}
src={choiceImgUrlAnswer}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
) : (
<BlankImage />
)
) : choiceImgUrlQuestion !== " " && choiceImgUrlQuestion !== null && choiceImgUrlQuestion.length > 0 ? (
<img
src={choiceImgUrlQuestion}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
) : currentQuestion.content.replText !== " " && currentQuestion.content.replText.length > 0 ? (
currentQuestion.content.replText
) : variant?.extendedText || isMobile ? (
t("Select an answer option below")
) : (
t("Select an answer option on the left")
)}
</Box>
</Box>
</Box>
);

@ -1,5 +1,5 @@
import { useQuizStore } from "@/stores/useQuizStore";
import { Button, Skeleton } from "@mui/material";
import { Button } from "@mui/material";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { useTranslation } from "react-i18next";
@ -9,19 +9,10 @@ interface Props {
}
export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }: Props) {
const { settings, nextLoading } = useQuizStore();
const { settings } = useQuizStore();
const { t } = useTranslation();
return nextLoading ? (
<Skeleton
variant="rectangular"
sx={{
borderRadius: "8px",
width: "96px",
height: "44px",
}}
/>
) : (
return (
<Button
disabled={!isNextButtonEnabled}
variant="contained"
@ -34,7 +25,7 @@ export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }:
}}
onClick={moveToNextQuestion}
>
{`${t("Next")}`}
далее {/* {t("Next")} → (*.*) */}
</Button>
);
}

@ -37,7 +37,8 @@ export default function PrevButton({ isPreviousButtonEnabled, moveToPrevQuestion
}}
onClick={moveToPrevQuestion}
>
{isMobileMini ? "←" : `${t("Prev")}`}
{isMobileMini ? "←" : `← назад`}
{/* {isMobileMini ? "←" : `← ${t("Prev")}`} (*.*) */}
</Button>
);
}

@ -3,7 +3,7 @@ import { QuizSettings } from "@model/settingsData";
export interface GetQuizDataResponse {
cnt: number;
settings?: {
settings: {
fp: boolean;
rep: boolean;
name: string;
@ -27,31 +27,9 @@ export interface GetQuizDataResponse {
}
export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizSettings, "recentlyCompleted"> {
console.log(quizDataResponse);
const readyData = {
cnt: quizDataResponse.cnt,
show_badge: quizDataResponse.show_badge,
settings: {} as QuizSettings["settings"],
questions: [] as QuizSettings["questions"],
} as QuizSettings;
const items: QuizSettings["questions"] = quizDataResponse.items.map((item) => {
const content = item.c
? JSON.parse(item.c)
: {
hint: { text: "", video: "" },
rule: { children: [], main: [], parentId: "", default: "" },
back: "",
originalBack: "",
autofill: false,
placeholder: "",
innerNameCheck: false,
innerName: "",
required: false,
answerType: "single",
onlyNumbers: false,
};
if (item.c) content.id = Math.floor(Math.random() * 9999999999) + 1;
const content = JSON.parse(item.c);
return {
description: item.desc,
id: item.id,
@ -63,22 +41,17 @@ export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizS
} as unknown as AnyTypedQuizQuestion;
});
readyData.questions = items;
const settings: QuizSettings["settings"] = {
fp: quizDataResponse.settings.fp,
rep: quizDataResponse.settings.rep,
name: quizDataResponse.settings.name,
cfg: JSON.parse(quizDataResponse?.settings.cfg),
lim: quizDataResponse.settings.lim,
due: quizDataResponse.settings.due,
delay: quizDataResponse.settings.delay,
pausable: quizDataResponse.settings.pausable,
status: quizDataResponse.settings.status,
};
if (quizDataResponse?.settings !== undefined) {
console.log("попытка парсануть сеттингс", quizDataResponse.settings);
readyData.settings = {
fp: quizDataResponse.settings.fp,
rep: quizDataResponse.settings.rep,
name: quizDataResponse.settings.name,
cfg: JSON.parse(quizDataResponse?.settings.cfg),
lim: quizDataResponse.settings.lim,
due: quizDataResponse.settings.due,
delay: quizDataResponse.settings.delay,
pausable: quizDataResponse.settings.pausable,
status: quizDataResponse.settings.status,
};
}
return readyData;
return { cnt: quizDataResponse.cnt, settings, questions: items, show_badge: quizDataResponse.show_badge };
}

@ -51,10 +51,7 @@ export type QuestionVariant = {
isMulti?: boolean;
/** Оригинал изображения (до кропа) */
originalImageUrl: string;
/** Локальный URL для предпросмотра */
localImageUrl?: string;
points?: number;
fileId?: string;
};
export interface QuestionVariantWithEditedImages extends QuestionVariant {
editedUrlImagesList?: EditedUrlImagesList | null;

@ -42,8 +42,6 @@ export type FCField = {
used: boolean;
};
export type Status = "start" | "stop" | "ai";
export type QuizSettingsConfig = {
fp: boolean;
rep: boolean;
@ -53,7 +51,7 @@ export type QuizSettingsConfig = {
delay: number;
pausable: boolean;
cfg: QuizConfig;
status: Status;
status: "start" | "stop" | "ai";
};
export type QuizSettings = {

@ -30,13 +30,7 @@ interface QuizViewStore {
interface QuizViewActions {
updateAnswer: (questionId: string, answer: string | string[] | Moment, points: number) => void;
deleteAnswer: (questionId: string) => void;
updateOwnVariant: (
id: string,
answer: string,
extendedText?: string,
originalImageUrl?: string,
localImageUrl?: string
) => void;
updateOwnVariant: (id: string, answer: string) => void;
deleteOwnVariant: (id: string) => void;
setCurrentQuizStep: (step: QuizStep) => void;
}
@ -96,7 +90,7 @@ export const createQuizViewStore = () =>
}
);
},
updateOwnVariant(id, answer, extendedText, originalImageUrl, localImageUrl) {
updateOwnVariant(id, answer) {
set(
(state) => {
const index = state.ownVariants.findIndex((variant) => variant.id === id);
@ -107,23 +101,13 @@ export const createQuizViewStore = () =>
variant: {
id: id,
answer,
extendedText: extendedText || "",
extendedText: "",
hints: "",
originalImageUrl: originalImageUrl || "",
localImageUrl: localImageUrl || "",
originalImageUrl: "",
},
});
} else {
state.ownVariants[index].variant.answer = answer;
if (extendedText !== undefined) {
state.ownVariants[index].variant.extendedText = extendedText;
}
if (originalImageUrl !== undefined) {
state.ownVariants[index].variant.originalImageUrl = originalImageUrl;
}
if (localImageUrl !== undefined) {
state.ownVariants[index].variant.localImageUrl = localImageUrl;
}
}
},
false,

@ -7,8 +7,6 @@ export type QuizStore = QuizSettings & {
quizId: string;
preview: boolean;
changeFaviconAndTitle: boolean;
quizStep: number;
nextLoading: boolean;
};
export const useQuizStore = create<QuizStore>(() => ({
@ -20,33 +18,17 @@ export const useQuizStore = create<QuizStore>(() => ({
cnt: 0,
recentlyCompleted: false,
show_badge: false,
quizStep: 0,
nextLoading: false,
}));
export const setQuizData = (data: QuizSettings) => {
console.log("setQuizData called with:");
console.log("data:", data);
console.log("data.settings:", data.settings);
console.log("data.questions:", data.questions);
const currentState = useQuizStore.getState();
console.log("Current state before update:", currentState);
useQuizStore.setState((state: QuizStore) => {
const newState = { ...state, ...data };
console.log("New state after update:", newState);
return newState;
});
const updatedState = useQuizStore.getState();
console.log("State after setState:", updatedState);
console.log("zusstand");
console.log(data);
useQuizStore.setState((state: QuizStore) => ({ ...state, ...data }));
};
export const addQuestions = (newQuestions: AnyTypedQuizQuestion[]) =>
export const addQuestion = (newQuestion: AnyTypedQuizQuestion) =>
useQuizStore.setState(
produce((state: QuizStore) => {
state.questions.push(...newQuestions);
state.questions.push(newQuestion);
})
);
export const addquizid = (id: string) =>
@ -55,29 +37,3 @@ export const addquizid = (id: string) =>
state.quizId = id;
})
);
export const quizStepInc = () =>
useQuizStore.setState(
produce((state: QuizStore) => {
//Дополнительная проверка что мы не вышли за пределы массива вопросов
if (state.quizStep + 1 <= state.questions.length) {
state.quizStep += 1;
}
})
);
export const quizStepDec = () =>
useQuizStore.setState(
produce((state: QuizStore) => {
//Дополнительная проверка что мы не вышли на менее чем 0 вопрос
if (state.quizStep > 0) {
state.quizStep--;
}
})
);
export const changeNextLoading = (status: boolean) =>
useQuizStore.setState(
produce((state: QuizStore) => {
state.nextLoading = status;
})
);

@ -0,0 +1 @@
export const isNeftyanka = window.location.pathname === "/cc006b40-ccbd-4600-a1d3-f902f85aa0a0";

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

80
lib/utils/fileUpload.ts Normal file

@ -0,0 +1,80 @@
import { UploadFileType } from "@model/questionTypes/file";
import { sendAnswer, sendFile } from "@api/quizRelase";
import { ACCEPT_SEND_FILE_TYPES_MAP, MAX_FILE_SIZE } from "@/components/ViewPublicationPage/tools/fileUpload";
export interface UploadFileOptions {
file: File;
questionId: string;
quizId: string;
fileType: UploadFileType;
preview: boolean;
onSuccess?: (fileUrl: string) => void;
onError?: (error: Error) => void;
onProgress?: (progress: number) => void;
}
export interface UploadFileResult {
success: boolean;
fileUrl?: string;
error?: Error;
}
export async function uploadFile({
file,
questionId,
quizId,
fileType,
preview,
onSuccess,
onError,
onProgress,
}: UploadFileOptions): Promise<UploadFileResult> {
try {
// Проверка размера файла
if (file.size > MAX_FILE_SIZE) {
const error = new Error("File is too big. Maximum size is 50 MB");
onError?.(error);
return { success: false, error };
}
// Проверка типа файла
const isFileTypeAccepted = ACCEPT_SEND_FILE_TYPES_MAP[fileType].some((fileType) =>
file.name.toLowerCase().endsWith(fileType)
);
if (!isFileTypeAccepted) {
const error = new Error("Incorrect file type selected");
onError?.(error);
return { success: false, error };
}
// Загрузка файла
const data = await sendFile({
questionId,
body: {
file,
name: file.name,
preview,
},
qid: quizId,
});
// Отправка ответа
await sendAnswer({
questionId,
body: `${data!.data.fileIDMap[questionId]}`,
qid: quizId,
preview,
});
const fileUrl = `${file.name}|${URL.createObjectURL(file)}`;
onSuccess?.(fileUrl);
onProgress?.(100);
return { success: true, fileUrl };
} catch (error) {
const err = error instanceof Error ? error : new Error("Unknown error occurred");
onError?.(err);
return { success: false, error: err };
}
}

@ -1,120 +0,0 @@
import { useCallback, useDebugValue, useEffect, useMemo, useState } from "react";
import { enqueueSnackbar } from "notistack";
import moment from "moment";
import { isResultQuestionEmpty } from "@/components/ViewPublicationPage/tools/checkEmptyData";
import { changeNextLoading, quizStepDec, quizStepInc, useQuizStore } from "@/stores/useQuizStore";
import { useQuizViewStore } from "@stores/quizView";
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
export function useAIQuiz() {
//Получаем инфо о квизе и список вопросов.
const { settings, questions, quizId, cnt, quizStep } = useQuizStore();
useEffect(() => {
console.log("useQuestionFlowControl useEffect");
console.log(questions);
}, [questions]);
//Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах
const answers = useQuizViewStore((state) => state.answers);
//Текущий шаг "startpage" | "question" | "contactform"
const setCurrentQuizStep = useQuizViewStore((state) => state.setCurrentQuizStep);
//Получение возможности управлять состоянием метрик
const vkMetrics = useVkMetricsGoals(settings.cfg.vkMetricsNumber);
const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber);
const currentQuestion = useMemo(() => {
console.log("выбор currentQuestion");
console.log("quizStep ", quizStep);
console.log("questions[quizStep] ", questions[quizStep]);
const calcQuestion = questions[quizStep];
if (calcQuestion) {
vkMetrics.questionPassed(calcQuestion.id);
yandexMetrics.questionPassed(calcQuestion.id);
return calcQuestion;
} else return questions[questions.length - 1];
}, [questions, quizStep]);
useEffect(() => {
if (currentQuestion.type === "result") showResult();
if (currentQuestion) changeNextLoading(false);
console.log("questions");
console.log(questions);
}, [currentQuestion, questions]);
//Показать визуалом юзеру результат
const showResult = useCallback(() => {
if (currentQuestion?.type !== "result") throw new Error("Current question is not result");
//Смотрим по настройкам показывать ли вообще форму контактов. Показывать ли страницу результатов до или после формы контактов (ФК)
if (
settings.cfg.showfc !== false &&
(settings.cfg.resultInfo.showResultForm === "after" || isResultQuestionEmpty(currentQuestion))
)
setCurrentQuizStep("contactform");
}, [currentQuestion, setCurrentQuizStep, settings.cfg.resultInfo.showResultForm, settings.cfg.showfc]);
//рычаг управления из визуала в этот контроллер
const showResultAfterContactForm = useCallback(() => {
if (currentQuestion?.type !== "result") throw new Error("Current question is not result");
if (isResultQuestionEmpty(currentQuestion)) return;
setCurrentQuizStep("question");
}, [currentQuestion, setCurrentQuizStep]);
//рычаг управления из визуала в этот контроллер
const moveToPrevQuestion = useCallback(() => {
if (quizStep > 0 && !questions[quizStep - 1]) throw new Error("Previous question not found");
if (settings.status === "ai" && quizStep > 0) quizStepDec();
}, [quizStep]);
//рычаг управления из визуала в этот контроллер
const moveToNextQuestion = useCallback(async () => {
changeNextLoading(true);
quizStepInc();
}, [quizStep, changeNextLoading, quizStepInc]);
//рычаг управления из визуала в этот контроллер
const setQuestion = useCallback((_: string) => {}, []);
//Анализ дисаблить ли кнопки навигации
const isPreviousButtonEnabled = quizStep > 0;
//Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => {
const hasAnswer = answers.some(({ questionId }) => questionId === currentQuestion.id);
if ("required" in currentQuestion.content && currentQuestion.content.required) {
return hasAnswer;
}
return quizStep < cnt;
}, [answers, currentQuestion]);
useDebugValue({
CurrentQuestionIndex: quizStep,
currentQuestion: currentQuestion,
prevQuestion: questions[quizStep + 1],
nextQuestion: questions[quizStep - 1],
});
return {
currentQuestion,
currentQuestionStepNumber: null,
nextQuestion: undefined,
isNextButtonEnabled,
isPreviousButtonEnabled,
moveToPrevQuestion,
moveToNextQuestion,
showResultAfterContactForm,
setQuestion,
};
}

@ -1,266 +0,0 @@
import { useCallback, useDebugValue, useEffect, useMemo, useState } from "react";
import { enqueueSnackbar } from "notistack";
import moment from "moment";
import { isResultQuestionEmpty } from "@/components/ViewPublicationPage/tools/checkEmptyData";
import { useQuizStore } from "@/stores/useQuizStore";
import { useQuizViewStore } from "@stores/quizView";
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
export function useBranchingQuiz() {
//Получаем инфо о квизе и список вопросов.
const { settings, questions, quizId, cnt } = useQuizStore();
useEffect(() => {
console.log("useQuestionFlowControl useEffect");
console.log(questions);
}, [questions]);
console.log(questions);
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page.
//За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page
const sortedQuestions = useMemo(() => {
return [...questions].sort((a, b) => a.page - b.page);
}, [questions]);
//React сам будет менять визуал - главное говорить из какого вопроса ему брать инфо. Изменение этой переменной меняет визуал.
const [currentQuestionId, setCurrentQuestionId] = useState<string | null>(getFirstQuestionId);
//Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах
const answers = useQuizViewStore((state) => state.answers);
//Список засчитанных баллов для балловых квизов
const pointsSum = useQuizViewStore((state) => state.pointsSum);
//Текущий шаг "startpage" | "question" | "contactform"
const setCurrentQuizStep = useQuizViewStore((state) => state.setCurrentQuizStep);
//Получение возможности управлять состоянием метрик
const vkMetrics = useVkMetricsGoals(settings.cfg.vkMetricsNumber);
const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber);
//Изменение стейта (переменной currentQuestionId) ведёт к пересчёту что же за объект сейчас используется. Мы каждый раз просто ищем в списке
const currentQuestion = sortedQuestions.find((question) => question.id === currentQuestionId) ?? sortedQuestions[0];
//Индекс текущего вопроса только если квиз линейный
const linearQuestionIndex = //: number | null
currentQuestion && sortedQuestions.every(({ content }) => content.rule.parentId !== "root") // null when branching enabled
? sortedQuestions.indexOf(currentQuestion)
: null;
//Индекс первого вопроса
function getFirstQuestionId() {
//: string | null
if (sortedQuestions.length === 0) return null; //Если нету сортированного списка, то и не рыпаемся
if (settings.cfg.haveRoot) {
// Если есть ветвление, то settings.cfg.haveRoot будет заполнен
//Если заполнен, то дерево растёт с root и это 1 вопрос :)
const nextQuestion = sortedQuestions.find(
//Функция ищет первое совпадение по массиву
(question) => question.id === settings.cfg.haveRoot || question.content.id === settings.cfg.haveRoot
);
if (!nextQuestion) return null;
return nextQuestion.id;
}
//Если не возникло исключительных ситуаций - первый вопрос - нулевой элемент сортированного массива
return sortedQuestions[0].id;
}
const nextQuestionIdPointsLogic = useCallback(() => {
return sortedQuestions.find((question) => question.type === "result" && question.content.rule.parentId === "line");
}, [sortedQuestions]);
//Анализируем какой вопрос должен быть следующим. Это главная логика
const nextQuestionIdMainLogic = useCallback(() => {
//Список ответов данных этому вопросу. Вернёт QuestionAnswer | undefined
const questionAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id);
//Если questionAnswer не undefined и ответ на вопрос не является временем:
if (questionAnswer && !moment.isMoment(questionAnswer.answer)) {
//Вопрос типизации. Получаем список строк ответов на этот вопрос
const userAnswers = Array.isArray(questionAnswer.answer) ? questionAnswer.answer : [questionAnswer.answer];
//цикл. Перебираем список условий .main и обзываем их переменной branchingRule
for (const branchingRule of currentQuestion.content.rule.main) {
// Перебираем список ответов. Если хоть один ответ из списка совпадает с прописанным правилом из условий - этот вопрос нужный нам. Его и дадимкак следующий
if (userAnswers.some((answer) => branchingRule.rules[0].answers.includes(answer))) {
return branchingRule.next;
}
}
}
//Не помню что это, но чёт при первом взгляде оно true только у результатов
if (!currentQuestion.required) {
//Готовим себе дефолтный путь
const defaultNextQuestionId = currentQuestion.content.rule.default;
//Если строка не пустая и не пробел. (Обычно при получении данных мы сразу чистим пустые строки только с пробелом на просто пустые строки. Это прост доп защита)
if (defaultNextQuestionId.length > 1 && defaultNextQuestionId !== " ") return defaultNextQuestionId;
//Вопросы типа страница, ползунок, своё поле для ввода и дата не могут иметь больше 1 ребёнка. Пользователь не может настроить там дефолт
//Кинуть на ребёнка надо даже если там нет дефолта
if (
["date", "page", "text", "number"].includes(currentQuestion.type) &&
currentQuestion.content.rule.children.length === 1
)
return currentQuestion.content.rule.children[0];
}
//ничё не нашли, ищем резулт
return sortedQuestions.find((q) => {
return q.type === "result" && q.content.rule.parentId === currentQuestion.content.id;
})?.id;
}, [answers, currentQuestion, sortedQuestions]);
//Анализ следующего вопроса. Это логика для вопроса с баллами
const nextQuestionId = useMemo(() => {
if (settings.cfg.score) {
return nextQuestionIdPointsLogic();
}
return nextQuestionIdMainLogic();
}, [nextQuestionIdMainLogic, nextQuestionIdPointsLogic, settings.cfg.score, questions]);
//Поиск предыдущго вопроса либо по индексу либо по id родителя
const prevQuestion =
linearQuestionIndex !== null
? sortedQuestions[linearQuestionIndex - 1]
: sortedQuestions.find(
(q) =>
q.id === currentQuestion?.content.rule.parentId || q.content.id === currentQuestion?.content.rule.parentId
);
//Анализ результата по количеству баллов
const findResultPointsLogic = useCallback(() => {
//Отбираем из массива только тип резулт И результы с информацией о ожидаемых баллах И те результы, чьи суммы баллов меньше или равны насчитанным баллам юзера
const results = sortedQuestions.filter(
(e) => e.type === "result" && e.content.rule.minScore !== undefined && e.content.rule.minScore <= pointsSum
);
//Создаём массив строк из результатов. У кого есть инфо о баллах - дают свои, остальные 0
const numbers = results.map((e) =>
e.type === "result" && e.content.rule.minScore !== undefined ? e.content.rule.minScore : 0
);
//Извлекаем самое большое число
const indexOfNext = Math.max(...numbers);
//Отдаём индекс нужного нам результата
return results[numbers.indexOf(indexOfNext)];
}, [pointsSum, sortedQuestions]);
//Ищем следующий вопрос (не его индекс, или id). Сам вопрос
const nextQuestion = useMemo(() => {
let next;
if (settings.cfg.score) {
//Ессли квиз балловый
if (linearQuestionIndex !== null) {
next = sortedQuestions[linearQuestionIndex + 1]; //ищем по индексу
if (next?.type === "result" || next == undefined) next = findResultPointsLogic(); //если в поисках пришли к результату - считаем нужный
}
} else {
//иначе
if (linearQuestionIndex !== null) {
//для линейных ищем по индексу
next =
sortedQuestions[linearQuestionIndex + 1] ??
sortedQuestions.find((question) => question.type === "result" && question.content.rule.parentId === "line");
} else {
// для нелинейных ищем по вычесленному id
next = sortedQuestions.find((q) => q.id === nextQuestionId || q.content.id === nextQuestionId);
}
}
return next;
}, [nextQuestionId, findResultPointsLogic, linearQuestionIndex, sortedQuestions, settings.cfg.score]);
//Показать визуалом юзеру результат
const showResult = useCallback(() => {
if (nextQuestion?.type !== "result") throw new Error("Current question is not result");
//Записать в переменную ид текущего вопроса
setCurrentQuestionId(nextQuestion.id);
//Смотрим по настройкам показывать ли вообще форму контактов. Показывать ли страницу результатов до или после формы контактов (ФК)
if (
settings.cfg.showfc !== false &&
(settings.cfg.resultInfo.showResultForm === "after" || isResultQuestionEmpty(nextQuestion))
)
setCurrentQuizStep("contactform");
}, [nextQuestion, setCurrentQuizStep, settings.cfg.resultInfo.showResultForm, settings.cfg.showfc]);
//рычаг управления из визуала в этот контроллер
const showResultAfterContactForm = useCallback(() => {
if (currentQuestion?.type !== "result") throw new Error("Current question is not result");
if (isResultQuestionEmpty(currentQuestion)) return;
setCurrentQuizStep("question");
}, [currentQuestion, setCurrentQuizStep]);
//рычаг управления из визуала в этот контроллер
const moveToPrevQuestion = useCallback(() => {
if (!prevQuestion) throw new Error("Previous question not found");
setCurrentQuestionId(prevQuestion.id);
}, [prevQuestion]);
//рычаг управления из визуала в этот контроллер
const moveToNextQuestion = useCallback(async () => {
// Если есть следующий вопрос в уже загруженных - используем его
if (nextQuestion) {
vkMetrics.questionPassed(currentQuestion.id);
yandexMetrics.questionPassed(currentQuestion.id);
if (nextQuestion.type === "result") return showResult();
setCurrentQuestionId(nextQuestion.id);
return;
}
}, [currentQuestion.id, nextQuestion, showResult, vkMetrics, yandexMetrics, linearQuestionIndex, questions]);
//рычаг управления из визуала в этот контроллер
const setQuestion = useCallback(
(questionId: string) => {
const question = sortedQuestions.find((q) => q.id === questionId);
if (!question) return;
setCurrentQuestionId(question.id);
},
[sortedQuestions]
);
//Анализ дисаблить ли кнопки навигации
const isPreviousButtonEnabled = Boolean(prevQuestion);
//Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => {
const hasAnswer = answers.some(({ questionId }) => questionId === currentQuestion.id);
if ("required" in currentQuestion.content && currentQuestion.content.required) {
return hasAnswer;
}
console.log(linearQuestionIndex);
console.log(questions.length);
console.log(cnt);
if (linearQuestionIndex !== null && questions.length < cnt) return true;
return Boolean(nextQuestion);
}, [answers, currentQuestion, nextQuestion]);
useDebugValue({
linearQuestionIndex,
currentQuestion: currentQuestion,
prevQuestion: prevQuestion,
nextQuestion: nextQuestion,
});
return {
currentQuestion,
currentQuestionStepNumber:
settings.status === "ai" ? null : linearQuestionIndex === null ? null : linearQuestionIndex + 1,
nextQuestion,
isNextButtonEnabled,
isPreviousButtonEnabled,
moveToPrevQuestion,
moveToNextQuestion,
showResultAfterContactForm,
setQuestion,
};
}

@ -1,266 +0,0 @@
import { useCallback, useDebugValue, useEffect, useMemo, useState } from "react";
import { enqueueSnackbar } from "notistack";
import moment from "moment";
import { isResultQuestionEmpty } from "@/components/ViewPublicationPage/tools/checkEmptyData";
import { useQuizStore } from "@/stores/useQuizStore";
import { useQuizViewStore } from "@stores/quizView";
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
export function useLinearQuiz() {
//Получаем инфо о квизе и список вопросов.
const { settings, questions, quizId, cnt } = useQuizStore();
useEffect(() => {
console.log("useQuestionFlowControl useEffect");
console.log(questions);
}, [questions]);
console.log(questions);
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page.
//За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page
const sortedQuestions = useMemo(() => {
return [...questions].sort((a, b) => a.page - b.page);
}, [questions]);
//React сам будет менять визуал - главное говорить из какого вопроса ему брать инфо. Изменение этой переменной меняет визуал.
const [currentQuestionId, setCurrentQuestionId] = useState<string | null>(getFirstQuestionId);
//Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах
const answers = useQuizViewStore((state) => state.answers);
//Список засчитанных баллов для балловых квизов
const pointsSum = useQuizViewStore((state) => state.pointsSum);
//Текущий шаг "startpage" | "question" | "contactform"
const setCurrentQuizStep = useQuizViewStore((state) => state.setCurrentQuizStep);
//Получение возможности управлять состоянием метрик
const vkMetrics = useVkMetricsGoals(settings.cfg.vkMetricsNumber);
const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber);
//Изменение стейта (переменной currentQuestionId) ведёт к пересчёту что же за объект сейчас используется. Мы каждый раз просто ищем в списке
const currentQuestion = sortedQuestions.find((question) => question.id === currentQuestionId) ?? sortedQuestions[0];
//Индекс текущего вопроса только если квиз линейный
const linearQuestionIndex = //: number | null
currentQuestion && sortedQuestions.every(({ content }) => content.rule.parentId !== "root") // null when branching enabled
? sortedQuestions.indexOf(currentQuestion)
: null;
//Индекс первого вопроса
function getFirstQuestionId() {
//: string | null
if (sortedQuestions.length === 0) return null; //Если нету сортированного списка, то и не рыпаемся
if (settings.cfg.haveRoot) {
// Если есть ветвление, то settings.cfg.haveRoot будет заполнен
//Если заполнен, то дерево растёт с root и это 1 вопрос :)
const nextQuestion = sortedQuestions.find(
//Функция ищет первое совпадение по массиву
(question) => question.id === settings.cfg.haveRoot || question.content.id === settings.cfg.haveRoot
);
if (!nextQuestion) return null;
return nextQuestion.id;
}
//Если не возникло исключительных ситуаций - первый вопрос - нулевой элемент сортированного массива
return sortedQuestions[0].id;
}
const nextQuestionIdPointsLogic = useCallback(() => {
return sortedQuestions.find((question) => question.type === "result" && question.content.rule.parentId === "line");
}, [sortedQuestions]);
//Анализируем какой вопрос должен быть следующим. Это главная логика
const nextQuestionIdMainLogic = useCallback(() => {
//Список ответов данных этому вопросу. Вернёт QuestionAnswer | undefined
const questionAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id);
//Если questionAnswer не undefined и ответ на вопрос не является временем:
if (questionAnswer && !moment.isMoment(questionAnswer.answer)) {
//Вопрос типизации. Получаем список строк ответов на этот вопрос
const userAnswers = Array.isArray(questionAnswer.answer) ? questionAnswer.answer : [questionAnswer.answer];
//цикл. Перебираем список условий .main и обзываем их переменной branchingRule
for (const branchingRule of currentQuestion.content.rule.main) {
// Перебираем список ответов. Если хоть один ответ из списка совпадает с прописанным правилом из условий - этот вопрос нужный нам. Его и дадимкак следующий
if (userAnswers.some((answer) => branchingRule.rules[0].answers.includes(answer))) {
return branchingRule.next;
}
}
}
//Не помню что это, но чёт при первом взгляде оно true только у результатов
if (!currentQuestion.required) {
//Готовим себе дефолтный путь
const defaultNextQuestionId = currentQuestion.content.rule.default;
//Если строка не пустая и не пробел. (Обычно при получении данных мы сразу чистим пустые строки только с пробелом на просто пустые строки. Это прост доп защита)
if (defaultNextQuestionId.length > 1 && defaultNextQuestionId !== " ") return defaultNextQuestionId;
//Вопросы типа страница, ползунок, своё поле для ввода и дата не могут иметь больше 1 ребёнка. Пользователь не может настроить там дефолт
//Кинуть на ребёнка надо даже если там нет дефолта
if (
["date", "page", "text", "number"].includes(currentQuestion.type) &&
currentQuestion.content.rule.children.length === 1
)
return currentQuestion.content.rule.children[0];
}
//ничё не нашли, ищем резулт
return sortedQuestions.find((q) => {
return q.type === "result" && q.content.rule.parentId === currentQuestion.content.id;
})?.id;
}, [answers, currentQuestion, sortedQuestions]);
//Анализ следующего вопроса. Это логика для вопроса с баллами
const nextQuestionId = useMemo(() => {
if (settings.cfg.score) {
return nextQuestionIdPointsLogic();
}
return nextQuestionIdMainLogic();
}, [nextQuestionIdMainLogic, nextQuestionIdPointsLogic, settings.cfg.score, questions]);
//Поиск предыдущго вопроса либо по индексу либо по id родителя
const prevQuestion =
linearQuestionIndex !== null
? sortedQuestions[linearQuestionIndex - 1]
: sortedQuestions.find(
(q) =>
q.id === currentQuestion?.content.rule.parentId || q.content.id === currentQuestion?.content.rule.parentId
);
//Анализ результата по количеству баллов
const findResultPointsLogic = useCallback(() => {
//Отбираем из массива только тип резулт И результы с информацией о ожидаемых баллах И те результы, чьи суммы баллов меньше или равны насчитанным баллам юзера
const results = sortedQuestions.filter(
(e) => e.type === "result" && e.content.rule.minScore !== undefined && e.content.rule.minScore <= pointsSum
);
//Создаём массив строк из результатов. У кого есть инфо о баллах - дают свои, остальные 0
const numbers = results.map((e) =>
e.type === "result" && e.content.rule.minScore !== undefined ? e.content.rule.minScore : 0
);
//Извлекаем самое большое число
const indexOfNext = Math.max(...numbers);
//Отдаём индекс нужного нам результата
return results[numbers.indexOf(indexOfNext)];
}, [pointsSum, sortedQuestions]);
//Ищем следующий вопрос (не его индекс, или id). Сам вопрос
const nextQuestion = useMemo(() => {
let next;
if (settings.cfg.score) {
//Ессли квиз балловый
if (linearQuestionIndex !== null) {
next = sortedQuestions[linearQuestionIndex + 1]; //ищем по индексу
if (next?.type === "result" || next == undefined) next = findResultPointsLogic(); //если в поисках пришли к результату - считаем нужный
}
} else {
//иначе
if (linearQuestionIndex !== null) {
//для линейных ищем по индексу
next =
sortedQuestions[linearQuestionIndex + 1] ??
sortedQuestions.find((question) => question.type === "result" && question.content.rule.parentId === "line");
} else {
// для нелинейных ищем по вычесленному id
next = sortedQuestions.find((q) => q.id === nextQuestionId || q.content.id === nextQuestionId);
}
}
return next;
}, [nextQuestionId, findResultPointsLogic, linearQuestionIndex, sortedQuestions, settings.cfg.score]);
//Показать визуалом юзеру результат
const showResult = useCallback(() => {
if (nextQuestion?.type !== "result") throw new Error("Current question is not result");
//Записать в переменную ид текущего вопроса
setCurrentQuestionId(nextQuestion.id);
//Смотрим по настройкам показывать ли вообще форму контактов. Показывать ли страницу результатов до или после формы контактов (ФК)
if (
settings.cfg.showfc !== false &&
(settings.cfg.resultInfo.showResultForm === "after" || isResultQuestionEmpty(nextQuestion))
)
setCurrentQuizStep("contactform");
}, [nextQuestion, setCurrentQuizStep, settings.cfg.resultInfo.showResultForm, settings.cfg.showfc]);
//рычаг управления из визуала в этот контроллер
const showResultAfterContactForm = useCallback(() => {
if (currentQuestion?.type !== "result") throw new Error("Current question is not result");
if (isResultQuestionEmpty(currentQuestion)) return;
setCurrentQuizStep("question");
}, [currentQuestion, setCurrentQuizStep]);
//рычаг управления из визуала в этот контроллер
const moveToPrevQuestion = useCallback(() => {
if (!prevQuestion) throw new Error("Previous question not found");
setCurrentQuestionId(prevQuestion.id);
}, [prevQuestion]);
//рычаг управления из визуала в этот контроллер
const moveToNextQuestion = useCallback(async () => {
// Если есть следующий вопрос в уже загруженных - используем его
if (nextQuestion) {
vkMetrics.questionPassed(currentQuestion.id);
yandexMetrics.questionPassed(currentQuestion.id);
if (nextQuestion.type === "result") return showResult();
setCurrentQuestionId(nextQuestion.id);
return;
}
}, [currentQuestion.id, nextQuestion, showResult, vkMetrics, yandexMetrics, linearQuestionIndex, questions]);
//рычаг управления из визуала в этот контроллер
const setQuestion = useCallback(
(questionId: string) => {
const question = sortedQuestions.find((q) => q.id === questionId);
if (!question) return;
setCurrentQuestionId(question.id);
},
[sortedQuestions]
);
//Анализ дисаблить ли кнопки навигации
const isPreviousButtonEnabled = Boolean(prevQuestion);
//Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => {
const hasAnswer = answers.some(({ questionId }) => questionId === currentQuestion.id);
if ("required" in currentQuestion.content && currentQuestion.content.required) {
return hasAnswer;
}
console.log(linearQuestionIndex);
console.log(questions.length);
console.log(cnt);
if (linearQuestionIndex !== null && questions.length < cnt) return true;
return Boolean(nextQuestion);
}, [answers, currentQuestion, nextQuestion]);
useDebugValue({
linearQuestionIndex,
currentQuestion: currentQuestion,
prevQuestion: prevQuestion,
nextQuestion: nextQuestion,
});
return {
currentQuestion,
currentQuestionStepNumber:
settings.status === "ai" ? null : linearQuestionIndex === null ? null : linearQuestionIndex + 1,
nextQuestion,
isNextButtonEnabled,
isPreviousButtonEnabled,
moveToPrevQuestion,
moveToNextQuestion,
showResultAfterContactForm,
setQuestion,
};
}

@ -1,50 +1,310 @@
import { useBranchingQuiz } from "./FlowControlLogic/useBranchingQuiz";
import { useLinearQuiz } from "./FlowControlLogic/useLinearQuiz";
import { useAIQuiz } from "./FlowControlLogic/useAIQuiz";
import { Status } from "@/model/settingsData";
import { useCallback, useDebugValue, useEffect, useMemo, useState } from "react";
import { enqueueSnackbar } from "notistack";
import moment from "moment";
interface StatusData {
status: Status;
haveRoot: string | null;
import { isResultQuestionEmpty } from "@/components/ViewPublicationPage/tools/checkEmptyData";
import { useQuizStore } from "@/stores/useQuizStore";
import { useQuizViewStore } from "@stores/quizView";
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
import { AnyTypedQuizQuestion } from "@/index";
import { getQuizData } from "@/api/quizRelase";
import { useQuizGetNext } from "@/api/useQuizGetNext";
let isgetting = false;
export function useQuestionFlowControl() {
//Получаем инфо о квизе и список вопросов.
const { loadMoreQuestions } = useQuizGetNext();
const { settings, questions, quizId, cnt } = useQuizStore();
useEffect(() => {
console.log("useQuestionFlowControl useEffect");
console.log(questions);
}, [questions]);
console.log(questions);
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page.
//За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page
const sortedQuestions = useMemo(() => {
return [...questions].sort((a, b) => a.page - b.page);
}, [questions]);
//React сам будет менять визуал - главное говорить из какого вопроса ему брать инфо. Изменение этой переменной меняет визуал.
const [currentQuestionId, setCurrentQuestionId] = useState<string | null>(getFirstQuestionId);
const [headAI, setHeadAI] = useState(0);
//Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах
const answers = useQuizViewStore((state) => state.answers);
//Список засчитанных баллов для балловых квизов
const pointsSum = useQuizViewStore((state) => state.pointsSum);
//Текущий шаг "startpage" | "question" | "contactform"
const setCurrentQuizStep = useQuizViewStore((state) => state.setCurrentQuizStep);
//Получение возможности управлять состоянием метрик
const vkMetrics = useVkMetricsGoals(settings.cfg.vkMetricsNumber);
const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber);
//Изменение стейта (переменной currentQuestionId) ведёт к пересчёту что же за объект сейчас используется. Мы каждый раз просто ищем в списке
const currentQuestion = sortedQuestions.find((question) => question.id === currentQuestionId) ?? sortedQuestions[0];
console.log("currentQuestion");
console.log(currentQuestion);
console.log("filted");
console.log(sortedQuestions.find((question) => question.id === currentQuestionId));
//Индекс текущего вопроса только если квиз линейный
const linearQuestionIndex = //: number | null
currentQuestion && sortedQuestions.every(({ content }) => content.rule.parentId !== "root") // null when branching enabled
? sortedQuestions.indexOf(currentQuestion)
: null;
//Индекс первого вопроса
function getFirstQuestionId() {
//: string | null
if (sortedQuestions.length === 0) return null; //Если нету сортированного списка, то и не рыпаемся
if (settings.cfg.haveRoot) {
// Если есть ветвление, то settings.cfg.haveRoot будет заполнен
//Если заполнен, то дерево растёт с root и это 1 вопрос :)
const nextQuestion = sortedQuestions.find(
//Функция ищет первое совпадение по массиву
(question) => question.id === settings.cfg.haveRoot || question.content.id === settings.cfg.haveRoot
);
if (!nextQuestion) return null;
return nextQuestion.id;
}
//Если не возникло исключительных ситуаций - первый вопрос - нулевой элемент сортированного массива
return sortedQuestions[0].id;
}
const nextQuestionIdPointsLogic = useCallback(() => {
return sortedQuestions.find((question) => question.type === "result" && question.content.rule.parentId === "line");
}, [sortedQuestions]);
//Анализируем какой вопрос должен быть следующим. Это главная логика
const nextQuestionIdMainLogic = useCallback(() => {
//Список ответов данных этому вопросу. Вернёт QuestionAnswer | undefined
const questionAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id);
//Если questionAnswer не undefined и ответ на вопрос не является временем:
if (questionAnswer && !moment.isMoment(questionAnswer.answer)) {
//Вопрос типизации. Получаем список строк ответов на этот вопрос
const userAnswers = Array.isArray(questionAnswer.answer) ? questionAnswer.answer : [questionAnswer.answer];
//цикл. Перебираем список условий .main и обзываем их переменной branchingRule
for (const branchingRule of currentQuestion.content.rule.main) {
// Перебираем список ответов. Если хоть один ответ из списка совпадает с прописанным правилом из условий - этот вопрос нужный нам. Его и дадимкак следующий
if (userAnswers.some((answer) => branchingRule.rules[0].answers.includes(answer))) {
return branchingRule.next;
}
}
}
//Не помню что это, но чёт при первом взгляде оно true только у результатов
if (!currentQuestion.required) {
//Готовим себе дефолтный путь
const defaultNextQuestionId = currentQuestion.content.rule.default;
//Если строка не пустая и не пробел. (Обычно при получении данных мы сразу чистим пустые строки только с пробелом на просто пустые строки. Это прост доп защита)
if (defaultNextQuestionId.length > 1 && defaultNextQuestionId !== " ") return defaultNextQuestionId;
//Вопросы типа страница, ползунок, своё поле для ввода и дата не могут иметь больше 1 ребёнка. Пользователь не может настроить там дефолт
//Кинуть на ребёнка надо даже если там нет дефолта
if (
["date", "page", "text", "number"].includes(currentQuestion.type) &&
currentQuestion.content.rule.children.length === 1
)
return currentQuestion.content.rule.children[0];
}
//ничё не нашли, ищем резулт
return sortedQuestions.find((q) => {
return q.type === "result" && q.content.rule.parentId === currentQuestion.content.id;
})?.id;
}, [answers, currentQuestion, sortedQuestions]);
//Анализ следующего вопроса. Это логика для вопроса с баллами
const nextQuestionId = useMemo(() => {
if (settings.cfg.score) {
return nextQuestionIdPointsLogic();
}
return nextQuestionIdMainLogic();
}, [nextQuestionIdMainLogic, nextQuestionIdPointsLogic, settings.cfg.score, questions]);
//Поиск предыдущго вопроса либо по индексу либо по id родителя
const prevQuestion =
linearQuestionIndex !== null
? sortedQuestions[linearQuestionIndex - 1]
: sortedQuestions.find(
(q) =>
q.id === currentQuestion?.content.rule.parentId || q.content.id === currentQuestion?.content.rule.parentId
);
//Анализ результата по количеству баллов
const findResultPointsLogic = useCallback(() => {
//Отбираем из массива только тип резулт И результы с информацией о ожидаемых баллах И те результы, чьи суммы баллов меньше или равны насчитанным баллам юзера
const results = sortedQuestions.filter(
(e) => e.type === "result" && e.content.rule.minScore !== undefined && e.content.rule.minScore <= pointsSum
);
//Создаём массив строк из результатов. У кого есть инфо о баллах - дают свои, остальные 0
const numbers = results.map((e) =>
e.type === "result" && e.content.rule.minScore !== undefined ? e.content.rule.minScore : 0
);
//Извлекаем самое большое число
const indexOfNext = Math.max(...numbers);
//Отдаём индекс нужного нам результата
return results[numbers.indexOf(indexOfNext)];
}, [pointsSum, sortedQuestions]);
//Ищем следующий вопрос (не его индекс, или id). Сам вопрос
const nextQuestion = useMemo(() => {
let next;
if (settings.cfg.score) {
//Ессли квиз балловый
if (linearQuestionIndex !== null) {
next = sortedQuestions[linearQuestionIndex + 1]; //ищем по индексу
if (next?.type === "result" || next == undefined) next = findResultPointsLogic(); //если в поисках пришли к результату - считаем нужный
}
} else {
//иначе
if (linearQuestionIndex !== null) {
//для линейных ищем по индексу
next =
sortedQuestions[linearQuestionIndex + 1] ??
sortedQuestions.find((question) => question.type === "result" && question.content.rule.parentId === "line");
} else {
// для нелинейных ищем по вычесленному id
next = sortedQuestions.find((q) => q.id === nextQuestionId || q.content.id === nextQuestionId);
}
}
return next;
}, [nextQuestionId, findResultPointsLogic, linearQuestionIndex, sortedQuestions, settings.cfg.score]);
//Показать визуалом юзеру результат
const showResult = useCallback(() => {
if (nextQuestion?.type !== "result") throw new Error("Current question is not result");
//Записать в переменную ид текущего вопроса
setCurrentQuestionId(nextQuestion.id);
//Смотрим по настройкам показывать ли вообще форму контактов. Показывать ли страницу результатов до или после формы контактов (ФК)
if (
settings.cfg.showfc !== false &&
(settings.cfg.resultInfo.showResultForm === "after" || isResultQuestionEmpty(nextQuestion))
)
setCurrentQuizStep("contactform");
}, [nextQuestion, setCurrentQuizStep, settings.cfg.resultInfo.showResultForm, settings.cfg.showfc]);
//рычаг управления из визуала в эту функцию
const showResultAfterContactForm = useCallback(() => {
if (currentQuestion?.type !== "result") throw new Error("Current question is not result");
if (isResultQuestionEmpty(currentQuestion)) {
enqueueSnackbar("Данные отправлены");
return;
}
setCurrentQuizStep("question");
}, [currentQuestion, setCurrentQuizStep]);
//рычаг управления из визуала в эту функцию
const moveToPrevQuestion = useCallback(() => {
if (!prevQuestion) throw new Error("Previous question not found");
if (settings.status === "ai" && headAI > 0) setHeadAI((old) => old--);
setCurrentQuestionId(prevQuestion.id);
}, [prevQuestion]);
//рычаг управления из визуала в эту функцию
const moveToNextQuestion = useCallback(async () => {
// Если есть следующий вопрос в уже загруженных - используем его
if (nextQuestion) {
vkMetrics.questionPassed(currentQuestion.id);
yandexMetrics.questionPassed(currentQuestion.id);
if (nextQuestion.type === "result") return showResult();
setCurrentQuestionId(nextQuestion.id);
return;
}
// Если следующего нет - загружаем новый
try {
const newQuestion = await loadMoreQuestions();
console.log("Ффункция некст вопрос получила его с бека: ");
console.log(newQuestion);
if (newQuestion) {
vkMetrics.questionPassed(currentQuestion.id);
yandexMetrics.questionPassed(currentQuestion.id);
console.log("МЫ ПАЛУЧИЛИ НОВЫЙ ВОПРОС");
console.log(newQuestion);
console.log("typeof newQuestion.id");
console.log(typeof newQuestion.id);
setCurrentQuestionId(newQuestion.id);
setHeadAI((old) => old++);
}
} catch (error) {
enqueueSnackbar("Ошибка загрузки следующего вопроса");
}
}, [
currentQuestion.id,
nextQuestion,
showResult,
vkMetrics,
yandexMetrics,
linearQuestionIndex,
loadMoreQuestions,
questions,
]);
//рычаг управления из визуала в эту функцию
const setQuestion = useCallback(
(questionId: string) => {
const question = sortedQuestions.find((q) => q.id === questionId);
if (!question) return;
setCurrentQuestionId(question.id);
},
[sortedQuestions]
);
//Анализ дисаблить ли кнопки навигации
const isPreviousButtonEnabled = Boolean(prevQuestion);
//Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => {
const hasAnswer = answers.some(({ questionId }) => questionId === currentQuestion.id);
if ("required" in currentQuestion.content && currentQuestion.content.required) {
return hasAnswer;
}
console.log(linearQuestionIndex);
console.log(questions.length);
console.log(cnt);
if (linearQuestionIndex !== null && questions.length < cnt) return true;
return Boolean(nextQuestion);
}, [answers, currentQuestion, nextQuestion]);
useDebugValue({
linearQuestionIndex,
currentQuestion: currentQuestion,
prevQuestion: prevQuestion,
nextQuestion: nextQuestion,
});
return {
currentQuestion,
currentQuestionStepNumber:
settings.status === "ai" ? null : linearQuestionIndex === null ? null : linearQuestionIndex + 1,
nextQuestion,
isNextButtonEnabled,
isPreviousButtonEnabled,
moveToPrevQuestion,
moveToNextQuestion,
showResultAfterContactForm,
setQuestion,
};
}
// выбор способа управления в зависимости от статуса
let cachedManager: () => ReturnType<typeof useLinearQuiz>;
export let statusOfQuiz: "line" | "branch" | "ai";
function analyicStatus({ status, haveRoot }: StatusData) {
if (status === "ai") {
statusOfQuiz = "ai";
return;
}
if (status === "start") {
// Если есть ветвление, то settings.cfg.haveRoot будет заполнен
if (haveRoot) statusOfQuiz = "branch";
else statusOfQuiz = "line";
return;
}
throw new Error("quiz is inactive");
}
export const initDataManager = (data: StatusData) => {
analyicStatus(data);
switch (statusOfQuiz) {
case "line":
cachedManager = useLinearQuiz;
break;
case "branch":
cachedManager = useBranchingQuiz;
break;
case "ai":
cachedManager = useAIQuiz;
break;
}
};
// Главный хук (интерфейс для потребителей)
export const useQuestionFlowControl = () => {
if (!cachedManager) {
throw new Error("DataManager not initialized! Call initDataManager() first.");
}
return cachedManager();
};

@ -50,17 +50,25 @@ export async function sendQuestionAnswer(
}
case "emoji": {
if (question.content.multi) {
const answer = questionAnswer.answer as string[];
let answerString = ``;
const answer = questionAnswer.answer;
const ownVariant = Array.isArray(answer)
? ownVariants[ownVariants.findIndex((variant) => answer.some((a: string) => a === variant.id))]?.variant || ""
: ownVariants[ownVariants.findIndex((variant) => variant.id === questionAnswer.answer)]?.variant || "";
if (moment.isMoment(answer)) throw new Error("Answer is Moment in Variant question");
//Оставляем только выбранные варианты
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}\`,`;
let answerString = ``;
selectedVariants.forEach((e) => {
if (e.isOwn) {
if (question.content.own && selectedVariants.some((v) => v.isOwn)) {
answerString += `\`${e.extendedText} ${ownVariant?.answer ?? ""}\`,`;
}
} else {
answerString += `\`${e.extendedText} ${e.answer ?? ""}\`,`;
}
});
answerString = answerString.slice(0, -1);
@ -72,27 +80,12 @@ export async function sendQuestionAnswer(
});
}
// 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();
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: body,
body: variant.extendedText + " " + variant.answer,
qid: quizId,
});
}
@ -116,25 +109,8 @@ export async function sendQuestionAnswer(
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,
Image: e.extendedText,
Description: e.isOwn ? ownAnswer : e.answer,
};
answerString += `\`${JSON.stringify(body)}\`,`;
@ -152,23 +128,8 @@ export async function sendQuestionAnswer(
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,
Image: variant.extendedText,
Description: variant.answer,
};
if (!body) throw new Error(`Body of answer in question ${question.id} is undefined`);
@ -267,24 +228,9 @@ export async function sendQuestionAnswer(
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,
Image: variant.extendedText,
Description: question.content.own ? ownAnswer : variant.answer,
};
if (!body) throw new Error(`Body of answer in question ${question.id} is undefined`);

@ -53,8 +53,5 @@
"and": "and",
"Get results": "Get results",
"Data sent successfully": "Data sent successfully",
"Step": "Step",
"questions are not ready yet": "There are no questions for the audience yet. Please wait",
"Add your image": "Add your image",
"select emoji": "select emoji"
"Step": "Step"
}

@ -54,7 +54,6 @@
"Get results": "Получить результаты",
"Data sent successfully": "Данные успешно отправлены",
"Step": "Шаг",
"questions are not ready yet": "Вопросы для аудитории ещё не созданы. Пожалуйста, подождите",
"Add your image": "Добавьте своё изображение",
"select emoji": "выберите смайлик"
"neftyanka FK": "Заполните форму, чтобы отправить ваши ответы на викторину",
"neftyanka button": "Отправить"
}

@ -53,8 +53,5 @@
"and": "va",
"Get results": "Natijalarni olish",
"Data sent successfully": "Ma'lumotlar muvaffaqiyatli yuborildi",
"Step": "Qadam",
"questions are not ready yet": "Tomoshabinlar uchun hozircha savollar yo'q. Iltimos kuting",
"Add your image": "Rasmingizni qo'shing",
"select emoji": "emoji tanlang"
"Step": "Qadam"
}

@ -1,245 +0,0 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
// 1. Функция для определения языка из URL
const getLanguageFromURL = (): string => {
const path = window.location.pathname;
const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i);
return langMatch ? langMatch[1].toLowerCase() : "ru"; // Фолбэк на 'ru'
};
// 2. Локали, встроенные прямо в конфиг
const r = {
ru: {
"quiz is inactive": "Квиз не активирован",
"no questions found": "Нет созданных вопросов",
"quiz is empty": "Квиз пуст",
"quiz already completed": "Вы уже прошли этот опрос",
"no quiz id": "Отсутствует id квиза",
"quiz data is null": "Не были переданы параметры квиза",
"invalid request data": "Такого квиза не существует",
"default message": "Что-то пошло не так",
"The request could not be sent": "Заявка не может быть отправлена",
"The number of points could not be sent": "Количество баллов не может быть отправлено",
"Your result": "Ваш результат",
"Your points": "Ваши баллы",
of: "из",
"View answers": "Посмотреть ответы",
"Find out more": "Узнать подробнее",
"Go to website": "Перейти на сайт",
"Question title": "Заголовок вопроса",
"Question without a title": "Вопрос без названия",
"Your answer": "Ваш ответ",
"Add image": "Добавить изображение",
"Accepts images": "Принимает изображения",
"Add video": "Добавить видео",
"Accepts .mp4 and .mov format - maximum 50mb": "Принимает .mp4 и .mov формат — максимум 50мб",
"Add audio file": "Добавить аудиофайл",
"Accepts audio files": "Принимает аудиофайлы",
"Add document": "Добавить документ",
"Accepts documents": "Принимает документы",
Next: "Далее",
Prev: "Назад",
From: "От",
До: "До",
"Enter your answer": "Введите свой ответ",
"Incorrect file type selected": "Выбран некорректный тип файла",
"File is too big. Maximum size is 50 MB": "Файл слишком большой. Максимальный размер 50 МБ",
"Acceptable file extensions": "Допустимые расширения файлов",
"You have uploaded": "Вы загрузили",
"The answer was not counted": "Ответ не был засчитан",
"Select an answer option below": "Выберите вариант ответа ниже",
"Select an answer option on the left": "Выберите вариант ответа слева",
"Fill out the form to receive your test results": "Заполните форму, чтобы получить результаты теста",
Enter: "Введите",
Name: "Имя",
"Phone number": "Номер телефона",
"Last name": "Фамилия",
Address: "Адрес",
"Incorrect email entered": "Введена некорректная почта",
"Please fill in the fields": "Пожалуйста, заполните поля",
"Please try again later": "повторите попытку позже",
"Regulation on the processing of personal data": "Положением об обработке персональных данных",
"Privacy Policy": "Политикой конфиденциальности",
familiarized: "ознакомлен",
and: "и",
"Get results": "Получить результаты",
"Data sent successfully": "Данные успешно отправлены",
Step: "Шаг",
"questions are not ready yet": "Вопросы для аудитории ещё не созданы. Пожалуйста, подождите",
"Add your image": "Добавьте своё изображение",
"select emoji": "выберите смайлик",
"": "", // Пустой ключ для fallback
},
en: {
"quiz is inactive": "Quiz is inactive",
"no questions found": "No questions found",
"quiz is empty": "Quiz is empty",
"quiz already completed": "You've already completed this quiz",
"no quiz id": "Missing quiz ID",
"quiz data is null": "No quiz parameters were provided",
"invalid request data": "This quiz doesn't exist",
"default message": "Something went wrong",
"The request could not be sent": "Request could not be sent",
"The number of points could not be sent": "Points could not be submitted",
"Your result": "Your result",
"Your points": "Your points",
of: "of",
"View answers": "View answers",
"Find out more": "Learn more",
"Go to website": "Go to website",
"Question title": "Question title",
"Question without a title": "Untitled question",
"Your answer": "Your answer",
"Add image": "Add image",
"Accepts images": "Accepts images",
"Add video": "Add video",
"Accepts .mp4 and .mov format - maximum 50mb": "Accepts .mp4 and .mov format - maximum 50MB",
"Add audio file": "Add audio file",
"Accepts audio files": "Accepts audio files",
"Add document": "Add document",
"Accepts documents": "Accepts documents",
Next: "Next",
Prev: "Previous",
From: "From",
До: "To",
"Enter your answer": "Enter your answer",
"Incorrect file type selected": "Invalid file type selected",
"File is too big. Maximum size is 50 MB": "File is too large. Maximum size is 50 MB",
"Acceptable file extensions": "Allowed file extensions",
"You have uploaded": "You've uploaded",
"The answer was not counted": "Answer wasn't counted",
"Select an answer option below": "Select an answer option below",
"Select an answer option on the left": "Select an answer option on the left",
"Fill out the form to receive your test results": "Fill out the form to receive your test results",
Enter: "Enter",
Name: "Name",
"Phone number": "Phone number",
"Last name": "Last name",
Address: "Address",
"Incorrect email entered": "Invalid email entered",
"Please fill in the fields": "Please fill in the fields",
"Please try again later": "Please try again later",
"Regulation on the processing of personal data": "Personal Data Processing Regulation",
"Privacy Policy": "Privacy Policy",
familiarized: "acknowledged",
and: "and",
"Get results": "Get results",
"Data sent successfully": "Data sent successfully",
Step: "Step",
"questions are not ready yet": "There are no questions for the audience yet. Please wait",
"Add your image": "Add your image",
"select emoji": "select emoji",
"": "", // Пустой ключ для fallback
},
uz: {
"quiz is inactive": "Test faol emas",
"no questions found": "Savollar topilmadi",
"quiz is empty": "Test boʻsh",
"quiz already completed": "Siz bu testni allaqachon topshirgansiz",
"no quiz id": "Test IDsi yoʻq",
"quiz data is null": "Test parametrlari yuborilmagan",
"invalid request data": "Bunday test mavjud emas",
"default message": "Xatolik yuz berdi",
"The request could not be sent": "Soʻrov yuborib boʻlmadi",
"The number of points could not be sent": "Ballar yuborib boʻlmadi",
"Your result": "Sizning natijangiz",
"Your points": "Sizning ballaringiz",
of: "/",
"View answers": "Javoblarni koʻrish",
"Find out more": "Batafsil maʼlumot",
"Go to website": "Veb-saytga oʻtish",
"Question title": "Savol sarlavhasi",
"Question without a title": "Sarlavhasiz savol",
"Your answer": "Sizning javobingiz",
"Add image": "Rasm qoʻshish",
"Accepts images": "Rasmlarni qabul qiladi",
"Add video": "Video qoʻshish",
"Accepts .mp4 and .mov format - maximum 50mb": ".mp4 va .mov formatlarini qabul qiladi - maksimal 50MB",
"Add audio file": "Audio fayl qoʻshish",
"Accepts audio files": "Audio fayllarni qabul qiladi",
"Add document": "Hujjat qoʻshish",
"Accepts documents": "Hujjatlarni qabul qiladi",
Next: "Keyingi",
Prev: "Oldingi",
From: "Dan",
До: "Gacha",
"Enter your answer": "Javobingizni kiriting",
"Incorrect file type selected": "Notoʻgʻri fayl turi tanlandi",
"File is too big. Maximum size is 50 MB": "Fayl juda katta. Maksimal hajmi 50 MB",
"Acceptable file extensions": "Qabul qilinadigan fayl kengaytmalari",
"You have uploaded": "Siz yuklagansiz",
"The answer was not counted": "Javob hisobga olinmadi",
"Select an answer option below": "Quyidagi javob variantlaridan birini tanlang",
"Select an answer option on the left": "Chapdagi javob variantlaridan birini tanlang",
"Fill out the form to receive your test results": "Test natijalaringizni olish uchun shaklni toʻldiring",
Enter: "Kiriting",
Name: "Ism",
"Phone number": "Telefon raqami",
"Last name": "Familiya",
Address: "Manzil",
"Incorrect email entered": "Notoʻgʻri elektron pochta kiritildi",
"Please fill in the fields": "Iltimos, maydonlarni toʻldiring",
"Please try again later": "Iltimos, keyinroq urinib koʻring",
"Regulation on the processing of personal data": "Shaxsiy maʼlumotlarni qayta ishlash qoidalari",
"Privacy Policy": "Maxfiylik siyosati",
familiarized: "tanishdim",
and: "va",
"Get results": "Natijalarni olish",
"Data sent successfully": "Ma'lumotlar muvaffaqiyatli yuborildi",
Step: "Qadam",
"questions are not ready yet": "Tomoshabinlar uchun hozircha savollar yo'q. Iltimos kuting",
"Add your image": "Rasmingizni qo'shing",
"select emoji": "emoji tanlang",
"": "", // Пустой ключ для fallback
},
};
// 3. Конфигурация i18n без Backend
i18n
.use(initReactI18next)
.init({
resources: r, // Используем встроенные переводы
lng: getLanguageFromURL(),
fallbackLng: "ru",
supportedLngs: ["en", "ru", "uz"],
debug: true,
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
detection: {
order: ["path"],
lookupFromPathIndex: 0,
caches: [],
},
parseMissingKeyHandler: (key) => {
console.warn("Missing translation:", key);
return key;
},
missingKeyHandler: (lngs, ns, key) => {
console.error("🚨 Missing i18n key:", {
key,
languages: lngs,
namespace: ns,
stack: new Error().stack,
});
},
})
.then(() => {
console.log("i18n initialized. Current language:", i18n.language);
console.log("Available languages:", i18n.languages);
console.log("Available keys for ru:", Object.keys(r.ru));
console.log("Available keys for en:", Object.keys(r.en));
console.log("Available keys for uz:", Object.keys(r.uz));
});
// 4. Логирование событий
i18n.on("languageChanged", (lng) => {
console.log("Language changed to:", lng);
});
export default i18n;

@ -2,7 +2,6 @@ import QuizAnswerer from "@/components/QuizAnswerer";
import { createRoot } from "react-dom/client";
// eslint-disable-next-line react-refresh/only-export-components
export * from "./widgets";
import "./i18n/i18nWidget";
// old widget
const widget = {
@ -20,13 +19,7 @@ const widget = {
const root = createRoot(element);
root.render(
<QuizAnswerer
quizId={quizId}
changeFaviconAndTitle={changeFaviconAndTitle}
disableGlobalCss
/>
);
root.render(<QuizAnswerer quizId={quizId} changeFaviconAndTitle={changeFaviconAndTitle} disableGlobalCss />);
},
};

@ -9,8 +9,6 @@ export default defineConfig({
alias,
},
build: {
minify: false, // Отключает минификацию
sourcemap: true, // Включает sourcemaps для отладки
copyPublicDir: false,
rollupOptions: {
input: "src/widget.tsx",

@ -275,7 +275,7 @@
"@emoji-mart/react@^1.1.1":
version "1.1.1"
resolved "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a"
resolved "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz"
integrity sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==
"@emotion/babel-plugin@^11.11.0":