Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6ea5054c2 | ||
|
|
654e8899a8 | ||
|
|
331b9d8547 | ||
|
|
408429d2e9 | ||
|
|
a75ac7e8c8 |
2
.npmrc
Normal file
2
.npmrc
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@wit-lab-llc:registry=https://npm.pkg.github.com
|
||||||
|
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import { BAKED_FUNNELS } from "./src/lib/funnel/bakedFunnels";
|
import { BAKED_FUNNELS } from "./src/lib/funnel/bakedFunnels";
|
||||||
|
|
||||||
|
const repoRoot = new URL("../", import.meta.url).pathname.replace(/\/$/, "");
|
||||||
|
|
||||||
const buildVariant =
|
const buildVariant =
|
||||||
process.env.FUNNEL_BUILD_VARIANT ??
|
process.env.FUNNEL_BUILD_VARIANT ??
|
||||||
process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT ??
|
process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT ??
|
||||||
@ -49,6 +51,12 @@ const nextConfig: NextConfig = {
|
|||||||
NEXT_PUBLIC_AUTH_REDIRECT_URL: process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL,
|
NEXT_PUBLIC_AUTH_REDIRECT_URL: process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
transpilePackages: ["@wit-lab-llc/frontend-shared"],
|
||||||
|
|
||||||
|
turbopack: {
|
||||||
|
root: repoRoot,
|
||||||
|
},
|
||||||
|
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return generateFunnelRedirects();
|
return generateFunnelRedirects();
|
||||||
},
|
},
|
||||||
|
|||||||
41
package-lock.json
generated
41
package-lock.json
generated
@ -20,13 +20,14 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@unleash/proxy-client-react": "^5.0.1",
|
"@unleash/proxy-client-react": "^5.0.1",
|
||||||
|
"@wit-lab-llc/frontend-shared": "^1.0.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.2",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"mongoose": "^8.18.2",
|
"mongoose": "^8.18.2",
|
||||||
"next": "15.5.7",
|
"next": "^15.5.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-circular-progressbar": "^2.2.0",
|
"react-circular-progressbar": "^2.2.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
@ -1024,6 +1025,12 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.3",
|
"version": "1.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||||
@ -1734,9 +1741,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
|
||||||
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
"integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
@ -4971,6 +4978,18 @@
|
|||||||
"@xtuc/long": "4.2.2"
|
"@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": {
|
"node_modules/@xtuc/ieee754": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||||
@ -9046,12 +9065,12 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
|
||||||
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.7",
|
"@next/env": "15.5.9",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
@ -10605,9 +10624,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/storybook": {
|
"node_modules/storybook": {
|
||||||
"version": "9.1.13",
|
"version": "9.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.17.tgz",
|
||||||
"integrity": "sha512-G3KZ36EVzXyHds72B/qtWiJnhUpM0xOUeYlDcO9DSHL1bDTv15cW4+upBl+mcBZrDvU838cn7Bv4GpF+O5MCfw==",
|
"integrity": "sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -18,7 +18,9 @@
|
|||||||
"sync:funnels": "node scripts/sync-funnels-from-db.mjs",
|
"sync:funnels": "node scripts/sync-funnels-from-db.mjs",
|
||||||
"migrate:arrow-hint": "node scripts/migrate-trial-choice-arrow-hint.mjs",
|
"migrate:arrow-hint": "node scripts/migrate-trial-choice-arrow-hint.mjs",
|
||||||
"storybook": "storybook dev -p 6006 --ci",
|
"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": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
@ -33,13 +35,14 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@unleash/proxy-client-react": "^5.0.1",
|
"@unleash/proxy-client-react": "^5.0.1",
|
||||||
|
"@wit-lab-llc/frontend-shared": "^1.0.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.2",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"mongoose": "^8.18.2",
|
"mongoose": "^8.18.2",
|
||||||
"next": "15.5.7",
|
"next": "^15.5.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-circular-progressbar": "^2.2.0",
|
"react-circular-progressbar": "^2.2.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { notFound } from "next/navigation";
|
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 { UnleashSessionProvider } from "@/lib/funnel/unleash";
|
||||||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||||||
import { BAKED_FUNNELS } from "@/lib/funnel/bakedFunnels";
|
import { BAKED_FUNNELS } from "@/lib/funnel/bakedFunnels";
|
||||||
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||||
|
import { fetchPixelsOnServer } from "@/lib/analytics/fetchPixels";
|
||||||
import {
|
import {
|
||||||
PaymentPlacementProvider,
|
PaymentPlacementProvider,
|
||||||
TrialVariantSelectionProvider,
|
TrialVariantSelectionProvider,
|
||||||
@ -72,21 +74,24 @@ export default async function FunnelLayout({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefetch analytics pixels на сервере
|
||||||
|
// Используем funnelId как source для API
|
||||||
|
const analyticsData = await fetchPixelsOnServer({ source: funnelId });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<WitLibInitializer debug={process.env.NODE_ENV === 'development'}>
|
||||||
<UnleashSessionProvider
|
<UnleashSessionProvider
|
||||||
funnelId={funnelId}
|
funnelId={funnelId}
|
||||||
googleAnalyticsId={funnel.meta.googleAnalyticsId}
|
googleAnalyticsId={funnel.meta.googleAnalyticsId}
|
||||||
>
|
>
|
||||||
<PixelsProvider
|
<AnalyticsProvider prefetchedData={analyticsData} debug={process.env.NODE_ENV === 'development'}>
|
||||||
googleAnalyticsId={funnel.meta.googleAnalyticsId}
|
|
||||||
yandexMetrikaId={funnel.meta.yandexMetrikaId}
|
|
||||||
>
|
|
||||||
<PaymentPlacementProvider>
|
<PaymentPlacementProvider>
|
||||||
<TrialVariantSelectionProvider>
|
<TrialVariantSelectionProvider>
|
||||||
{children}
|
{children}
|
||||||
</TrialVariantSelectionProvider>
|
</TrialVariantSelectionProvider>
|
||||||
</PaymentPlacementProvider>
|
</PaymentPlacementProvider>
|
||||||
</PixelsProvider>
|
</AnalyticsProvider>
|
||||||
</UnleashSessionProvider>
|
</UnleashSessionProvider>
|
||||||
|
</WitLibInitializer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
166
src/components/analytics/AnalyticsScripts.tsx
Normal file
166
src/components/analytics/AnalyticsScripts.tsx
Normal 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(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,3 +2,4 @@ export { FacebookPixels } from "./FacebookPixels";
|
|||||||
export { GoogleAnalytics } from "./GoogleAnalytics";
|
export { GoogleAnalytics } from "./GoogleAnalytics";
|
||||||
export { YandexMetrika } from "./YandexMetrika";
|
export { YandexMetrika } from "./YandexMetrika";
|
||||||
export { PageViewTracker } from "./PageViewTracker";
|
export { PageViewTracker } from "./PageViewTracker";
|
||||||
|
export { AnalyticsScripts } from "./AnalyticsScripts";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useCallback } from "react";
|
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 { resolveNextScreenId, type UnleashChecker } from "@/lib/funnel/navigation";
|
||||||
import { resolveScreenVariant } from "@/lib/funnel/variants";
|
import { resolveScreenVariant } from "@/lib/funnel/variants";
|
||||||
@ -66,7 +66,9 @@ interface FunnelRuntimeProps {
|
|||||||
|
|
||||||
export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
||||||
const router = useRouter();
|
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,
|
funnelId: funnel.meta.id,
|
||||||
googleAnalyticsId: funnel.meta.googleAnalyticsId,
|
googleAnalyticsId: funnel.meta.googleAnalyticsId,
|
||||||
});
|
});
|
||||||
@ -107,10 +109,6 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
// Флаги Unleash теперь обрабатываются автоматически через useUnleashAnalytics
|
// Флаги Unleash теперь обрабатываются автоматически через useUnleashAnalytics
|
||||||
// Нет необходимости собирать их вручную для отправки impression событий
|
// Нет необходимости собирать их вручную для отправки impression событий
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
createSession();
|
|
||||||
}, [createSession]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerScreen(currentScreen.id);
|
registerScreen(currentScreen.id);
|
||||||
}, [currentScreen.id, registerScreen]);
|
}, [currentScreen.id, registerScreen]);
|
||||||
@ -148,11 +146,24 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
return { current, total };
|
return { current, total };
|
||||||
}, [historyWithCurrent.length, funnel, answers, unleashChecker]);
|
}, [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) => {
|
const goToScreen = (screenId: string | undefined) => {
|
||||||
if (!screenId) {
|
if (!screenId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/${funnel.meta.id}/${screenId}`);
|
router.push(buildScreenUrl(screenId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContinue = () => {
|
const handleContinue = () => {
|
||||||
@ -319,7 +330,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
|
|
||||||
if (backTarget) {
|
if (backTarget) {
|
||||||
// Переназначаем назад на конкретный экран без роста истории
|
// Переназначаем назад на конкретный экран без роста истории
|
||||||
router.replace(`/${funnel.meta.id}/${backTarget}`);
|
router.replace(buildScreenUrl(backTarget));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,7 +384,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
// Выполняем редирект на целевой экран
|
// Выполняем редирект на целевой экран
|
||||||
router.replace(`/${funnel.meta.id}/${currentBackTarget}`);
|
router.replace(buildScreenUrl(currentBackTarget));
|
||||||
|
|
||||||
// Сбрасываем флаг
|
// Сбрасываем флаг
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -382,7 +393,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fallback: если router.replace не сработал, используем нативную навигацию
|
// Fallback: если router.replace не сработал, используем нативную навигацию
|
||||||
console.error('Router replace failed, using fallback navigation:', error);
|
console.error('Router replace failed, using fallback navigation:', error);
|
||||||
window.location.href = `/${funnel.meta.id}/${currentBackTarget}`;
|
window.location.href = buildScreenUrl(currentBackTarget);
|
||||||
}
|
}
|
||||||
}, 10);
|
}, 10);
|
||||||
};
|
};
|
||||||
@ -391,7 +402,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("popstate", handlePopState);
|
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;
|
const canGoBack = historyWithCurrent.lastIndexOf(currentScreen.id) > 0;
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import { getFormattedPrice } from "@/shared/utils/price";
|
|||||||
import { formatPeriod, formatPeriodHyphen } from "@/shared/utils/period";
|
import { formatPeriod, formatPeriodHyphen } from "@/shared/utils/period";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { getTrackingCookiesForRedirect } from "@/shared/utils/cookies";
|
import { getTrackingCookiesForRedirect } from "@/shared/utils/cookies";
|
||||||
|
import { getClientSessionId } from "@/shared/session/sessionId";
|
||||||
|
import { getStateParamForRedirect } from "@/shared/utils/url";
|
||||||
|
|
||||||
interface SpecialOfferProps {
|
interface SpecialOfferProps {
|
||||||
funnel: FunnelDefinition;
|
funnel: FunnelDefinition;
|
||||||
@ -70,9 +72,25 @@ export function SpecialOfferTemplate({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsLoadingRedirect(true);
|
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
|
(trialPrice || 100) / 100
|
||||||
).toFixed(2)}¤cy=${currency}&${getTrackingCookiesForRedirect()}`;
|
).toFixed(2)}¤cy=${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);
|
return window.location.replace(redirectUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,8 @@ import { getFormattedPrice } from "@/shared/utils/price";
|
|||||||
import { useClientToken } from "@/hooks/auth/useClientToken";
|
import { useClientToken } from "@/hooks/auth/useClientToken";
|
||||||
import { formatPeriod, formatPeriodHyphen } from "@/shared/utils/period";
|
import { formatPeriod, formatPeriodHyphen } from "@/shared/utils/period";
|
||||||
import { getTrackingCookiesForRedirect } from "@/shared/utils/cookies";
|
import { getTrackingCookiesForRedirect } from "@/shared/utils/cookies";
|
||||||
|
import { getClientSessionId } from "@/shared/session/sessionId";
|
||||||
|
import { getStateParamForRedirect } from "@/shared/utils/url";
|
||||||
|
|
||||||
interface TrialPaymentTemplateProps {
|
interface TrialPaymentTemplateProps {
|
||||||
funnel: FunnelDefinition;
|
funnel: FunnelDefinition;
|
||||||
@ -94,9 +96,25 @@ export function TrialPaymentTemplate({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingButtonIndex(buttonIndex);
|
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
|
(trialPrice || 100) / 100
|
||||||
).toFixed(2)}¤cy=${currency}&${getTrackingCookiesForRedirect()}`;
|
).toFixed(2)}¤cy=${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);
|
return window.location.replace(redirectUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
195
src/components/providers/AnalyticsProvider.tsx
Normal file
195
src/components/providers/AnalyticsProvider.tsx
Normal 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();
|
||||||
|
}
|
||||||
@ -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}</>;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
31
src/components/providers/WitLibInitializer.tsx
Normal file
31
src/components/providers/WitLibInitializer.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -44,7 +44,9 @@ export const GetPixelsRequestSchema = z.object({
|
|||||||
export const GetPixelsResponseSchema = z.object({
|
export const GetPixelsResponseSchema = z.object({
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
data: z.object({
|
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(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,140 +1,269 @@
|
|||||||
import {
|
import { IUpdateSessionRequest } from "@/entities/session/types";
|
||||||
ICreateSessionResponse,
|
import { useCallback, useMemo, useState, useEffect } from "react";
|
||||||
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 { setSessionIdToCookie } from "@/entities/session/serverActions";
|
import { setSessionIdToCookie } from "@/entities/session/serverActions";
|
||||||
import { metricService } from "@/services/analytics/metricService";
|
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";
|
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 {
|
interface IUseSessionProps {
|
||||||
funnelId: string;
|
funnelId: string;
|
||||||
googleAnalyticsId?: string;
|
googleAnalyticsId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSession = ({ funnelId, googleAnalyticsId }: IUseSessionProps) => {
|
export const useSession = ({ funnelId, googleAnalyticsId }: IUseSessionProps) => {
|
||||||
const localStorageKey = `${funnelId}_sessionId`;
|
const [collector] = useState(() => getSessionCollector());
|
||||||
const sessionId =
|
const [fingerprintCollector] = useState(() => getFingerprintCollector());
|
||||||
typeof window === "undefined" ? "" : localStorage.getItem(localStorageKey);
|
const [facebookCollector] = useState(() => getFacebookCollector());
|
||||||
|
|
||||||
const timezone = getClientTimezone();
|
|
||||||
|
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
const setSessionId = useCallback(
|
// Get current session ID from collector
|
||||||
async (sessionId: string) => {
|
const sessionId = collector.getSessionId();
|
||||||
localStorage.setItem(localStorageKey, sessionId);
|
|
||||||
localStorage.setItem("activeSessionId", sessionId);
|
|
||||||
await setSessionIdToCookie("activeSessionId", sessionId);
|
|
||||||
},
|
|
||||||
[localStorageKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
const createSession =
|
// Collect fingerprint and facebook data on mount (only once per app lifecycle)
|
||||||
useCallback(async (): Promise<ICreateSessionResponse> => {
|
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") {
|
if (typeof window === "undefined") {
|
||||||
return {
|
return { sessionId: "", status: "error" };
|
||||||
sessionId: "",
|
|
||||||
status: "error",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (sessionId?.length) {
|
|
||||||
setSessionId(sessionId);
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
status: "old",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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 (параметры посетителя)
|
try {
|
||||||
metricService.userParams({
|
const query = parseQueryParams();
|
||||||
sessionId: sessionFromServer.sessionId,
|
const utm = parseUtmParams();
|
||||||
|
const anonymousId = getOrCreateAnonymousId();
|
||||||
|
const lastActivityAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Get fingerprint data for session
|
||||||
|
let fingerprint: SessionFingerprintData | undefined;
|
||||||
|
try {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ✅ Отправляем контекст визита в params (параметры визита)
|
|
||||||
metricService.sendVisitContext({
|
metricService.sendVisitContext({
|
||||||
sessionId: sessionFromServer.sessionId,
|
sessionId: response.sessionId,
|
||||||
funnelId,
|
funnelId,
|
||||||
gaId: googleAnalyticsId,
|
gaId: googleAnalyticsId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return sessionFromServer;
|
return response;
|
||||||
}
|
}
|
||||||
console.error(
|
|
||||||
"Session creation failed - invalid response:",
|
|
||||||
sessionFromServer
|
|
||||||
);
|
|
||||||
setIsError(true);
|
setIsError(true);
|
||||||
return {
|
return { status: "error", sessionId: "" };
|
||||||
status: "error",
|
|
||||||
sessionId: "",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Session creation failed with error:", error);
|
debugLog("❌ Session creation failed:", error);
|
||||||
|
console.error("Session creation failed:", error);
|
||||||
setIsError(true);
|
setIsError(true);
|
||||||
return {
|
return { status: "error", sessionId: "" };
|
||||||
status: "error",
|
|
||||||
sessionId: "",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}, [sessionId, timezone, setSessionId, funnelId, googleAnalyticsId]);
|
}, [collector, funnelId, googleAnalyticsId, fingerprintCollector, facebookCollector]);
|
||||||
|
|
||||||
const updateSession = useCallback(
|
const updateSession = useCallback(
|
||||||
async (data: IUpdateSessionRequest["data"]) => {
|
async (data: IUpdateSessionRequest["data"]) => {
|
||||||
try {
|
try {
|
||||||
let _sessionId = sessionId;
|
const query = parseQueryParams();
|
||||||
if (!_sessionId) {
|
const anonymousId = getOrCreateAnonymousId();
|
||||||
const session = await createSession();
|
|
||||||
_sessionId = session.sessionId;
|
// Convert to library's UpdateSessionData format
|
||||||
}
|
const updateData: UpdateSessionData = {
|
||||||
const result = await updateSessionApi({
|
|
||||||
sessionId: _sessionId,
|
|
||||||
data: {
|
|
||||||
feature: "stripe",
|
feature: "stripe",
|
||||||
|
anonymousId,
|
||||||
|
query,
|
||||||
|
lastActivityAt: new Date().toISOString(),
|
||||||
...data,
|
...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;
|
return result;
|
||||||
} catch (error) {
|
} 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 () => {
|
const deleteSession = useCallback(() => {
|
||||||
if (typeof window === "undefined") {
|
collector.delete(funnelId);
|
||||||
return;
|
}, [collector, funnelId]);
|
||||||
}
|
|
||||||
localStorage.removeItem(localStorageKey);
|
|
||||||
}, [localStorageKey]);
|
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
74
src/lib/analytics/fetchPixels.ts
Normal file
74
src/lib/analytics/fetchPixels.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type ReactNode, useEffect, useState } from "react";
|
import { type ReactNode, useEffect, useState, useRef } from "react";
|
||||||
import { UnleashProvider } from "./UnleashProvider";
|
import { UnleashProvider } from "./UnleashProvider";
|
||||||
import { useSession } from "@/hooks/session/useSession";
|
import { useSession } from "@/hooks/session/useSession";
|
||||||
|
|
||||||
@ -21,28 +21,26 @@ export function UnleashSessionProvider({
|
|||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const { createSession } = useSession({ funnelId, googleAnalyticsId });
|
const { createSession } = useSession({ funnelId, googleAnalyticsId });
|
||||||
|
const initCalledRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Prevent multiple initializations
|
||||||
|
if (initCalledRef.current) return;
|
||||||
|
initCalledRef.current = true;
|
||||||
|
|
||||||
const initSession = async () => {
|
const initSession = async () => {
|
||||||
const localStorageKey = `${funnelId}_sessionId`;
|
console.log(`[UnleashSessionProvider] Initializing session...`);
|
||||||
let sid = localStorage.getItem(localStorageKey);
|
|
||||||
|
|
||||||
if (!sid) {
|
|
||||||
console.log(`[UnleashSessionProvider] Creating new session...`);
|
|
||||||
const result = await createSession();
|
const result = await createSession();
|
||||||
sid = result.sessionId;
|
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);
|
setIsReady(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
initSession();
|
initSession();
|
||||||
}, [funnelId, createSession]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [funnelId]);
|
||||||
|
|
||||||
// Показываем loading пока создается сессия
|
// Показываем loading пока создается сессия
|
||||||
if (!isReady || !sessionId) {
|
if (!isReady || !sessionId) {
|
||||||
|
|||||||
@ -84,7 +84,7 @@ export function useUnleashAnalytics() {
|
|||||||
queued: !!window.ym.a, // true если событие в очереди, false если отправлено
|
queued: !!window.ym.a, // true если событие в очереди, false если отправлено
|
||||||
});
|
});
|
||||||
} else if (!ymAvailable) {
|
} else if (!ymAvailable) {
|
||||||
console.warn("⚠️ [Yandex Metrika] Not initialized - check MetricsProvider");
|
console.warn("⚠️ [Yandex Metrika] Not initialized - check AnalyticsProvider");
|
||||||
} else if (!counterId) {
|
} else if (!counterId) {
|
||||||
console.warn("⚠️ [Yandex Metrika] Counter ID not set");
|
console.warn("⚠️ [Yandex Metrika] Counter ID not set");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -112,7 +112,7 @@ function trackFacebookPixelEvent(
|
|||||||
// Map EnteredEmail to Lead for Facebook
|
// Map EnteredEmail to Lead for Facebook
|
||||||
const fbEvent = event === AnalyticsEvent.ENTERED_EMAIL ? AnalyticsEvent.LEAD : event;
|
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);
|
console.log(`[FB] Event: ${fbEvent}`, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,8 +20,8 @@ declare global {
|
|||||||
dataLayer: any[];
|
dataLayer: any[];
|
||||||
|
|
||||||
// Facebook Pixel
|
// Facebook Pixel
|
||||||
fbq: (command: string, event: string, params?: Record<string, any>) => void;
|
fbq?: (command: string, event: string, params?: Record<string, any>) => void;
|
||||||
_fbq: any;
|
_fbq?: any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
|||||||
@ -17,3 +17,94 @@ export const parseQueryParams = () => {
|
|||||||
|
|
||||||
return result;
|
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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user