Compare commits

...

5 Commits

Author SHA1 Message Date
dev.daminik00
d6ea5054c2 fix utm unicode 2025-12-24 22:03:17 +03:00
dev.daminik00
654e8899a8 utm 2025-12-24 21:42:28 +03:00
dev.daminik00
331b9d8547 fix externalId 2025-12-24 19:43:35 +03:00
dev.daminik00
408429d2e9 fix externalId 2025-12-24 14:44:30 +03:00
dev.daminik00
a75ac7e8c8 session 2025-12-24 02:06:11 +03:00
23 changed files with 937 additions and 455 deletions

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
@wit-lab-llc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}

View File

@ -1,6 +1,8 @@
import type { NextConfig } from "next";
import { BAKED_FUNNELS } from "./src/lib/funnel/bakedFunnels";
const repoRoot = new URL("../", import.meta.url).pathname.replace(/\/$/, "");
const buildVariant =
process.env.FUNNEL_BUILD_VARIANT ??
process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT ??
@ -48,6 +50,12 @@ const nextConfig: NextConfig = {
DEV_LOGGER_SERVER_ENABLED: process.env.DEV_LOGGER_SERVER_ENABLED,
NEXT_PUBLIC_AUTH_REDIRECT_URL: process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL,
},
transpilePackages: ["@wit-lab-llc/frontend-shared"],
turbopack: {
root: repoRoot,
},
async redirects() {
return generateFunnelRedirects();

41
package-lock.json generated
View File

@ -20,13 +20,14 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@unleash/proxy-client-react": "^5.0.1",
"@wit-lab-llc/frontend-shared": "^1.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.2",
"idb": "^8.0.3",
"lucide-react": "^0.544.0",
"mongoose": "^8.18.2",
"next": "15.5.7",
"next": "^15.5.9",
"react": "19.1.0",
"react-circular-progressbar": "^2.2.0",
"react-dom": "19.1.0",
@ -1024,6 +1025,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fingerprintjs/fingerprintjs": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.0.1.tgz",
"integrity": "sha512-KbaeE/rk2WL8MfpRP6jTI4lSr42SJPjvkyrjP3QU6uUDkOMWWYC2Ts1sNSYcegHC8avzOoYTHBj+2fTqvZWQBA==",
"license": "MIT"
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
@ -1734,9 +1741,9 @@
"license": "MIT"
},
"node_modules/@next/env": {
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
"version": "15.5.9",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
"integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@ -4971,6 +4978,18 @@
"@xtuc/long": "4.2.2"
}
},
"node_modules/@wit-lab-llc/frontend-shared": {
"version": "1.0.4",
"resolved": "https://npm.pkg.github.com/download/@wit-lab-llc/frontend-shared/1.0.4/c342b071bc0716511d84bc3f3c9685aa7649ea5e",
"integrity": "sha512-9EZEpjWCdz+IP4RsgkVTPiUEKhm5LqnbgD/S/GJ07eG0Y10ulj3qXjDVXc7N9gURyZULshriDt350w6nhN7Z3w==",
"license": "UNLICENSED",
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1"
},
"peerDependencies": {
"react": ">=18.0.0"
}
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@ -9046,12 +9065,12 @@
"peer": true
},
"node_modules/next": {
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
"version": "15.5.9",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.7",
"@next/env": "15.5.9",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@ -10605,9 +10624,9 @@
}
},
"node_modules/storybook": {
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.13.tgz",
"integrity": "sha512-G3KZ36EVzXyHds72B/qtWiJnhUpM0xOUeYlDcO9DSHL1bDTv15cW4+upBl+mcBZrDvU838cn7Bv4GpF+O5MCfw==",
"version": "9.1.17",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.17.tgz",
"integrity": "sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -18,7 +18,9 @@
"sync:funnels": "node scripts/sync-funnels-from-db.mjs",
"migrate:arrow-hint": "node scripts/migrate-trial-choice-arrow-hint.mjs",
"storybook": "storybook dev -p 6006 --ci",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"link:shared": "npm link @wit-lab-llc/frontend-shared",
"unlink:shared": "npm unlink @wit-lab-llc/frontend-shared && npm install"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@ -33,13 +35,14 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@unleash/proxy-client-react": "^5.0.1",
"@wit-lab-llc/frontend-shared": "^1.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.2",
"idb": "^8.0.3",
"lucide-react": "^0.544.0",
"mongoose": "^8.18.2",
"next": "15.5.7",
"next": "^15.5.9",
"react": "19.1.0",
"react-circular-progressbar": "^2.2.0",
"react-dom": "19.1.0",

View File

@ -1,10 +1,12 @@
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { PixelsProvider } from "@/components/providers/PixelsProvider";
import { AnalyticsProvider } from "@/components/providers/AnalyticsProvider";
import { WitLibInitializer } from "@/components/providers/WitLibInitializer";
import { UnleashSessionProvider } from "@/lib/funnel/unleash";
import type { FunnelDefinition } from "@/lib/funnel/types";
import { BAKED_FUNNELS } from "@/lib/funnel/bakedFunnels";
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
import { fetchPixelsOnServer } from "@/lib/analytics/fetchPixels";
import {
PaymentPlacementProvider,
TrialVariantSelectionProvider,
@ -72,21 +74,24 @@ export default async function FunnelLayout({
notFound();
}
// Prefetch analytics pixels на сервере
// Используем funnelId как source для API
const analyticsData = await fetchPixelsOnServer({ source: funnelId });
return (
<UnleashSessionProvider
funnelId={funnelId}
googleAnalyticsId={funnel.meta.googleAnalyticsId}
>
<PixelsProvider
<WitLibInitializer debug={process.env.NODE_ENV === 'development'}>
<UnleashSessionProvider
funnelId={funnelId}
googleAnalyticsId={funnel.meta.googleAnalyticsId}
yandexMetrikaId={funnel.meta.yandexMetrikaId}
>
<PaymentPlacementProvider>
<TrialVariantSelectionProvider>
{children}
</TrialVariantSelectionProvider>
</PaymentPlacementProvider>
</PixelsProvider>
</UnleashSessionProvider>
<AnalyticsProvider prefetchedData={analyticsData} debug={process.env.NODE_ENV === 'development'}>
<PaymentPlacementProvider>
<TrialVariantSelectionProvider>
{children}
</TrialVariantSelectionProvider>
</PaymentPlacementProvider>
</AnalyticsProvider>
</UnleashSessionProvider>
</WitLibInitializer>
);
}

View File

@ -0,0 +1,166 @@
"use client";
import Script from "next/script";
import { useEffect, useMemo, useRef } from "react";
interface AnalyticsData {
facebook_pixel?: string[];
google_analytics?: string[];
yandex_metrica?: string[];
}
interface AnalyticsScriptsProps {
data: AnalyticsData | null;
externalId?: string; // visitorId from fingerprint - used for matching across Pixel and CAPI
}
/**
* Компонент для инъекции аналитических скриптов через next/script
*
* Использует стратегию afterInteractive (default) - скрипты загружаются
* после частичной hydration страницы, что оптимально для аналитик.
*
* Преимущества над document.createElement:
* - Интеграция с Next.js lifecycle
* - Автоматическая дедупликация скриптов
* - Лучшая производительность через оптимизации Next.js
*/
export function AnalyticsScripts({ data, externalId }: AnalyticsScriptsProps) {
const facebookPixels = useMemo(() => data?.facebook_pixel || [], [data?.facebook_pixel]);
const googleAnalyticsIds = data?.google_analytics || [];
const yandexMetrikaIds = data?.yandex_metrica || [];
// Track which pixels have been initialized (basic init)
const initializedPixelsRef = useRef<Set<string>>(new Set());
// Track which pixels have been enhanced with external_id
const enhancedPixelsRef = useRef<Set<string>>(new Set());
// Initialize FB Pixel immediately when SDK is ready
// external_id is OPTIONAL - we must not block tracking for users without fingerprint
useEffect(() => {
if (facebookPixels.length === 0) return;
const initPixels = () => {
if (!window.fbq) {
// SDK not loaded yet, retry in 100ms
setTimeout(initPixels, 100);
return;
}
facebookPixels.forEach((pixelId) => {
if (initializedPixelsRef.current.has(pixelId)) return;
// Initialize with external_id if available, otherwise without
// This ensures ALL users get tracked, not just those with fingerprint
if (externalId) {
window.fbq!("init", pixelId, { external_id: externalId });
enhancedPixelsRef.current.add(pixelId);
} else {
window.fbq!("init", pixelId);
}
window.fbq!("track", "PageView");
initializedPixelsRef.current.add(pixelId);
});
};
initPixels();
}, [facebookPixels, externalId]);
// Enhance already-initialized pixels with external_id when it becomes available later
// This improves matching for Conversions API deduplication
useEffect(() => {
if (!externalId || facebookPixels.length === 0) return;
if (!window.fbq) return;
facebookPixels.forEach((pixelId) => {
// Only enhance if pixel was initialized but not yet enhanced with external_id
if (initializedPixelsRef.current.has(pixelId) && !enhancedPixelsRef.current.has(pixelId)) {
// Re-init with external_id for better matching (doesn't fire PageView again)
window.fbq!("init", pixelId, { external_id: externalId });
enhancedPixelsRef.current.add(pixelId);
}
});
}, [externalId, facebookPixels]);
if (!data) return null;
return (
<>
{/* Facebook Pixel SDK - loads immediately, init happens in useEffect (with or without externalId) */}
{facebookPixels.length > 0 && (
<Script
id="fb-pixel-sdk"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
`.trim(),
}}
/>
)}
{/* Google Analytics Scripts */}
{googleAnalyticsIds.map((measurementId) => (
<Script
key={`ga-${measurementId}`}
id={`ga-${measurementId}`}
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
var script = document.createElement('script');
script.async = true;
script.src = 'https://www.googletagmanager.com/gtag/js?id=${measurementId}';
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', '${measurementId}', { send_page_view: false });
})();
`.trim(),
}}
/>
))}
{/* Yandex Metrika Scripts */}
{yandexMetrikaIds.map((counterId) => (
<Script
key={`ym-${counterId}`}
id={`ym-${counterId}`}
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");
window.__YM_COUNTER_ID__ = ${counterId};
ym(${counterId}, "init", {
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
webvisor: true
});
`.trim(),
}}
/>
))}
</>
);
}

View File

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

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { resolveNextScreenId, type UnleashChecker } from "@/lib/funnel/navigation";
import { resolveScreenVariant } from "@/lib/funnel/variants";
@ -66,7 +66,9 @@ interface FunnelRuntimeProps {
export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
const router = useRouter();
const { createSession, updateSession } = useSession({
const searchParams = useSearchParams();
// Session is already created by UnleashSessionProvider, we only need updateSession here
const { updateSession } = useSession({
funnelId: funnel.meta.id,
googleAnalyticsId: funnel.meta.googleAnalyticsId,
});
@ -107,10 +109,6 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
// Флаги Unleash теперь обрабатываются автоматически через useUnleashAnalytics
// Нет необходимости собирать их вручную для отправки impression событий
useEffect(() => {
createSession();
}, [createSession]);
useEffect(() => {
registerScreen(currentScreen.id);
}, [currentScreen.id, registerScreen]);
@ -148,11 +146,24 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
return { current, total };
}, [historyWithCurrent.length, funnel, answers, unleashChecker]);
const funnelQueryString = useMemo(() => {
return searchParams.toString();
}, [searchParams]);
const buildScreenUrl = useCallback(
(screenId: string) => {
return funnelQueryString
? `/${funnel.meta.id}/${screenId}?${funnelQueryString}`
: `/${funnel.meta.id}/${screenId}`;
},
[funnel.meta.id, funnelQueryString]
);
const goToScreen = (screenId: string | undefined) => {
if (!screenId) {
return;
}
router.push(`/${funnel.meta.id}/${screenId}`);
router.push(buildScreenUrl(screenId));
};
const handleContinue = () => {
@ -319,7 +330,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
if (backTarget) {
// Переназначаем назад на конкретный экран без роста истории
router.replace(`/${funnel.meta.id}/${backTarget}`);
router.replace(buildScreenUrl(backTarget));
return;
}
@ -373,7 +384,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
setTimeout(() => {
try {
// Выполняем редирект на целевой экран
router.replace(`/${funnel.meta.id}/${currentBackTarget}`);
router.replace(buildScreenUrl(currentBackTarget));
// Сбрасываем флаг
setTimeout(() => {
@ -382,7 +393,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
} catch (error) {
// Fallback: если router.replace не сработал, используем нативную навигацию
console.error('Router replace failed, using fallback navigation:', error);
window.location.href = `/${funnel.meta.id}/${currentBackTarget}`;
window.location.href = buildScreenUrl(currentBackTarget);
}
}, 10);
};
@ -391,7 +402,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, [currentScreen.id, currentScreen.navigation?.onBackScreenId, funnel.meta.id, router]);
}, [buildScreenUrl, currentScreen.id, currentScreen.navigation?.onBackScreenId, router]);
const canGoBack = historyWithCurrent.lastIndexOf(currentScreen.id) > 0;

View File

@ -18,6 +18,8 @@ import { getFormattedPrice } from "@/shared/utils/price";
import { formatPeriod, formatPeriodHyphen } from "@/shared/utils/period";
import { useState } from "react";
import { getTrackingCookiesForRedirect } from "@/shared/utils/cookies";
import { getClientSessionId } from "@/shared/session/sessionId";
import { getStateParamForRedirect } from "@/shared/utils/url";
interface SpecialOfferProps {
funnel: FunnelDefinition;
@ -70,9 +72,25 @@ export function SpecialOfferTemplate({
return;
}
setIsLoadingRedirect(true);
const redirectUrl = `${paymentUrl}?paywallId=${paywallId}&placementId=${placementId}&productId=${productId}&jwtToken=${token}&price=${(
// Build redirect URL with payment params
const baseParams = `paywallId=${paywallId}&placementId=${placementId}&productId=${productId}&jwtToken=${token}&price=${(
(trialPrice || 100) / 100
).toFixed(2)}&currency=${currency}&${getTrackingCookiesForRedirect()}`;
).toFixed(2)}&currency=${currency}`;
// Add sessionId
const sessionId = getClientSessionId();
const sessionParam = sessionId ? `&sessionId=${sessionId}` : "";
// Add state param with current UTM (base64 encoded JSON)
const stateParam = getStateParamForRedirect();
const stateStr = stateParam ? `&state=${stateParam}` : "";
// Add tracking cookies
const trackingCookies = getTrackingCookiesForRedirect();
const trackingStr = trackingCookies ? `&${trackingCookies}` : "";
const redirectUrl = `${paymentUrl}?${baseParams}${sessionParam}${stateStr}${trackingStr}`;
return window.location.replace(redirectUrl);
};

View File

@ -41,6 +41,8 @@ import { getFormattedPrice } from "@/shared/utils/price";
import { useClientToken } from "@/hooks/auth/useClientToken";
import { formatPeriod, formatPeriodHyphen } from "@/shared/utils/period";
import { getTrackingCookiesForRedirect } from "@/shared/utils/cookies";
import { getClientSessionId } from "@/shared/session/sessionId";
import { getStateParamForRedirect } from "@/shared/utils/url";
interface TrialPaymentTemplateProps {
funnel: FunnelDefinition;
@ -94,9 +96,25 @@ export function TrialPaymentTemplate({
return;
}
setLoadingButtonIndex(buttonIndex);
const redirectUrl = `${paymentUrl}?paywallId=${paywallId}&placementId=${placementId}&productId=${productId}&jwtToken=${token}&price=${(
// Build redirect URL with payment params
const baseParams = `paywallId=${paywallId}&placementId=${placementId}&productId=${productId}&jwtToken=${token}&price=${(
(trialPrice || 100) / 100
).toFixed(2)}&currency=${currency}&${getTrackingCookiesForRedirect()}`;
).toFixed(2)}&currency=${currency}`;
// Add sessionId
const sessionId = getClientSessionId();
const sessionParam = sessionId ? `&sessionId=${sessionId}` : "";
// Add state param with current UTM (base64 encoded JSON)
const stateParam = getStateParamForRedirect();
const stateStr = stateParam ? `&state=${stateParam}` : "";
// Add tracking cookies
const trackingCookies = getTrackingCookiesForRedirect();
const trackingStr = trackingCookies ? `&${trackingCookies}` : "";
const redirectUrl = `${paymentUrl}?${baseParams}${sessionParam}${stateStr}${trackingStr}`;
return window.location.replace(redirectUrl);
};

View File

@ -0,0 +1,195 @@
"use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import {
initWitLib,
isWitLibInitialized,
AnalyticsManager,
FingerprintCollector,
type AnalyticsState,
} from "@wit-lab-llc/frontend-shared";
import { PageViewTracker } from "@/components/analytics";
import { AnalyticsScripts } from "@/components/analytics/AnalyticsScripts";
import { getSourceByPathname } from "@/shared/utils/source";
import type { AnalyticsData } from "@/lib/analytics/fetchPixels";
interface AnalyticsContextValue {
manager: AnalyticsManager;
state: AnalyticsState;
isReady: boolean;
}
const AnalyticsContext = createContext<AnalyticsContextValue | null>(null);
// Singleton instances
let analyticsManagerInstance: AnalyticsManager | null = null;
let fingerprintCollectorInstance: FingerprintCollector | null = null;
function getAnalyticsManager(): AnalyticsManager {
if (!analyticsManagerInstance) {
analyticsManagerInstance = new AnalyticsManager();
}
return analyticsManagerInstance;
}
function getFingerprintCollector(): FingerprintCollector {
if (!fingerprintCollectorInstance) {
fingerprintCollectorInstance = new FingerprintCollector();
}
return fingerprintCollectorInstance;
}
interface AnalyticsProviderProps {
children: ReactNode;
/** Prefetched analytics data from server */
prefetchedData?: AnalyticsData | null;
apiBaseUrl?: string;
debug?: boolean;
}
/**
* Analytics Provider Component
*
* Инициализирует @wit-lab-llc/frontend-shared библиотеку и загружает все аналитики:
* - Facebook Pixel
* - Google Analytics
* - Yandex Metrika
*
* Flow:
* 1. Инициализирует библиотеку с baseUrl
* 2. Загружает данные аналитик с бэкенда через AnalyticsManager
* 3. Инъецирует скрипты в DOM
* 4. Предоставляет контекст для доступа к менеджеру
*/
export function AnalyticsProvider({
children,
prefetchedData,
apiBaseUrl,
debug = false,
}: AnalyticsProviderProps) {
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(prefetchedData || null);
const [state, setState] = useState<AnalyticsState>({
isLoaded: !!prefetchedData,
isLoading: false,
data: null,
error: null,
});
const [isReady, setIsReady] = useState(!!prefetchedData);
const [manager] = useState(() => getAnalyticsManager());
const [fingerprintCollector] = useState(() => getFingerprintCollector());
const [externalId, setExternalId] = useState<string | undefined>(undefined);
// Collect fingerprint for external_id
useEffect(() => {
fingerprintCollector.getOrCollect()
.then((result) => {
if (result?.visitorId) {
setExternalId(result.visitorId);
if (debug) {
console.log("✅ [AnalyticsProvider] Fingerprint collected, external_id:", result.visitorId);
}
}
})
.catch((error) => {
console.warn("[AnalyticsProvider] Failed to collect fingerprint:", error);
});
}, [fingerprintCollector, debug]);
useEffect(() => {
// Если есть prefetched данные - используем их, скрипты уже инъецируются через AnalyticsScripts
if (prefetchedData) {
if (debug) {
console.log("✅ [AnalyticsProvider] Using prefetched analytics data:", prefetchedData);
}
return;
}
// Fallback: загружаем на клиенте если нет prefetched данных
const initAndLoad = async () => {
try {
// Initialize library if not already initialized
if (!isWitLibInitialized()) {
const baseUrl = apiBaseUrl || process.env.NEXT_PUBLIC_API_URL || "";
if (!baseUrl) {
console.warn("[AnalyticsProvider] No API base URL configured");
setIsReady(true);
return;
}
initWitLib({
baseUrl,
debug,
});
}
// Load and inject analytics
const source = getSourceByPathname();
await manager.loadAndInject({ source });
setState(manager.getState());
setAnalyticsData(manager.getData() as AnalyticsData | null);
setIsReady(true);
if (debug) {
console.log("✅ [AnalyticsProvider] Analytics loaded via client:", {
facebook: manager.getFacebookPixels(),
google: manager.getGoogleAnalyticsIds(),
yandex: manager.getYandexMetrikaIds(),
});
}
} catch (error) {
console.warn("[AnalyticsProvider] Failed to load analytics:", error);
setIsReady(true);
}
};
initAndLoad();
}, [manager, apiBaseUrl, debug, prefetchedData]);
return (
<AnalyticsContext.Provider value={{ manager, state, isReady }}>
{/* next/script для инъекции скриптов с оптимизациями Next.js */}
<AnalyticsScripts data={analyticsData} externalId={externalId} />
<PageViewTracker />
{children}
</AnalyticsContext.Provider>
);
}
/**
* Hook to access AnalyticsManager
*/
export function useAnalytics(): AnalyticsContextValue {
const context = useContext(AnalyticsContext);
if (!context) {
throw new Error("useAnalytics must be used within AnalyticsProvider");
}
return context;
}
/**
* Hook to get Facebook Pixel IDs
*/
export function useFacebookPixels(): string[] {
const { manager, isReady } = useAnalytics();
if (!isReady) return [];
return manager.getFacebookPixels();
}
/**
* Hook to get Google Analytics IDs
*/
export function useGoogleAnalyticsIds(): string[] {
const { manager, isReady } = useAnalytics();
if (!isReady) return [];
return manager.getGoogleAnalyticsIds();
}
/**
* Hook to get Yandex Metrika IDs
*/
export function useYandexMetrikaIds(): string[] {
const { manager, isReady } = useAnalytics();
if (!isReady) return [];
return manager.getYandexMetrikaIds();
}

View File

@ -1,154 +0,0 @@
"use client";
import { useEffect, type ReactNode } from "react";
import ReactGA from "react-ga4";
interface MetricsProviderProps {
children: ReactNode;
googleAnalyticsId?: string;
yandexMetrikaId?: string;
facebookPixels?: string[];
}
/**
* Metrics Provider Component
*
* Инициализирует все метрики СИНХРОННО при загрузке приложения
* по аналогии с aura-webapp для гарантии создания куки ДО авторизации.
*
* Метрики:
* - Google Analytics (через react-ga4) - синхронная инициализация
* - Yandex Metrika - синхронная загрузка скрипта
* - Facebook Pixel - синхронная загрузка скрипта
*
* Это гарантирует что куки (_ga, _gid, _fbp, _fbc, _ym_uid) создаются
* ДО того как пользователь достигнет экрана авторизации.
*/
export function MetricsProvider({
children,
googleAnalyticsId,
yandexMetrikaId,
facebookPixels = []
}: MetricsProviderProps) {
// Инициализация Google Analytics (синхронно через react-ga4)
useEffect(() => {
if (!googleAnalyticsId) return;
try {
// Включаем debug mode для develop окружения
const isDevelopEnvironment = typeof window !== 'undefined' &&
window.location.hostname.includes('develop.funnel.witlab.us') ||
window.location.hostname.includes('localhost');
ReactGA.initialize(googleAnalyticsId, {
gaOptions: {
debug_mode: isDevelopEnvironment,
},
});
console.log('✅ [Metrics] Google Analytics initialized:', {
measurementId: googleAnalyticsId,
debugMode: isDevelopEnvironment,
ready: true,
});
} catch (error) {
console.error('[Metrics] Failed to initialize Google Analytics:', error);
}
}, [googleAnalyticsId]);
// Инициализация Yandex Metrika (официальный способ)
useEffect(() => {
if (!yandexMetrikaId) return;
try {
// Проверяем что скрипт еще не загружен
if (typeof window.ym === 'function' && window.__YM_COUNTER_ID__) {
console.log('[Metrics] Yandex Metrika already initialized');
return;
}
// ✅ Официальный код инициализации Яндекс Метрики
// Создает функцию-заглушку ym() для накопления вызовов до загрузки скрипта
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(function(m: any, e: Document, t: string, r: string, i: string, k: HTMLScriptElement, a: HTMLScriptElement | null) {
// eslint-disable-next-line prefer-rest-params
m[i] = m[i] || function() { (m[i].a = m[i].a || []).push(arguments); };
m[i].l = 1 * new Date().getTime();
k = e.createElement(t) as HTMLScriptElement;
a = e.getElementsByTagName(t)[0] as HTMLScriptElement;
k.async = true;
k.src = r;
if (a && a.parentNode) {
a.parentNode.insertBefore(k, a);
}
})(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym", {} as HTMLScriptElement, null);
// ✅ Вызываем 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) {
console.error('[Metrics] Failed to initialize Yandex Metrika:', error);
}
}, [yandexMetrikaId]);
// Инициализация Facebook Pixel (синхронно через скрипт)
useEffect(() => {
if (!facebookPixels || facebookPixels.length === 0) return;
try {
// Проверяем что fbq еще не загружен
if (typeof window.fbq === 'function') {
console.log('[Metrics] Facebook Pixel already loaded');
return;
}
// Базовый код Facebook Pixel
const fbqScript = `
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
`;
// Выполняем базовый код
const script = document.createElement('script');
script.type = 'text/javascript';
script.innerHTML = fbqScript;
document.head.appendChild(script);
// Инициализируем каждый пиксель
if (typeof window.fbq === 'function') {
const fbq = window.fbq as ((...args: unknown[]) => void);
facebookPixels.forEach((pixelId) => {
fbq('init', pixelId);
console.log('[Metrics] Facebook Pixel initialized:', pixelId);
});
// Отправляем PageView для всех пикселей
fbq('track', 'PageView');
}
} catch (error) {
console.error('[Metrics] Failed to initialize Facebook Pixel:', error);
}
}, [facebookPixels]);
return <>{children}</>;
}

View File

@ -1,86 +0,0 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { PageViewTracker } from "@/components/analytics";
import { getPixels } from "@/entities/session/actions";
import { getSourceByPathname } from "@/shared/utils/source";
import { MetricsProvider } from "./MetricsProvider";
interface PixelsProviderProps {
children: ReactNode;
googleAnalyticsId?: string;
yandexMetrikaId?: string;
}
/**
* Pixels Provider Component
*
* Инициализирует все метрики синхронно через MetricsProvider
* по аналогии с aura-webapp для гарантии создания куки ДО авторизации.
*
* 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 FB pixels
* 2. If not cached, request from backend (errors are handled gracefully)
* 3. Save to localStorage if pixels received
* 4. Pass all IDs to MetricsProvider for synchronous initialization
*/
export function PixelsProvider({ children, googleAnalyticsId, yandexMetrikaId }: PixelsProviderProps) {
const [pixels, setPixels] = useState<string[]>([]);
useEffect(() => {
const loadPixels = async () => {
try {
// Check localStorage first
const cachedPixels = localStorage.getItem("fb_pixels");
if (cachedPixels) {
const parsed = JSON.parse(cachedPixels);
setPixels(parsed);
return;
}
// Load from backend
const locale = "en"; // TODO: Get from context or config
const source = getSourceByPathname();
const domain = window.location.hostname;
const response = await getPixels({
domain,
source,
locale,
});
const pixelIds = response?.data?.fb || [];
// Save to localStorage only if we got pixels
if (pixelIds.length > 0) {
localStorage.setItem("fb_pixels", JSON.stringify(pixelIds));
}
setPixels(pixelIds);
} catch (error) {
// Silently handle errors - pixels are optional
console.warn("Facebook pixels not available:", error instanceof Error ? error.message : error);
setPixels([]);
}
};
loadPixels();
}, []);
return (
<MetricsProvider
googleAnalyticsId={googleAnalyticsId}
yandexMetrikaId={yandexMetrikaId}
facebookPixels={pixels}
>
<PageViewTracker />
{children}
</MetricsProvider>
);
}

View File

@ -0,0 +1,31 @@
"use client";
import { initWitLib, isWitLibInitialized } from "@wit-lab-llc/frontend-shared";
interface WitLibInitializerProps {
children: React.ReactNode;
apiBaseUrl?: string;
debug?: boolean;
}
export function WitLibInitializer({
children,
apiBaseUrl,
debug = false,
}: WitLibInitializerProps) {
// Initialize on first render (before useEffect)
if (typeof window !== "undefined" && !isWitLibInitialized()) {
const baseUrl = apiBaseUrl || process.env.NEXT_PUBLIC_API_URL || "";
if (baseUrl) {
initWitLib({
baseUrl,
debug,
});
if (debug) {
console.log("✅ [WitLibInitializer] Library initialized with baseUrl:", baseUrl);
}
}
}
return <>{children}</>;
}

View File

@ -1,49 +0,0 @@
import { http } from "@/shared/api/httpClient";
import {
CreateSessionResponseSchema,
ICreateSessionRequest,
ICreateSessionResponse,
IUpdateSessionRequest,
IUpdateSessionResponse,
UpdateSessionResponseSchema,
IGetPixelsRequest,
IGetPixelsResponse,
GetPixelsResponseSchema,
} from "./types";
import { API_ROUTES } from "@/shared/constants/api-routes";
export const createSession = async (
payload: ICreateSessionRequest
): Promise<ICreateSessionResponse> => {
return http.post<ICreateSessionResponse>(API_ROUTES.session(), payload, {
tags: ["session", "create"],
schema: CreateSessionResponseSchema,
revalidate: 0,
});
};
export const updateSession = async (
payload: IUpdateSessionRequest
): Promise<IUpdateSessionResponse> => {
// Отправляем только data без вложенности
return http.patch<IUpdateSessionResponse>(
API_ROUTES.session(payload.sessionId),
payload.data,
{
tags: ["session", "update"],
schema: UpdateSessionResponseSchema,
revalidate: 0,
}
);
};
export const getPixels = async (
payload: IGetPixelsRequest
): Promise<IGetPixelsResponse> => {
return http.get<IGetPixelsResponse>(API_ROUTES.sessionPixels(), {
tags: ["session", "pixels"],
schema: GetPixelsResponseSchema,
revalidate: 3600, // Cache for 1 hour
query: payload,
});
};

View File

@ -44,7 +44,9 @@ export const GetPixelsRequestSchema = z.object({
export const GetPixelsResponseSchema = z.object({
status: z.string(),
data: z.object({
fb: z.array(z.string()).optional(),
facebook_pixel: z.array(z.string()).optional(),
google_analytics: z.array(z.string()).optional(),
yandex_metrica: z.array(z.string()).optional(),
}),
});

View File

@ -1,140 +1,269 @@
import {
ICreateSessionResponse,
IUpdateSessionRequest,
} from "@/entities/session/types";
import {
createSession as createSessionApi,
updateSession as updateSessionApi,
} from "@/entities/session/actions";
import { getClientTimezone } from "@/shared/utils/locales";
import { parseQueryParams } from "@/shared/utils/url";
import { useCallback, useMemo, useState } from "react";
import { IUpdateSessionRequest } from "@/entities/session/types";
import { useCallback, useMemo, useState, useEffect } from "react";
import { setSessionIdToCookie } from "@/entities/session/serverActions";
import { metricService } from "@/services/analytics/metricService";
import {
SessionCollector,
FingerprintCollector,
FacebookCollector,
parseUtmParams,
getOrCreateAnonymousId,
type CreateSessionResponse,
type UpdateSessionData,
type SessionFingerprintData,
type SessionFacebookData,
} from "@wit-lab-llc/frontend-shared";
import { parseQueryParams } from "@/shared/utils/url";
// TODO
// TODO: Get locale from context or i18n
const locale = "en";
// Debug logging helper - only logs in development
const isDev = process.env.NODE_ENV === "development";
const debugLog = (message: string, ...args: unknown[]) => {
if (isDev) {
console.log(`🔍 [useSession] ${message}`, ...args);
}
};
// Singleton instances
let sessionCollectorInstance: SessionCollector | null = null;
let fingerprintCollectorInstance: FingerprintCollector | null = null;
let facebookCollectorInstance: FacebookCollector | null = null;
// Module-level flag to track if data collection has been done (persists across remounts)
let dataCollectionDone = false;
function getSessionCollector(): SessionCollector {
if (!sessionCollectorInstance) {
sessionCollectorInstance = new SessionCollector();
}
return sessionCollectorInstance;
}
function getFingerprintCollector(): FingerprintCollector {
if (!fingerprintCollectorInstance) {
fingerprintCollectorInstance = new FingerprintCollector();
}
return fingerprintCollectorInstance;
}
function getFacebookCollector(): FacebookCollector {
if (!facebookCollectorInstance) {
facebookCollectorInstance = new FacebookCollector();
}
return facebookCollectorInstance;
}
interface IUseSessionProps {
funnelId: string;
googleAnalyticsId?: string;
}
export const useSession = ({ funnelId, googleAnalyticsId }: IUseSessionProps) => {
const localStorageKey = `${funnelId}_sessionId`;
const sessionId =
typeof window === "undefined" ? "" : localStorage.getItem(localStorageKey);
const timezone = getClientTimezone();
const [collector] = useState(() => getSessionCollector());
const [fingerprintCollector] = useState(() => getFingerprintCollector());
const [facebookCollector] = useState(() => getFacebookCollector());
const [isError, setIsError] = useState(false);
const setSessionId = useCallback(
async (sessionId: string) => {
localStorage.setItem(localStorageKey, sessionId);
localStorage.setItem("activeSessionId", sessionId);
await setSessionIdToCookie("activeSessionId", sessionId);
},
[localStorageKey]
);
// Get current session ID from collector
const sessionId = collector.getSessionId();
const createSession =
useCallback(async (): Promise<ICreateSessionResponse> => {
if (typeof window === "undefined") {
return {
sessionId: "",
status: "error",
};
}
if (sessionId?.length) {
setSessionId(sessionId);
return {
sessionId,
status: "old",
};
}
// Collect fingerprint and facebook data on mount (only once per app lifecycle)
useEffect(() => {
if (typeof window === "undefined" || dataCollectionDone) return;
dataCollectionDone = true;
// Collect data in background
debugLog("Starting fingerprint and Facebook data collection...");
Promise.all([
fingerprintCollector.collect().catch((e) => {
debugLog("Fingerprint collection failed:", e);
return null;
}),
Promise.resolve(facebookCollector.collect()),
]).then(([fpResult, fbResult]) => {
debugLog("✅ Data collection completed", {
fingerprint: fpResult ? { visitorId: fpResult.visitorId, confidence: fpResult.confidence } : null,
facebook: fbResult ? { fbp: fbResult.fbp, fbc: fbResult.fbc, fbclid: fbResult.fbclid } : null,
});
});
}, [fingerprintCollector, facebookCollector]);
const createSession = useCallback(async (): Promise<CreateSessionResponse> => {
if (typeof window === "undefined") {
return { sessionId: "", status: "error" };
}
try {
const query = parseQueryParams();
const utm = parseUtmParams();
const anonymousId = getOrCreateAnonymousId();
const lastActivityAt = new Date().toISOString();
// Get fingerprint data for session
let fingerprint: SessionFingerprintData | undefined;
try {
const utm = parseQueryParams();
const sessionParams = {
feature: "stripe",
locale,
timezone,
source: funnelId,
sign: false,
utm,
domain: window.location.hostname,
};
console.log("Creating session with parameters:", sessionParams);
const sessionFromServer = await createSessionApi(sessionParams);
console.log("Session creation response:", sessionFromServer);
if (
sessionFromServer?.sessionId?.length &&
sessionFromServer?.status === "success"
) {
await setSessionId(sessionFromServer.sessionId);
// ✅ Отправляем sessionId в userParams (параметры посетителя)
metricService.userParams({
sessionId: sessionFromServer.sessionId,
debugLog("Getting fingerprint data for session...");
const fpData = await fingerprintCollector.getOrCollect();
if (fpData) {
const payload = fingerprintCollector.toServerPayload();
fingerprint = {
visitorId: fpData.visitorId,
confidence: fpData.confidence,
collectedAt: fpData.collectedAt,
...payload,
} as SessionFingerprintData;
debugLog("✅ Fingerprint data ready", {
visitorId: fingerprint.visitorId,
confidence: fingerprint.confidence,
});
// ✅ Отправляем контекст визита в params (параметры визита)
}
} catch (e) {
console.warn("[useSession] Failed to collect fingerprint:", e);
}
// Get facebook data for session
let facebookData: SessionFacebookData | undefined;
try {
debugLog("Getting Facebook data for session...");
const fbData = facebookCollector.getData() || facebookCollector.collect();
if (fbData) {
facebookData = {
fbp: fbData.fbp ?? undefined,
fbc: fbData.fbc ?? undefined,
fbclid: fbData.fbclid ?? undefined,
externalId: fingerprint?.visitorId, // Use visitorId as external_id
landingPage: fbData.landingPage ?? undefined,
referrer: fbData.referrer,
clientUserAgent: fbData.userAgent,
eventSourceUrl: fbData.currentUrl,
browserLanguage: fbData.browserLanguage,
screenResolution: fbData.screenResolution,
viewportSize: fbData.viewportSize,
colorDepth: fbData.colorDepth,
devicePixelRatio: fbData.devicePixelRatio,
touchSupport: fbData.touchSupport,
cookiesEnabled: fbData.cookiesEnabled,
doNotTrack: fbData.doNotTrack,
collectedAt: fbData.collectedAt,
};
debugLog("✅ Facebook data ready", {
fbp: facebookData.fbp,
fbc: facebookData.fbc,
fbclid: facebookData.fbclid,
externalId: facebookData.externalId,
landingPage: facebookData.landingPage,
});
}
} catch (e) {
console.warn("[useSession] Failed to collect Facebook data:", e);
}
debugLog("Creating session with data:", {
source: funnelId,
hasFingerprint: !!fingerprint,
hasFacebookData: !!facebookData,
utm,
});
const response = await collector.create({
source: funnelId,
feature: "stripe",
locale,
sign: false,
utm,
anonymousId,
query,
landingQuery: query,
lastActivityAt,
fingerprint,
facebookData,
});
debugLog("Session creation response:", response);
if (response.sessionId && response.status !== "error") {
// Set cookie for server-side access
await setSessionIdToCookie("activeSessionId", response.sessionId);
// For old sessions, update with current feature and data
if (response.status === "old") {
debugLog("Updating old session with current feature and data...");
await collector.update({
feature: "stripe",
anonymousId,
query,
lastActivityAt: new Date().toISOString(),
});
}
// Send analytics only for new sessions
if (response.status === "success") {
metricService.userParams({
sessionId: response.sessionId,
});
metricService.sendVisitContext({
sessionId: sessionFromServer.sessionId,
sessionId: response.sessionId,
funnelId,
gaId: googleAnalyticsId,
});
return sessionFromServer;
}
console.error(
"Session creation failed - invalid response:",
sessionFromServer
);
setIsError(true);
return {
status: "error",
sessionId: "",
};
} catch (error) {
console.error("Session creation failed with error:", error);
setIsError(true);
return {
status: "error",
sessionId: "",
};
return response;
}
}, [sessionId, timezone, setSessionId, funnelId, googleAnalyticsId]);
setIsError(true);
return { status: "error", sessionId: "" };
} catch (error) {
debugLog("❌ Session creation failed:", error);
console.error("Session creation failed:", error);
setIsError(true);
return { status: "error", sessionId: "" };
}
}, [collector, funnelId, googleAnalyticsId, fingerprintCollector, facebookCollector]);
const updateSession = useCallback(
async (data: IUpdateSessionRequest["data"]) => {
try {
let _sessionId = sessionId;
if (!_sessionId) {
const session = await createSession();
_sessionId = session.sessionId;
}
const result = await updateSessionApi({
sessionId: _sessionId,
data: {
feature: "stripe",
...data,
},
const query = parseQueryParams();
const anonymousId = getOrCreateAnonymousId();
// Convert to library's UpdateSessionData format
const updateData: UpdateSessionData = {
feature: "stripe",
anonymousId,
query,
lastActivityAt: new Date().toISOString(),
...data,
};
const result = await collector.update(updateData, {
source: funnelId,
feature: "stripe",
locale,
sign: false,
utm: parseUtmParams(),
anonymousId,
query,
landingQuery: query,
lastActivityAt: new Date().toISOString(),
});
return result;
} catch (error) {
console.log(error);
debugLog("❌ Session update failed:", error);
console.error("Session update failed:", error);
return null;
}
},
[sessionId, createSession]
[collector, funnelId]
);
const deleteSession = useCallback(async () => {
if (typeof window === "undefined") {
return;
}
localStorage.removeItem(localStorageKey);
}, [localStorageKey]);
const deleteSession = useCallback(() => {
collector.delete(funnelId);
}, [collector, funnelId]);
return useMemo(
() => ({

View File

@ -0,0 +1,74 @@
import { headers } from "next/headers";
export interface AnalyticsData {
facebook_pixel?: string[];
google_analytics?: string[];
yandex_metrica?: string[];
}
interface FetchPixelsParams {
source: string;
locale?: string;
}
/**
* Server-side функция для получения аналитических пикселей
*
* Вызывается в Server Component (layout.tsx) для prefetch данных
* до отправки HTML клиенту.
*
* @param params.source - ID воронки (funnelId)
* @param params.locale - Локаль (опционально, определяется из headers)
*/
export async function fetchPixelsOnServer(
params: FetchPixelsParams
): Promise<AnalyticsData | null> {
try {
const headersList = await headers();
// Получаем domain из headers
const host = headersList.get("host") || headersList.get("x-forwarded-host");
const domain = host?.split(":")[0] || "localhost";
// Получаем locale из headers или используем default
const acceptLanguage = headersList.get("accept-language");
const locale = params.locale || acceptLanguage?.split(",")[0]?.split("-")[0] || "en";
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
if (!apiUrl) {
console.warn("[fetchPixelsOnServer] NEXT_PUBLIC_API_URL not configured");
return null;
}
const queryParams = new URLSearchParams({
domain,
source: params.source,
locale,
});
const response = await fetch(`${apiUrl}/v2/session/pixels?${queryParams}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
// Кэшируем на 5 минут для одинаковых параметров
next: { revalidate: 300 },
});
if (!response.ok) {
console.warn(`[fetchPixelsOnServer] API returned ${response.status}`);
return null;
}
const json = await response.json();
if (json.status === "success" && json.data) {
return json.data as AnalyticsData;
}
return null;
} catch (error) {
console.warn("[fetchPixelsOnServer] Failed to fetch pixels:", error);
return null;
}
}

