252 lines
6.3 KiB
TypeScript
252 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
createContext,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useMemo,
|
||
useState,
|
||
type ReactNode,
|
||
} from "react";
|
||
|
||
import type { FunnelAnswers } from "./types";
|
||
import {
|
||
saveFunnelState,
|
||
loadFunnelState,
|
||
clearExpiredFunnelStates,
|
||
clearFunnelState,
|
||
} from "./storage";
|
||
|
||
interface FunnelRuntimeState {
|
||
answers: FunnelAnswers;
|
||
history: string[];
|
||
version: number;
|
||
}
|
||
|
||
interface FunnelContextValue {
|
||
state: Record<string, FunnelRuntimeState>;
|
||
registerScreenVisit: (funnelId: string, screenId: string) => void;
|
||
updateScreenAnswers: (
|
||
funnelId: string,
|
||
screenId: string,
|
||
answers: string[]
|
||
) => void;
|
||
resetFunnel: (funnelId: string) => void;
|
||
}
|
||
|
||
const DEFAULT_RUNTIME_STATE: FunnelRuntimeState = {
|
||
answers: {},
|
||
history: [],
|
||
version: 0,
|
||
};
|
||
|
||
function createInitialState(): FunnelRuntimeState {
|
||
return {
|
||
answers: {},
|
||
history: [],
|
||
version: 0,
|
||
};
|
||
}
|
||
|
||
function arraysEqual(left: string[] | undefined, right: string[]): boolean {
|
||
if (!left && right.length === 0) {
|
||
return true;
|
||
}
|
||
if (!left || left.length !== right.length) {
|
||
return false;
|
||
}
|
||
|
||
return left.every((value, index) => value === right[index]);
|
||
}
|
||
|
||
const FunnelContext = createContext<FunnelContextValue | undefined>(undefined);
|
||
|
||
interface FunnelProviderProps {
|
||
children: ReactNode;
|
||
}
|
||
|
||
export function FunnelProvider({ children }: FunnelProviderProps) {
|
||
const [state, setState] = useState<Record<string, FunnelRuntimeState>>({});
|
||
const [isHydrated, setIsHydrated] = useState(false);
|
||
|
||
// Восстанавливаем состояние из localStorage при монтировании
|
||
useEffect(() => {
|
||
// Очищаем устаревшие состояния
|
||
clearExpiredFunnelStates();
|
||
setIsHydrated(true);
|
||
}, []);
|
||
|
||
// Автоматически сохраняем состояние при изменениях
|
||
useEffect(() => {
|
||
if (!isHydrated) {
|
||
return;
|
||
}
|
||
|
||
// Сохраняем состояние каждой воронки
|
||
for (const [funnelId, funnelState] of Object.entries(state)) {
|
||
saveFunnelState(funnelId, funnelState);
|
||
}
|
||
}, [state, isHydrated]);
|
||
|
||
const registerScreenVisit = useCallback((funnelId: string, screenId: string) => {
|
||
setState((prev) => {
|
||
// Пытаемся загрузить сохраненное состояние, если его еще нет в памяти
|
||
let previousState = prev[funnelId];
|
||
if (!previousState) {
|
||
const loaded = loadFunnelState(funnelId);
|
||
previousState = loaded ?? createInitialState();
|
||
}
|
||
const history = previousState.history ?? [];
|
||
|
||
let nextHistory = history;
|
||
|
||
if (history.length === 0 || history[history.length - 1] !== screenId) {
|
||
const existingIndex = history.indexOf(screenId);
|
||
|
||
if (existingIndex === -1) {
|
||
nextHistory = [...history, screenId];
|
||
} else if (existingIndex !== history.length - 1) {
|
||
nextHistory = history.slice(0, existingIndex + 1);
|
||
}
|
||
}
|
||
|
||
if (nextHistory === history) {
|
||
return prev;
|
||
}
|
||
|
||
return {
|
||
...prev,
|
||
[funnelId]: {
|
||
...previousState,
|
||
history: nextHistory,
|
||
},
|
||
};
|
||
});
|
||
}, []);
|
||
|
||
const updateScreenAnswers = useCallback(
|
||
(funnelId: string, screenId: string, answers: string[]) => {
|
||
setState((prev) => {
|
||
// Пытаемся загрузить сохраненное состояние, если его еще нет в памяти
|
||
let previousState = prev[funnelId];
|
||
if (!previousState) {
|
||
const loaded = loadFunnelState(funnelId);
|
||
previousState = loaded ?? createInitialState();
|
||
}
|
||
const previousAnswers = previousState.answers ?? {};
|
||
const existingAnswers = previousAnswers[screenId];
|
||
|
||
if (answers.length === 0) {
|
||
if (!existingAnswers) {
|
||
return prev;
|
||
}
|
||
|
||
const rest = { ...previousAnswers };
|
||
delete rest[screenId];
|
||
|
||
return {
|
||
...prev,
|
||
[funnelId]: {
|
||
...previousState,
|
||
answers: rest,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (arraysEqual(existingAnswers, answers)) {
|
||
return prev;
|
||
}
|
||
|
||
return {
|
||
...prev,
|
||
[funnelId]: {
|
||
...previousState,
|
||
answers: {
|
||
...previousAnswers,
|
||
[screenId]: answers,
|
||
},
|
||
},
|
||
};
|
||
});
|
||
},
|
||
[]
|
||
);
|
||
|
||
const resetFunnel = useCallback((funnelId: string) => {
|
||
setState((prev) => {
|
||
const previousState = prev[funnelId];
|
||
if (!previousState) {
|
||
return prev;
|
||
}
|
||
|
||
if (
|
||
previousState.history.length === 0 &&
|
||
Object.keys(previousState.answers).length === 0
|
||
) {
|
||
return prev;
|
||
}
|
||
|
||
// Очищаем состояние из localStorage
|
||
clearFunnelState(funnelId);
|
||
|
||
return {
|
||
...prev,
|
||
[funnelId]: {
|
||
...createInitialState(),
|
||
version: (previousState.version ?? 0) + 1,
|
||
},
|
||
};
|
||
});
|
||
}, []);
|
||
|
||
const value = useMemo<FunnelContextValue>(
|
||
() => ({ state, registerScreenVisit, updateScreenAnswers, resetFunnel }),
|
||
[state, registerScreenVisit, updateScreenAnswers, resetFunnel]
|
||
);
|
||
|
||
return <FunnelContext.Provider value={value}>{children}</FunnelContext.Provider>;
|
||
}
|
||
|
||
function useFunnelContext() {
|
||
const context = useContext(FunnelContext);
|
||
if (!context) {
|
||
throw new Error("useFunnelContext must be used within a FunnelProvider");
|
||
}
|
||
return context;
|
||
}
|
||
|
||
export function useFunnelRuntime(funnelId: string) {
|
||
const { state, registerScreenVisit, updateScreenAnswers, resetFunnel } =
|
||
useFunnelContext();
|
||
|
||
const runtime = state[funnelId] ?? DEFAULT_RUNTIME_STATE;
|
||
|
||
const setAnswers = useCallback(
|
||
(screenId: string, answers: string[]) => {
|
||
updateScreenAnswers(funnelId, screenId, answers);
|
||
},
|
||
[funnelId, updateScreenAnswers]
|
||
);
|
||
|
||
const register = useCallback(
|
||
(screenId: string) => {
|
||
registerScreenVisit(funnelId, screenId);
|
||
},
|
||
[funnelId, registerScreenVisit]
|
||
);
|
||
|
||
const reset = useCallback(() => {
|
||
resetFunnel(funnelId);
|
||
}, [funnelId, resetFunnel]);
|
||
|
||
return {
|
||
answers: runtime.answers,
|
||
history: runtime.history,
|
||
version: runtime.version,
|
||
setAnswers,
|
||
registerScreen: register,
|
||
reset,
|
||
};
|
||
}
|