ab test
This commit is contained in:
parent
54fdf8dc5a
commit
ea381ea399
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useVariant } from "@unleash/proxy-client-react";
|
import { useVariant } from "@unleash/proxy-client-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef, memo } from "react";
|
||||||
|
|
||||||
interface FlagVariantFetcherProps {
|
interface FlagVariantFetcherProps {
|
||||||
flag: string;
|
flag: string;
|
||||||
@ -12,13 +12,26 @@ interface FlagVariantFetcherProps {
|
|||||||
* Компонент для получения варианта одного флага
|
* Компонент для получения варианта одного флага
|
||||||
* Каждый экземпляр этого компонента вызывает useVariant на верхнем уровне
|
* Каждый экземпляр этого компонента вызывает useVariant на верхнем уровне
|
||||||
* Это позволяет обходить ограничение правил хуков React
|
* Это позволяет обходить ограничение правил хуков 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 variant = useVariant(flag);
|
||||||
|
const lastVariantRef = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onVariantLoaded(flag, variant?.name);
|
const currentVariant = variant?.name;
|
||||||
|
|
||||||
|
// Отправляем только если вариант изменился
|
||||||
|
if (currentVariant !== lastVariantRef.current) {
|
||||||
|
lastVariantRef.current = currentVariant;
|
||||||
|
onVariantLoaded(flag, currentVariant);
|
||||||
|
}
|
||||||
}, [flag, variant?.name, onVariantLoaded]);
|
}, [flag, variant?.name, onVariantLoaded]);
|
||||||
|
|
||||||
return null; // Этот компонент не рендерит UI
|
return null; // Этот компонент не рендерит UI
|
||||||
}
|
});
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"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 { useFlagsStatus } from "@unleash/proxy-client-react";
|
||||||
import { UnleashContextProvider } from "@/lib/funnel/unleash";
|
import { UnleashContextProvider } from "@/lib/funnel/unleash";
|
||||||
|
import { useUnleashAnalytics } from "@/lib/funnel/unleash/useUnleashAnalytics";
|
||||||
import { FunnelLoadingScreen } from "./FunnelLoadingScreen";
|
import { FunnelLoadingScreen } from "./FunnelLoadingScreen";
|
||||||
import { FlagVariantFetcher } from "./FlagVariantFetcher";
|
import { FlagVariantFetcher } from "./FlagVariantFetcher";
|
||||||
import type { NavigationConditionDefinition } from "@/lib/funnel/types";
|
import type { NavigationConditionDefinition } from "@/lib/funnel/types";
|
||||||
@ -37,6 +38,10 @@ export function FunnelUnleashWrapper({
|
|||||||
funnel,
|
funnel,
|
||||||
currentScreenId,
|
currentScreenId,
|
||||||
}: FunnelUnleashWrapperProps) {
|
}: FunnelUnleashWrapperProps) {
|
||||||
|
// ✅ КРИТИЧЕСКИ ВАЖНО: Подписываемся на impression события ДО загрузки флагов
|
||||||
|
// Это гарантирует что события от useVariant() будут пойманы и отправлены в аналитику
|
||||||
|
useUnleashAnalytics();
|
||||||
|
|
||||||
const { flagsReady } = useFlagsStatus();
|
const { flagsReady } = useFlagsStatus();
|
||||||
|
|
||||||
// Собираем флаги ТОЛЬКО для текущего экрана (или все, если currentScreenId не передан)
|
// Собираем флаги ТОЛЬКО для текущего экрана (или все, если currentScreenId не передан)
|
||||||
@ -80,16 +85,6 @@ export function FunnelUnleashWrapper({
|
|||||||
// Состояние для хранения вариантов флагов
|
// Состояние для хранения вариантов флагов
|
||||||
const [loadedVariants, setLoadedVariants] = useState<Record<string, string>>({});
|
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 компонента
|
// Колбэк для получения варианта от FlagVariantFetcher компонента
|
||||||
const handleVariantLoaded = useCallback((flag: string, variant: string | undefined) => {
|
const handleVariantLoaded = useCallback((flag: string, variant: string | undefined) => {
|
||||||
// ✅ Сохраняем вариант в любом случае (даже если undefined или "disabled")
|
// ✅ Сохраняем вариант в любом случае (даже если undefined или "disabled")
|
||||||
@ -99,9 +94,7 @@ export function FunnelUnleashWrapper({
|
|||||||
|
|
||||||
// Обновляем только если значение изменилось
|
// Обновляем только если значение изменилось
|
||||||
if (prev[flag] !== newVariant) {
|
if (prev[flag] !== newVariant) {
|
||||||
if (process.env.NODE_ENV === "development") {
|
console.log(`🚩 [FunnelUnleashWrapper] Flag loaded: "${flag}" = "${newVariant}"`);
|
||||||
console.log(`[FunnelUnleashWrapper] Flag "${flag}" = "${newVariant}"`);
|
|
||||||
}
|
|
||||||
return { ...prev, [flag]: newVariant };
|
return { ...prev, [flag]: newVariant };
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
@ -111,11 +104,13 @@ export function FunnelUnleashWrapper({
|
|||||||
// Проверяем что ВСЕ флаги текущего экрана загружены
|
// Проверяем что ВСЕ флаги текущего экрана загружены
|
||||||
const allFlagsLoaded = useMemo(() => {
|
const allFlagsLoaded = useMemo(() => {
|
||||||
if (!flagsReady) {
|
if (!flagsReady) {
|
||||||
|
console.log("⏳ [FunnelUnleashWrapper] Waiting for Unleash client to be ready...");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если нет флагов на экране - сразу готовы
|
// Если нет флагов на экране - сразу готовы
|
||||||
if (currentScreenFlags.length === 0) {
|
if (currentScreenFlags.length === 0) {
|
||||||
|
console.log("✅ [FunnelUnleashWrapper] No AB test flags on current screen - ready to render");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,16 +119,16 @@ export function FunnelUnleashWrapper({
|
|||||||
return flag in loadedVariants;
|
return flag in loadedVariants;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
console.log(`${allLoaded ? '✅' : '⏳'} [FunnelUnleashWrapper] Flags status:`, {
|
||||||
console.log("[FunnelUnleashWrapper] Flags status:", {
|
currentScreenId,
|
||||||
currentScreenFlags,
|
flagsRequired: currentScreenFlags,
|
||||||
loadedVariants,
|
flagsLoaded: Object.keys(loadedVariants),
|
||||||
allLoaded,
|
allReady: allLoaded,
|
||||||
});
|
variants: loadedVariants,
|
||||||
}
|
});
|
||||||
|
|
||||||
return allLoaded;
|
return allLoaded;
|
||||||
}, [flagsReady, currentScreenFlags, loadedVariants]);
|
}, [flagsReady, currentScreenFlags, loadedVariants, currentScreenId]);
|
||||||
|
|
||||||
// Создаем объект активных вариантов
|
// Создаем объект активных вариантов
|
||||||
const activeVariants = useMemo(() => {
|
const activeVariants = useMemo(() => {
|
||||||
|
|||||||
@ -4,33 +4,25 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import { FunnelProvider } from "@/lib/funnel/FunnelProvider";
|
import { FunnelProvider } from "@/lib/funnel/FunnelProvider";
|
||||||
import { UnleashProvider } from "./UnleashProvider";
|
import { UnleashProvider } from "./UnleashProvider";
|
||||||
import { useUnleashAnalytics } from "@/lib/funnel/unleash/useUnleashAnalytics";
|
|
||||||
|
|
||||||
interface AppProvidersProps {
|
interface AppProvidersProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Компонент для инициализации автоматической отправки AB test impression событий
|
|
||||||
* Идентично aura-webapp: events отправляются когда пользователь доходит до экрана
|
|
||||||
*/
|
|
||||||
function UnleashAnalyticsInitializer() {
|
|
||||||
useUnleashAnalytics();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Корневой Provider приложения
|
* Корневой Provider приложения
|
||||||
*
|
*
|
||||||
* Структура идентична aura-webapp:
|
* ВАЖНО: UnleashAnalyticsInitializer перемещен в FunnelUnleashWrapper
|
||||||
* 1. UnleashProvider (FlagProvider) - инициализация Unleash Client
|
* чтобы гарантировать что impression listener готов ДО загрузки флагов
|
||||||
* 2. UnleashAnalyticsInitializer - автоматическая подписка на impression события
|
*
|
||||||
* 3. FunnelProvider - управление состоянием воронки
|
* Структура:
|
||||||
|
* 1. UnleashProvider (FlagProvider) - инициализация Unleash Client (глобально)
|
||||||
|
* 2. FunnelProvider - управление состоянием воронки
|
||||||
|
* 3. FunnelUnleashWrapper (в layout) - подписка на impression события + загрузка флагов
|
||||||
*/
|
*/
|
||||||
export function AppProviders({ children }: AppProvidersProps) {
|
export function AppProviders({ children }: AppProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<UnleashProvider>
|
<UnleashProvider>
|
||||||
<UnleashAnalyticsInitializer />
|
|
||||||
<FunnelProvider>{children}</FunnelProvider>
|
<FunnelProvider>{children}</FunnelProvider>
|
||||||
</UnleashProvider>
|
</UnleashProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -47,50 +47,59 @@ export function MetricsProvider({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[Metrics] Google Analytics initialized:', {
|
console.log('✅ [Metrics] Google Analytics initialized:', {
|
||||||
measurementId: googleAnalyticsId,
|
measurementId: googleAnalyticsId,
|
||||||
debugMode: isDevelopEnvironment,
|
debugMode: isDevelopEnvironment,
|
||||||
|
ready: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Metrics] Failed to initialize Google Analytics:', error);
|
console.error('[Metrics] Failed to initialize Google Analytics:', error);
|
||||||
}
|
}
|
||||||
}, [googleAnalyticsId]);
|
}, [googleAnalyticsId]);
|
||||||
|
|
||||||
// Инициализация Yandex Metrika (синхронно через скрипт)
|
// Инициализация Yandex Metrika (официальный способ)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!yandexMetrikaId) return;
|
if (!yandexMetrikaId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Проверяем что скрипт еще не загружен
|
// Проверяем что скрипт еще не загружен
|
||||||
if (typeof window.ym === 'function') {
|
if (typeof window.ym === 'function' && window.__YM_COUNTER_ID__) {
|
||||||
console.log('[Metrics] Yandex Metrika already loaded');
|
console.log('[Metrics] Yandex Metrika already initialized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем скрипт Yandex Metrika
|
// ✅ Официальный код инициализации Яндекс Метрики
|
||||||
const script = document.createElement('script');
|
// Создает функцию-заглушку ym() для накопления вызовов до загрузки скрипта
|
||||||
script.type = 'text/javascript';
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
script.async = true;
|
(function(m: any, e: Document, t: string, r: string, i: string, k: HTMLScriptElement, a: HTMLScriptElement | null) {
|
||||||
script.src = 'https://mc.yandex.ru/metrika/tag.js';
|
// eslint-disable-next-line prefer-rest-params
|
||||||
|
m[i] = m[i] || function() { (m[i].a = m[i].a || []).push(arguments); };
|
||||||
script.onload = () => {
|
m[i].l = 1 * new Date().getTime();
|
||||||
// Инициализируем счетчик после загрузки скрипта
|
k = e.createElement(t) as HTMLScriptElement;
|
||||||
if (typeof window.ym === 'function') {
|
a = e.getElementsByTagName(t)[0] as HTMLScriptElement;
|
||||||
window.ym(Number(yandexMetrikaId), 'init', {
|
k.async = true;
|
||||||
clickmap: true,
|
k.src = r;
|
||||||
trackLinks: true,
|
if (a && a.parentNode) {
|
||||||
accurateTrackBounce: true,
|
a.parentNode.insertBefore(k, a);
|
||||||
webvisor: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Сохраняем ID счетчика для использования в analytics service
|
|
||||||
window.__YM_COUNTER_ID__ = Number(yandexMetrikaId);
|
|
||||||
|
|
||||||
console.log('[Metrics] Yandex Metrika initialized:', yandexMetrikaId);
|
|
||||||
}
|
}
|
||||||
};
|
})(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) {
|
} catch (error) {
|
||||||
console.error('[Metrics] Failed to initialize Yandex Metrika:', error);
|
console.error('[Metrics] Failed to initialize Yandex Metrika:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,8 +45,6 @@ export function UnleashProvider({
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[UnleashProvider] Initializing with sessionId:", sessionId || userId || "anonymous");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlagProvider config={config}>
|
<FlagProvider config={config}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -30,52 +30,81 @@ export function useUnleashAnalytics() {
|
|||||||
const unleashClient = useUnleashClient();
|
const unleashClient = useUnleashClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Подписываемся на все impression события от Unleash
|
console.log("🎯 [Unleash Analytics] Impression listener initialized");
|
||||||
|
|
||||||
|
// Подписываемся на все impression события от Unleash (идентично aura-webapp)
|
||||||
unleashClient.on("impression", (impressionEvent: UnleashImpressionEvent) => {
|
unleashClient.on("impression", (impressionEvent: UnleashImpressionEvent) => {
|
||||||
|
console.log("📊 [Unleash Analytics] Impression event received:", {
|
||||||
|
feature: impressionEvent.featureName,
|
||||||
|
variant: impressionEvent.variant,
|
||||||
|
enabled: impressionEvent.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
// Проверяем что флаг включен (идентично aura-webapp)
|
// Проверяем что флаг включен (идентично aura-webapp)
|
||||||
if ("enabled" in impressionEvent && impressionEvent.enabled) {
|
if ("enabled" in impressionEvent && impressionEvent.enabled) {
|
||||||
const isDevelopEnvironment = typeof window !== "undefined" &&
|
const isDevelopEnvironment = typeof window !== "undefined" &&
|
||||||
window.location.hostname.includes('develop.funnel.witlab.us') ||
|
(window.location.hostname.includes('develop.funnel.witlab.us') ||
|
||||||
window.location.hostname.includes('localhost');
|
window.location.hostname.includes('localhost'));
|
||||||
|
|
||||||
// ✅ 1. Отправляем в Google Analytics
|
// ✅ 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", {
|
window.gtag("event", "experiment_impression", {
|
||||||
app_name: impressionEvent.context.appName || "witlab-funnel",
|
app_name: impressionEvent.context.appName || "witlab-funnel",
|
||||||
feature: impressionEvent.featureName,
|
feature: impressionEvent.featureName,
|
||||||
treatment: impressionEvent.variant,
|
treatment: impressionEvent.variant,
|
||||||
debug_mode: isDevelopEnvironment,
|
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 (параметры визита)
|
// ✅ 2. Отправляем в Яндекс Метрику через params (параметры визита)
|
||||||
if (typeof window !== "undefined" && typeof window.ym === "function") {
|
const ymAvailable = typeof window !== "undefined" && typeof window.ym !== "undefined";
|
||||||
const counterId = window.__YM_COUNTER_ID__;
|
const counterId = typeof window !== "undefined" ? window.__YM_COUNTER_ID__ : undefined;
|
||||||
if (counterId) {
|
|
||||||
// Отправляем параметры визита для AB теста
|
if (ymAvailable && counterId) {
|
||||||
window.ym(counterId, 'params', {
|
// Отправляем параметры визита для AB теста
|
||||||
[`ab_test_${impressionEvent.featureName}`]: impressionEvent.variant,
|
// ym() накопит вызов в очереди если скрипт еще не загрузился
|
||||||
ab_test_app: impressionEvent.context.appName || "witlab-funnel",
|
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
|
// Summary log
|
||||||
if (process.env.NODE_ENV === "development") {
|
console.log("📈 [Unleash Analytics] AB Test Impression Summary:", {
|
||||||
console.log(`[Analytics] AB Test Impression:`, {
|
feature: impressionEvent.featureName,
|
||||||
feature: impressionEvent.featureName,
|
variant: impressionEvent.variant,
|
||||||
variant: impressionEvent.variant,
|
appName: impressionEvent.context.appName,
|
||||||
appName: impressionEvent.context.appName,
|
debugMode: isDevelopEnvironment,
|
||||||
debugMode: isDevelopEnvironment,
|
sentToGA: gaAvailable,
|
||||||
sentToGA: typeof window.gtag !== 'undefined',
|
sentToYM: ymAvailable && !!window.__YM_COUNTER_ID__,
|
||||||
sentToYM: typeof window.ym === 'function' && !!window.__YM_COUNTER_ID__,
|
});
|
||||||
});
|
} else {
|
||||||
}
|
console.log("⏭️ [Unleash Analytics] Impression event skipped - flag not enabled");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Отписываемся при unmount (идентично aura-webapp)
|
// Отписываемся при unmount (идентично aura-webapp)
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log("🔌 [Unleash Analytics] Impression listener removed");
|
||||||
unleashClient.off("impression");
|
unleashClient.off("impression");
|
||||||
};
|
};
|
||||||
}, [unleashClient]);
|
}, [unleashClient]);
|
||||||
|
|||||||
@ -8,7 +8,11 @@
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
// Yandex Metrika
|
// 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;
|
__YM_COUNTER_ID__?: number | string;
|
||||||
|
|
||||||
// Google Analytics (GA4)
|
// Google Analytics (GA4)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user