w-funnel/src/components/funnel/FunnelRuntime.tsx
dev.daminik00 6c50d05123 ab
2025-10-21 01:27:08 +02:00

409 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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,
});
}