This commit is contained in:
dev.daminik00 2025-10-30 01:56:59 +01:00
parent 54fdf8dc5a
commit ea381ea399
7 changed files with 134 additions and 94 deletions

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useVariant } from "@unleash/proxy-client-react"; import { useVariant } from "@unleash/proxy-client-react";
import { useEffect } from "react"; import { useEffect, useRef, memo } from "react";
interface FlagVariantFetcherProps { interface FlagVariantFetcherProps {
flag: string; flag: string;
@ -12,13 +12,26 @@ interface FlagVariantFetcherProps {
* Компонент для получения варианта одного флага * Компонент для получения варианта одного флага
* Каждый экземпляр этого компонента вызывает useVariant на верхнем уровне * Каждый экземпляр этого компонента вызывает useVariant на верхнем уровне
* Это позволяет обходить ограничение правил хуков React * Это позволяет обходить ограничение правил хуков 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 variant = useVariant(flag);
const lastVariantRef = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
onVariantLoaded(flag, variant?.name); const currentVariant = variant?.name;
// Отправляем только если вариант изменился
if (currentVariant !== lastVariantRef.current) {
lastVariantRef.current = currentVariant;
onVariantLoaded(flag, currentVariant);
}
}, [flag, variant?.name, onVariantLoaded]); }, [flag, variant?.name, onVariantLoaded]);
return null; // Этот компонент не рендерит UI return null; // Этот компонент не рендерит UI
} });

View File

