add pixel and metrika

This commit is contained in:
dev.daminik00 2025-10-08 01:55:37 +02:00
parent fb7c21a7f5
commit 8c4a5f0ebe
18 changed files with 554 additions and 152 deletions

View File

@ -3,7 +3,8 @@
"id": "soulmate",
"title": "Новая воронка",
"description": "Описание новой воронки",
"firstScreenId": "onboarding"
"firstScreenId": "onboarding",
"yandexMetrikaId": "104471567"
},
"defaultTexts": {
"nextButton": "Continue"

View File

@ -3,7 +3,8 @@
"id": "soulmate_prod",
"title": "Soulmate V1",
"description": "Soulmate",
"firstScreenId": "onboarding"
"firstScreenId": "onboarding",
"yandexMetrikaId": "104471567"
},
"defaultTexts": {
"nextButton": "Next",

View File

@ -0,0 +1,77 @@
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { PixelsProvider } from "@/components/providers/PixelsProvider";
import type { FunnelDefinition } from "@/lib/funnel/types";
import { BAKED_FUNNELS } from "@/lib/funnel/bakedFunnels";
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
// Функция для загрузки воронки из базы данных
async function loadFunnelFromDatabase(
funnelId: string
): Promise<FunnelDefinition | null> {
if (!IS_FULL_SYSTEM_BUILD) {
return null;
}
try {
const { default: connectMongoDB } = await import("@/lib/mongodb");
const { default: FunnelModel } = await import("@/lib/models/Funnel");
await connectMongoDB();
const funnel = await FunnelModel.findOne({
"funnelData.meta.id": funnelId,
status: { $in: ["published", "draft"] },
}).lean();
if (funnel) {
return funnel.funnelData as FunnelDefinition;
}
return null;
} catch (error) {
console.error(
`Failed to load funnel '${funnelId}' from database:`,
error
);
return null;
}
}
interface FunnelLayoutProps {
children: ReactNode;
params: Promise<{
funnelId: string;
}>;
}
export default async function FunnelLayout({
children,
params,
}: FunnelLayoutProps) {
const { funnelId } = await params;
let funnel: FunnelDefinition | null = null;
// Сначала пытаемся загрузить из базы данных
funnel = await loadFunnelFromDatabase(funnelId);
// Если не найдено в базе, пытаемся загрузить из JSON файлов
if (!funnel) {
funnel = BAKED_FUNNELS[funnelId] || null;
}
// Если воронка не найдена ни в базе, ни в файлах
if (!funnel) {
notFound();
}
return (
<PixelsProvider
googleAnalyticsId={funnel.meta.googleAnalyticsId}
yandexMetrikaId={funnel.meta.yandexMetrikaId}
>
{children}
</PixelsProvider>
);
}

View File

@ -409,6 +409,27 @@ export function BuilderSidebar() {
</label>
</Section>
<Section title="Аналитика" description="Настройки трекинга">
<TextInput
label="Google Analytics ID"
placeholder="G-XXXXXXXXXX"
value={state.meta.googleAnalyticsId ?? ""}
onChange={(event) =>
handleMetaChange("googleAnalyticsId", event.target.value)
}
className="placeholder:text-sm"
/>
<TextInput
label="Yandex Metrika ID"
placeholder="95799066"
value={state.meta.yandexMetrikaId ?? ""}
onChange={(event) =>
handleMetaChange("yandexMetrikaId", event.target.value)
}
className="placeholder:text-sm"
/>
</Section>
<Section
title="Дефолтные тексты"
description="Текст кнопок и баннеров"

View File

@ -1,97 +0,0 @@
import { useMemo } from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import {
useBuilderDispatch,
useBuilderState,
} from "@/lib/admin/builder/context";
import { Section } from "./Section";
import type { BuilderScreen } from "@/lib/admin/builder/types";
export function FunnelSettingsPanel() {
const state = useBuilderState();
const dispatch = useBuilderDispatch();
const screenOptions = useMemo(
() =>
state.screens.map((screen: BuilderScreen) => ({
id: screen.id,
title: screen.title?.text,
})),
[state.screens]
);
const handleMetaChange = (field: keyof typeof state.meta, value: string) => {
dispatch({ type: "set-meta", payload: { [field]: value } });
};
const handleFirstScreenChange = (value: string) => {
dispatch({ type: "set-meta", payload: { firstScreenId: value } });
};
const handleDefaultTextsChange = (
field: keyof NonNullable<typeof state.defaultTexts>,
value: string
) => {
dispatch({ type: "set-default-texts", payload: { [field]: value } });
};
return (
<>
<Section title="Настройки воронки">
<div className="space-y-3">
<TextInput
label="ID воронки"
value={state.meta.id}
onChange={(e) => handleMetaChange("id", e.target.value)}
/>
<TextInput
label="Название"
value={state.meta.title || ""}
onChange={(e) => handleMetaChange("title", e.target.value)}
/>
<TextInput
label="Описание"
value={state.meta.description || ""}
onChange={(e) => handleMetaChange("description", e.target.value)}
/>
<label className="flex flex-col gap-2 text-sm">
<span className="text-muted-foreground">Первый экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2"
value={state.meta.firstScreenId || ""}
onChange={(e) => handleFirstScreenChange(e.target.value)}
>
<option value=""></option>
{screenOptions.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</div>
</Section>
<Section title="Дефолтные тексты">
<div className="space-y-3">
<TextInput
label='Кнопка "Next"'
placeholder="Next"
value={state.defaultTexts?.nextButton || ""}
onChange={(e) =>
handleDefaultTextsChange("nextButton", e.target.value)
}
/>
<TextInput
label='Кнопка "Continue"'
placeholder="Continue"
value={state.defaultTexts?.continueButton || ""}
onChange={(e) =>
handleDefaultTextsChange("continueButton", e.target.value)
}
/>
</div>
</Section>
</>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import Script from "next/script";
interface GoogleAnalyticsProps {
measurementId?: string;
}
/**
* Google Analytics Integration Component
*
* Loads Google Analytics (GA4) tracking script dynamically based on measurement ID
* received from the funnel configuration.
*
* Page views are tracked by PageViewTracker component on route changes.
*
* @param measurementId - Google Analytics Measurement ID (e.g., "G-XXXXXXXXXX")
*/
export function GoogleAnalytics({ measurementId }: GoogleAnalyticsProps) {
if (!measurementId) {
return null;
}
return (
<>
<Script
id="google-analytics"
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
/>
<Script
id="google-analytics-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${measurementId}', {
send_page_view: false
});
`,
}}
/>
</>
);
}

View File

@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
/**
* Page View Tracker Component
*
* Tracks page views in Google Analytics and Yandex Metrika
* when route changes occur (client-side navigation).
*
* Must be included in the app layout or root component.
*/
export function PageViewTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const url = pathname + (searchParams?.toString() ? `?${searchParams.toString()}` : "");
// Track page view in Google Analytics
if (typeof window !== "undefined" && typeof window.gtag === "function") {
window.gtag("event", "page_view", {
page_path: url,
page_location: window.location.href,
page_title: document.title,
});
console.log(`[GA] Page view tracked: ${url}`);
}
// Track page view in Yandex Metrika
if (typeof window !== "undefined" && typeof window.ym === "function") {
const counterId = window.__YM_COUNTER_ID__;
if (counterId) {
window.ym(counterId, "hit", url);
console.log(`[YM] Page view tracked: ${url}`);
}
}
}, [pathname, searchParams]);
return null;
}

View File

@ -0,0 +1,65 @@
"use client";
import Script from "next/script";
interface YandexMetrikaProps {
counterId?: string;
}
/**
* Yandex Metrika Integration Component
*
* Loads Yandex Metrika tracking script dynamically based on counter ID
* received from the funnel configuration.
*
* Initializes with: clickmap, trackLinks, accurateTrackBounce, webvisor.
* Page views are tracked by PageViewTracker component on route changes.
*
* @param counterId - Yandex Metrika Counter ID (e.g., "95799066")
*/
export function YandexMetrika({ counterId }: YandexMetrikaProps) {
if (!counterId) {
return null;
}
return (
<>
<Script
id="yandex-metrika"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {
if (document.scripts[j].src === r) { return; }
}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document, "script", "https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js", "ym");
// Store counter ID for analyticsService
window.__YM_COUNTER_ID__ = ${counterId};
ym(${counterId}, "init", {
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
webvisor: true
});
`,
}}
/>
<noscript>
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://mc.yandex.ru/watch/${counterId}`}
style={{ position: "absolute", left: "-9999px" }}
alt=""
/>
</div>
</noscript>
</>
);
}

View File

@ -1 +1,4 @@
export { FacebookPixels } from "./FacebookPixels";
export { GoogleAnalytics } from "./GoogleAnalytics";
export { YandexMetrika } from "./YandexMetrika";
export { PageViewTracker } from "./PageViewTracker";

View File

@ -3,16 +3,11 @@
import type { ReactNode } from "react";
import { FunnelProvider } from "@/lib/funnel/FunnelProvider";
import { PixelsProvider } from "@/components/providers/PixelsProvider";
interface AppProvidersProps {
children: ReactNode;
}
export function AppProviders({ children }: AppProvidersProps) {
return (
<PixelsProvider>
<FunnelProvider>{children}</FunnelProvider>
</PixelsProvider>
);
return <FunnelProvider>{children}</FunnelProvider>;
}

View File

@ -1,26 +1,34 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { FacebookPixels } from "@/components/analytics/FacebookPixels";
import { FacebookPixels, GoogleAnalytics, YandexMetrika, PageViewTracker } from "@/components/analytics";
import { getPixels } from "@/entities/session/actions";
import { getSourceByPathname } from "@/shared/utils/source";
interface PixelsProviderProps {
children: ReactNode;
googleAnalyticsId?: string;
yandexMetrikaId?: string;
}
/**
* Pixels Provider Component
*
* Loads Facebook pixels from backend and renders them.
* Pixels are loaded once on mount and cached in localStorage.
* Loads tracking scripts for Facebook Pixels, Google Analytics, and Yandex Metrika.
*
* IMPORTANT: This component should be placed in a layout (not in components that re-render on navigation)
* to avoid duplicate API requests. Currently used in app/[funnelId]/layout.tsx
*
* Facebook Pixels are loaded from backend API (cached in localStorage).
* Google Analytics and Yandex Metrika IDs come from funnel configuration.
*
* Flow:
* 1. Check localStorage for cached pixels
* 2. If not cached, request from backend
* 3. Save to localStorage and render
* 1. Check localStorage for cached FB pixels
* 2. If not cached, request from backend (errors are handled gracefully)
* 3. Save to localStorage if pixels received
* 4. Render GA and YM if IDs provided in funnel config
*/
export function PixelsProvider({ children }: PixelsProviderProps) {
export function PixelsProvider({ children, googleAnalyticsId, yandexMetrikaId }: PixelsProviderProps) {
const [pixels, setPixels] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -49,12 +57,15 @@ export function PixelsProvider({ children }: PixelsProviderProps) {
const pixelIds = response?.data?.fb || [];
// Save to localStorage
// Save to localStorage only if we got pixels
if (pixelIds.length > 0) {
localStorage.setItem("fb_pixels", JSON.stringify(pixelIds));
}
setPixels(pixelIds);
} catch (error) {
console.error("Failed to load Facebook pixels:", error);
// Silently handle errors - pixels are optional
console.warn("Facebook pixels not available:", error instanceof Error ? error.message : error);
setPixels([]);
} finally {
setIsLoading(false);
@ -67,6 +78,9 @@ export function PixelsProvider({ children }: PixelsProviderProps) {
return (
<>
{!isLoading && <FacebookPixels pixels={pixels} />}
<GoogleAnalytics measurementId={googleAnalyticsId} />
<YandexMetrika counterId={yandexMetrikaId} />
<PageViewTracker />
{children}
</>
);

View File

@ -7,6 +7,7 @@ import { ICreateAuthorizeRequest } from "@/entities/user/types";
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";
// TODO
const locale = "en";
@ -84,49 +85,40 @@ export const useAuth = ({ funnelId, registrationData }: IUseAuthProps) => {
}
const payload = getAuthorizationPayload(email);
const {
token,
// userId: userIdFromApi,
// generatingVideo,
// videoId,
// authCode,
} = await createAuthorization(payload);
const { token, userId } = await createAuthorization(payload);
// Track registration events in analytics
// Send EnteredEmail to Yandex Metrika and Google Analytics
analyticsService.trackEvent(
AnalyticsEvent.ENTERED_EMAIL,
[AnalyticsPlatform.YANDEX_METRIKA, AnalyticsPlatform.GOOGLE_ANALYTICS]
);
// Send Lead to Facebook Pixel
analyticsService.trackEvent(
AnalyticsEvent.LEAD,
[AnalyticsPlatform.FACEBOOK]
);
// Set user ID and properties in analytics
if (userId) {
analyticsService.setUserId(userId);
analyticsService.setUserProperties({
email,
source: funnelId,
UserID: userId,
});
}
await setAuthTokenToCookie(token);
return token;
// const { user: userMe } = await api.getMe({ token });
// const userId = userIdFromApi || userMe?._id;
// if (userId?.length) {
// dispatch(actions.userId.update({ userId }));
// metricService.userParams({
// hasPersonalVideo: generatingVideo || false,
// email: user?.email,
// UserID: userId,
// });
// metricService.setUserID(userId);
// }
// signUp(token, userMe, isAnonymous);
// setToken(token);
// dispatch(actions.userConfig.setAuthCode(authCode || ""));
// dispatch(
// actions.personalVideo.updateStatus({
// generatingVideo: generatingVideo || false,
// videoId: videoId || "",
// })
// );
// if (generatingVideo) {
// metricService.reachGoal(EGoals.ROSE_VIDEO_CREATION_START, [
// EMetrics.YANDEX,
// EMetrics.KLAVIYO,
// ]);
// }
// dispatch(actions.status.update("registred"));
} catch (error) {
setError((error as Error).message);
} finally {
setIsLoading(false);
}
},
[getAllCookies, getAuthorizationPayload, updateSession]
[getAllCookies, getAuthorizationPayload, updateSession, funnelId]
);
return useMemo(

View File

@ -11,7 +11,8 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"id": "soulmate_prod",
"title": "Soulmate V1",
"description": "Soulmate",
"firstScreenId": "onboarding"
"firstScreenId": "onboarding",
"yandexMetrikaId": "104471567"
},
"defaultTexts": {
"nextButton": "Next",
@ -2697,7 +2698,8 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"id": "soulmate",
"title": "Новая воронка",
"description": "Описание новой воронки",
"firstScreenId": "onboarding"
"firstScreenId": "onboarding",
"yandexMetrikaId": "104471567"
},
"defaultTexts": {
"nextButton": "Continue"

View File

@ -517,6 +517,16 @@ export interface FunnelMetaDefinition {
title?: string;
description?: string;
firstScreenId?: string;
/**
* Google Analytics Measurement ID (e.g., "G-XXXXXXXXXX")
* If provided, Google Analytics will be loaded for this funnel
*/
googleAnalyticsId?: string;
/**
* Yandex Metrika Counter ID (e.g., "95799066")
* If provided, Yandex Metrika will be loaded for this funnel
*/
yandexMetrikaId?: string;
}
export interface FunnelDefinition {

View File

@ -222,6 +222,8 @@ const FunnelMetaSchema = new Schema(
title: String,
description: String,
firstScreenId: String,
googleAnalyticsId: String,
yandexMetrikaId: String,
},
{ _id: false }
);

View File

@ -0,0 +1,201 @@
/**
* Analytics Service
*
* Centralized service for sending events to multiple analytics platforms:
* - Facebook Pixel
* - Google Analytics (GA4)
* - Yandex Metrika
*
* Page views are tracked automatically by GA4 and Yandex Metrika.
*
* Usage:
* analyticsService.trackEvent(AnalyticsEvent.ENTERED_EMAIL);
* analyticsService.setUserId(userId);
*/
import "./types";
/**
* Available analytics events
*/
export enum AnalyticsEvent {
// Registration events
ENTERED_EMAIL = "EnteredEmail",
LEAD = "Lead", // Facebook-specific event name for EnteredEmail
}
/**
* Analytics platforms
*/
export enum AnalyticsPlatform {
FACEBOOK = "Facebook",
GOOGLE_ANALYTICS = "GoogleAnalytics",
YANDEX_METRIKA = "YandexMetrika",
}
interface EventOptions {
[key: string]: unknown;
}
/**
* Check if Yandex Metrika is available
*/
function isYandexMetrikaAvailable(): boolean {
return typeof window !== "undefined" && typeof window.ym === "function";
}
/**
* Check if Google Analytics is available
*/
function isGoogleAnalyticsAvailable(): boolean {
return typeof window !== "undefined" && typeof window.gtag === "function";
}
/**
* Check if Facebook Pixel is available
*/
function isFacebookPixelAvailable(): boolean {
return typeof window !== "undefined" && typeof window.fbq === "function";
}
/**
* Track event in Yandex Metrika
*/
function trackYandexMetrikaEvent(
event: AnalyticsEvent,
options?: EventOptions
): void {
if (!isYandexMetrikaAvailable()) {
console.warn("Yandex Metrika is not available");
return;
}
// Get counter ID from the page (injected by YandexMetrika component)
const counterId = window.__YM_COUNTER_ID__;
if (!counterId) {
console.warn("Yandex Metrika counter ID not found");
return;
}
window.ym(counterId, "reachGoal", event, options);
console.log(`[YM] Event: ${event}`, options);
}
/**
* Track event in Google Analytics
*/
function trackGoogleAnalyticsEvent(
event: AnalyticsEvent,
options?: EventOptions
): void {
if (!isGoogleAnalyticsAvailable()) {
console.warn("Google Analytics is not available");
return;
}
window.gtag("event", event, options);
console.log(`[GA] Event: ${event}`, options);
}
/**
* Track event in Facebook Pixel
*/
function trackFacebookPixelEvent(
event: AnalyticsEvent,
options?: EventOptions
): void {
if (!isFacebookPixelAvailable()) {
console.warn("Facebook Pixel is not available");
return;
}
// Map EnteredEmail to Lead for Facebook
const fbEvent = event === AnalyticsEvent.ENTERED_EMAIL ? AnalyticsEvent.LEAD : event;
window.fbq("track", fbEvent, options);
console.log(`[FB] Event: ${fbEvent}`, options);
}
/**
* Track event across multiple platforms
*
* @param event - Event to track
* @param platforms - Platforms to send event to (defaults to all available)
* @param options - Additional event data
*/
export function trackEvent(
event: AnalyticsEvent,
platforms?: AnalyticsPlatform[],
options?: EventOptions
): void {
// If no platforms specified, send to all available
const targetPlatforms = platforms || [
AnalyticsPlatform.FACEBOOK,
AnalyticsPlatform.GOOGLE_ANALYTICS,
AnalyticsPlatform.YANDEX_METRIKA,
];
targetPlatforms.forEach((platform) => {
switch (platform) {
case AnalyticsPlatform.YANDEX_METRIKA:
trackYandexMetrikaEvent(event, options);
break;
case AnalyticsPlatform.GOOGLE_ANALYTICS:
trackGoogleAnalyticsEvent(event, options);
break;
case AnalyticsPlatform.FACEBOOK:
trackFacebookPixelEvent(event, options);
break;
}
});
}
/**
* Set user ID across all analytics platforms
*/
export function setUserId(userId: string): void {
// Yandex Metrika
if (isYandexMetrikaAvailable()) {
const counterId = window.__YM_COUNTER_ID__;
if (counterId) {
window.ym(counterId, "setUserID", userId);
console.log(`[YM] User ID set: ${userId}`);
}
}
// Google Analytics - set user_id property for all subsequent events
if (isGoogleAnalyticsAvailable()) {
window.gtag("set", { user_id: userId });
console.log(`[GA] User ID set: ${userId}`);
}
}
/**
* Set user properties/parameters
*/
export function setUserProperties(properties: Record<string, unknown>): void {
// Yandex Metrika
if (isYandexMetrikaAvailable()) {
const counterId = window.__YM_COUNTER_ID__;
if (counterId) {
window.ym(counterId, "userParams", properties);
console.log(`[YM] User params set:`, properties);
}
}
// Google Analytics
if (isGoogleAnalyticsAvailable()) {
window.gtag("set", "user_properties", properties);
console.log(`[GA] User properties set:`, properties);
}
}
const analyticsService = {
trackEvent,
setUserId,
setUserProperties,
AnalyticsEvent,
AnalyticsPlatform,
};
export default analyticsService;

View File

@ -0,0 +1 @@
export { default as analyticsService, AnalyticsEvent, AnalyticsPlatform } from "./analyticsService";

View File

@ -0,0 +1,25 @@
/**
* Analytics Window Extensions
*
* Type definitions for analytics scripts injected into window object
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
declare global {
interface Window {
// Yandex Metrika
ym: (counterId: number | string, method: string, ...args: any[]) => void;
__YM_COUNTER_ID__?: number | string;
// Google Analytics (GA4)
gtag: (command: string, ...args: any[]) => void;
dataLayer: any[];
// Facebook Pixel
fbq: (command: string, event: string, params?: Record<string, any>) => void;
_fbq: any;
}
}
/* eslint-enable @typescript-eslint/no-explicit-any */
export {};