Merge branch 'alone' into staging
Some checks failed
Deploy / DeployService (push) Has been skipped
Deploy / CreateImage (push) Has been cancelled

This commit is contained in:
Nastya 2025-06-30 05:08:57 +03:00
commit 66865ff6d9
13 changed files with 897 additions and 668 deletions

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

@ -79,16 +79,28 @@ export const publicationMakeRequest = ({ url, body }: PublicationMakeRequestPara
let globalStatus: string | null = null;
let isFirstRequest = true;
export async function getData({ quizId }: { quizId: string }): Promise<{
/*
если запросить 0 вопросов - придёт items: null
если не запрашивать конфиг - поле конфига вообще не придёт
*/
interface GetDataProps {
quizId: string;
limit: number;
page: number;
needConfig: boolean;
}
export async function getData({ quizId, limit, page, needConfig }: GetDataProps): Promise<{
data: GetQuizDataResponse | null;
isRecentlyCompleted: boolean;
error?: AxiosError;
}> {
const body = {
quiz_id: quizId,
limit: 100,
page: 0,
need_config: true,
limit,
page,
need_config: needConfig,
} as any;
if (paudParam) body.auditory = Number(paudParam);
@ -111,7 +123,12 @@ export async function getData({ quizId }: { quizId: string }): Promise<{
const sessions = JSON.parse(localStorage.getItem("sessions") || "{}");
//Тут ещё проверка на антифрод без парса конфига. Нам не интересно время если не нужно запрещать проходить чаще чем в сутки
if (typeof sessions[quizId] === "number" && data.settings.cfg.includes('antifraud":true')) {
if (
needConfig &&
data?.settings !== undefined &&
typeof sessions[quizId] === "number" &&
data.settings.cfg.includes('antifraud":true')
) {
// unix время. Если меньше суток прошло - выводить ошибку, иначе пустить дальше
if (Date.now() - sessions[quizId] < 86400000) {
return { data, isRecentlyCompleted: true };
@ -128,113 +145,10 @@ export async function getData({ quizId }: { quizId: string }): Promise<{
}
}
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 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 response = await getData(props);
const quizDataResponse = response.data;
if (response.error) {
@ -252,8 +166,10 @@ export async function getQuizData({ quizId, status = "" }: { quizId: string; sta
throw new Error("Quiz not found");
}
//Парсим строки в строках
const quizSettings = replaceSpacesToEmptyLines(parseQuizData(quizDataResponse));
//Единоразово стрингифаим ВСЁ распаршенное и удаляем лишние пробелы
const res = JSON.parse(
JSON.stringify({ data: quizSettings })
.replaceAll(/\\" \\"/g, '""')
@ -263,124 +179,6 @@ export async function getQuizData({ quizId, status = "" }: { quizId: string; sta
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 });
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 });
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[];
@ -389,8 +187,6 @@ type SendAnswerProps = {
};
export function sendAnswer({ questionId, body, qid, preview = false }: SendAnswerProps) {
console.log("qid");
console.log(qid);
if (preview) return;
const formData = new FormData();

@ -1,77 +0,0 @@
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 };
};

@ -21,6 +21,7 @@ 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;
@ -32,16 +33,7 @@ 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,
@ -78,17 +70,15 @@ function QuizAnswererInner({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
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);
//Хук на случай если данные переданы нам сразу, а не "нам нужно их запросить"
if (quizSettings !== undefined) {
setQuizData(quizSettings);
initDataManager({
status: quizSettings.settings.status,
haveRoot: quizSettings.settings.cfg.haveRoot,
});
}
}, [quizSettings, data]);
}, [quizSettings]);
useLayoutEffect(() => {
if (rootContainerRef.current) setRootContainerWidth(rootContainerRef.current.clientWidth);
@ -109,13 +99,14 @@ function QuizAnswererInner({
console.log("settings");
console.log(settings);
if (isLoading) return <LoadingSkeleton />;
if (isLoading && !questions.length) return <LoadingSkeleton />;
if (error) return <ApologyPage error={error} />;
if (Object.keys(settings).length == 0) return <ApologyPage error={new Error("quiz data is null")} />;
if (questions.length === 0) return <ApologyPage error={new Error("No questions found")} />;
if (questions.length === 1 && settings.cfg.noStartPage) return <ApologyPage error={new Error("quiz is empty")} />;
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")} />;
const quizContainer = (

@ -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 { useQuestionFlowControl } from "@utils/hooks/useQuestionFlowControl";
import { statusOfQuiz, useQuestionFlowControl } from "@utils/hooks/useQuestionFlowControl";
import { notReachable } from "@utils/notReachable";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { enqueueSnackbar } from "notistack";
@ -18,7 +18,7 @@ import { StartPageViewPublication } from "./StartPageViewPublication";
import NextButton from "./tools/NextButton";
import PrevButton from "./tools/PrevButton";
import unscreen from "@/ui_kit/unscreen";
import { useQuizStore } from "@/stores/useQuizStore";
import { changeNextLoading, useQuizStore } from "@/stores/useQuizStore";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
polyfillCountryFlagEmojis();
@ -111,6 +111,7 @@ 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,5 +1,5 @@
import { useQuizStore } from "@/stores/useQuizStore";
import { Button } from "@mui/material";
import { Button, Skeleton } from "@mui/material";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { useTranslation } from "react-i18next";
@ -9,10 +9,19 @@ interface Props {
}
export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }: Props) {
const { settings } = useQuizStore();
const { settings, nextLoading } = useQuizStore();
const { t } = useTranslation();
return (
return nextLoading ? (
<Skeleton
variant="rectangular"
sx={{
borderRadius: "8px",
width: "96px",
height: "44px",
}}
/>
) : (
<Button
disabled={!isNextButtonEnabled}
variant="contained"
@ -25,7 +34,7 @@ export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }:
}}
onClick={moveToNextQuestion}
>
далее {/* {t("Next")} → (*.*) */}
{t("Next")}
</Button>
);
}

@ -3,7 +3,7 @@ import { QuizSettings } from "@model/settingsData";
export interface GetQuizDataResponse {
cnt: number;
settings: {
settings?: {
fp: boolean;
rep: boolean;
name: string;
@ -27,6 +27,13 @@ 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 = JSON.parse(item.c);
@ -41,17 +48,21 @@ export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizS
} as unknown as AnyTypedQuizQuestion;
});
const settings: QuizSettings["settings"] = {
fp: quizDataResponse.settings.fp,
rep: quizDataResponse.settings.rep,
name: quizDataResponse.settings.name,
cfg: JSON.parse(quizDataResponse?.settings.cfg),
lim: quizDataResponse.settings.lim,
due: quizDataResponse.settings.due,
delay: quizDataResponse.settings.delay,
pausable: quizDataResponse.settings.pausable,
status: quizDataResponse.settings.status,
};
readyData.questions = items;
return { cnt: quizDataResponse.cnt, settings, questions: items, show_badge: quizDataResponse.show_badge };
if (quizDataResponse?.settings !== undefined) {
readyData.settings = {
fp: quizDataResponse.settings.fp,
rep: quizDataResponse.settings.rep,
name: quizDataResponse.settings.name,
cfg: JSON.parse(quizDataResponse?.settings.cfg),
lim: quizDataResponse.settings.lim,
due: quizDataResponse.settings.due,
delay: quizDataResponse.settings.delay,
pausable: quizDataResponse.settings.pausable,
status: quizDataResponse.settings.status,
};
}
return readyData;
}

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

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

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

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

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