View File

@ -1,6 +1,6 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, useEffect, useState, useRef } from "react";
import { UnleashProvider } from "./UnleashProvider";
import { useSession } from "@/hooks/session/useSession";
@ -21,28 +21,26 @@ export function UnleashSessionProvider({
const [sessionId, setSessionId] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false);
const { createSession } = useSession({ funnelId, googleAnalyticsId });
const initCalledRef = useRef(false);
useEffect(() => {
// Prevent multiple initializations
if (initCalledRef.current) return;
initCalledRef.current = true;
const initSession = async () => {
const localStorageKey = `${funnelId}_sessionId`;
let sid = localStorage.getItem(localStorageKey);
if (!sid) {
console.log(`[UnleashSessionProvider] Creating new session...`);
const result = await createSession();
sid = result.sessionId;
console.log(`[UnleashSessionProvider] Initializing session...`);
const result = await createSession();
if (result.sessionId) {
console.log(`[UnleashSessionProvider] Using sessionId: ${result.sessionId}`);
setSessionId(result.sessionId);
}
if (sid) {
console.log(`[UnleashSessionProvider] Using sessionId: ${sid}`);
setSessionId(sid);
}
setIsReady(true);
};
initSession();
}, [funnelId, createSession]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [funnelId]);
// Показываем loading пока создается сессия
if (!isReady || !sessionId) {

View File

@ -84,7 +84,7 @@ export function useUnleashAnalytics() {
queued: !!window.ym.a, // true если событие в очереди, false если отправлено
});
} else if (!ymAvailable) {
console.warn("⚠️ [Yandex Metrika] Not initialized - check MetricsProvider");
console.warn("⚠️ [Yandex Metrika] Not initialized - check AnalyticsProvider");
} else if (!counterId) {
console.warn("⚠️ [Yandex Metrika] Counter ID not set");
}

View File

@ -112,7 +112,7 @@ function trackFacebookPixelEvent(
// Map EnteredEmail to Lead for Facebook
const fbEvent = event === AnalyticsEvent.ENTERED_EMAIL ? AnalyticsEvent.LEAD : event;
window.fbq("track", fbEvent, options);
window.fbq?.("track", fbEvent, options);
console.log(`[FB] Event: ${fbEvent}`, options);
}

View File

@ -20,8 +20,8 @@ declare global {
dataLayer: any[];
// Facebook Pixel
fbq: (command: string, event: string, params?: Record<string, any>) => void;
_fbq: any;
fbq?: (command: string, event: string, params?: Record<string, any>) => void;
_fbq?: any;
}
}
/* eslint-enable @typescript-eslint/no-explicit-any */

