/** * External dependencies */ import type { ExperimentId, Maybe } from '@nab/types'; /** * Internal dependencies */ import { log } from './log'; import { canVisitorParticipate } from './internal/can-visitor-participate'; import { getSettings } from './internal/get-settings'; import { getApiUrl, isStagingSimulated } from '../helpers'; import { addCookieListener, getCookie, removeCookie, removeCookieListener, setCookie, } from '../cookies'; import { updateSegmentationSettings, getAllActiveSegments, } from '../segmentation'; import { getSegmentationSettings, setSegmentationSettings, } from '../segmentation/segmentation-settings'; import { getQueryArg, REFERRER_PARAM, removeTestingArgs } from '../url'; import { getExperimentsWithPageViews, getUniqueViews as doGetUniqueViews, } from '../tracking'; import { isGdprAccepted } from '../../tracking/utils'; import type { AlternativeIndex, ExperimentSummary, Session, Settings, } from '../../types'; import type { Uuid } from 'uuid'; import { getGA4ClientId } from '../../tracking/ga4'; type QuotaCheck = { readonly zero: boolean; readonly timestamp: number; }; const MINUTE_IN_MILLIS = 60_000; const QUOTA_CHECK_LIVESPAN = 10 * MINUTE_IN_MILLIS; export function setNabReferrer(): void { // @ts-expect-error We’re creating this variable. window.nabReferrer = getQueryArg( document.location.href, REFERRER_PARAM ) || document.referrer; } export const getSession = async (): Promise< Maybe< Session > > => { const settings = getSettings(); if ( ! canVisitorParticipate( settings ) ) { return; } await updateSegmentationSettings( settings ); const alternative = updateAlternativeCookie( settings ); if ( 'none' === alternative ) { log( 'No alternative assigned' ); return; } const session = { ...settings, alternative, currentUrl: window.location.href, isStagingSite: settings.isStagingSite || isStagingSimulated(), experiments: settings.experiments.map( ( e ) => ( { ...e, alternative: getExperimentAlternative( alternative, e ), } ) ), untestedUrl: removeTestingArgs( window.location.href ), hasQuota: hasQuota( settings ), }; if ( isGdprAccepted( session ) ) { void updateECommerceSession( session ); } else { const id = addCookieListener( () => { if ( ! isGdprAccepted( session ) ) { return; } void updateECommerceSession( session ); removeCookieListener( id ); } ); } return session; }; // ======= // HELPERS // ======= function getExperimentAlternative( alternative: number, experiment: ExperimentSummary ): number { if ( ! experiment.alternativeCuts ) { return alternative % experiment.alternatives.length; } for ( let i = 0; i < experiment.alternativeCuts.length; ++i ) { const chance = experiment.alternativeCuts[ i ]; if ( undefined !== chance && alternative < chance ) { return i; } } return 0; } function updateAlternativeCookie( settings: Settings ) { const forcedAlternative = getForcedAlternativeValue( settings ); if ( false !== forcedAlternative ) { resetCookies( forcedAlternative ); setSegmentationSettings( { ...getSegmentationSettings(), alternativeChecksum: settings.alternativeChecksum, } ); return forcedAlternative; } if ( doesAltSessionNeedUpdating( settings ) ) { const alternative = generateAlternativeValue( settings ); resetCookies( alternative ); setSegmentationSettings( { ...getSegmentationSettings(), alternativeChecksum: settings.alternativeChecksum, } ); return alternative; } const currentAlternative = getAlternativeValue( settings ); const newAlternative = currentAlternative !== false ? currentAlternative : generateAlternativeValue( settings ); if ( currentAlternative !== newAlternative ) { resetCookies( newAlternative ); } resetCookieExpirations(); return newAlternative; } function getForcedAlternativeValue( settings: Settings ) { const search = document.location.search; const queryAlternative = /\bnab\b=[0-9]+/.test( search ) && /\bnabforce\b/.test( search ) ? Number.parseInt( search.replace( /^.*\bnab\b=([0-9]+).*$/, '$1' ) ) || 0 : false; if ( false === queryAlternative ) { return false; } return queryAlternative % settings.maxCombinations; } function doesAltSessionNeedUpdating( settings: Settings ) { const alternative = getCookie( 'nabAlternative' ); if ( undefined === alternative ) { return true; } const segmentation = getSegmentationSettings(); if ( segmentation.alternativeChecksum === settings.alternativeChecksum ) { return false; } const experimentIds = settings.experiments.map( ( e ) => e.id ); const experimentIdsWithViews = Object.keys( getExperimentsWithPageViews() ) .map( ( id ) => Number.parseInt( id ) ) .filter( ( id ): id is ExperimentId => !! id ); if ( experimentIds.some( ( id ) => experimentIdsWithViews.includes( id ) ) ) { return false; } return true; } const generateAlternativeValue = ( settings: Settings ) => random( 100 ) <= settings.participationChance ? random( settings.maxCombinations - 1 ) : 'none'; function getAlternativeValue( settings: Settings ): false | number | 'none' { const cookieAlternative = settings.cookieTesting; if ( false !== cookieAlternative ) { return cookieAlternative; } const nabAlt = getCookie( 'nabAlternative' ) ?? ''; if ( 'none' === nabAlt ) { return 'none'; } const value = Number.parseInt( nabAlt ); if ( isNaN( value ) ) { return false; } if ( value < 0 ) { return false; } if ( value >= settings.maxCombinations ) { return false; } return value; } const updateECommerceSession = async ( session: Session ) => { if ( ! session.ajaxUrl ) { return; } const oldChecksum = getCookie( 'nabSessionChecksum' ); const checksum = getSessionChecksum( session.alternative ); if ( ! session.forceECommerceSessionSync && oldChecksum === checksum ) { return; } const body = new FormData(); body.append( 'action', 'nab_sync_ecommerce_session' ); body.append( 'alternative', `${ session.alternative }` ); body.append( 'expsWithView', JSON.stringify( getViewedAlternatives( session ) ) ); body.append( 'expSegments', JSON.stringify( getSegments( session ) ) ); body.append( 'uniqueViews', JSON.stringify( getUniqueViews( session ) ) ); if ( session.isGA4Integrated ) { body.append( 'ga4ClientId', getGA4ClientId() ?? '' ); } // NOTE. We’re syncing the session using an event to prevent GTMetrix from grading sites poorly. await new Promise< void >( ( resolve ) => { let running = false; const sync = () => { if ( running ) { return; } running = true; void fetch( session.ajaxUrl, { method: 'POST', credentials: 'same-origin', body, } ).finally( () => { window.removeEventListener( 'keyup', sync ); window.removeEventListener( 'mousemove', sync ); window.removeEventListener( 'click', sync ); resolve(); } ); }; window.addEventListener( 'keyup', sync ); window.addEventListener( 'mousemove', sync ); window.addEventListener( 'click', sync ); } ); setCookie( 'nabSessionChecksum', checksum ); }; const random = ( max: number ) => Math.min( Math.floor( Math.random() * ( max + 1 ) ), max ); const getViewedAlternatives = ( session: Session ): Partial< Record< ExperimentId, AlternativeIndex > > => Object.keys( getExperimentsWithPageViews() ).reduce( ( res, key ) => { const id = ( Number.parseInt( key ) || 0 ) as ExperimentId; const exp = session.experiments.find( ( e ) => e.id === id ); if ( exp ) { res[ id ] = exp.alternative; } return res; }, {} as Partial< Record< ExperimentId, AlternativeIndex > > ); const getSegments = ( session: Session ): Partial< Record< ExperimentId, ReadonlyArray< number > > > => { const allSegments = getAllActiveSegments(); return Object.keys( allSegments ).reduce( ( res, key ) => { const id = ( Number.parseInt( key ) || 0 ) as ExperimentId; const exp = session.experiments.find( ( e ) => e.id === id ); if ( exp ) { res[ id ] = allSegments[ id ] ?? [ 0 ]; } return res; }, {} as Partial< Record< ExperimentId, ReadonlyArray< number > > > ); }; const getUniqueViews = ( session: Session ): Partial< Record< ExperimentId, Uuid > > => { const uniqueViews = doGetUniqueViews(); return session.experiments.reduce( ( res, experiment ) => { const id = experiment.id; const viewUuid = uniqueViews[ id ]; if ( viewUuid ) { res[ id ] = viewUuid; } return res; }, {} as Record< ExperimentId, Uuid > ); }; const getSessionChecksum = ( alternative: number ): string => { const epv = getExperimentsWithPageViews(); const expIds = Object.keys( epv ).sort(); const tenMinutesTimestamp = new Date( Object.values( epv ).sort().reverse()[ 0 ] || 0 ) .toISOString() .substring( 0, 15 ) + '0'; return hashString( [ alternative, ...expIds, tenMinutesTimestamp ].join( ':' ) ); }; function hashString( str: string ): string { let hash = 5381; for ( let i = 0; i < str.length; i++ ) { // eslint-disable-next-line no-bitwise hash = ( hash * 33 ) ^ str.charCodeAt( i ); } // eslint-disable-next-line no-bitwise return ( hash >>> 0 ).toString( 16 ); } function resetCookies( alternative: number | 'none' ): void { setCookie( 'nabAlternative', alternative, { expires: 120 } ); removeCookie( 'nabExperimentsWithPageViews' ); removeCookie( 'nabUniqueViews' ); } function resetCookieExpirations(): void { ( [ 'nabAlternative', 'nabExperimentsWithPageViews', 'nabUniqueViews', ] as const ) .map( ( n ) => [ n, getCookie( n ) ?? '' ] as const ) .filter( ( [ _, v ] ) => !! v ) .forEach( ( [ n, v ] ) => setCookie( n, v, { expires: 120 } ) ); } async function hasQuota( settings: Settings ): Promise< boolean > { try { const quotaString = sessionStorage.getItem( 'nabq' ) ?? ''; const quotaCheck = JSON.parse( quotaString ) as unknown; if ( isValidQuotaCheck( quotaCheck ) ) { return ! quotaCheck.zero; } } catch ( _ ) {} sessionStorage.removeItem( 'nabq' ); const quotaCheck = await checkQuota( settings ); if ( ! quotaCheck ) { return true; } sessionStorage.setItem( 'nabq', JSON.stringify( quotaCheck ) ); return ! quotaCheck.zero; } function isValidQuotaCheck( q: unknown ): q is QuotaCheck { return ( !! q && 'object' === typeof q && 'timestamp' in q && 'zero' in q && 'boolean' === typeof q.zero && 'number' === typeof q.timestamp && Date.now() - q.timestamp <= QUOTA_CHECK_LIVESPAN ); } async function checkQuota( settings: Settings ): Promise< Maybe< QuotaCheck > > { try { const url = getApiUrl( settings.api, '/check', { siteId: settings.site, } ); const res = await window.fetch( url ); const data: string = await res.text(); const zero = ! JSON.parse( data ); return { timestamp: Date.now(), zero }; } catch ( _ ) {} return undefined; }