add yandex metrika improve

This commit is contained in:
dev.daminik00 2025-10-31 19:47:15 +01:00
parent f01469c1a5
commit 02bfbb1112
11 changed files with 402 additions and 78 deletions

123
ANALYTICS_IMPLEMENTATION.md Normal file
View File

@ -0,0 +1,123 @@
# Analytics System Implementation
## Обзор
В проект `witlab-app` была добавлена система аналитики на основе Yandex Metrika по аналогии с `witlab-funnel`:
- **Yandex Metrika** - отслеживание просмотров страниц и событий
- **PageViewTracker** - автоматическая отслежка переходов между страницами
- **AnalyticsService** - управление параметрами пользователя и событиями
## Структура файлов
```
src/
├── components/analytics/
│ ├── PageViewTracker.tsx # Отслеживание переходов
│ ├── YandexMetrika/
│ │ ├── YandexMetrika.tsx # Компонент Яндекс Метрики
│ │ └── index.ts
│ └── index.ts # Экспорты аналитики
├── providers/
│ └── analytics-provider.tsx # Провайдер аналитики
├── services/analytics/
│ ├── analyticsService.ts # Сервис аналитики
│ └── index.ts
└── shared/constants/
└── analytics.ts # Константы ID счетчиков
```
## Конфигурация
### Статичные константы
```typescript
// src/shared/constants/analytics.ts
export const YANDEX_METRIKA_ID = "103412914";
```
## Использование
### Автоматическая отслежка страниц
Система автоматически отслеживает все переходы между страницами через `PageViewTracker`.
### Параметры пользователя
При инициализации система автоматически загружает данные пользователя через `/users/me` и устанавливает параметры в Yandex Metrika:
- `user_id` - ID пользователя
- `email` - email пользователя
- `locale` - язык пользователя
- `timezone` - часовой пояс
- `profile_name`, `profile_gender`, `profile_age`, `profile_sign` - данные профиля
- `partner_gender`, `partner_age`, `partner_sign` - данные партнера
- `country`, `region`, `city` - геолокация
### Отслеживание событий
```typescript
import { analyticsService } from "@/services/analytics";
// Отправить событие в Yandex Metrika
analyticsService.trackEvent("button_click", {
button_name: "subscribe_now",
screen: "retaining"
});
```
### Доступ к данным пользователя
```typescript
import { analyticsService } from "@/services/analytics";
if (analyticsService.isReady()) {
const user = analyticsService.getUser();
console.log("Current user:", user);
}
```
## Логирование
Система предоставляет детальное логирование в консоли:
```
✅ [YM] Page View Event Sent
✅ [Analytics] Service initialized with user data
✅ [YM] User parameters set
```
## Интеграция в layout
Компоненты аналитики добавлены в `src/app/[locale]/layout.tsx`:
```tsx
<YandexMetrika />
<AnalyticsProvider>
{/* ... */}
<PageViewTracker />
{/* ... */}
</AnalyticsProvider>
```
## Отличия от witlab-funnel
1. **Только Yandex Metrika** - без Google Analytics
2. **Статичный ID** - Яндекс Метрика использует статичный ID вместо динамического из воронки
3. **Параметры пользователя** - Автоматическая загрузка и установка параметров из `/me`
4. **Унифицированный сервис** - Единый `AnalyticsService` для управления
## Проверка работы
1. Откройте консоль разработчика в браузере
2. Перейдите между страницами приложения
3. Вы увидите сообщения об отправке событий в YM
4. Проверьте наличие cookies `_ym_uid`
## Совместимость
- ✅ TypeScript
- ✅ ESLint (без ошибок, только предупреждения о существующих `any`)
- ✅ Next.js 15
- ✅ Server-side rendering
- ✅ Динамические маршруты

View File

