/** * External dependencies */ import type { Dict, ExperimentId, Maybe } from '@nab/types'; /** * Internal dependencies */ import { initConversionTracking } from './conversions'; import { initGA4Tracking } from './ga4'; import { initHeatmapTracking } from './heatmaps'; import { sendConversions, sendViews } from './sync'; import { isGdprAccepted } from './utils'; import { domReady, getActiveExperiments, getCollapsedAlternative, log, logIf, } from '../utils/helpers'; import { cleanOldSegments } from '../utils/segmentation'; import type { ConvertingGoal, GoalIndex, MissingSessionRationale, Session, } from '../types'; import { addCookieListener, removeCookieListener } from '../utils/cookies'; import { getExperimentsWithPageViews } from '../utils/tracking'; let isTrackingEnabled = false; let pendingEvents: ReadonlyArray< PendingEvent > = []; let isContentReady = false; let pendingCallbacks: ReadonlyArray< () => void > = []; export function exportTrackingApi( mode: MissingSessionRationale ): void { const nab = initExports( mode ); ( window as unknown as Dict ).nab = nab; } export function markContentAsReady( session: Maybe< Session >, loaded: boolean ): void { if ( session?.experiments.some( ( e ) => e.active ) && loaded ) { window.dispatchEvent( new CustomEvent( 'nelio-ab-testing/variant-ready', { detail: { experiments: session.experiments .filter( ( e ) => e.active ) .map( ( e ) => ( { id: e.id, variant: e.alternative, } ) ), session: { ...session }, variant: getCollapsedAlternative( session ), }, } ) ); } // @ts-expect-error This variable exists. window.nabIsLoading = false; window.dispatchEvent( new CustomEvent( 'nelio-ab-testing/content-ready', { detail: session ? { ...session } : undefined, } ) ); isContentReady = true; pendingCallbacks.forEach( ( callback ) => callback() ); pendingCallbacks = []; } export function maybeStartTracking( session: Session, trackViews: boolean ): void { let awaitingGdpr = false; const id = addCookieListener( () => { if ( isGdprAccepted( session ) ) { logIf( awaitingGdpr, 'GDPR ready!' ); removeCookieListener( id ); startTracking( session, trackViews ); notifyGdprStatus( 'ready' ); return; } if ( awaitingGdpr ) { return; } awaitingGdpr = true; notifyGdprStatus( 'awaiting' ); log( 'Awaiting GDPR cookie…' ); const nab = initExports( 'awaiting-gdpr' ); ( window as unknown as Dict ).nab = nab; } ); } // ======== // INTERNAL // ======== function startTracking( session: Session, trackViews: boolean ) { domReady( () => { const experimentIds = session.experiments.map( ( e ) => e.id ); cleanOldSegments( experimentIds ); const nab = initExports( session ); ( window as unknown as Dict ).nab = nab; initGA4Tracking( session ); enableTracking( session ); if ( trackViews ) { trackHeaderViews( session ); trackFooterViews( session ); } initConversionTracking( session ); initHeatmapTracking( session ); } ); } function notifyGdprStatus( status: 'ready' | 'awaiting' ) { window.dispatchEvent( new CustomEvent( 'nelio-ab-testing/gdpr-status', { detail: status } ) ); } type PendingEvent = | { readonly type: 'view'; readonly experimentIds: | ExperimentId | ReadonlyArray< ExperimentId >; } | { readonly type: 'trigger'; readonly eventName: string; } | { readonly type: 'convert'; readonly experiment: ExperimentId | ReadonlyArray< ConvertingGoal >; readonly goal?: GoalIndex; }; const initExports = ( session: Session | MissingSessionRationale | 'awaiting-gdpr' ) => { if ( 'string' === typeof session && 'awaiting-gdpr' !== session ) { window.dispatchEvent( new CustomEvent( 'nelio-ab-testing/bootstrap', { detail: session } ) ); } return { view: ( experimentIds: unknown ): void => { if ( ! isExperimentId( experimentIds ) && ! isExperimentIdArray( experimentIds ) ) { throw new Error( 'Invalid Argument Type. Positive integer or array of positive integers expected.' ); } if ( ! isTrackingEnabled || session === 'awaiting-session' || session === 'missing-session' || session === 'awaiting-variant' || session === 'awaiting-gdpr' ) { pendingEvents = [ ...pendingEvents, { type: 'view', experimentIds }, ]; return; } view( session )( experimentIds ); }, trigger: ( eventName: unknown ): void => { if ( 'string' !== typeof eventName ) { throw new Error( 'Invalid Argument Type. String expected.' ); } if ( session === 'awaiting-gdpr' ) { return; } if ( ! isTrackingEnabled || session === 'awaiting-session' || session === 'missing-session' || session === 'awaiting-variant' ) { pendingEvents = [ ...pendingEvents, { type: 'trigger', eventName }, ]; return; } trigger( session )( eventName ); }, convert: ( experiment: unknown, goal: unknown = 0 ): void => { if ( ! isExperimentId( experiment ) ) { throw new Error( 'Invalid Argument Type. Positive integer expected.' ); } if ( ! isNonNegativeInteger( goal ) ) { throw new Error( 'Invalid Argument Type. Non-negative integer expected.' ); } if ( session === 'awaiting-gdpr' ) { return; } if ( ! isTrackingEnabled || session === 'awaiting-session' || session === 'missing-session' || session === 'awaiting-variant' ) { pendingEvents = [ ...pendingEvents, { type: 'convert', experiment, goal }, ]; return; } convert( session )( experiment, goal ); }, session: () => 'string' === typeof session ? session : ( JSON.parse( JSON.stringify( { ...session, hasQuota: undefined } ) ) as unknown ), user: () => { if ( 'string' === typeof session ) { return undefined; } const ewpv = getExperimentsWithPageViews(); const pairs = Object.keys( ewpv ) .map( ( id ) => Number.parseInt( id ) ) .filter( ( id ): id is ExperimentId => !! id ) .map( ( id ): [ ExperimentId, number ] => [ id, ewpv[ id ] ?? 0, ] ); pairs.sort( ( a, b ) => ( a[ 1 ] < b[ 1 ] ? 1 : -1 ) ); return { experiments: session.experiments.map( ( { id, alternative } ) => ( { id, alternative, seen: !! ewpv[ id ], } ) ), lastExperiment: pairs .map( ( [ id ] ) => session.experiments.find( ( e ) => e.id === id ) ) .filter( ( x ) => !! x ) .map( ( { id, alternative } ) => ( { id, alternative, } ) )[ 0 ], }; }, ready: ( callback: unknown ) => { if ( ! isFunction( callback ) ) { throw new Error( 'Invalid Argument Type. Function expected.' ); } if ( ! isContentReady ) { pendingCallbacks = [ ...pendingCallbacks, callback ]; return; } callback(); }, }; }; const view = ( session: Session ) => ( expIds: ExperimentId | ReadonlyArray< ExperimentId > ) => { const experimentIds = Array.isArray( expIds ) ? expIds : [ expIds ]; if ( ! experimentIds.length ) { return; } sendViews( experimentIds, session ); }; // DEPRECATED. Remove “trigger” eventually. const trigger = ( session: Session ) => ( eventName: string ) => { /* eslint-disable */ console.groupCollapsed( 'Deprecation Warning!' ); console.log( 'Nelio A/B Testing deprecated “nab.trigger”.' ); console.log( 'New “Custom Event” conversion actions no longer require you to manually embed scripts in your theme.' ); console.groupEnd(); /* eslint-enable */ const { experiments } = session; const events = experiments.reduce( ( memo, experiment ) => { experiment.goals.forEach( ( goal, index ) => { const hasCustomEventAction = goal.conversionActions.reduce( ( found, { type, attributes = {} } ) => found || ( 'nab/custom-event' === type && eventName === attributes.eventName ), false ); if ( hasCustomEventAction ) { memo = [ ...memo, { experiment: experiment.id, goal: index } ]; } } ); return memo; }, [] as ReadonlyArray< ConvertingGoal > ); sendConversions( events, session ); }; const convert = ( session: Session ) => ( experiment: ExperimentId | ReadonlyArray< ConvertingGoal >, goal?: GoalIndex ) => { let events: ReadonlyArray< ConvertingGoal > = []; if ( isExperimentId( experiment ) ) { if ( undefined !== goal ) { events = [ { experiment, goal } ]; } } else { events = experiment; } sendConversions( events.map( ( ev ) => ( { experiment: ev.experiment, goal: ev.goal, } ) ), session ); }; const enableTracking = ( session: Session ) => { isTrackingEnabled = true; pendingEvents.forEach( ( ev ) => { if ( ev.type === 'view' ) { view( session )( ev.experimentIds ); } else if ( ev.type === 'trigger' ) { trigger( session )( ev.eventName ); } else { convert( session )( ev.experiment, ev.goal ); } } ); pendingEvents = []; }; const trackHeaderViews = ( session: Session ): void => { view( session )( getActiveExperiments( session ) .filter( ( e ) => e.pageViewTracking === 'header' ) .map( ( e ) => e.id ) ); view( session )( session.heatmaps.map( ( e ) => e.id ) ); }; const trackFooterViews = ( session: Session ): void => { const footerIds = getFooterViews(); view( session )( footerIds ); }; const getFooterViews = (): ReadonlyArray< ExperimentId > => ( ( window as unknown as Dict ) .nabFooterViews as ReadonlyArray< ExperimentId > ) || []; const isFunction = ( f: unknown ): f is () => void => 'function' === typeof f; const isExperimentId = ( n: unknown ): n is ExperimentId => isNonNegativeInteger( n ) && n > 0; const isExperimentIdArray = ( a: unknown ): a is ExperimentId[] => Array.isArray( a ) && a.every( isExperimentId ); const isNonNegativeInteger = ( n: unknown ): n is number => 'number' === typeof n && n >= 0 && Math.round( n ) === n;