"use client"; import { useEffect, useMemo, useCallback } from "react"; import { useRouter } from "next/navigation"; import { resolveNextScreenId, type UnleashChecker } from "@/lib/funnel/navigation"; import { resolveScreenVariant } from "@/lib/funnel/variants"; import { useFunnelRuntime } from "@/lib/funnel/FunnelProvider"; import { renderScreen } from "@/lib/funnel/screenRenderer"; import type { FunnelDefinition, FunnelAnswers, ListScreenDefinition, DateScreenDefinition, } from "@/lib/funnel/types"; import { getZodiacSign } from "@/lib/funnel/zodiac"; import { useSession } from "@/hooks/session/useSession"; import { buildSessionDataFromScreen } from "@/lib/funnel/registrationHelpers"; import { useUnleashContext, sendUnleashImpression } from "@/lib/funnel/unleash"; // Функция для оценки длины пути пользователя на основе текущих ответов function estimatePathLength( funnel: FunnelDefinition, answers: FunnelAnswers, unleashChecker?: UnleashChecker ): number { const visited = new Set(); let currentScreenId = funnel.meta.firstScreenId || funnel.screens[0]?.id; // Симулируем прохождение воронки с текущими ответами while (currentScreenId && !visited.has(currentScreenId)) { visited.add(currentScreenId); const currentScreen = funnel.screens.find((s) => s.id === currentScreenId); if (!currentScreen) break; const resolvedScreen = resolveScreenVariant( currentScreen, answers, funnel.screens, unleashChecker ); const nextScreenId = resolveNextScreenId( resolvedScreen, answers, funnel.screens, unleashChecker ); // Если достигли конца или зацикливание if (!nextScreenId || visited.has(nextScreenId)) { break; } currentScreenId = nextScreenId; } return visited.size; } interface FunnelRuntimeProps { funnel: FunnelDefinition; initialScreenId: string; } export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { const router = useRouter(); const { createSession, updateSession } = useSession({ funnelId: funnel.meta.id, }); const { answers, registerScreen, setAnswers, history } = useFunnelRuntime( funnel.meta.id ); const { checkVariant, activeVariants } = useUnleashContext(); // Создаем unleashChecker функцию для передачи в navigation/variants const unleashChecker: UnleashChecker = useCallback( (flag, expectedVariants, operator) => { return checkVariant(flag, expectedVariants, operator); }, [checkVariant] ); // ✅ Screen Map для O(1) поиска вместо O(n) const screenMap = useMemo(() => { return new Map(funnel.screens.map((screen) => [screen.id, screen])); }, [funnel.screens]); const baseScreen = useMemo(() => { const screen = screenMap.get(initialScreenId) ?? funnel.screens[0]; if (!screen) { throw new Error("Funnel definition does not contain any screens"); } return screen; }, [screenMap, initialScreenId, funnel.screens]); const currentScreen = useMemo(() => { return resolveScreenVariant(baseScreen, answers, funnel.screens, unleashChecker); }, [baseScreen, answers, funnel.screens, unleashChecker]); const selectedOptionIds = answers[currentScreen.id] ?? []; // Собираем флаги которые используются на текущем экране const currentScreenFlags = useMemo(() => { const flags = new Set(); // Флаги из вариантов текущего экрана currentScreen.variants?.forEach((variant) => { variant.conditions.forEach((condition) => { if (condition.conditionType === "unleash" && condition.unleashFlag) { flags.add(condition.unleashFlag); } }); }); // Флаги из правил навигации текущего экрана currentScreen.navigation?.rules?.forEach((rule) => { rule.conditions.forEach((condition) => { if (condition.conditionType === "unleash" && condition.unleashFlag) { flags.add(condition.unleashFlag); } }); }); return Array.from(flags); }, [currentScreen]); useEffect(() => { createSession(); }, [createSession]); useEffect(() => { registerScreen(currentScreen.id); }, [currentScreen.id, registerScreen]); // Создаем стабильный ключ для текущих вариантов флагов const currentFlagsKey = useMemo(() => { if (currentScreenFlags.length === 0) { return ""; } // Создаем строку вида "flag1:variant1,flag2:variant2" return currentScreenFlags .map(flag => `${flag}:${activeVariants[flag] || "loading"}`) .sort() .join(","); }, [currentScreenFlags, activeVariants]); // Отправляем impression события в GA когда пользователь видит экран с AB тестами useEffect(() => { if (currentScreenFlags.length === 0) { return; // Нет AB тестов на этом экране } // Проверяем что все флаги загружены const allFlagsLoaded = currentScreenFlags.every(flag => { const variant = activeVariants[flag]; return variant !== undefined && variant !== "loading"; }); if (!allFlagsLoaded) { // Ждем пока все флаги загрузятся return; } // Отправляем impression для каждого флага на этом экране currentScreenFlags.forEach((flag) => { const variant = activeVariants[flag]; sendUnleashImpression(flag, variant); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentScreen.id, currentFlagsKey]); const historyWithCurrent = useMemo(() => { if (history.length === 0) { return [currentScreen.id]; } const last = history[history.length - 1]; if (last === currentScreen.id) { return history; } const existingIndex = history.lastIndexOf(currentScreen.id); if (existingIndex >= 0) { return history.slice(0, existingIndex + 1); } return [...history, currentScreen.id]; }, [history, currentScreen.id]); // Calculate automatic progress based on user's actual path const screenProgress = useMemo(() => { const total = estimatePathLength(funnel, answers, unleashChecker); const current = historyWithCurrent.length; // Номер текущего экрана = количество посещенных return { current, total }; }, [historyWithCurrent.length, funnel, answers, unleashChecker]); const goToScreen = (screenId: string | undefined) => { if (!screenId) { return; } router.push(`/${funnel.meta.id}/${screenId}`); }; const handleContinue = () => { if (answers[currentScreen.id] && currentScreen.template !== "email") { // Собираем данные для сессии const sessionData = buildSessionDataFromScreen( currentScreen, answers[currentScreen.id] ); // Для date экранов с registrationFieldKey НЕ отправляем answers const shouldSkipAnswers = currentScreen.template === "date" && "dateInput" in currentScreen && currentScreen.dateInput?.registrationFieldKey; updateSession({ ...(shouldSkipAnswers ? {} : { answers: { [currentScreen.id]: answers[currentScreen.id], }, }), // Добавляем данные с registrationFieldKey ...sessionData, }); } const nextScreenId = resolveNextScreenId( currentScreen, answers, funnel.screens, unleashChecker ); goToScreen(nextScreenId); }; const handleSelectionChange = (ids: string[], skipCheckChanges = false) => { const prevSelectedIds = selectedOptionIds; const hasChanged = skipCheckChanges || prevSelectedIds.length !== ids.length || prevSelectedIds.some((value, index) => value !== ids[index]); // Check if this is a single selection list without action button const shouldAutoAdvance = currentScreen.template === "list" && (() => { const listScreen = currentScreen as ListScreenDefinition; const selectionType = listScreen.list.selectionType; // Простая логика: автопереход если single selection и кнопка отключена const bottomActionButton = listScreen.bottomActionButton; const isButtonExplicitlyDisabled = bottomActionButton?.show === false; return ( selectionType === "single" && isButtonExplicitlyDisabled && ids.length > 0 ); })(); // ПРАВИЛЬНОЕ РЕШЕНИЕ: Автопереход ТОЛЬКО при изменении значения // Это исключает автопереход при возврате назад, когда компоненты // восстанавливают состояние и вызывают callbacks без реального изменения const shouldProceed = hasChanged; if (!shouldProceed) { return; // Блокируем программные вызовы useEffect без изменений } const nextAnswers = { ...answers, [currentScreen.id]: ids, } as typeof answers; if (ids.length === 0) { delete nextAnswers[currentScreen.id]; } // Only save answers if they actually changed if (hasChanged) { setAnswers(currentScreen.id, ids); } if (currentScreen.template === "date") { const dateScreen = currentScreen as DateScreenDefinition; const zodiacSettings = dateScreen.dateInput?.zodiac; const storageKey = zodiacSettings?.storageKey?.trim(); if (storageKey) { if (zodiacSettings?.enabled) { const [monthValue, dayValue] = ids; const month = parseInt(monthValue ?? "", 10); const day = parseInt(dayValue ?? "", 10); const zodiac = Number.isNaN(month) || Number.isNaN(day) ? null : getZodiacSign(month, day); if (zodiac) { setAnswers(storageKey, [zodiac]); } else { setAnswers(storageKey, []); } } else { setAnswers(storageKey, []); } } } // Auto-advance for single selection without action button if (shouldAutoAdvance) { // Собираем данные для сессии const sessionData = buildSessionDataFromScreen(currentScreen, ids); updateSession({ answers: { [currentScreen.id]: ids, }, // Добавляем данные с registrationFieldKey если они есть ...sessionData, }); const nextScreenId = resolveNextScreenId( currentScreen, nextAnswers, funnel.screens, unleashChecker ); goToScreen(nextScreenId); } }; const onBack = () => { const backTarget: string | undefined = currentScreen.navigation?.onBackScreenId; if (backTarget) { // Переназначаем назад на конкретный экран без роста истории router.replace(`/${funnel.meta.id}/${backTarget}`); return; } const currentIndex = historyWithCurrent.lastIndexOf(currentScreen.id); if (currentIndex > 0) { goToScreen(historyWithCurrent[currentIndex - 1]); return; } if (historyWithCurrent.length > 1) { goToScreen(historyWithCurrent[historyWithCurrent.length - 2]); return; } router.back(); }; // Перехват аппаратной/браузерной кнопки Назад, когда настроен onBackScreenId useEffect(() => { const backTarget: string | undefined = currentScreen.navigation?.onBackScreenId; if (!backTarget) return; // Флаг для предотвращения множественных срабатываний let isRedirecting = false; const pushTrap = () => { try { window.history.pushState( { __trap: true, __screenId: currentScreen.id }, "", window.location.href ); } catch {} }; // Добавляем trap state в историю pushTrap(); const handlePopState = () => { // Проверяем наличие backTarget на момент события const currentBackTarget = currentScreen.navigation?.onBackScreenId; if (!currentBackTarget || isRedirecting) return; isRedirecting = true; // Перемещаемся вперед на 1 шаг чтобы отменить переход назад window.history.go(1); // Небольшая задержка для завершения history.go setTimeout(() => { try { // Выполняем редирект на целевой экран router.replace(`/${funnel.meta.id}/${currentBackTarget}`); // Сбрасываем флаг setTimeout(() => { isRedirecting = false; }, 100); } catch (error) { // Fallback: если router.replace не сработал, используем нативную навигацию console.error('Router replace failed, using fallback navigation:', error); window.location.href = `/${funnel.meta.id}/${currentBackTarget}`; } }, 10); }; window.addEventListener("popstate", handlePopState); return () => { window.removeEventListener("popstate", handlePopState); }; }, [currentScreen.id, currentScreen.navigation?.onBackScreenId, funnel.meta.id, router]); const canGoBack = historyWithCurrent.lastIndexOf(currentScreen.id) > 0; return renderScreen({ funnel, screen: currentScreen, selectedOptionIds, onSelectionChange: handleSelectionChange, onContinue: handleContinue, canGoBack, onBack, screenProgress, defaultTexts: funnel.defaultTexts, answers, }); }