import aa from 'search-insights'; const getOrCreateAlgoliaUserHash = (): string | null => { const STORAGE_KEY = 'algoliaUserHash'; try { const existingHash = localStorage.getItem(STORAGE_KEY); if (existingHash) { return existingHash; } const generateUniqueHash = (): string => { // Use crypto.randomUUID if available (modern browsers) if ( typeof crypto !== 'undefined' && typeof (crypto as any).randomUUID === 'function' ) { return (crypto as any).randomUUID(); } // Fallback: Generate a UUID v4-like string manually return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.floor(Math.random() * 16); const v = c === 'x' ? r : Math.floor(r % 4) + 8; return v.toString(16); }); }; const newHash = generateUniqueHash(); localStorage.setItem(STORAGE_KEY, newHash); return newHash; } catch (error) { return null; } }; export const initializeAlgoliaEvent = (appId: string, apiKey: string): void => { const userAlgoliaToken = getOrCreateAlgoliaUserHash(); const initConfig: { appId: string; apiKey: string; userToken?: string; } = { appId, apiKey }; // Only include userToken if we successfully generated/retrieved one if (userAlgoliaToken) { initConfig.userToken = userAlgoliaToken; } aa('init', initConfig); }; export const sanitizeEventName = (name: string): string => { const visibleAsciiOnly = name.replace(/[^\x20-\x7E]/g, ''); if (visibleAsciiOnly.length > 64) { return visibleAsciiOnly.substring(0, 60) + '...'; } return visibleAsciiOnly; }; interface AlgoliaConversionData { queryID: string; objectID: string; indexName: string; applicationId: string; apiKey: string; timestamp: number; eventName: string; articleUrl: string; } export const sendAlgoliaConversionEvent = ({ eventName, objectID, queryID, indexName }: { eventName: string; objectID: string; queryID: string; indexName: string; }): void => { const truncatedEventName = sanitizeEventName(eventName); if (!indexName || !queryID || !objectID) { return; } aa('convertedObjectIDsAfterSearch', { eventName: truncatedEventName, index: indexName, queryID, objectIDs: [objectID], // @ts-expect-error: analyticsTags is a custom property supported by Algolia Insights but not in the type definition analyticsTags: ['web'] }); }; /** * Retrieves and validates Algolia conversion data from localStorage * Supports multiple articles stored as an array * @returns AlgoliaConversionData if valid for current URL, null otherwise */ export const getValidConversionData = (): AlgoliaConversionData | null => { try { const currentUrl = window.location.pathname; const storedArrayData = localStorage.getItem('algoliaConversions'); if (storedArrayData) { const conversionsArray: AlgoliaConversionData[] = JSON.parse( storedArrayData ); if (Array.isArray(conversionsArray)) { const matchingConversion = conversionsArray.find( conversion => conversion.articleUrl === currentUrl ); if (matchingConversion && isValidConversionData(matchingConversion)) { return matchingConversion; } } } return null; } catch (error) { return null; } }; const isValidConversionData = ( conversionData: any ): conversionData is AlgoliaConversionData => { return ( conversionData && conversionData.eventName && conversionData.queryID && conversionData.objectID && conversionData.indexName && conversionData.applicationId && conversionData.apiKey && conversionData.articleUrl ); }; const cleanupConversionData = (articleUrl: string): void => { const storedArrayData = localStorage.getItem('algoliaConversions'); if (storedArrayData) { const conversionsArray: AlgoliaConversionData[] = JSON.parse( storedArrayData ); if (Array.isArray(conversionsArray)) { const filteredArray = conversionsArray.filter( conversion => conversion.articleUrl !== articleUrl ); if (filteredArray.length === 0) { localStorage.removeItem('algoliaConversions'); } else { localStorage.setItem( 'algoliaConversions', JSON.stringify(filteredArray) ); } } } }; /** * Tracks Algolia conversion after user spends 8 seconds on an article page * Assumes conversionData has already been validated by the caller */ export const trackAlgoliaConversionWithDwellTime = ( conversionData: AlgoliaConversionData ): void => { try { initializeAlgoliaEvent(conversionData.applicationId, conversionData.apiKey); } catch (error) { return; } let tracked = false; let dwellStartTime = Date.now(); let totalDwellTime = 0; let isPageVisible = !document.hidden; const trackConversion = (): void => { if (tracked) { return; } sendAlgoliaConversionEvent({ eventName: conversionData.eventName, indexName: conversionData.indexName, queryID: conversionData.queryID, objectID: conversionData.objectID }); tracked = true; cleanupConversionData(conversionData.articleUrl); }; const updateDwellTime = (): void => { if (isPageVisible) { const currentTime = Date.now(); const sessionTime = currentTime - dwellStartTime; totalDwellTime += sessionTime; dwellStartTime = currentTime; } }; const checkDwellTime = (): boolean => { updateDwellTime(); if (totalDwellTime >= 8000) { trackConversion(); return true; } return false; }; // Check dwell time every second // --- Setup listeners and timer --- const visibilityHandler = () => { const now = Date.now(); if (document.hidden && isPageVisible) { const sessionTime = now - dwellStartTime; totalDwellTime += sessionTime; isPageVisible = false; } else if (!document.hidden && !isPageVisible) { dwellStartTime = now; isPageVisible = true; } }; const beforeUnloadHandler = () => { const conversionTracked = checkDwellTime(); if (!conversionTracked) { cleanupConversionData(conversionData.articleUrl); } }; document.addEventListener('visibilitychange', visibilityHandler); window.addEventListener('beforeunload', beforeUnloadHandler); const dwellTimer = setInterval(() => { if (!tracked) { if (checkDwellTime()) { clearInterval(dwellTimer); document.removeEventListener('visibilitychange', visibilityHandler); window.removeEventListener('beforeunload', beforeUnloadHandler); } } else { clearInterval(dwellTimer); document.removeEventListener('visibilitychange', visibilityHandler); window.removeEventListener('beforeunload', beforeUnloadHandler); } }, 1000); };