View File

@ -17,3 +17,94 @@ export const parseQueryParams = () => {
return result;
};
// Params that should NOT be included in state (they are passed separately)
const EXCLUDED_STATE_PARAMS = [
"paywallId",
"placementId",
"productId",
"jwtToken",
"price",
"currency",
"fb_pixels",
"sessionId",
"state",
// Tracking cookies (passed separately)
"_fbc",
"_fbp",
"_ym_uid",
"_ym_d",
"_ym_isad",
"_ym_visorc",
"yandexuid",
"ymex",
];
/**
* Get current query params that should be passed between screens and to payment
* Includes ALL params except internal ones (productId, placementId, etc.)
* Works with utm_*, fbclid, gclid, and any other marketing params
*/
export const getCurrentQueryParams = (): Record<string, string> => {
if (typeof window === "undefined") return {};
const params = parseQueryParams();
const utmParams: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
const isExcluded =
EXCLUDED_STATE_PARAMS.includes(key) ||
key.startsWith("_ga") ||
key.startsWith("_gid");
if (!isExcluded && value) {
utmParams[key] = value;
}
}
return utmParams;
};
/**
* Convert bytes to base64 string (handles UTF-8 properly)
* MDN recommended approach: https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa#unicode_strings
*/
const bytesToBase64 = (bytes: Uint8Array): string => {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte)
).join("");
return btoa(binString);
};
/**
* Encode params as base64 JSON for state parameter
* Uses URL-safe base64 encoding with UTF-8 support
* Handles Unicode characters (e.g., utm_campaign=)
*/
export const encodeStateParam = (params: Record<string, string>): string => {
if (Object.keys(params).length === 0) return "";
try {
const json = JSON.stringify(params);
// Encode string as UTF-8 bytes, then convert to base64
const bytes = new TextEncoder().encode(json);
const base64 = bytesToBase64(bytes)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return base64;
} catch {
return "";
}
};
/**
* Get base64-encoded state parameter with current query params
*/
export const getStateParamForRedirect = (): string => {
const params = getCurrentQueryParams();
return encodeStateParam(params);
};
// Backward compatibility alias
export const getCurrentUtmParams = getCurrentQueryParams;