lilith-platform.live/codebase/@features/user-data/frontend-client/src/analytics-context.tsx

117 lines
3.7 KiB
TypeScript

/** @jsxImportSource react */
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import type { FC, ReactNode } from 'react';
import { AnalyticsClient } from '@lilith/analytics-client';
import type { AnalyticsConfig, AnalyticsContext, ScrollDepth } from '@lilith/analytics-client';
const AnalyticsContextInstance = createContext<AnalyticsContext | null>(null);
interface AnalyticsProviderProps {
config: AnalyticsConfig;
children: ReactNode;
}
export const AnalyticsProvider: FC<AnalyticsProviderProps> = ({
config,
children,
}) => {
const client = useMemo(() => new AnalyticsClient(config), [config]);
useEffect(() => {
return () => {
client.destroy();
};
}, [client]);
const contextValue: AnalyticsContext = useMemo(
() => ({
trackView: client.trackView.bind(client),
trackEngagement: client.trackEngagement.bind(client),
trackInteraction: client.trackInteraction.bind(client),
flush: client.flush.bind(client),
}),
[client],
);
// Automatic scroll tracking - use lazy initializer to avoid impure function during render
const [scrollState] = useState<{
reached: Set<ScrollDepth>;
pageLoadTime: number;
}>(() => ({ reached: new Set(), pageLoadTime: Date.now() }));
const scrollStateRef = useRef(scrollState);
useEffect(() => {
if (!config.scrollTracking?.enabled || typeof window === 'undefined') {
return;
}
const thresholds = config.scrollTracking.thresholds ?? [25, 50, 75, 100];
const debounceMs = config.scrollTracking.debounceMs ?? 150;
// Reset state on mount (handles route changes)
scrollStateRef.current = { reached: new Set(), pageLoadTime: Date.now() };
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const calculateScrollDepth = (): number => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const viewportHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollableHeight = documentHeight - viewportHeight;
if (scrollableHeight <= 0) return 100;
return Math.min(100, Math.round((scrollTop / scrollableHeight) * 100));
};
const handleScroll = () => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
const currentDepth = calculateScrollDepth();
const foldPosition = window.innerHeight;
const isBeyondFold = window.scrollY > foldPosition;
const { reached, pageLoadTime } = scrollStateRef.current;
for (const threshold of thresholds) {
if (currentDepth >= threshold && !reached.has(threshold)) {
reached.add(threshold);
client.trackInteraction({
type: 'scroll',
data: {
pageUrl: window.location.href,
depth: threshold,
timeToReachMs: Date.now() - pageLoadTime,
isBeyondFold,
},
});
}
}
}, debounceMs);
};
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll(); // Check initial scroll position
return () => {
window.removeEventListener('scroll', handleScroll);
if (timeoutId) clearTimeout(timeoutId);
};
}, [client, config.scrollTracking]);
return (
<AnalyticsContextInstance.Provider value={contextValue}>
{children}
</AnalyticsContextInstance.Provider>
);
};
export const useAnalytics = (): AnalyticsContext => {
const context = useContext(AnalyticsContextInstance);
if (!context) {
throw new Error('useAnalytics must be used within an AnalyticsProvider');
}
return context;
};