@ -9,10 +9,11 @@ import { hasLocale, NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import clsx from "clsx";
import YandexMetrika from "@/components/analytics/YandexMetrika";
import { PageViewTracker,YandexMetrika } from "@/components/analytics";
import { loadChatsList } from "@/entities/chats/loaders";
import { loadUser, loadUserId } from "@/entities/user/loaders";
import { routing } from "@/i18n/routing";
import { AnalyticsProvider } from "@/providers/analytics-provider";
import { AppUiStoreProvider } from "@/providers/app-ui-store-provider";
import { AudioProvider } from "@/providers/audio-provider";
import { ChatsInitializationProvider } from "@/providers/chats-initialization-provider";
@ -68,24 +69,29 @@ export default async function RootLayout({
return (
<html lang={locale}>
<body className={clsx(inter.variable, styles.body)}>
{/* Analytics Components */}
<YandexMetrika />
<NextIntlClientProvider messages={messages}>
<UserProvider user={user}>
<SocketProvider userId={userId}>
<RetainingStoreProvider>
<AudioProvider>
<ChatsInitializationProvider>
<ChatsProvider initialChats={chats}>
<ToastProvider maxVisible={3}>
<AppUiStoreProvider>{children}</AppUiStoreProvider>
</ToastProvider>
</ChatsProvider>
</ChatsInitializationProvider>
</AudioProvider>
</RetainingStoreProvider>
</SocketProvider>
</UserProvider>
</NextIntlClientProvider>
<AnalyticsProvider>
<NextIntlClientProvider messages={messages}>
<UserProvider user={user}>
<SocketProvider userId={userId}>
<RetainingStoreProvider>
<AudioProvider>
<ChatsInitializationProvider>
<ChatsProvider initialChats={chats}>
<ToastProvider maxVisible={3}>
<PageViewTracker />
<AppUiStoreProvider>{children}</AppUiStoreProvider>
</ToastProvider>
</ChatsProvider>
</ChatsInitializationProvider>
</AudioProvider>
</RetainingStoreProvider>
</SocketProvider>
</UserProvider>
</NextIntlClientProvider>
</AnalyticsProvider>
</body>
</html>
);

View File

@ -0,0 +1,54 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
/**
* Wait for Yandex Metrika to be loaded
* Retry mechanism with timeout to handle async script loading
*/
async function waitForYandexMetrika(maxAttempts = 10, delayMs = 100): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
if (typeof window !== "undefined" &&
typeof window.ym === "function" &&
window.__YM_COUNTER_ID__) {
return true;
}
await new Promise(resolve => setTimeout(resolve, delayMs));
}
return false;
}
/**
* Page View Tracker Component
*
* Tracks page views in 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 Yandex Metrika (with retry logic)
const trackYandexMetrika = async () => {
const isYmAvailable = await waitForYandexMetrika();
if (isYmAvailable && typeof window.ym === "function") {
const counterId = window.__YM_COUNTER_ID__;
if (counterId) {
window.ym(counterId, "hit", url);
}
}
};
// Execute YM tracking
trackYandexMetrika();
}, [pathname, searchParams]);
return null;
}

View File

@ -1,79 +1,52 @@
"use client";
import { useEffect } from "react";
import Script from "next/script";
const YANDEX_METRIKA_ID = 103412914;
export default function YandexMetrika() {
useEffect(() => {
// Initialize Yandex.Metrika after script loads
const initializeYandexMetrika = () => {
if (typeof window.ym === "function") {
try {
window.ym(YANDEX_METRIKA_ID, "init", {
webvisor: true,
clickmap: true,
accurateTrackBounce: true,
trackLinks: true,
});
} catch {
// Silently handle initialization errors
}
}
};
// Check if ym is already available or wait for it
if (typeof window.ym === "function") {
initializeYandexMetrika();
} else {
// Wait for script to load
const checkYm = setInterval(() => {
if (typeof window.ym === "function") {
initializeYandexMetrika();
clearInterval(checkYm);
}
}, 100);
// Cleanup interval after 10 seconds
setTimeout(() => {
clearInterval(checkYm);
}, 10000);
return () => clearInterval(checkYm);
}
}, []);
import { YANDEX_METRIKA_ID } from "@/shared/constants/analytics";
/**
* Yandex Metrika Integration Component
*
* Loads Yandex Metrika tracking script dynamically using static counter ID.
*
* Initializes with: clickmap, trackLinks, accurateTrackBounce, webvisor.
* Page views are tracked by PageViewTracker component on route changes.
*/
export function YandexMetrika() {
return (
<>
{/* Yandex.Metrika counter */}
<Script
id="yandex-metrika-site-wide"
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://mc.yandex.ru/metrika/tag.js', 'ym');
(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__ = ${YANDEX_METRIKA_ID};
ym(${YANDEX_METRIKA_ID}, "init", {
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
webvisor: true
});
`,
}}
/>
{/* Noscript fallback */}
<noscript>
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://mc.yandex.ru/watch/${YANDEX_METRIKA_ID}`}
style={{
position: "absolute",
left: "-9999px",
}}
style={{ position: "absolute", left: "-9999px" }}
alt=""
/>
</div>

View File

@ -1 +1 @@
export { default } from "./YandexMetrika";
export { YandexMetrika } from "./YandexMetrika";

View File

@ -0,0 +1,2 @@
export { PageViewTracker } from "./PageViewTracker";
export { YandexMetrika } from "./YandexMetrika/YandexMetrika";

View File

@ -0,0 +1,24 @@
"use client";
import { type ReactNode,useEffect } from "react";
import { analyticsService } from "@/services/analytics";
interface AnalyticsProviderProps {
children: ReactNode;
}
/**
* Analytics Provider Component
*
* Initializes analytics service with user data when the app loads.
* Provides analytics context to the entire application.
*/
export function AnalyticsProvider({ children }: AnalyticsProviderProps) {
useEffect(() => {
// Initialize analytics service
analyticsService.initialize();
}, []);
return <>{children}</>;
}

View File

@ -0,0 +1,140 @@
import type { IMeResponse, IUser } from "@/entities/user/types";
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
/**
* Analytics Service
*
* Provides methods for tracking user parameters and events
* in Yandex Metrika.
*/
export class AnalyticsService {
private static instance: AnalyticsService;
private user: IUser | null = null;
private isInitialized = false;
static getInstance(): AnalyticsService {
if (!AnalyticsService.instance) {
AnalyticsService.instance = new AnalyticsService();
}
return AnalyticsService.instance;
}
/**
* Initialize analytics service with user data
*/
async initialize(): Promise<void> {
if (this.isInitialized) {
return;
}
try {
const response = await http.get<IMeResponse>(API_ROUTES.usersMe());
this.user = response.user;
// Set user parameters in analytics
await this.setUserParameters();
this.isInitialized = true;
} catch {
// Initialization failed silently
}
}
/**
* Set user parameters in Yandex Metrika
*/
private async setUserParameters(): Promise<void> {
if (!this.user) {
return;
}
const userParams = this.extractUserParameters();
// Set parameters in Yandex Metrika
if (typeof window.ym === "function" && window.__YM_COUNTER_ID__) {
try {
window.ym(window.__YM_COUNTER_ID__, "userParams", userParams);
} catch {
// Failed to set user parameters silently
}
}
}
/**
* Extract relevant user parameters for analytics
*/
private extractUserParameters(): Record<string, unknown> {
if (!this.user) {
return {};
}
const params: Record<string, unknown> = {
user_id: this.user._id,
email: this.user.email,
locale: this.user.locale,
timezone: this.user.timezone,
source: this.user.source,
has_sign: this.user.sign,
created_at: this.user.createdAt,
};
// Add profile information if available
if (this.user.profile) {
params.profile_name = this.user.profile.name;
params.profile_gender = this.user.profile.gender;
params.profile_age = this.user.profile.age;
params.profile_sign = this.user.profile.sign;
params.profile_birthdate = this.user.profile.birthdate;
}
// Add partner information if available
if (this.user.partner) {
params.partner_gender = this.user.partner.gender;
params.partner_age = this.user.partner.age;
params.partner_sign = this.user.partner.sign;
params.partner_birthdate = this.user.partner.birthdate;
}
// Add location information if available
if (this.user.ipLookup) {
params.country = this.user.ipLookup.country;
params.region = this.user.ipLookup.region;
params.city = this.user.ipLookup.city;
params.is_eu = this.user.ipLookup.eu;
}
return params;
}
/**
* Track custom event in Yandex Metrika
*/
trackEvent(eventName: string, parameters?: Record<string, unknown>): void {
// Track in Yandex Metrika
if (typeof window.ym === "function" && window.__YM_COUNTER_ID__) {
try {
window.ym(window.__YM_COUNTER_ID__, "reachGoal", eventName, parameters);
} catch {
// Failed to track event silently
}
}
}
/**
* Get current user data
*/
getUser(): IUser | null {
return this.user;
}
/**
* Check if service is initialized
*/
isReady(): boolean {
return this.isInitialized;
}
}
// Export singleton instance
export const analyticsService = AnalyticsService.getInstance();

View File

@ -0,0 +1 @@
export { AnalyticsService,analyticsService } from "./analyticsService";

View File

@ -0,0 +1 @@
export const YANDEX_METRIKA_ID = "103412914";

View File

@ -36,8 +36,8 @@ declare global {
fbq: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
CollectJS: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gtag: any;
__YM_COUNTER_ID__: number;
}
}