"use client"; import { useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; import { resolveNextScreenId } 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"; // Функция для оценки длины пути пользователя на основе текущих ответов function estimatePathLength( funnel: FunnelDefinition, answers: FunnelAnswers ): 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 ); const nextScreenId = resolveNextScreenId( resolvedScreen, answers, funnel.screens ); // Если достигли конца или зацикливание 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 ); // ✅ 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); }, [baseScreen, answers, funnel.screens]); const selectedOptionIds = answers[currentScreen.id] ?? []; useEffect(() => { createSession(); }, [createSession]); useEffect(() => { registerScreen(currentScreen.id); }, [currentScreen.id, registerScreen]); 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); const current = historyWithCurrent.length; // Номер текущего экрана = количество посещенных return { current, total }; }, [historyWithCurrent.length, funnel, answers]); 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 ); 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 ); 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; const pushTrap = () => { try { window.history.pushState({ __trap: true }, "", window.location.href); } catch {} }; pushTrap(); function isTrapState(state: unknown): state is { __trap?: boolean } { return typeof state === "object" && state !== null && "__trap" in (state as Record); } const handlePopState = (e: PopStateEvent) => { if (isTrapState(e.state) && e.state.__trap) { pushTrap(); router.replace(`/${funnel.meta.id}/${backTarget}`); } }; 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, }); }