@ -1,8 +1,9 @@
"use client"; "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 { useFlagsStatus } from "@unleash/proxy-client-react";
import { UnleashContextProvider } from "@/lib/funnel/unleash"; import { UnleashContextProvider } from "@/lib/funnel/unleash";
import { useUnleashAnalytics } from "@/lib/funnel/unleash/useUnleashAnalytics";
import { FunnelLoadingScreen } from "./FunnelLoadingScreen"; import { FunnelLoadingScreen } from "./FunnelLoadingScreen";
import { FlagVariantFetcher } from "./FlagVariantFetcher"; import { FlagVariantFetcher } from "./FlagVariantFetcher";
import type { NavigationConditionDefinition } from "@/lib/funnel/types"; import type { NavigationConditionDefinition } from "@/lib/funnel/types";
@ -37,6 +38,10 @@ export function FunnelUnleashWrapper({
funnel, funnel,
currentScreenId, currentScreenId,
}: FunnelUnleashWrapperProps) { }: FunnelUnleashWrapperProps) {
// ✅ КРИТИЧЕСКИ ВАЖНО: Подписываемся на impression события ДО загрузки флагов
// Это гарантирует что события от useVariant() будут пойманы и отправлены в аналитику
useUnleashAnalytics();
const { flagsReady } = useFlagsStatus(); const { flagsReady } = useFlagsStatus();
// Собираем флаги ТОЛЬКО для текущего экрана (или все, если currentScreenId не передан) // Собираем флаги ТОЛЬКО для текущего экрана (или все, если currentScreenId не передан)
@ -80,16 +85,6 @@ export function FunnelUnleashWrapper({
// Состояние для хранения вариантов флагов // Состояние для хранения вариантов флагов
const [loadedVariants, setLoadedVariants] = useState<Record<string, string>>({}); const [loadedVariants, setLoadedVariants] = useState<Record<string, string>>({});
// ✅ КРИТИЧЕСКИ ВАЖНО: Очищаем loadedVariants при смене экрана
// Это предотвращает ситуацию когда старые варианты используются для нового экрана
useEffect(() => {
setLoadedVariants({});
if (process.env.NODE_ENV === "development") {
console.log(`[FunnelUnleashWrapper] Screen changed to "${currentScreenId}", clearing loaded variants`);
}
}, [currentScreenId]);
// Колбэк для получения варианта от FlagVariantFetcher компонента // Колбэк для получения варианта от FlagVariantFetcher компонента
const handleVariantLoaded = useCallback((flag: string, variant: string | undefined) => { const handleVariantLoaded = useCallback((flag: string, variant: string | undefined) => {
// ✅ Сохраняем вариант в любом случае (даже если undefined или "disabled") // ✅ Сохраняем вариант в любом случае (даже если undefined или "disabled")
@ -99,9 +94,7 @@ export function FunnelUnleashWrapper({
// Обновляем только если значение изменилось // Обновляем только если значение изменилось
if (prev[flag] !== newVariant) { if (prev[flag] !== newVariant) {
if (process.env.NODE_ENV === "development") { console.log(`🚩 [FunnelUnleashWrapper] Flag loaded: "${flag}" = "${newVariant}"`);
console.log(`[FunnelUnleashWrapper] Flag "${flag}" = "${newVariant}"`);
}
return { ...prev, [flag]: newVariant }; return { ...prev, [flag]: newVariant };
} }
return prev; return prev;
@ -111,11 +104,13 @@ export function FunnelUnleashWrapper({
// Проверяем что ВСЕ флаги текущего экрана загружены // Проверяем что ВСЕ флаги текущего экрана загружены
const allFlagsLoaded = useMemo(() => { const allFlagsLoaded = useMemo(() => {
if (!flagsReady) { if (!flagsReady) {
console.log("⏳ [FunnelUnleashWrapper] Waiting for Unleash client to be ready...");
return false; return false;
} }
// Если нет флагов на экране - сразу готовы // Если нет флагов на экране - сразу готовы
if (currentScreenFlags.length === 0) { if (currentScreenFlags.length === 0) {
console.log("✅ [FunnelUnleashWrapper] No AB test flags on current screen - ready to render");
return true; return true;
} }
@ -124,16 +119,16 @@ export function FunnelUnleashWrapper({
return flag in loadedVariants; return flag in loadedVariants;
}); });
if (process.env.NODE_ENV === "development") { console.log(`${allLoaded ? '✅' : '⏳'} [FunnelUnleashWrapper] Flags status:`, {
console.log("[FunnelUnleashWrapper] Flags status:", { currentScreenId,
currentScreenFlags, flagsRequired: currentScreenFlags,
loadedVariants, flagsLoaded: Object.keys(loadedVariants),
allLoaded, allReady: allLoaded,
}); variants: loadedVariants,
} });
return allLoaded; return allLoaded;
}, [flagsReady, currentScreenFlags, loadedVariants]); }, [flagsReady, currentScreenFlags, loadedVariants, currentScreenId]);
// Создаем объект активных вариантов // Создаем объект активных вариантов
const activeVariants = useMemo(() => { const activeVariants = useMemo(() => {

View File

@ -4,33 +4,25 @@ import type { ReactNode } from "react";
import { FunnelProvider } from "@/lib/funnel/FunnelProvider"; import { FunnelProvider } from "@/lib/funnel/FunnelProvider";
import { UnleashProvider } from "./UnleashProvider"; import { UnleashProvider } from "./UnleashProvider";
import { useUnleashAnalytics } from "@/lib/funnel/unleash/useUnleashAnalytics";
interface AppProvidersProps { interface AppProvidersProps {
children: ReactNode; children: ReactNode;
} }
/**
* Компонент для инициализации автоматической отправки AB test impression событий
* Идентично aura-webapp: events отправляются когда пользователь доходит до экрана
*/
function UnleashAnalyticsInitializer() {
useUnleashAnalytics();
return null;
}
/** /**
* Корневой Provider приложения * Корневой Provider приложения
* *
* Структура идентична aura-webapp: * ВАЖНО: UnleashAnalyticsInitializer перемещен в FunnelUnleashWrapper
* 1. UnleashProvider (FlagProvider) - инициализация Unleash Client * чтобы гарантировать что impression listener готов ДО загрузки флагов
* 2. UnleashAnalyticsInitializer - автоматическая подписка на impression события *
* 3. FunnelProvider - управление состоянием воронки * Структура:
* 1. UnleashProvider (FlagProvider) - инициализация Unleash Client (глобально)
* 2. FunnelProvider - управление состоянием воронки
* 3. FunnelUnleashWrapper (в layout) - подписка на impression события + загрузка флагов
*/ */
export function AppProviders({ children }: AppProvidersProps) { export function AppProviders({ children }: AppProvidersProps) {
return ( return (
<UnleashProvider> <UnleashProvider>
<UnleashAnalyticsInitializer />
<FunnelProvider>{children}</FunnelProvider> <FunnelProvider>{children}</FunnelProvider>
</UnleashProvider> </UnleashProvider>
); );

View File

@ -47,50 +47,59 @@ export function MetricsProvider({
}, },
}); });
console.log('[Metrics] Google Analytics initialized:', { console.log('[Metrics] Google Analytics initialized:', {
measurementId: googleAnalyticsId, measurementId: googleAnalyticsId,
debugMode: isDevelopEnvironment, debugMode: isDevelopEnvironment,
ready: true,
}); });
} catch (error) { } catch (error) {
console.error('[Metrics] Failed to initialize Google Analytics:', error); console.error('[Metrics] Failed to initialize Google Analytics:', error);
} }
}, [googleAnalyticsId]); }, [googleAnalyticsId]);
// Инициализация Yandex Metrika (синхронно через скрипт) // Инициализация Yandex Metrika (официальный способ)
useEffect(() => { useEffect(() => {
if (!yandexMetrikaId) return; if (!yandexMetrikaId) return;
try { try {
// Проверяем что скрипт еще не загружен // Проверяем что скрипт еще не загружен
if (typeof window.ym === 'function') { if (typeof window.ym === 'function' && window.__YM_COUNTER_ID__) {
console.log('[Metrics] Yandex Metrika already loaded'); console.log('[Metrics] Yandex Metrika already initialized');
return; return;
} }
// Загружаем скрипт Yandex Metrika // ✅ Официальный код инициализации Яндекс Метрики
const script = document.createElement('script'); // Создает функцию-заглушку ym() для накопления вызовов до загрузки скрипта
script.type = 'text/javascript'; // eslint-disable-next-line @typescript-eslint/no-explicit-any
script.async = true; (function(m: any, e: Document, t: string, r: string, i: string, k: HTMLScriptElement, a: HTMLScriptElement | null) {
script.src = 'https://mc.yandex.ru/metrika/tag.js'; // eslint-disable-next-line prefer-rest-params
m[i] = m[i] || function() { (m[i].a = m[i].a || []).push(arguments); };
script.onload = () => { m[i].l = 1 * new Date().getTime();
// Инициализируем счетчик после загрузки скрипта k = e.createElement(t) as HTMLScriptElement;
if (typeof window.ym === 'function') { a = e.getElementsByTagName(t)[0] as HTMLScriptElement;
window.ym(Number(yandexMetrikaId), 'init', { k.async = true;
clickmap: true, k.src = r;
trackLinks: true, if (a && a.parentNode) {
accurateTrackBounce: true, a.parentNode.insertBefore(k, a);
webvisor: true,
});
// Сохраняем ID счетчика для использования в analytics service
window.__YM_COUNTER_ID__ = Number(yandexMetrikaId);
console.log('[Metrics] Yandex Metrika initialized:', yandexMetrikaId);
} }
}; })(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) { } catch (error) {
console.error('[Metrics] Failed to initialize Yandex Metrika:', error); console.error('[Metrics] Failed to initialize Yandex Metrika:', error);
} }

View File

@ -45,8 +45,6 @@ export function UnleashProvider({
return <>{children}</>; return <>{children}</>;
} }
console.log("[UnleashProvider] Initializing with sessionId:", sessionId || userId || "anonymous");
return ( return (
<FlagProvider config={config}> <FlagProvider config={config}>
{children} {children}

View File

@ -30,52 +30,81 @@ export function useUnleashAnalytics() {
const unleashClient = useUnleashClient(); const unleashClient = useUnleashClient();
useEffect(() => { useEffect(() => {
// Подписываемся на все impression события от Unleash console.log("🎯 [Unleash Analytics] Impression listener initialized");
// Подписываемся на все impression события от Unleash (идентично aura-webapp)
unleashClient.on("impression", (impressionEvent: UnleashImpressionEvent) => { unleashClient.on("impression", (impressionEvent: UnleashImpressionEvent) => {
console.log("📊 [Unleash Analytics] Impression event received:", {
feature: impressionEvent.featureName,
variant: impressionEvent.variant,
enabled: impressionEvent.enabled,
});
// Проверяем что флаг включен (идентично aura-webapp) // Проверяем что флаг включен (идентично aura-webapp)
if ("enabled" in impressionEvent && impressionEvent.enabled) { if ("enabled" in impressionEvent && impressionEvent.enabled) {
const isDevelopEnvironment = typeof window !== "undefined" && const isDevelopEnvironment = typeof window !== "undefined" &&
window.location.hostname.includes('develop.funnel.witlab.us') || (window.location.hostname.includes('develop.funnel.witlab.us') ||
window.location.hostname.includes('localhost'); window.location.hostname.includes('localhost'));
// ✅ 1. Отправляем в Google Analytics // ✅ 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", { window.gtag("event", "experiment_impression", {
app_name: impressionEvent.context.appName || "witlab-funnel", app_name: impressionEvent.context.appName || "witlab-funnel",
feature: impressionEvent.featureName, feature: impressionEvent.featureName,
treatment: impressionEvent.variant, treatment: impressionEvent.variant,
debug_mode: isDevelopEnvironment, 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 (параметры визита) // ✅ 2. Отправляем в Яндекс Метрику через params (параметры визита)
if (typeof window !== "undefined" && typeof window.ym === "function") { const ymAvailable = typeof window !== "undefined" && typeof window.ym !== "undefined";
const counterId = window.__YM_COUNTER_ID__; const counterId = typeof window !== "undefined" ? window.__YM_COUNTER_ID__ : undefined;
if (counterId) {
// Отправляем параметры визита для AB теста if (ymAvailable && counterId) {
window.ym(counterId, 'params', { // Отправляем параметры визита для AB теста
[`ab_test_${impressionEvent.featureName}`]: impressionEvent.variant, // ym() накопит вызов в очереди если скрипт еще не загрузился
ab_test_app: impressionEvent.context.appName || "witlab-funnel", 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 // Summary log
if (process.env.NODE_ENV === "development") { console.log("📈 [Unleash Analytics] AB Test Impression Summary:", {
console.log(`[Analytics] AB Test Impression:`, { feature: impressionEvent.featureName,
feature: impressionEvent.featureName, variant: impressionEvent.variant,
variant: impressionEvent.variant, appName: impressionEvent.context.appName,
appName: impressionEvent.context.appName, debugMode: isDevelopEnvironment,
debugMode: isDevelopEnvironment, sentToGA: gaAvailable,
sentToGA: typeof window.gtag !== 'undefined', sentToYM: ymAvailable && !!window.__YM_COUNTER_ID__,
sentToYM: typeof window.ym === 'function' && !!window.__YM_COUNTER_ID__, });
}); } else {
} console.log("⏭️ [Unleash Analytics] Impression event skipped - flag not enabled");
} }
}); });
// Отписываемся при unmount (идентично aura-webapp) // Отписываемся при unmount (идентично aura-webapp)
return () => { return () => {
console.log("🔌 [Unleash Analytics] Impression listener removed");
unleashClient.off("impression"); unleashClient.off("impression");
}; };
}, [unleashClient]); }, [unleashClient]);

View File

@ -8,7 +8,11 @@
declare global { declare global {
interface Window { interface Window {
// Yandex Metrika // 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; __YM_COUNTER_ID__?: number | string;
// Google Analytics (GA4) // Google Analytics (GA4)