This commit is contained in:
dev.daminik00 2025-10-06 03:26:20 +02:00
parent aa956adebb
commit c4ba2ff541
4 changed files with 209 additions and 2 deletions

View File

@ -81,6 +81,11 @@
"align": "left", "align": "left",
"color": "default" "color": "default"
}, },
"bottomActionButton": {
"show": false,
"cornerRadius": "3xl",
"showPrivacyTermsConsent": false
},
"navigation": { "navigation": {
"rules": [], "rules": [],
"defaultNextScreenId": "relationship-status", "defaultNextScreenId": "relationship-status",

View File

@ -4,12 +4,19 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useMemo, useMemo,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import type { FunnelAnswers } from "./types"; import type { FunnelAnswers } from "./types";
import {
saveFunnelState,
loadFunnelState,
clearExpiredFunnelStates,
clearFunnelState,
} from "./storage";
interface FunnelRuntimeState { interface FunnelRuntimeState {
answers: FunnelAnswers; answers: FunnelAnswers;
@ -61,10 +68,35 @@ interface FunnelProviderProps {
export function FunnelProvider({ children }: FunnelProviderProps) { export function FunnelProvider({ children }: FunnelProviderProps) {
const [state, setState] = useState<Record<string, FunnelRuntimeState>>({}); 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) => { const registerScreenVisit = useCallback((funnelId: string, screenId: string) => {
setState((prev) => { setState((prev) => {
const previousState = prev[funnelId] ?? createInitialState(); // Пытаемся загрузить сохраненное состояние, если его еще нет в памяти
let previousState = prev[funnelId];
if (!previousState) {
const loaded = loadFunnelState(funnelId);
previousState = loaded ?? createInitialState();
}
const history = previousState.history ?? []; const history = previousState.history ?? [];
let nextHistory = history; let nextHistory = history;
@ -96,7 +128,12 @@ export function FunnelProvider({ children }: FunnelProviderProps) {
const updateScreenAnswers = useCallback( const updateScreenAnswers = useCallback(
(funnelId: string, screenId: string, answers: string[]) => { (funnelId: string, screenId: string, answers: string[]) => {
setState((prev) => { setState((prev) => {
const previousState = prev[funnelId] ?? createInitialState(); // Пытаемся загрузить сохраненное состояние, если его еще нет в памяти
let previousState = prev[funnelId];
if (!previousState) {
const loaded = loadFunnelState(funnelId);
previousState = loaded ?? createInitialState();
}
const previousAnswers = previousState.answers ?? {}; const previousAnswers = previousState.answers ?? {};
const existingAnswers = previousAnswers[screenId]; const existingAnswers = previousAnswers[screenId];
@ -150,6 +187,9 @@ export function FunnelProvider({ children }: FunnelProviderProps) {
return prev; return prev;
} }
// Очищаем состояние из localStorage
clearFunnelState(funnelId);
return { return {
...prev, ...prev,
[funnelId]: { [funnelId]: {

View File

@ -89,6 +89,11 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"align": "left", "align": "left",
"color": "default" "color": "default"
}, },
"bottomActionButton": {
"show": false,
"cornerRadius": "3xl",
"showPrivacyTermsConsent": false
},
"navigation": { "navigation": {
"rules": [], "rules": [],
"defaultNextScreenId": "relationship-status", "defaultNextScreenId": "relationship-status",

157
src/lib/funnel/storage.ts Normal file
View File

@ -0,0 +1,157 @@
import type { FunnelAnswers } from "./types";
/**
* Структура данных для хранения состояния воронки
*/
export interface FunnelRuntimeState {
answers: FunnelAnswers;
history: string[];
version: number;
timestamp?: number; // Для отслеживания времени последнего обновления
}
/**
* Префикс для ключей в localStorage
*/
const STORAGE_PREFIX = "funnel_state_";
/**
* Время жизни данных в localStorage (7 дней)
*/
const STORAGE_TTL = 7 * 24 * 60 * 60 * 1000;
/**
* Проверяет доступность localStorage
*/
function isLocalStorageAvailable(): boolean {
try {
const testKey = "__storage_test__";
localStorage.setItem(testKey, "test");
localStorage.removeItem(testKey);
return true;
} catch {
return false;
}
}
/**
* Получает ключ для localStorage по ID воронки
*/
function getStorageKey(funnelId: string): string {
return `${STORAGE_PREFIX}${funnelId}`;
}
/**
* Сохраняет состояние воронки в localStorage
*/
export function saveFunnelState(
funnelId: string,
state: FunnelRuntimeState
): void {
if (!isLocalStorageAvailable()) {
return;
}
try {
const dataToSave: FunnelRuntimeState = {
...state,
timestamp: Date.now(),
};
localStorage.setItem(getStorageKey(funnelId), JSON.stringify(dataToSave));
} catch (error) {
console.warn("Failed to save funnel state to localStorage:", error);
}
}
/**
* Загружает состояние воронки из localStorage
*/
export function loadFunnelState(funnelId: string): FunnelRuntimeState | null {
if (!isLocalStorageAvailable()) {
return null;
}
try {
const stored = localStorage.getItem(getStorageKey(funnelId));
if (!stored) {
return null;
}
const data = JSON.parse(stored) as FunnelRuntimeState;
// Проверяем TTL
if (data.timestamp) {
const age = Date.now() - data.timestamp;
if (age > STORAGE_TTL) {
// Данные устарели, удаляем
clearFunnelState(funnelId);
return null;
}
}
return data;
} catch (error) {
console.warn("Failed to load funnel state from localStorage:", error);
return null;
}
}
/**
* Очищает состояние воронки из localStorage
*/
export function clearFunnelState(funnelId: string): void {
if (!isLocalStorageAvailable()) {
return;
}
try {
localStorage.removeItem(getStorageKey(funnelId));
} catch (error) {
console.warn("Failed to clear funnel state from localStorage:", error);
}
}
/**
* Очищает все устаревшие состояния воронок
*/
export function clearExpiredFunnelStates(): void {
if (!isLocalStorageAvailable()) {
return;
}
try {
const now = Date.now();
const keysToRemove: string[] = [];
// Проходим по всем ключам в localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key?.startsWith(STORAGE_PREFIX)) {
continue;
}
const value = localStorage.getItem(key);
if (!value) {
continue;
}
try {
const data = JSON.parse(value) as FunnelRuntimeState;
if (data.timestamp && now - data.timestamp > STORAGE_TTL) {
keysToRemove.push(key);
}
} catch {
// Если не удалось распарсить - удаляем
keysToRemove.push(key);
}
}
// Удаляем устаревшие ключи
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
} catch (error) {
console.warn("Failed to clear expired funnel states:", error);
}
}