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";
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<string | undefined>(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
}
});

View File

@ -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<Record<string, string>>({});
// ✅ КРИТИЧЕСКИ ВАЖНО: Очищаем 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(() => {

View File

@ -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 (
<UnleashProvider>
<UnleashAnalyticsInitializer />
<FunnelProvider>{children}</FunnelProvider>
</UnleashProvider>
);

View File

@ -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);
}

View File

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

View File

@ -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]);

View File

@ -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)