diff --git a/lib/api/hooks.ts b/lib/api/hooks.ts index 5550583..555c982 100644 --- a/lib/api/hooks.ts +++ b/lib/api/hooks.ts @@ -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"; + +/* + У хука есть три режмиа работы: "line" | "branch" | "ai" + Для branch и line единовременно запрашиваются ВСЕ данные (пока что это количество на 100 штук. Позже нужно впилить доп запросы чтобы получить все вопросы.) + Для ai идёт последовательный запрос данных. При первом попадании на result - блокируется возможность запрашивать новые данные +*/ 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, - }); + 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, + } + ); } diff --git a/lib/api/quizRelase.ts b/lib/api/quizRelase.ts index 92ce9dd..62c47e2 100644 --- a/lib/api/quizRelase.ts +++ b/lib/api/quizRelase.ts @@ -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( - 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( - 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(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(); diff --git a/lib/api/useQuizGetNext.ts b/lib/api/useQuizGetNext.ts deleted file mode 100644 index 6fa96ff..0000000 --- a/lib/api/useQuizGetNext.ts +++ /dev/null @@ -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(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 }; -}; diff --git a/lib/components/QuizAnswerer.tsx b/lib/components/QuizAnswerer.tsx index 7e8216b..da5d58f 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, 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 ; + if (isLoading && !questions.length) return ; if (error) return ; if (Object.keys(settings).length == 0) return ; if (questions.length === 0) return ; - if (questions.length === 1 && settings.cfg.noStartPage) return ; + if (questions.length === 1 && settings.cfg.noStartPage && statusOfQuiz != "ai") + return ; if (!quizId) return ; const quizContainer = ( diff --git a/lib/components/ViewPublicationPage/ViewPublicationPage.tsx b/lib/components/ViewPublicationPage/ViewPublicationPage.tsx index 73e64d2..b1b33f2 100644 --- a/lib/components/ViewPublicationPage/ViewPublicationPage.tsx +++ b/lib/components/ViewPublicationPage/ViewPublicationPage.tsx @@ -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() { { + if (statusOfQuiz == "ai") changeNextLoading(true); if (!preview) { await sendQuestionAnswer(quizId, currentQuestion, currentAnswer, ownVariants)?.catch((e) => { enqueueSnackbar("Ошибка при отправке ответа"); diff --git a/lib/components/ViewPublicationPage/tools/NextButton.tsx b/lib/components/ViewPublicationPage/tools/NextButton.tsx index 5ed3b93..77376fc 100644 --- a/lib/components/ViewPublicationPage/tools/NextButton.tsx +++ b/lib/components/ViewPublicationPage/tools/NextButton.tsx @@ -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 ? ( + + ) : ( ); } diff --git a/lib/model/api/getQuizData.ts b/lib/model/api/getQuizData.ts index 384ef1b..c701641 100644 --- a/lib/model/api/getQuizData.ts +++ b/lib/model/api/getQuizData.ts @@ -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 { + 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(() => ({ @@ -18,6 +20,8 @@ export const useQuizStore = create(() => ({ 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; + }) + ); diff --git a/lib/utils/hooks/FlowControlLogic/useAIQuiz.ts b/lib/utils/hooks/FlowControlLogic/useAIQuiz.ts new file mode 100644 index 0000000..5457a63 --- /dev/null +++ b/lib/utils/hooks/FlowControlLogic/useAIQuiz.ts @@ -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, + }; +} diff --git a/lib/utils/hooks/FlowControlLogic/useBranchingQuiz.ts b/lib/utils/hooks/FlowControlLogic/useBranchingQuiz.ts new file mode 100644 index 0000000..bc750cf --- /dev/null +++ b/lib/utils/hooks/FlowControlLogic/useBranchingQuiz.ts @@ -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(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, + }; +} diff --git a/lib/utils/hooks/FlowControlLogic/useLinearQuiz.ts b/lib/utils/hooks/FlowControlLogic/useLinearQuiz.ts new file mode 100644 index 0000000..119b3a9 --- /dev/null +++ b/lib/utils/hooks/FlowControlLogic/useLinearQuiz.ts @@ -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(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, + }; +} diff --git a/lib/utils/hooks/useQuestionFlowControl.ts b/lib/utils/hooks/useQuestionFlowControl.ts index 7bda474..53ea6de 100644 --- a/lib/utils/hooks/useQuestionFlowControl.ts +++ b/lib/utils/hooks/useQuestionFlowControl.ts @@ -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(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"; + 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(); +};