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 { 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
41
package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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 { YandexMetrika } from "./YandexMetrika";
|
||||
export { PageViewTracker } from "./PageViewTracker";
|
||||
export { AnalyticsScripts } from "./AnalyticsScripts";
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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)}¤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);
|
||||
};
|
||||
|
||||
|
||||
@ -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)}¤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);
|
||||
};
|
||||
|
||||
|
||||
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({
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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(
|
||||
() => ({
|
||||
|
||||
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";
|
||||
|
||||
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) {
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user