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
65 changed files with 1455 additions and 2712 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

@ -2,29 +2,23 @@ name: Deploy
run-name: ${{ gitea.actor }} build image and push to container registry
on:
registry_package:
types: [published]
#package_name: "gitea.pena/squiz/frontanswerer/main:latest"
push:
branches:
- "main"
jobs:
# CreateImage:
# runs-on: [skeris]
# uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
# with:
# runner: skeris
# secrets:
# REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
# REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
CreateImage:
runs-on: [skeris]
uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
with:
runner: skeris
secrets:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DeployService:
if: contains(github.event.package.name, 'main')
runs-on: [frontprod]
container:
image: gitea.pena/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/PenaDevops/actions.git/checkout@v1
- run: compose -f deployments/main/docker-compose.yaml up -d
needs: CreateImage
uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/deploy.yml@v1.1.4-p7
with:
runner: hubprod
actionid: ${{ gitea.run_id }}

@ -2,28 +2,23 @@ name: Deploy
run-name: ${{ gitea.actor }} build image and push to container registry
on:
registry_package:
types: [published]
push:
branches:
- "staging"
jobs:
# CreateImage:
# runs-on: [skeris]
# uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
# with:
# runner: hubstaging
# secrets:
# REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
# REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
CreateImage:
runs-on: [skeris]
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
with:
runner: hubstaging
secrets:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DeployService:
if: contains(github.event.package.name, 'staging')
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: compose -f deployments/staging/docker-compose.yaml up -d
needs: CreateImage
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/deploy.yml@v1.1.4-p7
with:
runner: frontstaging
actionid: ${{ gitea.run_id }}

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

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

