328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
"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<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
|
||
);
|
||
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<string, unknown>);
|
||
}
|
||
|
||
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,
|
||
});
|
||
}
|