diff --git a/lib/api/hooks.ts b/lib/api/hooks.ts index 5550583..2ccbbe6 100644 --- a/lib/api/hooks.ts +++ b/lib/api/hooks.ts @@ -1,11 +1,78 @@ import useSWR from "swr"; -import { getQuizData } from "./quizRelase"; +import { getQuizData, getFirstQuizData } 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 } = 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 getQuizData({ + quizId: id, + limit: 1, + page: currentPage, + needConfig: true, + }); + + initDataManager({ + status: firstData.settings.status, + haveRoot: firstData.settings.cfg.haveRoot, + }); + setQuizData(firstData); + + // Определяем нужно ли загружать все данные + if (["line", "branch"].includes(firstData.status)) { + setNeedFullLoad(true); // Триггерит новый запрос через изменение ключа + return firstData; + } + return firstData; + } + + // Полная загрузка для line/branch + if (fullLoad) { + const allQuestions = await getQuestionsData({ + quizId: id, + limit: 100, + page: 0, + needConfig: false, + }); + + addQuestions(allQuestions); + return allQuestions; + } + + // Для AI режима - последовательная загрузка + const allQuestions = await getQuestionsData({ + quizId: id, + page: currentPage, + limit: 1, + needConfig: false, + }); + return allQuestions; + }, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, + refreshInterval: 0, + } + ); } diff --git a/lib/components/QuizAnswerer.tsx b/lib/components/QuizAnswerer.tsx index 7e8216b..08b6d7b 100644 --- a/lib/components/QuizAnswerer.tsx +++ b/lib/components/QuizAnswerer.tsx @@ -21,6 +21,7 @@ import { HelmetProvider } from "react-helmet-async"; import "moment/dist/locale/ru"; import { useQuizStore, setQuizData, addquizid } from "@/stores/useQuizStore"; +import { initDataManager } 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); diff --git a/lib/model/settingsData.ts b/lib/model/settingsData.ts index fd62f78..16a6fac 100644 --- a/lib/model/settingsData.ts +++ b/lib/model/settingsData.ts @@ -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 = { diff --git a/lib/stores/useQuizStore.ts b/lib/stores/useQuizStore.ts index f80f710..1228c70 100644 --- a/lib/stores/useQuizStore.ts +++ b/lib/stores/useQuizStore.ts @@ -7,6 +7,7 @@ export type QuizStore = QuizSettings & { quizId: string; preview: boolean; changeFaviconAndTitle: boolean; + quizStep: number; }; export const useQuizStore = create(() => ({ @@ -18,6 +19,7 @@ export const useQuizStore = create(() => ({ cnt: 0, recentlyCompleted: false, show_badge: false, + quizStep: 0, })); export const setQuizData = (data: QuizSettings) => { @@ -25,10 +27,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 +39,9 @@ export const addquizid = (id: string) => state.quizId = id; }) ); +export const changeQuizStep = (step: number) => + useQuizStore.setState( + produce((state: QuizStore) => { + state.quizStep += step; + }) + ); diff --git a/lib/utils/hooks/FlowControlLogic/useAIQuiz.ts b/lib/utils/hooks/FlowControlLogic/useAIQuiz.ts new file mode 100644 index 0000000..e69de29 diff --git a/lib/utils/hooks/FlowControlLogic/useBranchingQuiz.ts b/lib/utils/hooks/FlowControlLogic/useBranchingQuiz.ts new file mode 100644 index 0000000..d172644 --- /dev/null +++ b/lib/utils/hooks/FlowControlLogic/useBranchingQuiz.ts @@ -0,0 +1,308 @@ +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"; +import { useQuizGetNext } from "@/api/useQuizGetNext"; + +let isgetting = false; + +export function useBranchingQuiz() { + //Получаем инфо о квизе и список вопросов. + 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(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, + }; +} diff --git a/lib/utils/hooks/FlowControlLogic/useLinearQuiz.ts b/lib/utils/hooks/FlowControlLogic/useLinearQuiz.ts new file mode 100644 index 0000000..529d525 --- /dev/null +++ b/lib/utils/hooks/FlowControlLogic/useLinearQuiz.ts @@ -0,0 +1,306 @@ +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"; +import { useQuizGetNext } from "@/api/useQuizGetNext"; + +export function useLinearQuiz() { + //Получаем инфо о квизе и список вопросов. + 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(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, + }; +} diff --git a/lib/utils/hooks/useQuestionFlowControl.ts b/lib/utils/hooks/useQuestionFlowControl.ts index 7bda474..768bb98 100644 --- a/lib/utils/hooks/useQuestionFlowControl.ts +++ b/lib/utils/hooks/useQuestionFlowControl.ts @@ -1,310 +1,45 @@ -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(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; +export let statusOfQuiz: "line" | "branch" | "ai"; + +function analyicStatus({ status, haveRoot }: StatusData) { + if (status === "ai") statusOfQuiz = "ai"; + if (status === "start") { + // Если есть ветвление, то settings.cfg.haveRoot будет заполнен + if (haveRoot) statusOfQuiz = "branch"; + else statusOfQuiz = "line"; + } else 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(); +};