@ -1,99 +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";
initDataManager({
status: firstData.settings.status,
haveRoot: firstData.settings.cfg.haveRoot,
});
setQuizData(firstData);
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));
// Возвращаем полную структуру данных с настройками из store
const currentState = useQuizStore.getState();
return {
...currentState,
questions: [...currentState.questions, ...data.questions.slice(1)],
};
}
if (currentPage >= questions.length) {
try {
// Для AI режима - последовательная загрузка
const data = await getAndParceData({
quizId: id,
page: currentPage,
limit: 1,
needConfig: false,
});
addQuestions(data.questions);
changeNextLoading(false);
// Возвращаем полную структуру данных с настройками из store
const currentState = useQuizStore.getState();
return {
...currentState,
questions: [...currentState.questions, ...data.questions],
};
} catch (p) {
setPage(questions.length);
changeNextLoading(false);
}
}
},
{
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,9 +246,8 @@ export async function getAndParceData(props: GetDataProps) {
throw new Error("Quiz not found");
}
//Парсим строки в строках
const quizSettings = replaceSpacesToEmptyLines(parseQuizData(quizDataResponse));
//Единоразово стрингифаим ВСЁ распаршенное и удаляем лишние пробелы
const res = JSON.parse(
JSON.stringify({ data: quizSettings })
.replaceAll(/\\" \\"/g, '""')
@ -179,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[];
@ -187,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

File diff suppressed because one or more lines are too long

@ -1,21 +1,7 @@
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
try {
// Пытаемся использовать React Router
const { useLocation } = require("react-router-dom");
const location = useLocation();
const pathname = location.pathname;
@ -29,11 +15,7 @@ export const NameplateLogoFQ: FC<SVGProps<SVGSVGElement>> = (props) => {
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,21 +1,7 @@
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
try {
// Пытаемся использовать React Router
const { useLocation } = require("react-router-dom");
const location = useLocation();
const pathname = location.pathname;
@ -29,11 +15,7 @@ export const NameplateLogoFQDark: FC<SVGProps<SVGSVGElement>> = (props) => {
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,8 +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;
@ -34,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,
@ -51,13 +58,18 @@ function QuizAnswererInner({
const yandexMetrics = useYandexMetricsGoals(quizSettings?.settings.cfg.yandexMetricsNumber);
const r = useQuizStore();
const { settings, questions } = useQuizStore();
console.log("settings", settings);
console.log("questions", questions);
useEffect(() => {
addquizid(quizId);
}, []);
useEffect(() => {
console.log(settings);
console.log(questions);
console.log("r");
console.log(r);
}, [questions, settings]);
useEffect(() => {
setTimeout(() => {
vkMetrics.quizOpened();
@ -66,15 +78,17 @@ function QuizAnswererInner({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
//Хук на случай если данные переданы нам сразу, а не "нам нужно их запросить"
if (quizSettings !== undefined) {
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);
@ -93,26 +107,16 @@ function QuizAnswererInner({
};
}, []);
if (isLoading && !questions.length) {
return <LoadingSkeleton />;
}
if (error) {
return <ApologyPage error={error} />;
}
console.log("settings");
console.log(settings);
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 (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 (!quizId) {
return <ApologyPage error={new Error("no quiz id")} />;
}
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 = (
<Box

@ -5,8 +5,12 @@ 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();
console.log("t");
console.log(t(message.toLowerCase()));
return (
<Box

@ -26,7 +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 { NameplateLogoDark } from "@/assets/icons/NameplateLogoDark";
import { isNeftyanka } from "@/ui_kit/neftyankacrutch";
type Props = {
currentQuestion: AnyTypedQuizQuestion;
@ -46,8 +46,6 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
const [text, setText] = useState("");
const [adress, setAdress] = useState("");
const [screenHeight, setScreenHeight] = useState<number>(window.innerHeight);
const [emailError, setEmailError] = useState("");
const [phoneError, setPhoneError] = useState("");
const fireOnce = useRef(true);
const [fire, setFire] = useState(false);
@ -123,23 +121,13 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
async function handleShowResultsClick() {
const FC = settings.cfg.formContact.fields;
// Проверяем email только если поле отображается
if (isEmailFieldVisible && !EMAIL_REGEXP.test(email)) {
if (!isDisableEmail && FC["email"].used !== EMAIL_REGEXP.test(email)) {
return enqueueSnackbar("Incorrect email entered");
}
if (fireOnce.current) {
// Проверяем, что хотя бы одно видимое поле заполнено
const hasVisibleFieldsFilled =
(isNameFieldVisible() && name.length > 0) ||
(isEmailFieldVisible && email.length > 0) ||
(isPhoneFieldVisible() && phone.length > 0) ||
(isTextFieldVisible() && text.length > 0) ||
(isAddressFieldVisible() && adress.length > 0);
if (!hasVisibleFieldsFilled) {
if (name.length === 0 && email.length === 0 && phone.length === 0 && text.length === 0 && adress.length === 0)
return enqueueSnackbar(t("Please fill in the fields"));
}
//почта валидна, хоть одно поле заполнено
setFire(true);
@ -190,115 +178,6 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Функция валидации телефона
const validatePhone = (phoneValue: string) => {
// Убираем все нецифровые символы и считаем только цифры
const digitsOnly = phoneValue.replace(/\D/g, "");
// Для российских номеров (начинающихся с +7) нужно 11 цифр
// Для остальных стран - минимум 10 цифр
const isRussianNumber = phoneValue.startsWith("+7");
const minDigits = isRussianNumber ? 11 : 10;
// Если есть какие-то символы в инпуте, но цифр меньше минимума - это ошибка
if (phoneValue.trim().length > 0 && digitsOnly.length < minDigits) {
return t("Please complete the phone number");
}
return "";
};
// Проверяем валидность телефона при каждом изменении
const digitsOnly = phone.replace(/\D/g, "");
const isRussianNumber = phone.startsWith("+7");
const minDigits = isRussianNumber ? 11 : 10;
const isPhoneValid = phone.trim().length === 0 || digitsOnly.length >= minDigits;
// Проверяем валидность email - должен быть заполнен и соответствовать формату
const validateEmail = (emailValue: string) => {
if (emailValue.trim().length === 0) return false;
// Проверяем наличие @ и .
const atIndex = emailValue.indexOf("@");
const dotIndex = emailValue.lastIndexOf(".");
if (atIndex === -1 || dotIndex === -1) return false;
// Точка должна быть после @
if (dotIndex <= atIndex) return false;
// Между @ и . должно быть минимум 3 символа
const domainPart = emailValue.substring(atIndex + 1, dotIndex);
if (domainPart.length < 3) return false;
// После точки должно быть минимум 2 символа
const tldPart = emailValue.substring(dotIndex + 1);
if (tldPart.length < 2) return false;
return true;
};
const isEmailValid = validateEmail(email);
// Определяем, отображается ли поле email
const isEmailFieldVisible = settings.cfg.formContact.fields?.email?.used && !isDisableEmail;
// Функции для определения видимости полей
const isNameFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["name"].used : true;
};
const isPhoneFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["phone"].used : true;
};
const isTextFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["text"].used : false;
};
const isAddressFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["address"].used : false;
};
// Обработчик изменения телефона
const handlePhoneChange = (newPhone: string) => {
setPhone(newPhone);
// Очищаем ошибку если поле стало пустым
if (newPhone.trim().length === 0) {
setPhoneError("");
}
};
// Обработчик изменения email
const handleEmailChange = (newEmail: string) => {
setEmail(newEmail);
// Очищаем ошибку если поле стало пустым
if (newEmail.trim().length === 0) {
setEmailError("");
}
};
// Обработчик потери фокуса для email
const handleEmailBlur = () => {
if (email.trim().length > 0 && !validateEmail(email)) {
setEmailError(t("Please enter a valid email"));
} else {
setEmailError("");
}
};
// Обработчик потери фокуса для телефона
const handlePhoneBlur = () => {
if (phone.trim().length > 0 && !isPhoneValid) {
setPhoneError(t("Please enter a valid phone number"));
} else {
setPhoneError("");
}
};
return (
<Box
sx={{
@ -372,17 +251,13 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
name={name}
setName={setName}
email={email}
setEmail={handleEmailChange}
setEmail={setEmail}
phone={phone}
setPhone={handlePhoneChange}
setPhone={setPhone}
text={text}
setText={setText}
adress={adress}
setAdress={setAdress}
emailError={emailError}
phoneError={phoneError}
onEmailBlur={handleEmailBlur}
onPhoneBlur={handlePhoneBlur}
crutch={{
disableEmail: isDisableEmail,
}}
@ -430,7 +305,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
</Box>
<Button
disabled={!(ready && !fire && isPhoneValid && (isEmailFieldVisible ? isEmailValid : true))}
disabled={!(ready && !fire)}
variant="contained"
onClick={handleShowResultsClick}
sx={{
@ -444,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 && (
@ -462,7 +337,12 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
margitTop: "auto",
}}
>
{quizThemes[settings.cfg.theme].isLight ? <NameplateLogoDark /> : <NameplateLogo />}
<NameplateLogo
style={{
fontSize: "20px",
color: quizThemes[settings.cfg.theme].isLight ? "#151515" : "#FFFFFF",
}}
/>
</Box>
)}
</Box>

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

@ -17,8 +17,6 @@ type InputProps = {
isPhone?: boolean;
type?: HTMLInputTypeAttribute;
value?: string;
onBlur?: () => void;
error?: string;
};
const TextField = MuiTextField as unknown as FC<TextFieldProps>;
@ -36,18 +34,7 @@ function phoneChange(e: ChangeEvent<HTMLInputElement>, mask: string) {
return a || "";
}
export const CustomInput = ({
title,
desc,
Icon,
onChange,
onChangePhone,
isPhone,
type,
value,
onBlur,
error,
}: InputProps) => {
export const CustomInput = ({ title, desc, Icon, onChange, onChangePhone, isPhone, type, value }: InputProps) => {
const theme = useTheme();
const isMobile = useRootContainerSize() < 600;
const { settings } = useQuizStore();
@ -70,11 +57,8 @@ export const CustomInput = ({
onChange={(e: ChangeEvent<HTMLInputElement>) =>
isPhone ? onChangePhone?.(phoneChange(e, mask)) : onChange?.(e)
}
onBlur={onBlur}
type={isPhone ? "tel" : type}
value={value}
error={!!error}
helperText={error}
sx={{
width: isMobile ? "100%" : "390px",
backgroundColor: theme.palette.background.default,

@ -13,17 +13,13 @@ type InputsProps = {
name: string;
setName: Dispatch<SetStateAction<string>>;
email: string;
setEmail: (email: string) => void;
setEmail: Dispatch<SetStateAction<string>>;
phone: string;
setPhone: (phone: string) => void;
setPhone: Dispatch<SetStateAction<string>>;
text: string;
setText: Dispatch<SetStateAction<string>>;
adress: string;
setAdress: Dispatch<SetStateAction<string>>;
emailError?: string;
phoneError?: string;
onEmailBlur?: () => void;
onPhoneBlur?: () => void;
crutch: {
disableEmail: boolean;
};
@ -43,10 +39,6 @@ export const Inputs = ({
setText,
adress,
setAdress,
emailError,
phoneError,
onEmailBlur,
onPhoneBlur,
crutch,
}: InputsProps) => {
const { settings } = useQuizStore();
@ -72,13 +64,11 @@ export const Inputs = ({
onChange={({ target }) => {
setEmail(target.value.replaceAll(/\s/g, ""));
}}
onBlur={onEmailBlur}
id={email}
title={FC["email"].innerText || `${t("Enter")} Email`}
desc={FC["email"].text || "Email"}
Icon={EmailIcon}
type="email"
error={emailError}
/>
);
const Phone = (
@ -87,14 +77,12 @@ export const Inputs = ({
onChangePhone={(phone: string) => {
setPhone(phone);
}}
onBlur={onPhoneBlur}
value={phone}
id={phone}
title={FC["phone"].innerText || `${t("Enter")} ${t("Phone number").toLowerCase()}`}
desc={FC["phone"].text || t("Phone number")}
Icon={PhoneIcon}
isPhone={true}
error={phoneError}
/>
);
const Text = (

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

@ -6,23 +6,6 @@ import { AnyTypedQuizQuestion, QuizQuestionVariant } from "@/index";
import { useTranslation } from "react-i18next";
import { useQuizStore } from "@/stores/useQuizStore";
const dinocrutch = window.location.pathname === "/413b9e24-996a-400e-9076-c158f64b9bd7";
// Функция для определения вопроса "спасибо"
const isThankYouQuestion = (question: QuizQuestionVariant): boolean => {
// Проверяем что у вопроса только один вариант ответа
if (question.content.variants.length !== 1) {
return false;
}
// Проверяем что текст варианта полностью состоит из слова "спасибо"
const variant = question.content.variants[0];
const answerText = variant.answer.toLowerCase().trim();
// Проверяем точное совпадение со словом "спасибо"
return answerText === "далее";
};
export const PointSystemResultList = () => {
const theme = useTheme();
const { questions } = useQuizStore();
@ -33,12 +16,7 @@ export const PointSystemResultList = () => {
(q: AnyTypedQuizQuestion): q is QuizQuestionVariant => q.type === "variant"
);
// Фильтруем вопросы "спасибо" только для указанного квиза
const filteredQuestions = dinocrutch
? questionsWothoutResult.filter((q) => !isThankYouQuestion(q))
: questionsWothoutResult;
return filteredQuestions.map((currentQuestion, index) => {
return questionsWothoutResult.map((currentQuestion) => {
let answerIndex = 0;
let currentVariants = currentQuestion.content.variants;
@ -75,7 +53,7 @@ export const PointSystemResultList = () => {
color: theme.palette.grey[500],
}}
>
{index + 1}.
{currentQuestion.page + 1}.
</Typography>
<Typography
sx={{

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

@ -9,7 +9,6 @@ import { useUADevice } from "@utils/hooks/useUADevice";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { NameplateLogo } from "@icons/NameplateLogo";
import { NameplateLogoDark } from "@icons/NameplateLogoDark";
import { useQuizViewStore } from "@/stores/quizView";
import { DESIGN_LIST } from "@/utils/designList";
@ -154,13 +153,17 @@ export const StartPageViewPublication = () => {
: undefined,
}}
>
{settings.cfg.startpageType === "expanded" ? (
<NameplateLogo />
) : quizThemes[settings.cfg.theme].isLight ? (
<NameplateLogoDark />
) : (
<NameplateLogo />
)}
<NameplateLogo
style={{
fontSize: "23px",
color:
settings.cfg.startpageType === "expanded"
? "#FFFFFF"
: quizThemes[settings.cfg.theme].isLight
? "#151515"
: "#FFFFFF",
}}
/>
</Box>
);

@ -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();
@ -70,7 +67,7 @@ export default function ViewPublicationPage() {
if (settings.cfg.antifraud && recentlyCompleted) throw new Error("Quiz already completed");
if (currentQuizStep === "startpage" && settings.cfg.noStartPage) currentQuizStep = "question";
if (!currentQuestion) {
if (!currentQuestion)
return (
<ThemeProvider theme={quizThemes[settings.cfg.theme || "StandardTheme"].theme}>
<Typography
@ -81,7 +78,6 @@ export default function ViewPublicationPage() {
</Typography>
</ThemeProvider>
);
}
const currentAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id);
@ -112,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 {
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);
} else {
updateAnswer(questionId, variant.id, variant.points || 0);
}
}
};
const handleEmojiSelect = (emoji: string) => {
// We store custom emoji in ownVariants store, with a specific field to differentiate
const currentOwnAnswer = ownVariants.find((v: OwnVariant) => v.id === variant.id)?.variant.answer || "";
updateOwnVariant(variant.id, currentOwnAnswer, emoji);
};
const handleEmojiRemove = () => {
// Сохраняем текущий answer, очищаем только extendedText (эмодзи)
const currentOwnAnswer = ownVariants.find((v: OwnVariant) => v.id === variant.id)?.variant.answer || "";
updateOwnVariant(variant.id, currentOwnAnswer, "");
};
const isSelected = isMulti ? Array.isArray(answer) && answer.includes(variant.id) : answer === variant.id;
return (
<FormControl
key={index}
sx={{
borderRadius: "12px",
border: `1px solid`,
borderColor: isSelected ? theme.palette.primary.main : "#9A9AAF",
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,13 +165,6 @@ export const EmojiVariant = ({
cursor: "pointer",
}}
>
{own ? (
<OwnEmojiPicker
emoji={customEmoji || variant.extendedText}
onEmojiSelect={handleEmojiSelect}
onEmojiRemove={customEmoji ? handleEmojiRemove : undefined}
/>
) : (
<Box
sx={{
width: "100%",
@ -193,7 +174,6 @@ export const EmojiVariant = ({
>
{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,34 +214,14 @@ export const ImageVariant = ({
onClick={onVariantClick}
>
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Box sx={{ width: "100%", height: "300px" }}>
{own ? (
<OwnImage
imageUrl={choiceImgUrl}
{variant.extendedText && (
<ImageCard
questionId={questionId}
variantId={variant.id}
onValidationError={(errorType) => {
enqueueSnackbar(errorType === "size" ? t("file is too big") : t("file type is not supported"), {
variant: "warning",
});
}}
imageUrl={choiceImgUrl}
isOwn={own}
/>
) : (
variant.extendedText && (
<canvas
ref={canvasRef}
style={{
display: "block",
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: "12px 12px 0 0",
}}
/>
)
)}
</Box>
</Box>
{own && (
<Typography
sx={{
@ -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, null, "", 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>
);
};

@ -36,6 +36,10 @@ export const Number = ({ currentQuestion }: NumberProps) => {
answer ||
(reversed ? max + min - currentQuestion.content.start + "—" + max : currentQuestion.content.start + "—" + max);
useEffect(() => {
console.log("reversed:", reversed);
}, [reversed]);
const sendAnswerToBackend = async (value: string, noUpdate = false) => {
if (!noUpdate) {
updateAnswer(currentQuestion.id, value, 0);

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

@ -28,18 +28,11 @@ interface OwnInputProps {
}
const OwnInput = ({ questionId, variant, largeCheck, ownPlaceholder }: OwnInputProps) => {
const theme = useTheme();
console.log("здравствуй, я овн вопрос. Вот инфо обо мне: ");
console.log("questionId ", questionId);
console.log("variant", variant);
console.log("largeCheck", largeCheck);
console.log("ownPlaceholder", ownPlaceholder);
console.log("--------------------------");
const ownVariants = useQuizViewStore((state) => state.ownVariants);
const { updateOwnVariant } = useQuizViewStore((state) => state);
const ownAnswer = ownVariants[ownVariants.findIndex((v) => v.id === variant.id)]?.variant.answer || "";
console.log("Вот каков стор в данный момент времени:", ownVariants);
return largeCheck ? (
<TextareaAutosize
@ -66,7 +59,6 @@ const OwnInput = ({ questionId, variant, largeCheck, ownPlaceholder }: OwnInputP
value={ownAnswer}
onClick={(e: React.MouseEvent<HTMLTextAreaElement>) => e.stopPropagation()}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
console.log("клик", e.target.value);
updateOwnVariant(variant.id, e.target.value);
}}
/>

@ -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, null, "", 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={
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 (
<>
{answer ? (
choiceImgUrlAnswer ? (
<img
key={imageUrl}
src={imageUrl}
key={choiceImgUrlAnswer}
src={choiceImgUrlAnswer}
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 (
)
) : choiceImgUrlQuestion !== " " && choiceImgUrlQuestion !== null && choiceImgUrlQuestion.length > 0 ? (
<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>
) : 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 { t, i18n } = useTranslation();
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>
);
}

@ -13,8 +13,7 @@ export default function PrevButton({ isPreviousButtonEnabled, moveToPrevQuestion
const theme = useTheme();
const { settings } = useQuizStore();
const isMobileMini = useRootContainerSize() < 382;
const { t, i18n } = useTranslation();
const { t } = useTranslation();
return (
<Button
disabled={!isPreviousButtonEnabled}
@ -38,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"> {
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,
};
// Убираю замену ID - оставляю оригинальный с бэкенда
// 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,10 +41,7 @@ export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizS
} as unknown as AnyTypedQuizQuestion;
});
readyData.questions = items;
if (quizDataResponse?.settings !== undefined) {
readyData.settings = {
const settings: QuizSettings["settings"] = {
fp: quizDataResponse.settings.fp,
rep: quizDataResponse.settings.rep,
name: quizDataResponse.settings.name,
@ -77,7 +52,6 @@ export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizS
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 = {
@ -119,7 +117,6 @@ export interface QuizConfig {
showfc?: boolean;
yandexMetricsNumber?: number;
vkMetricsNumber?: number;
backBlocked?: boolean;
}
export type FormContactFieldName = "name" | "email" | "phone" | "text" | "address";

@ -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 | null, //
extendedText?: string, //
originalImageUrl?: string, //
localImageUrl?: string //
) => void;
updateOwnVariant: (id: string, answer: string) => void;
deleteOwnVariant: (id: string) => void;
setCurrentQuizStep: (step: QuizStep) => void;
}
@ -55,8 +49,8 @@ export const createQuizViewStore = () =>
immer(
devtools(
(set, get) => ({
answers: [], //Массив вообще всех ответов. Объекты хранят инфо о id вопроса и списке выбранных ответов
ownVariants: [], //Подробное описание ответов для особенных. Здесь подробно расписана вся кастомизация ответов в own вариантах
answers: [],
ownVariants: [],
points: {},
pointsSum: 0,
currentQuizStep: "startpage",
@ -96,11 +90,9 @@ export const createQuizViewStore = () =>
}
);
},
updateOwnVariant(id, answer, extendedText, originalImageUrl, localImageUrl) {
updateOwnVariant(id, answer) {
set(
(state) => {
console.log("!!! STORE !!! ___ Случился вызов варианта свой ответ. Вот что я получил:");
console.log(id, answer, extendedText, originalImageUrl, localImageUrl);
const index = state.ownVariants.findIndex((variant) => variant.id === id);
if (index < 0) {
@ -108,24 +100,14 @@ export const createQuizViewStore = () =>
id,
variant: {
id: id,
answer: answer === null ? state.ownVariants[index].variant.answer : answer,
extendedText: extendedText || "",
answer,
extendedText: "",
hints: "",
originalImageUrl: originalImageUrl || "",
localImageUrl: localImageUrl || "",
originalImageUrl: "",
},
});
} else {
if (answer !== null) 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;
}
state.ownVariants[index].variant.answer = answer;
}
},
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,25 +18,17 @@ export const useQuizStore = create<QuizStore>(() => ({
cnt: 0,
recentlyCompleted: false,
show_badge: false,
quizStep: 0,
nextLoading: false,
}));
export const setQuizData = (data: QuizSettings) => {
const currentState = useQuizStore.getState();
useQuizStore.setState((state: QuizStore) => {
const newState = { ...state, ...data };
return newState;
});
const updatedState = useQuizStore.getState();
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) =>
@ -47,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";

@ -12,7 +12,6 @@ const isProduction = !(
);
//туризм больше не в исключениях
if (!isProduction) domain = "https://hbpn.link";
// domain = "https://hbpn.link";
if (!isProduction) domain = "https://s.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 };
}
}

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

@ -1,110 +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();
//Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах
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(() => {
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);
}, [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 = settings.cfg?.backBlocked ? false : 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,257 +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();
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их 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 = settings.cfg?.backBlocked ? false : Boolean(prevQuestion);
//Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => {
const hasAnswer = answers.some(({ questionId }) => questionId === currentQuestion.id);
if ("required" in currentQuestion.content && currentQuestion.content.required) {
return hasAnswer;
}
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,257 +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();
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их 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 = settings.cfg?.backBlocked ? false : Boolean(prevQuestion);
//Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => {
const hasAnswer = answers.some(({ questionId }) => questionId === currentQuestion.id);
if ("required" in currentQuestion.content && currentQuestion.content.required) {
return hasAnswer;
}
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";
// выбор способа управления в зависимости от статуса
let cachedManager: () => ReturnType<typeof useLinearQuiz>;
export let statusOfQuiz: "line" | "branch" | "ai";
import { useQuizViewStore } from "@stores/quizView";
function analyicStatus({ status, haveRoot }: StatusData) {
if (status === "ai") {
statusOfQuiz = "ai";
return;
}
if (status === "start") {
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 будет заполнен
if (haveRoot) statusOfQuiz = "branch";
else statusOfQuiz = "line";
//Если заполнен, то дерево растёт с 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;
}
throw new Error("quiz is inactive");
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,
};
}
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,
});
}
@ -100,9 +93,7 @@ export async function sendQuestionAnswer(
return;
}
case "images": {
console.log("Работает отправщик ответа для типа КАРТИНКИ");
if (question.content.multi) {
console.log("Этот вопрос есть МУЛЬТИ");
const answer = questionAnswer.answer;
const ownAnswer = Array.isArray(answer)
? ownVariants[ownVariants.findIndex((variant) => answer.some((a: string) => a === variant.id))]?.variant
@ -118,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)}\`,`;
@ -150,38 +124,13 @@ export async function sendQuestionAnswer(
qid: quizId,
});
}
console.log("Этот вопрос НЕ есть МУЛЬТИ");
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}`);
// Берем fileId из ownVariants для own вариантов
const ownVariantData = ownVariants.find((v) => v.id === variant.id)?.variant;
console.log("Был выбран вариант ", variant);
console.log("Был выбран ownVariant ", ownVariantData);
let imageValue = variant.extendedText;
if (variant.isOwn) {
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;
}
}
console.log("В конечном итоге я планирую отправить вот эти данные: ", {
Image: imageValue,
Description: ownVariantData ? ownVariantData.answer : variant.answer,
});
const body = {
Image: imageValue,
Description: ownVariantData ? ownVariantData.answer : variant.answer,
Image: variant.extendedText,
Description: variant.answer,
};
if (!body) throw new Error(`Body of answer in question ${question.id} is undefined`);
@ -279,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`);

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

@ -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,10 +54,6 @@
"Get results": "Получить результаты",
"Data sent successfully": "Данные успешно отправлены",
"Step": "Шаг",
"questions are not ready yet": "Вопросы для аудитории ещё не созданы. Пожалуйста, подождите",
"Add your image": "Добавьте своё изображение",
"select emoji": "выберите смайлик",
"Please complete the phone number": "Пожалуйста, завершите номер телефона",
"Please enter a valid email": "Пожалуйста, введите корректную почту",
"Please enter a valid phone number": "Пожалуйста, введите корректный номер телефона"
"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"
}

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

@ -1,287 +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);
const detectedLang = langMatch ? langMatch[1].toLowerCase() : "ru"; // Фолбэк на 'ru'
return detectedLang;
};
// 2. Локали, встроенные прямо в конфиг
const r = {
ru: {
translation: {
"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": "выберите смайлик",
"Please complete the phone number": "Пожалуйста, заполните номер телефона до конца",
"": "", // Пустой ключ для fallback
},
},
en: {
translation: {
"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",
"Please complete the phone number": "Please complete the phone number",
"": "", // Пустой ключ для fallback
},
},
uz: {
translation: {
"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",
"Please complete the phone number": "Iltimos, telefon raqamini to'liq kiriting",
"": "", // Пустой ключ для fallback
},
},
};
// Проверяем, не инициализирован ли уже i18n
if (i18n.isInitialized) {
// Добавляем ресурсы к существующему экземпляру
(Object.keys(r) as Array<"ru" | "en" | "uz">).forEach((lng) => {
if (i18n.store.data[lng] && i18n.store.data[lng].translation) {
// Объединяем с существующими переводами
i18n.store.data[lng].translation = {
...(i18n.store.data[lng].translation as Record<string, string>),
...r[lng].translation,
};
} else {
// Добавляем новые переводы
i18n.store.data[lng] = {
...(i18n.store.data[lng] as Record<string, any>),
translation: r[lng].translation,
};
}
});
} else {
// 3. Конфигурация i18n без Backend
i18n
.use(initReactI18next)
.init({
resources: r, // Используем встроенные переводы
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("⚠️ Widget i18n: Missing translation key:", key);
return key;
},
missingKeyHandler: (lngs, ns, key) => {
console.error("🚨 Widget i18n: Missing i18n key:", {
key,
languages: lngs,
namespace: ns,
stack: new Error().stack,
});
},
})
.catch((error) => {
console.error("❌ Widget i18n: Initialization failed:", error);
});
}
// 4. Логирование событий
i18n.on("languageChanged", (lng) => {
console.log("🔄 Widget i18n: Language changed to:", lng);
});
i18n.on("initialized", (options) => {
console.log("🎯 Widget i18n: Initialized event fired with options:", options);
});
i18n.on("loaded", (loaded) => {
console.log("📦 Widget i18n: Loaded event fired:", loaded);
});
i18n.on("failedLoading", (lng, ns, msg) => {
console.error("💥 Widget i18n: Failed loading:", { lng, ns, msg });
});
i18n.on("missingKey", (lngs, namespace, key, res) => {
console.warn("⚠️ Widget i18n: Missing key event:", { lngs, namespace, key, res });
});
export default i18n;

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

@ -3,8 +3,6 @@ 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 = {
create({
@ -17,20 +15,11 @@ const widget = {
changeFaviconAndTitle: boolean;
}) {
const element = document.getElementById(selector);
if (!element) {
console.error("❌ Widget: Element for widget doesn't exist:", selector);
throw new Error("Element for widget doesn't exist");
}
if (!element) throw new Error("Element for widget doesn't exist");
const root = createRoot(element);
root.render(
<QuizAnswerer
quizId={quizId}
changeFaviconAndTitle={changeFaviconAndTitle}
disableGlobalCss
/>
);
root.render(<QuizAnswerer quizId={quizId} changeFaviconAndTitle={changeFaviconAndTitle} disableGlobalCss />);
},
};

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

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

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