409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
"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<string>();
|
||
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<string>();
|
||
|
||
// Флаги из вариантов текущего экрана
|
||
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]);
|
||
|
||
// Отправляем impression события в GA когда пользователь видит экран с AB тестами
|
||
useEffect(() => {
|
||
if (currentScreenFlags.length === 0) {
|
||
return; // Нет AB тестов на этом экране
|
||
}
|
||
|
||
// Отправляем impression для каждого флага на этом экране
|
||
currentScreenFlags.forEach((flag) => {
|
||
// Получаем вариант для флага из контекста (он уже загружен через FunnelUnleashWrapper)
|
||
const variant = activeVariants[flag];
|
||
|
||
// Отправляем событие (внутри есть защита от дубликатов через sessionStorage)
|
||
sendUnleashImpression(flag, variant);
|
||
});
|
||
}, [currentScreenFlags, activeVariants]);
|
||
|
||
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,
|
||
});
|
||
}
|