w-funnel/src/components/funnel/FunnelRuntime.tsx
dev.daminik00 3e735ab0f6 add
2025-10-24 00:05:07 +02:00

432 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]);
// Создаем стабильный ключ для текущих вариантов флагов
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,
});
}