add ym data

This commit is contained in:
dev.daminik00 2025-10-30 02:38:00 +01:00
parent ea381ea399
commit fcd3e0da3f
9 changed files with 414 additions and 8 deletions

View File

@ -72,7 +72,10 @@ export default async function FunnelLayout({
}
return (
<UnleashSessionProvider funnelId={funnelId}>
<UnleashSessionProvider
funnelId={funnelId}
googleAnalyticsId={funnel.meta.googleAnalyticsId}
>
<PixelsProvider
googleAnalyticsId={funnel.meta.googleAnalyticsId}
yandexMetrikaId={funnel.meta.yandexMetrikaId}

View File

@ -17,6 +17,7 @@ import { getZodiacSign } from "@/lib/funnel/zodiac";
import { useSession } from "@/hooks/session/useSession";
import { buildSessionDataFromScreen } from "@/lib/funnel/registrationHelpers";
import { useUnleashContext } from "@/lib/funnel/unleash";
import { metricService } from "@/services/analytics/metricService";
// Функция для оценки длины пути пользователя на основе текущих ответов
function estimatePathLength(
@ -67,6 +68,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
const router = useRouter();
const { createSession, updateSession } = useSession({
funnelId: funnel.meta.id,
googleAnalyticsId: funnel.meta.googleAnalyticsId,
});
const { answers, registerScreen, setAnswers, history } = useFunnelRuntime(
funnel.meta.id
@ -161,6 +163,19 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
answers[currentScreen.id]
);
// ✅ Отправляем данные в userParams (параметры посетителя)
// Используем ту же структуру что и для сессии (через registrationFieldKey)
if (Object.keys(sessionData).length > 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: {

View File

@ -55,6 +55,7 @@ export function EmailTemplate({
const { authorization, isLoading, error } = useAuth({
funnelId: funnel?.meta?.id ?? "preview",
googleAnalyticsId: funnel?.meta?.googleAnalyticsId,
registrationData,
});

View File

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

View File

@ -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<string, any>;
}
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<string | null>(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);

View File

@ -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"]) => {

View File

@ -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<string | null>(null);
const [isReady, setIsReady] = useState(false);
const { createSession } = useSession({ funnelId });
const { createSession } = useSession({ funnelId, googleAnalyticsId });
useEffect(() => {
const initSession = async () => {

View File

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

View File

@ -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<IUserParams>): 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<string, unknown>): 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<string, unknown>): void => {
const metrics: Partial<IUserParams> = {};
// Извлекаем 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<IVisitParams>): 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,
};