w-funnel/src/components/funnel/FunnelRuntime.tsx
gofnnp f8305e193a special-offer
add setting to admin
2025-10-11 20:34:04 +04:00

328 lines
10 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 } 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,
});
}