From fcd3e0da3fa0739b74e12e21022bd961f7a049ed Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Thu, 30 Oct 2025 02:38:00 +0100 Subject: [PATCH] add ym data --- src/app/[funnelId]/layout.tsx | 5 +- src/components/funnel/FunnelRuntime.tsx | 25 ++ .../templates/EmailTemplate/EmailTemplate.tsx | 1 + .../payment/TrialVariantSelectionContext.tsx | 6 + src/hooks/auth/useAuth.ts | 19 +- src/hooks/session/useSession.ts | 19 +- .../funnel/unleash/UnleashSessionProvider.tsx | 4 +- src/lib/funnel/unleash/useUnleashAnalytics.ts | 5 +- src/services/analytics/metricService.ts | 338 ++++++++++++++++++ 9 files changed, 414 insertions(+), 8 deletions(-) create mode 100644 src/services/analytics/metricService.ts diff --git a/src/app/[funnelId]/layout.tsx b/src/app/[funnelId]/layout.tsx index 67254e4..8393454 100644 --- a/src/app/[funnelId]/layout.tsx +++ b/src/app/[funnelId]/layout.tsx @@ -72,7 +72,10 @@ export default async function FunnelLayout({ } return ( - + 0) { + metricService.userParams(sessionData); + } + + // ✅ Отправляем выбор пользователя в params (параметры визита) + if (currentScreen.template === "list" && answers[currentScreen.id].length > 0) { + metricService.sendVisitContext({ + [`answer_${currentScreen.id}`]: answers[currentScreen.id].join(','), + }); + } + // Для date экранов с registrationFieldKey НЕ отправляем answers const shouldSkipAnswers = currentScreen.template === "date" && @@ -266,6 +281,16 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { if (shouldAutoAdvance) { // Собираем данные для сессии const sessionData = buildSessionDataFromScreen(currentScreen, ids); + + // ✅ Отправляем данные в userParams (параметры посетителя) + metricService.sendSessionDataToMetrics(sessionData); + + // ✅ Отправляем выбор пользователя в params (параметры визита) + if (currentScreen.template === "list" && ids.length > 0) { + metricService.sendVisitContext({ + [`answer_${currentScreen.id}`]: ids.join(','), + }); + } updateSession({ answers: { diff --git a/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx index 107e267..b553ed4 100644 --- a/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx +++ b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx @@ -55,6 +55,7 @@ export function EmailTemplate({ const { authorization, isLoading, error } = useAuth({ funnelId: funnel?.meta?.id ?? "preview", + googleAnalyticsId: funnel?.meta?.googleAnalyticsId, registrationData, }); diff --git a/src/entities/session/payment/TrialVariantSelectionContext.tsx b/src/entities/session/payment/TrialVariantSelectionContext.tsx index 07253c6..60d4261 100644 --- a/src/entities/session/payment/TrialVariantSelectionContext.tsx +++ b/src/entities/session/payment/TrialVariantSelectionContext.tsx @@ -1,6 +1,7 @@ "use client"; import React, { createContext, useContext, useState } from "react"; +import { metricService } from "@/services/analytics/metricService"; interface TrialVariantSelectionContextValue { selectedVariantId: string | null; @@ -31,6 +32,11 @@ export function TrialVariantSelectionProvider({ if (typeof window !== 'undefined') { if (id) { sessionStorage.setItem(STORAGE_KEY, id); + + // ✅ Отправляем выбранный продукт в параметры визита + metricService.sendVisitContext({ + selectedProductId: id, + }); } else { sessionStorage.removeItem(STORAGE_KEY); } diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 930bf1b..02aa06b 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -8,12 +8,14 @@ import { filterNullKeysOfObject } from "@/shared/utils/filter-object"; import { createAuthorization } from "@/entities/user/actions"; import { setAuthTokenToCookie } from "@/entities/user/serverActions"; import analyticsService, { AnalyticsEvent, AnalyticsPlatform } from "@/services/analytics/analyticsService"; +import { metricService } from "@/services/analytics/metricService"; // TODO const locale = "en"; interface IUseAuthProps { funnelId: string; + googleAnalyticsId?: string; /** * Дополнительные данные для регистрации пользователя. * Будут объединены с базовым payload при авторизации. @@ -22,8 +24,8 @@ interface IUseAuthProps { registrationData?: Record; } -export const useAuth = ({ funnelId, registrationData }: IUseAuthProps) => { - const { updateSession } = useSession({ funnelId }); +export const useAuth = ({ funnelId, googleAnalyticsId, registrationData }: IUseAuthProps) => { + const { updateSession } = useSession({ funnelId, googleAnalyticsId }); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -108,6 +110,19 @@ export const useAuth = ({ funnelId, registrationData }: IUseAuthProps) => { source: funnelId, UserID: userId, }); + + // ✅ Отправляем UserID и email в userParams (параметры посетителя) + metricService.setUserID(userId); + metricService.userParams({ + UserID: userId, + email, + }); + + // ✅ Отправляем email и userId в params (параметры визита) + metricService.sendVisitContext({ + email, + userId, + }); } await setAuthTokenToCookie(token); diff --git a/src/hooks/session/useSession.ts b/src/hooks/session/useSession.ts index 3add5dc..6449e30 100644 --- a/src/hooks/session/useSession.ts +++ b/src/hooks/session/useSession.ts @@ -10,15 +10,17 @@ import { getClientTimezone } from "@/shared/utils/locales"; import { parseQueryParams } from "@/shared/utils/url"; import { useCallback, useMemo, useState } from "react"; import { setSessionIdToCookie } from "@/entities/session/serverActions"; +import { metricService } from "@/services/analytics/metricService"; // TODO const locale = "en"; interface IUseSessionProps { funnelId: string; + googleAnalyticsId?: string; } -export const useSession = ({ funnelId }: IUseSessionProps) => { +export const useSession = ({ funnelId, googleAnalyticsId }: IUseSessionProps) => { const localStorageKey = `${funnelId}_sessionId`; const sessionId = typeof window === "undefined" ? "" : localStorage.getItem(localStorageKey); @@ -70,6 +72,19 @@ export const useSession = ({ funnelId }: IUseSessionProps) => { sessionFromServer?.status === "success" ) { await setSessionId(sessionFromServer.sessionId); + + // ✅ Отправляем sessionId в userParams (параметры посетителя) + metricService.userParams({ + sessionId: sessionFromServer.sessionId, + }); + + // ✅ Отправляем контекст визита в params (параметры визита) + metricService.sendVisitContext({ + sessionId: sessionFromServer.sessionId, + funnelId, + gaId: googleAnalyticsId, + }); + return sessionFromServer; } console.error( @@ -89,7 +104,7 @@ export const useSession = ({ funnelId }: IUseSessionProps) => { sessionId: "", }; } - }, [sessionId, timezone, setSessionId, funnelId]); + }, [sessionId, timezone, setSessionId, funnelId, googleAnalyticsId]); const updateSession = useCallback( async (data: IUpdateSessionRequest["data"]) => { diff --git a/src/lib/funnel/unleash/UnleashSessionProvider.tsx b/src/lib/funnel/unleash/UnleashSessionProvider.tsx index 5f63eb1..0fb5f32 100644 --- a/src/lib/funnel/unleash/UnleashSessionProvider.tsx +++ b/src/lib/funnel/unleash/UnleashSessionProvider.tsx @@ -7,6 +7,7 @@ import { useSession } from "@/hooks/session/useSession"; interface UnleashSessionProviderProps { children: ReactNode; funnelId: string; + googleAnalyticsId?: string; } /** @@ -15,10 +16,11 @@ interface UnleashSessionProviderProps { export function UnleashSessionProvider({ children, funnelId, + googleAnalyticsId, }: UnleashSessionProviderProps) { const [sessionId, setSessionId] = useState(null); const [isReady, setIsReady] = useState(false); - const { createSession } = useSession({ funnelId }); + const { createSession } = useSession({ funnelId, googleAnalyticsId }); useEffect(() => { const initSession = async () => { diff --git a/src/lib/funnel/unleash/useUnleashAnalytics.ts b/src/lib/funnel/unleash/useUnleashAnalytics.ts index 524efe6..294c071 100644 --- a/src/lib/funnel/unleash/useUnleashAnalytics.ts +++ b/src/lib/funnel/unleash/useUnleashAnalytics.ts @@ -65,7 +65,9 @@ export function useUnleashAnalytics() { console.warn("⚠️ [Google Analytics] Not available - gtag function not found"); } - // ✅ 2. Отправляем в Яндекс Метрику через params (параметры визита) + // ✅ 2. Отправляем в Яндекс Метрику через params (параметры ВИЗИТА) + // ВАЖНО: AB тесты - это параметры ВИЗИТА (params), а не посетителя (userParams) + // Идентично aura-webapp: используем прямой вызов window.ym() const ymAvailable = typeof window !== "undefined" && typeof window.ym !== "undefined"; const counterId = typeof window !== "undefined" ? window.__YM_COUNTER_ID__ : undefined; @@ -74,7 +76,6 @@ export function useUnleashAnalytics() { // 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, diff --git a/src/services/analytics/metricService.ts b/src/services/analytics/metricService.ts new file mode 100644 index 0000000..1d601fd --- /dev/null +++ b/src/services/analytics/metricService.ts @@ -0,0 +1,338 @@ +/** + * Metric Service для Яндекс Метрики и Google Analytics + * + * Паттерн идентичен aura-webapp/src/services/metric/metricService.ts + * + * Основные методы: + * - setUserID: установить ID пользователя (для YM и GA) + * - userParams: отправить параметры ПОСЕТИТЕЛЯ (sessionId, email, age, gender и т.д.) + * - params: отправить параметры ВИЗИТА (AB тест варианты, источник и т.д.) + * - reachGoal: достижение цели + */ + +/** + * Параметры посетителя (постоянные характеристики) + * Передаются через window.ym(counterId, "userParams", {...}) + * + * Эти данные привязываются к ClientID и распространяются + * на всю историю визитов пользователя + */ +interface IUserParams { + UserID?: string | number; + sessionId?: string; + email?: string; + gender?: string; + age?: number; + partnerGender?: string; + partnerAge?: number; +} + +/** + * Параметры визита (временные данные о визите) + * Передаются через window.ym(counterId, "params", {...}) + * + * Эти данные привязываются к конкретному визиту + */ +interface IVisitParams { + email?: string; + userId?: string | number; + sessionId?: string; + gaId?: string; // Google Analytics Measurement ID (e.g., G-XXXXXXXXXX) + gaClientId?: string; // Google Analytics Client ID from _ga cookie + ymClientId?: string; // Yandex Metrika Client ID from _ym_uid cookie + funnelId?: string; + selectedProductId?: string; + [key: string]: string | number | boolean | undefined; +} + +const checkIsAvailableYandexMetric = (): boolean => { + if (typeof window === 'undefined') return false; + + if (typeof window.ym === 'undefined' || !window.__YM_COUNTER_ID__) { + console.warn('[MetricService] Yandex Metrika not initialized'); + return false; + } + + return true; +}; + +const checkIsAvailableGoogleAnalytics = (): boolean => { + if (typeof window === 'undefined') return false; + + if (typeof window.gtag === 'undefined') { + console.warn('[MetricService] Google Analytics not initialized'); + return false; + } + + return true; +}; + +/** + * Установить ID пользователя + * Вызывается после авторизации + */ +const setUserID = (userId: string | number): void => { + console.log('[MetricService] setUserID:', userId); + + // Yandex Metrika + if (checkIsAvailableYandexMetric() && window.__YM_COUNTER_ID__) { + window.ym(window.__YM_COUNTER_ID__, 'setUserID', String(userId)); + console.log('✅ [Yandex Metrika] setUserID:', userId); + } + + // Google Analytics + if (checkIsAvailableGoogleAnalytics()) { + window.gtag('config', 'GA_MEASUREMENT_ID', { + user_id: String(userId), + }); + console.log('✅ [Google Analytics] setUserID:', userId); + } +}; + +/** + * Отправить параметры ПОСЕТИТЕЛЯ + * + * Эти параметры привязываются к ClientID и распространяются + * на всю историю визитов пользователя + * + * Примеры: + * - userParams({ sessionId: 'abc123' }) - при создании сессии + * - userParams({ email: 'user@example.com', UserID: 123 }) - после авторизации + * - userParams({ gender: 'female', age: 25 }) - после ввода данных + */ +const userParams = (parameters: Partial): void => { + console.log('[MetricService] userParams:', parameters); + + // Yandex Metrika + if (checkIsAvailableYandexMetric() && window.__YM_COUNTER_ID__) { + window.ym(window.__YM_COUNTER_ID__, 'userParams', parameters); + console.log('✅ [Yandex Metrika] userParams sent:', parameters); + } + + // Google Analytics + if (checkIsAvailableGoogleAnalytics()) { + window.gtag('config', 'GA_MEASUREMENT_ID', { + send_page_view: false, + ...parameters, + }); + console.log('✅ [Google Analytics] config sent:', parameters); + } +}; + +/** + * Отправить параметры ВИЗИТА + * + * Эти параметры привязываются к конкретному визиту + * + * Примеры: + * - params({ ab_test_button: 'v1' }) - AB тест вариант + * - params({ source: 'facebook' }) - источник трафика + */ +const params = (parameters: IVisitParams): void => { + console.log('[MetricService] params:', parameters); + + // Yandex Metrika + if (checkIsAvailableYandexMetric() && window.__YM_COUNTER_ID__) { + window.ym(window.__YM_COUNTER_ID__, 'params', parameters); + console.log('✅ [Yandex Metrika] params sent:', parameters); + } +}; + +/** + * Достижение цели + */ +const reachGoal = (goal: string, params?: Record): void => { + console.log('[MetricService] reachGoal:', goal, params); + + // Yandex Metrika + if (checkIsAvailableYandexMetric() && window.__YM_COUNTER_ID__) { + window.ym(window.__YM_COUNTER_ID__, 'reachGoal', goal, params); + console.log('✅ [Yandex Metrika] reachGoal sent:', goal); + } + + // Google Analytics + if (checkIsAvailableGoogleAnalytics()) { + window.gtag('event', goal, params); + console.log('✅ [Google Analytics] event sent:', goal); + } +}; + +/** + * Извлекает данные для метрики из session data + * Отправляет только релевантные поля: gender, age, partnerGender, partnerAge + */ +const sendSessionDataToMetrics = (sessionData: Record): void => { + const metrics: Partial = {}; + + // Извлекаем gender (может быть в profile.gender или просто gender) + if (typeof sessionData.gender === 'string') { + metrics.gender = sessionData.gender; + } else if (sessionData.profile && typeof sessionData.profile === 'object') { + const profile = sessionData.profile as Record; + if (typeof profile.gender === 'string') { + metrics.gender = profile.gender; + } + if (typeof profile.partnerGender === 'string') { + metrics.partnerGender = profile.partnerGender; + } + } + + // Извлекаем partner gender (может быть в partner.gender или partnerGender) + if (typeof sessionData.partnerGender === 'string') { + metrics.partnerGender = sessionData.partnerGender; + } else if (sessionData.partner && typeof sessionData.partner === 'object') { + const partner = sessionData.partner as Record; + if (typeof partner.gender === 'string') { + metrics.partnerGender = partner.gender; + } + } + + // Вычисляем age из birthdate если есть + if (typeof sessionData.birthdate === 'string') { + const age = calculateAge(sessionData.birthdate); + if (age) metrics.age = age; + } else if (sessionData.profile && typeof sessionData.profile === 'object') { + const profile = sessionData.profile as Record; + if (typeof profile.birthdate === 'string') { + const age = calculateAge(profile.birthdate); + if (age) metrics.age = age; + } + } + + // Вычисляем partner age если есть + if (typeof sessionData.partnerBirthdate === 'string') { + const age = calculateAge(sessionData.partnerBirthdate); + if (age) metrics.partnerAge = age; + } else if (sessionData.partner && typeof sessionData.partner === 'object') { + const partner = sessionData.partner as Record; + if (typeof partner.birthdate === 'string') { + const age = calculateAge(partner.birthdate); + if (age) metrics.partnerAge = age; + } + } + + // Отправляем только если есть данные + if (Object.keys(metrics).length > 0) { + userParams(metrics); + } +}; + +/** + * Вычисляет возраст из даты рождения + * @param birthdate - дата в формате YYYY-MM-DD HH:mm + */ +const calculateAge = (birthdate: string): number | undefined => { + try { + const date = new Date(birthdate); + if (isNaN(date.getTime())) return undefined; + + const today = new Date(); + let age = today.getFullYear() - date.getFullYear(); + const monthDiff = today.getMonth() - date.getMonth(); + + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < date.getDate())) { + age--; + } + + return age > 0 && age < 150 ? age : undefined; + } catch { + return undefined; + } +}; + +/** + * Извлекает Google Analytics Client ID из куки _ga + * Формат куки: GA1.1.XXXXXXXXXX.YYYYYYYYYY + * Возвращает: XXXXXXXXXX.YYYYYYYYYY + */ +const getGoogleAnalyticsClientId = (): string | undefined => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return undefined; + } + + try { + const gaCookie = document.cookie + .split('; ') + .find(row => row.startsWith('_ga=')); + + if (!gaCookie) return undefined; + + const cookieValue = gaCookie.split('=')[1]; + // Формат: GA1.1.XXXXXXXXXX.YYYYYYYYYY + // Извлекаем последние две части + const parts = cookieValue.split('.'); + if (parts.length >= 3) { + return parts.slice(2).join('.'); + } + + return undefined; + } catch { + return undefined; + } +}; + +/** + * Извлекает Yandex Metrika Client ID из куки _ym_uid + * Возвращает значение напрямую + */ +const getYandexMetrikaClientId = (): string | undefined => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return undefined; + } + + try { + const ymCookie = document.cookie + .split('; ') + .find(row => row.startsWith('_ym_uid=')); + + if (!ymCookie) return undefined; + + return ymCookie.split('=')[1]; + } catch { + return undefined; + } +}; + +/** + * Отправляет контекст визита в Яндекс Метрику + * Это параметры визита (params), а не посетителя (userParams) + * + * Вызывается при: + * - Создании сессии (sessionId, funnelId, gaId, gaClientId) + * - Авторизации (email, userId) + * - Выборе продукта на экране оплаты (selectedProductId) + */ +const sendVisitContext = (context: Partial): void => { + // Автоматически добавляем GA Client ID и YM Client ID если их нет в контексте + const gaClientId = context.gaClientId || getGoogleAnalyticsClientId(); + const ymClientId = context.ymClientId || getYandexMetrikaClientId(); + + const contextWithClientIds = { + ...context, + ...(gaClientId ? { gaClientId } : {}), + ...(ymClientId ? { ymClientId } : {}), + }; + + console.log('[MetricService] sendVisitContext:', contextWithClientIds); + + // Фильтруем undefined значения + const filteredContext = Object.entries(contextWithClientIds).reduce((acc, [key, value]) => { + if (value !== undefined) { + acc[key] = value; + } + return acc; + }, {} as IVisitParams); + + if (Object.keys(filteredContext).length > 0) { + params(filteredContext); + } +}; + +export const metricService = { + setUserID, + userParams, + params, + reachGoal, + sendSessionDataToMetrics, + sendVisitContext, +};