diff --git a/src/components/funnel/FlagVariantFetcher.tsx b/src/components/funnel/FlagVariantFetcher.tsx index eab1615..4949fb9 100644 --- a/src/components/funnel/FlagVariantFetcher.tsx +++ b/src/components/funnel/FlagVariantFetcher.tsx @@ -1,7 +1,7 @@ "use client"; import { useVariant } from "@unleash/proxy-client-react"; -import { useEffect } from "react"; +import { useEffect, useRef, memo } from "react"; interface FlagVariantFetcherProps { flag: string; @@ -12,13 +12,26 @@ interface FlagVariantFetcherProps { * Компонент для получения варианта одного флага * Каждый экземпляр этого компонента вызывает useVariant на верхнем уровне * Это позволяет обходить ограничение правил хуков React + * + * ВАЖНО: Мемоизация и useRef критически важны для предотвращения + * множественных impression событий. Каждый вызов useVariant() генерирует + * новое impression событие, поэтому минимизируем ре-рендеры. + * + * В aura-webapp дедупликации нет - каждое impression отправляется в GA напрямую. */ -export function FlagVariantFetcher({ flag, onVariantLoaded }: FlagVariantFetcherProps) { +export const FlagVariantFetcher = memo(function FlagVariantFetcher({ flag, onVariantLoaded }: FlagVariantFetcherProps) { const variant = useVariant(flag); + const lastVariantRef = useRef(undefined); useEffect(() => { - onVariantLoaded(flag, variant?.name); + const currentVariant = variant?.name; + + // Отправляем только если вариант изменился + if (currentVariant !== lastVariantRef.current) { + lastVariantRef.current = currentVariant; + onVariantLoaded(flag, currentVariant); + } }, [flag, variant?.name, onVariantLoaded]); return null; // Этот компонент не рендерит UI -} +}); diff --git a/src/components/funnel/FunnelUnleashWrapper.tsx b/src/components/funnel/FunnelUnleashWrapper.tsx index 87250d6..ac1172e 100644 --- a/src/components/funnel/FunnelUnleashWrapper.tsx +++ b/src/components/funnel/FunnelUnleashWrapper.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState, useMemo, useCallback, useEffect, type ReactNode } from "react"; +import { useState, useMemo, useCallback, type ReactNode } from "react"; import { useFlagsStatus } from "@unleash/proxy-client-react"; import { UnleashContextProvider } from "@/lib/funnel/unleash"; +import { useUnleashAnalytics } from "@/lib/funnel/unleash/useUnleashAnalytics"; import { FunnelLoadingScreen } from "./FunnelLoadingScreen"; import { FlagVariantFetcher } from "./FlagVariantFetcher"; import type { NavigationConditionDefinition } from "@/lib/funnel/types"; @@ -37,6 +38,10 @@ export function FunnelUnleashWrapper({ funnel, currentScreenId, }: FunnelUnleashWrapperProps) { + // ✅ КРИТИЧЕСКИ ВАЖНО: Подписываемся на impression события ДО загрузки флагов + // Это гарантирует что события от useVariant() будут пойманы и отправлены в аналитику + useUnleashAnalytics(); + const { flagsReady } = useFlagsStatus(); // Собираем флаги ТОЛЬКО для текущего экрана (или все, если currentScreenId не передан) @@ -80,16 +85,6 @@ export function FunnelUnleashWrapper({ // Состояние для хранения вариантов флагов const [loadedVariants, setLoadedVariants] = useState>({}); - // ✅ КРИТИЧЕСКИ ВАЖНО: Очищаем loadedVariants при смене экрана - // Это предотвращает ситуацию когда старые варианты используются для нового экрана - useEffect(() => { - setLoadedVariants({}); - - if (process.env.NODE_ENV === "development") { - console.log(`[FunnelUnleashWrapper] Screen changed to "${currentScreenId}", clearing loaded variants`); - } - }, [currentScreenId]); - // Колбэк для получения варианта от FlagVariantFetcher компонента const handleVariantLoaded = useCallback((flag: string, variant: string | undefined) => { // ✅ Сохраняем вариант в любом случае (даже если undefined или "disabled") @@ -99,9 +94,7 @@ export function FunnelUnleashWrapper({ // Обновляем только если значение изменилось if (prev[flag] !== newVariant) { - if (process.env.NODE_ENV === "development") { - console.log(`[FunnelUnleashWrapper] Flag "${flag}" = "${newVariant}"`); - } + console.log(`🚩 [FunnelUnleashWrapper] Flag loaded: "${flag}" = "${newVariant}"`); return { ...prev, [flag]: newVariant }; } return prev; @@ -111,11 +104,13 @@ export function FunnelUnleashWrapper({ // Проверяем что ВСЕ флаги текущего экрана загружены const allFlagsLoaded = useMemo(() => { if (!flagsReady) { + console.log("⏳ [FunnelUnleashWrapper] Waiting for Unleash client to be ready..."); return false; } // Если нет флагов на экране - сразу готовы if (currentScreenFlags.length === 0) { + console.log("✅ [FunnelUnleashWrapper] No AB test flags on current screen - ready to render"); return true; } @@ -124,16 +119,16 @@ export function FunnelUnleashWrapper({ return flag in loadedVariants; }); - if (process.env.NODE_ENV === "development") { - console.log("[FunnelUnleashWrapper] Flags status:", { - currentScreenFlags, - loadedVariants, - allLoaded, - }); - } + console.log(`${allLoaded ? '✅' : '⏳'} [FunnelUnleashWrapper] Flags status:`, { + currentScreenId, + flagsRequired: currentScreenFlags, + flagsLoaded: Object.keys(loadedVariants), + allReady: allLoaded, + variants: loadedVariants, + }); return allLoaded; - }, [flagsReady, currentScreenFlags, loadedVariants]); + }, [flagsReady, currentScreenFlags, loadedVariants, currentScreenId]); // Создаем объект активных вариантов const activeVariants = useMemo(() => { diff --git a/src/components/providers/AppProviders.tsx b/src/components/providers/AppProviders.tsx index 3480994..36bca5d 100644 --- a/src/components/providers/AppProviders.tsx +++ b/src/components/providers/AppProviders.tsx @@ -4,33 +4,25 @@ import type { ReactNode } from "react"; import { FunnelProvider } from "@/lib/funnel/FunnelProvider"; import { UnleashProvider } from "./UnleashProvider"; -import { useUnleashAnalytics } from "@/lib/funnel/unleash/useUnleashAnalytics"; interface AppProvidersProps { children: ReactNode; } -/** - * Компонент для инициализации автоматической отправки AB test impression событий - * Идентично aura-webapp: events отправляются когда пользователь доходит до экрана - */ -function UnleashAnalyticsInitializer() { - useUnleashAnalytics(); - return null; -} - /** * Корневой Provider приложения * - * Структура идентична aura-webapp: - * 1. UnleashProvider (FlagProvider) - инициализация Unleash Client - * 2. UnleashAnalyticsInitializer - автоматическая подписка на impression события - * 3. FunnelProvider - управление состоянием воронки + * ВАЖНО: UnleashAnalyticsInitializer перемещен в FunnelUnleashWrapper + * чтобы гарантировать что impression listener готов ДО загрузки флагов + * + * Структура: + * 1. UnleashProvider (FlagProvider) - инициализация Unleash Client (глобально) + * 2. FunnelProvider - управление состоянием воронки + * 3. FunnelUnleashWrapper (в layout) - подписка на impression события + загрузка флагов */ export function AppProviders({ children }: AppProvidersProps) { return ( - {children} ); diff --git a/src/components/providers/MetricsProvider.tsx b/src/components/providers/MetricsProvider.tsx index b8382ed..34aa777 100644 --- a/src/components/providers/MetricsProvider.tsx +++ b/src/components/providers/MetricsProvider.tsx @@ -47,50 +47,59 @@ export function MetricsProvider({ }, }); - console.log('[Metrics] Google Analytics initialized:', { + console.log('✅ [Metrics] Google Analytics initialized:', { measurementId: googleAnalyticsId, debugMode: isDevelopEnvironment, + ready: true, }); } catch (error) { console.error('[Metrics] Failed to initialize Google Analytics:', error); } }, [googleAnalyticsId]); - // Инициализация Yandex Metrika (синхронно через скрипт) + // Инициализация Yandex Metrika (официальный способ) useEffect(() => { if (!yandexMetrikaId) return; try { // Проверяем что скрипт еще не загружен - if (typeof window.ym === 'function') { - console.log('[Metrics] Yandex Metrika already loaded'); + if (typeof window.ym === 'function' && window.__YM_COUNTER_ID__) { + console.log('[Metrics] Yandex Metrika already initialized'); return; } - // Загружаем скрипт Yandex Metrika - const script = document.createElement('script'); - script.type = 'text/javascript'; - script.async = true; - script.src = 'https://mc.yandex.ru/metrika/tag.js'; - - script.onload = () => { - // Инициализируем счетчик после загрузки скрипта - if (typeof window.ym === 'function') { - window.ym(Number(yandexMetrikaId), 'init', { - clickmap: true, - trackLinks: true, - accurateTrackBounce: true, - webvisor: true, - }); - - // Сохраняем ID счетчика для использования в analytics service - window.__YM_COUNTER_ID__ = Number(yandexMetrikaId); - - console.log('[Metrics] Yandex Metrika initialized:', yandexMetrikaId); + // ✅ Официальный код инициализации Яндекс Метрики + // Создает функцию-заглушку ym() для накопления вызовов до загрузки скрипта + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (function(m: any, e: Document, t: string, r: string, i: string, k: HTMLScriptElement, a: HTMLScriptElement | null) { + // eslint-disable-next-line prefer-rest-params + m[i] = m[i] || function() { (m[i].a = m[i].a || []).push(arguments); }; + m[i].l = 1 * new Date().getTime(); + k = e.createElement(t) as HTMLScriptElement; + a = e.getElementsByTagName(t)[0] as HTMLScriptElement; + k.async = true; + k.src = r; + if (a && a.parentNode) { + a.parentNode.insertBefore(k, a); } - }; + })(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym", {} as HTMLScriptElement, null); - document.head.appendChild(script); + // ✅ Вызываем init сразу (накопится в очереди до загрузки скрипта) + window.ym(Number(yandexMetrikaId), 'init', { + clickmap: true, + trackLinks: true, + accurateTrackBounce: true, + webvisor: true, + }); + + // Сохраняем ID счетчика для использования в analytics service + window.__YM_COUNTER_ID__ = Number(yandexMetrikaId); + + console.log('✅ [Metrics] Yandex Metrika initialized:', { + counterId: yandexMetrikaId, + method: 'official', + ready: true, + }); } catch (error) { console.error('[Metrics] Failed to initialize Yandex Metrika:', error); } diff --git a/src/lib/funnel/unleash/UnleashProvider.tsx b/src/lib/funnel/unleash/UnleashProvider.tsx index c000eb9..f93f4ad 100644 --- a/src/lib/funnel/unleash/UnleashProvider.tsx +++ b/src/lib/funnel/unleash/UnleashProvider.tsx @@ -45,8 +45,6 @@ export function UnleashProvider({ return <>{children}; } - console.log("[UnleashProvider] Initializing with sessionId:", sessionId || userId || "anonymous"); - return ( {children} diff --git a/src/lib/funnel/unleash/useUnleashAnalytics.ts b/src/lib/funnel/unleash/useUnleashAnalytics.ts index 716334c..524efe6 100644 --- a/src/lib/funnel/unleash/useUnleashAnalytics.ts +++ b/src/lib/funnel/unleash/useUnleashAnalytics.ts @@ -30,52 +30,81 @@ export function useUnleashAnalytics() { const unleashClient = useUnleashClient(); useEffect(() => { - // Подписываемся на все impression события от Unleash + console.log("🎯 [Unleash Analytics] Impression listener initialized"); + + // Подписываемся на все impression события от Unleash (идентично aura-webapp) unleashClient.on("impression", (impressionEvent: UnleashImpressionEvent) => { + console.log("📊 [Unleash Analytics] Impression event received:", { + feature: impressionEvent.featureName, + variant: impressionEvent.variant, + enabled: impressionEvent.enabled, + }); + // Проверяем что флаг включен (идентично aura-webapp) if ("enabled" in impressionEvent && impressionEvent.enabled) { const isDevelopEnvironment = typeof window !== "undefined" && - window.location.hostname.includes('develop.funnel.witlab.us') || - window.location.hostname.includes('localhost'); + (window.location.hostname.includes('develop.funnel.witlab.us') || + window.location.hostname.includes('localhost')); // ✅ 1. Отправляем в Google Analytics - if (typeof window !== "undefined" && window.gtag) { + const gaAvailable = typeof window !== "undefined" && typeof window.gtag !== "undefined"; + if (gaAvailable) { window.gtag("event", "experiment_impression", { app_name: impressionEvent.context.appName || "witlab-funnel", feature: impressionEvent.featureName, treatment: impressionEvent.variant, debug_mode: isDevelopEnvironment, }); + console.log("✅ [Google Analytics] AB test event sent:", { + event: "experiment_impression", + feature: impressionEvent.featureName, + treatment: impressionEvent.variant, + debug_mode: isDevelopEnvironment, + }); + } else { + console.warn("⚠️ [Google Analytics] Not available - gtag function not found"); } // ✅ 2. Отправляем в Яндекс Метрику через params (параметры визита) - if (typeof window !== "undefined" && typeof window.ym === "function") { - const counterId = window.__YM_COUNTER_ID__; - if (counterId) { - // Отправляем параметры визита для AB теста - window.ym(counterId, 'params', { - [`ab_test_${impressionEvent.featureName}`]: impressionEvent.variant, - ab_test_app: impressionEvent.context.appName || "witlab-funnel", - }); - } + const ymAvailable = typeof window !== "undefined" && typeof window.ym !== "undefined"; + const counterId = typeof window !== "undefined" ? window.__YM_COUNTER_ID__ : undefined; + + if (ymAvailable && counterId) { + // Отправляем параметры визита для AB теста + // ym() накопит вызов в очереди если скрипт еще не загрузился + window.ym(counterId, 'params', { + [`ab_test_${impressionEvent.featureName}`]: impressionEvent.variant, + ab_test_app: impressionEvent.context.appName || "witlab-funnel", + }); + console.log("✅ [Yandex Metrika] AB test params sent:", { + counterId, + param: `ab_test_${impressionEvent.featureName}`, + value: impressionEvent.variant, + queued: !!window.ym.a, // true если событие в очереди, false если отправлено + }); + } else if (!ymAvailable) { + console.warn("⚠️ [Yandex Metrika] Not initialized - check MetricsProvider"); + } else if (!counterId) { + console.warn("⚠️ [Yandex Metrika] Counter ID not set"); } - // Логирование для debug - if (process.env.NODE_ENV === "development") { - console.log(`[Analytics] AB Test Impression:`, { - feature: impressionEvent.featureName, - variant: impressionEvent.variant, - appName: impressionEvent.context.appName, - debugMode: isDevelopEnvironment, - sentToGA: typeof window.gtag !== 'undefined', - sentToYM: typeof window.ym === 'function' && !!window.__YM_COUNTER_ID__, - }); - } + // Summary log + console.log("📈 [Unleash Analytics] AB Test Impression Summary:", { + feature: impressionEvent.featureName, + variant: impressionEvent.variant, + appName: impressionEvent.context.appName, + debugMode: isDevelopEnvironment, + sentToGA: gaAvailable, + sentToYM: ymAvailable && !!window.__YM_COUNTER_ID__, + }); + } else { + console.log("⏭️ [Unleash Analytics] Impression event skipped - flag not enabled"); } }); // Отписываемся при unmount (идентично aura-webapp) return () => { + console.log("🔌 [Unleash Analytics] Impression listener removed"); unleashClient.off("impression"); }; }, [unleashClient]); diff --git a/src/services/analytics/types.ts b/src/services/analytics/types.ts index d8e47b2..0d26494 100644 --- a/src/services/analytics/types.ts +++ b/src/services/analytics/types.ts @@ -8,7 +8,11 @@ declare global { interface Window { // Yandex Metrika - ym: (counterId: number | string, method: string, ...args: any[]) => void; + ym: { + (counterId: number | string, method: string, ...args: any[]): void; + a?: any[]; // Queue for calls before script loads + l?: number; // Timestamp + }; __YM_COUNTER_ID__?: number | string; // Google Analytics (GA4)